From 1e1fd3db38e7b17f7944fde65e74e810175d146f Mon Sep 17 00:00:00 2001 From: Jo Date: Tue, 8 Jul 2025 15:38:11 +0200 Subject: [PATCH 01/21] OAuth: Add access token as third source for user info extraction (#107636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add access token as third source for user info extraction - Add extractFromAccessToken method to extract user info from JWT access tokens - Mutualize code by creating parseUserInfoFromJSON helper method - Rename methods for clarity: extractFromToken -> extractFromIDToken, retrieveRawIDToken -> retrieveRawJWTPayload - Update test suite to include comprehensive access token retrieval scenarios - Support three sources in priority order: ID token, API response, access token - Maintain backward compatibility while adding new functionality * Update Generic OAuth documentation to reflect access token support - Add access token as a third source for user information extraction - Update configuration sections to mention access tokens alongside ID tokens and UserInfo endpoint - Document the priority order: ID token → UserInfo endpoint → access token - Update configuration option descriptions to reflect new functionality - Maintain consistency with implementation changes * Refactor access token test cases to use parameter instead of hardcoded logic - Add AccessToken field to test case struct for explicit access token specification - Remove hardcoded string matching logic that determined access token based on test name - Update all access token test cases to include the AccessToken field with appropriate JWT values - Improve test maintainability and clarity by making access tokens explicit parameters - Remove unused strings import that was only needed for the hardcoded logic * fix doc lint * reduce cyclomatic complexity --- .../generic-oauth/index.md | 96 ++++---- pkg/login/social/connectors/generic_oauth.go | 226 ++++++++++++------ .../social/connectors/generic_oauth_test.go | 62 ++++- pkg/login/social/connectors/gitlab_oauth.go | 2 +- pkg/login/social/connectors/google_oauth.go | 2 +- pkg/login/social/connectors/social_base.go | 10 +- 6 files changed, 270 insertions(+), 128 deletions(-) diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md index f46f131eedf..3beaeca97ff 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md @@ -134,7 +134,7 @@ To integrate your OAuth2 provider with Grafana using our Generic OAuth authentic ### Configure login -Grafana can resolve a user's login from the OAuth2 ID token or user information retrieved from the OAuth2 UserInfo endpoint. +Grafana can resolve a user's login from the OAuth2 ID token, user information retrieved from the OAuth2 UserInfo endpoint, or the OAuth2 access token. Grafana looks at these sources in the order listed until it finds a login. If no login is found, then the user's login is set to user's email address. @@ -146,10 +146,12 @@ Refer to the following table for information on what to configure based on how y | Another field of the OAuth2 ID token. | Set `login_attribute_path` configuration option. | | `login` or `username` field of the user information from the UserInfo endpoint. | N/A | | Another field of the user information from the UserInfo endpoint. | Set `login_attribute_path` configuration option. | +| `login` or `username` field of the OAuth2 access token. | N/A | +| Another field of the OAuth2 access token. | Set `login_attribute_path` configuration option. | ### Configure display name -Grafana can resolve a user's display name from the OAuth2 ID token or user information retrieved from the OAuth2 UserInfo endpoint. +Grafana can resolve a user's display name from the OAuth2 ID token, user information retrieved from the OAuth2 UserInfo endpoint, or the OAuth2 access token. Grafana looks at these sources in the order listed until it finds a display name. If no display name is found, then user's login is displayed instead. @@ -161,10 +163,12 @@ Refer to the following table for information on what you need to configure depen | Another field of the OAuth2 ID token. | Set `name_attribute_path` configuration option. | | `name` or `display_name` field of the user information from the UserInfo endpoint. | N/A | | Another field of the user information from the UserInfo endpoint. | Set `name_attribute_path` configuration option. | +| `name` or `display_name` field of the OAuth2 access token. | N/A | +| Another field of the OAuth2 access token. | Set `name_attribute_path` configuration option. | ### Configure email address -Grafana can resolve the user's email address from the OAuth2 ID token, the user information retrieved from the OAuth2 UserInfo endpoint, or the OAuth2 `/emails` endpoint. +Grafana can resolve the user's email address from the OAuth2 ID token, the user information retrieved from the OAuth2 UserInfo endpoint, the OAuth2 access token, or the OAuth2 `/emails` endpoint. Grafana looks at these sources in the order listed until an email address is found. If no email is found, then the email address of the user is set to an empty string. @@ -177,6 +181,10 @@ Refer to the following table for information on what to configure based on how t | `upn` field of the OAuth2 ID token. | N/A | | `email` field of the user information from the UserInfo endpoint. | N/A | | Another field of the user information from the UserInfo endpoint. | Set `email_attribute_path` configuration option. | +| `email` field of the OAuth2 access token. | N/A | +| `attributes` map of the OAuth2 access token. | Set `email_attribute_name` configuration option. By default, Grafana searches for email under `email:primary` key. | +| `upn` field of the OAuth2 access token. | N/A | +| Another field of the OAuth2 access token. | Set `email_attribute_path` configuration option. | | Email address marked as primary from the `/emails` endpoint of
the OAuth2 provider (obtained by appending `/emails` to the URL
configured with `api_url`) | N/A | ### Configure a refresh token @@ -199,6 +207,7 @@ The `accessTokenExpirationCheck` feature toggle has been removed in Grafana v10. Unless `skip_org_role_sync` option is enabled, the user's role will be set to the role retrieved from the auth provider upon user login. The user's role is retrieved using a [JMESPath](http://jmespath.org/examples.html) expression from the `role_attribute_path` configuration option. +Grafana will first evaluate the expression using the OAuth2 ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. If still no role is found, the expression will be evaluated using the OAuth2 access token. To map the server administrator role, use the `allow_assign_grafana_admin` configuration option. Refer to [configuration options](#configuration-options) for more information. @@ -326,6 +335,7 @@ By using Team Sync, you can link your OAuth2 groups to teams within Grafana. Thi Teams for each user are synchronized when the user logs in. Generic OAuth groups can be referenced by group ID, such as `8bab1c86-8fba-33e5-2089-1d1c80ec267d` or `myteam`. +Group information can be extracted from the OAuth2 ID token, user information from the UserInfo endpoint, or the OAuth2 access token. For information on configuring OAuth2 groups with Grafana using the `groups_attribute_path` configuration option, refer to [configuration options](#configuration-options). To learn more about Team Sync, refer to [Configure team sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-team-sync/). @@ -359,46 +369,46 @@ The following table outlines the various Generic OAuth configuration options. Yo If the configuration option requires a JMESPath expression that includes a colon, enclose the entire expression in quotes to prevent parsing errors. For example `role_attribute_path: "role:view"` {{< /admonition >}} -| Setting | Required | Supported on Cloud | Description | Default | -| ---------------------------- | -------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | -| `enabled` | No | Yes | Enables Generic OAuth authentication. | `false` | -| `name` | No | Yes | Name that refers to the Generic OAuth authentication from the Grafana user interface. | `OAuth` | -| `icon` | No | Yes | Icon used for the Generic OAuth authentication in the Grafana user interface. | `signin` | -| `client_id` | Yes | Yes | Client ID provided by your OAuth2 app. | | -| `client_secret` | Yes | Yes | Client secret provided by your OAuth2 app. | | -| `auth_url` | Yes | Yes | Authorization endpoint of your OAuth2 provider. | | -| `token_url` | Yes | Yes | Endpoint used to obtain the OAuth2 access token. | | -| `api_url` | Yes | Yes | Endpoint used to obtain user information compatible with [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo). | | -| `auth_style` | No | Yes | Name of the [OAuth2 AuthStyle](https://pkg.go.dev/golang.org/x/oauth2#AuthStyle) to be used when ID token is requested from OAuth2 provider. It determines how `client_id` and `client_secret` are sent to Oauth2 provider. Available values are `AutoDetect`, `InParams` and `InHeader`. | `AutoDetect` | -| `scopes` | No | Yes | List of comma- or space-separated OAuth2 scopes. | `user:email` | -| `empty_scopes` | No | Yes | Set to `true` to use an empty scope during authentication. | `false` | -| `allow_sign_up` | No | Yes | Controls Grafana user creation through the Generic OAuth login. Only existing Grafana users can log in with Generic OAuth if set to `false`. | `true` | -| `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | -| `id_token_attribute_name` | No | Yes | The name of the key used to extract the ID token from the returned OAuth2 token. | `id_token` | -| `login_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user login lookup from the user ID token. For more information on how user login is retrieved, refer to [Configure login](#configure-login). | | -| `name_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user name lookup from the user ID token. This name will be used as the user's display name. For more information on how user display name is retrieved, refer to [Configure display name](#configure-display-name). | | -| `email_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user email lookup from the user information. For more information on how user email is retrieved, refer to [Configure email address](#configure-email-address). | | -| `email_attribute_name` | No | Yes | Name of the key to use for user email lookup within the `attributes` map of OAuth2 ID token. For more information on how user email is retrieved, refer to [Configure email address](#configure-email-address). | `email:primary` | -| `role_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a valid Grafana role (`None`, `Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | | -| `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` | -| `skip_org_role_sync` | No | Yes | Set to `true` to stop automatically syncing user roles. This will allow you to set organization roles for your users from within Grafana manually. | `false` | -| `org_attribute_path` | No | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana org to role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no value is returned, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation will be mapped to org roles based on `org_mapping`. For more information on org to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | | -| `org_mapping` | No | No | List of comma- or space-separated `::` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | | -| `allow_assign_grafana_admin` | No | No | Set to `true` to enable automatic sync of the Grafana server administrator role. If this option is set to `true` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user the server administrator privileges and organization administrator role. If this option is set to `false` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user only organization administrator role. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` | -| `groups_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user group lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no groups are found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. The result of the evaluation should be a string array of groups. | | -| `allowed_groups` | No | Yes | List of comma- or space-separated groups. The user should be a member of at least one group to log in. If you configure `allowed_groups`, you must also configure `groups_attribute_path`. | | -| `allowed_organizations` | No | Yes | List of comma- or space-separated organizations. The user should be a member of at least one organization to log in. | | -| `allowed_domains` | No | Yes | List of comma- or space-separated domains. The user should belong to at least one domain to log in. | | -| `team_ids` | No | Yes | String list of team IDs. If set, the user must be a member of one of the given teams to log in. If you configure `team_ids`, you must also configure `teams_url` and `team_ids_attribute_path`. | | -| `team_ids_attribute_path` | No | Yes | The [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana team ID lookup within the results returned by the `teams_url` endpoint. | | -| `teams_url` | No | Yes | The URL used to query for team IDs. If not set, the default value is `/teams`. If you configure `teams_url`, you must also configure `team_ids_attribute_path`. | | -| `tls_skip_verify_insecure` | No | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` | -| `tls_client_cert` | No | No | The path to the certificate. | | -| `tls_client_key` | No | No | The path to the key. | | -| `tls_client_ca` | No | No | The path to the trusted certificate authority list. | | -| `use_pkce` | No | Yes | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `false` | -| `use_refresh_token` | No | Yes | Set to `true` to use refresh token and check access token expiration. | `false` | -| `signout_redirect_url` | No | Yes | URL to redirect to after the user logs out. | | +| Setting | Required | Supported on Cloud | Description | Default | +| ---------------------------- | -------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| `enabled` | No | Yes | Enables Generic OAuth authentication. | `false` | +| `name` | No | Yes | Name that refers to the Generic OAuth authentication from the Grafana user interface. | `OAuth` | +| `icon` | No | Yes | Icon used for the Generic OAuth authentication in the Grafana user interface. | `signin` | +| `client_id` | Yes | Yes | Client ID provided by your OAuth2 app. | | +| `client_secret` | Yes | Yes | Client secret provided by your OAuth2 app. | | +| `auth_url` | Yes | Yes | Authorization endpoint of your OAuth2 provider. | | +| `token_url` | Yes | Yes | Endpoint used to obtain the OAuth2 access token. | | +| `api_url` | Yes | Yes | Endpoint used to obtain user information compatible with [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo). | | +| `auth_style` | No | Yes | Name of the [OAuth2 AuthStyle](https://pkg.go.dev/golang.org/x/oauth2#AuthStyle) to be used when ID token is requested from OAuth2 provider. It determines how `client_id` and `client_secret` are sent to Oauth2 provider. Available values are `AutoDetect`, `InParams` and `InHeader`. | `AutoDetect` | +| `scopes` | No | Yes | List of comma- or space-separated OAuth2 scopes. | `user:email` | +| `empty_scopes` | No | Yes | Set to `true` to use an empty scope during authentication. | `false` | +| `allow_sign_up` | No | Yes | Controls Grafana user creation through the Generic OAuth login. Only existing Grafana users can log in with Generic OAuth if set to `false`. | `true` | +| `auto_login` | No | Yes | Set to `true` to enable users to bypass the login screen and automatically log in. This setting is ignored if you configure multiple auth providers to use auto-login. | `false` | +| `id_token_attribute_name` | No | Yes | The name of the key used to extract the ID token from the returned OAuth2 token. | `id_token` | +| `login_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user login lookup from the user ID token. For more information on how user login is retrieved, refer to [Configure login](#configure-login). | | +| `name_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user name lookup from the user ID token. This name will be used as the user's display name. For more information on how user display name is retrieved, refer to [Configure display name](#configure-display-name). | | +| `email_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user email lookup from the user information. For more information on how user email is retrieved, refer to [Configure email address](#configure-email-address). | | +| `email_attribute_name` | No | Yes | Name of the key to use for user email lookup within the `attributes` map of OAuth2 ID token. For more information on how user email is retrieved, refer to [Configure email address](#configure-email-address). | `email:primary` | +| `role_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no role is found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. If still no role is found, the expression will be evaluated using the OAuth2 access token. The result of the evaluation should be a valid Grafana role (`None`, `Viewer`, `Editor`, `Admin` or `GrafanaAdmin`). For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | | +| `role_attribute_strict` | No | Yes | Set to `true` to deny user login if the Grafana org role cannot be extracted using `role_attribute_path` or `org_mapping`. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` | +| `skip_org_role_sync` | No | Yes | Set to `true` to stop automatically syncing user roles. This will allow you to set organization roles for your users from within Grafana manually. | `false` | +| `org_attribute_path` | No | No | [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana org to role lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no value is returned, the expression will be evaluated using the user information obtained from the UserInfo endpoint. If still no value is returned, the expression will be evaluated using the OAuth2 access token. The result of the evaluation will be mapped to org roles based on `org_mapping`. For more information on org to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | | +| `org_mapping` | No | No | List of comma- or space-separated `::` mappings. Value can be `*` meaning "All users". Role is optional and can have the following values: `None`, `Viewer`, `Editor` or `Admin`. For more information on external organization to role mapping, refer to [Org roles mapping example](#org-roles-mapping-example). | | +| `allow_assign_grafana_admin` | No | No | Set to `true` to enable automatic sync of the Grafana server administrator role. If this option is set to `true` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user the server administrator privileges and organization administrator role. If this option is set to `false` and the result of evaluating `role_attribute_path` for a user is `GrafanaAdmin`, Grafana grants the user only organization administrator role. For more information on user role mapping, refer to [Configure role mapping](#configure-role-mapping). | `false` | +| `groups_attribute_path` | No | Yes | [JMESPath](http://jmespath.org/examples.html) expression to use for user group lookup. Grafana will first evaluate the expression using the OAuth2 ID token. If no groups are found, the expression will be evaluated using the user information obtained from the UserInfo endpoint. If still no groups are found, the expression will be evaluated using the OAuth2 access token. The result of the evaluation should be a string array of groups. | | +| `allowed_groups` | No | Yes | List of comma- or space-separated groups. The user should be a member of at least one group to log in. If you configure `allowed_groups`, you must also configure `groups_attribute_path`. | | +| `allowed_organizations` | No | Yes | List of comma- or space-separated organizations. The user should be a member of at least one organization to log in. | | +| `allowed_domains` | No | Yes | List of comma- or space-separated domains. The user should belong to at least one domain to log in. | | +| `team_ids` | No | Yes | String list of team IDs. If set, the user must be a member of one of the given teams to log in. If you configure `team_ids`, you must also configure `teams_url` and `team_ids_attribute_path`. | | +| `team_ids_attribute_path` | No | Yes | The [JMESPath](http://jmespath.org/examples.html) expression to use for Grafana team ID lookup within the results returned by the `teams_url` endpoint. | | +| `teams_url` | No | Yes | The URL used to query for team IDs. If not set, the default value is `/teams`. If you configure `teams_url`, you must also configure `team_ids_attribute_path`. | | +| `tls_skip_verify_insecure` | No | No | If set to `true`, the client accepts any certificate presented by the server and any host name in that certificate. _You should only use this for testing_, because this mode leaves SSL/TLS susceptible to man-in-the-middle attacks. | `false` | +| `tls_client_cert` | No | No | The path to the certificate. | | +| `tls_client_key` | No | No | The path to the key. | | +| `tls_client_ca` | No | No | The path to the trusted certificate authority list. | | +| `use_pkce` | No | Yes | Set to `true` to use [Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636). Grafana uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. | `false` | +| `use_refresh_token` | No | Yes | Set to `true` to use refresh token and check access token expiration. | `false` | +| `signout_redirect_url` | No | Yes | URL to redirect to after the user logs out. | | ## Examples of setting up Generic OAuth diff --git a/pkg/login/social/connectors/generic_oauth.go b/pkg/login/social/connectors/generic_oauth.go index 7d1d5842a47..02b93285e93 100644 --- a/pkg/login/social/connectors/generic_oauth.go +++ b/pkg/login/social/connectors/generic_oauth.go @@ -245,78 +245,136 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client, defer s.reloadMutex.RUnlock() s.log.Debug("Getting user info") - toCheck := make([]*UserInfoJson, 0, 2) - if tokenData := s.extractFromToken(token); tokenData != nil { - toCheck = append(toCheck, tokenData) + // 1. Collect user info data from various sources + dataSources := s.collectUserInfoData(ctx, client, token) + + // 2. Build user info from collected data + userInfo, externalOrgs, err := s.buildUserInfo(dataSources) + if err != nil { + return nil, err + } + + // 3. Post-process user info + err = s.postProcessUserInfo(ctx, client, userInfo, externalOrgs) + if err != nil { + return nil, err + } + + // 4. Validate user access + err = s.validateUserAccess(ctx, client, userInfo) + if err != nil { + return nil, err + } + + s.log.Debug("User info result", "result", userInfo) + return userInfo, nil +} + +// collectUserInfoData gathers user information from ID token, API, and access token +func (s *SocialGenericOAuth) collectUserInfoData(ctx context.Context, client *http.Client, token *oauth2.Token) []*UserInfoJson { + dataSources := make([]*UserInfoJson, 0, 3) + + if idTokenData := s.extractFromIDToken(token); idTokenData != nil { + dataSources = append(dataSources, idTokenData) } if apiData := s.extractFromAPI(ctx, client); apiData != nil { - toCheck = append(toCheck, apiData) + dataSources = append(dataSources, apiData) + } + if accessTokenData := s.extractFromAccessToken(token); accessTokenData != nil { + dataSources = append(dataSources, accessTokenData) } + return dataSources +} + +// buildUserInfo constructs BasicUserInfo from collected data sources +func (s *SocialGenericOAuth) buildUserInfo(dataSources []*UserInfoJson) (*social.BasicUserInfo, []string, error) { userInfo := &social.BasicUserInfo{} var externalOrgs []string - for _, data := range toCheck { + + for _, data := range dataSources { s.log.Debug("Processing external user info", "source", data.source, "data", data) - if userInfo.Id == "" { - userInfo.Id = data.Sub + s.extractBasicUserFields(userInfo, data) + + if err := s.extractRoleAndOrgs(userInfo, &externalOrgs, data); err != nil { + return nil, nil, err } - if userInfo.Name == "" { - userInfo.Name = s.extractUserName(data) - } + s.extractUserGroups(userInfo, data) + } - if userInfo.Login == "" { - userInfo.Login = s.extractLogin(data) - } + return userInfo, externalOrgs, nil +} - if userInfo.Email == "" { - userInfo.Email = s.extractEmail(data) - if userInfo.Email != "" { - s.log.Debug("Set user info email from extracted email", "email", userInfo.Email) - } - } +// extractBasicUserFields extracts basic user fields (ID, Name, Login, Email) from data +func (s *SocialGenericOAuth) extractBasicUserFields(userInfo *social.BasicUserInfo, data *UserInfoJson) { + if userInfo.Id == "" { + userInfo.Id = data.Sub + } - if userInfo.Role == "" && !s.info.SkipOrgRoleSync { - role, grafanaAdmin, err := s.extractRoleAndAdminOptional(data.rawJSON, []string{}) - if err != nil { - s.log.Warn("Failed to extract role", "err", err) - } else { - userInfo.Role = role - if s.info.AllowAssignGrafanaAdmin { - userInfo.IsGrafanaAdmin = &grafanaAdmin - } - } - } + if userInfo.Name == "" { + userInfo.Name = s.extractUserName(data) + } - if len(externalOrgs) == 0 && !s.info.SkipOrgRoleSync { - var err error - externalOrgs, err = s.extractOrgs(data.rawJSON) - if err != nil { - s.log.Warn("Failed to extract orgs", "err", err) - return nil, err - } - } + if userInfo.Login == "" { + userInfo.Login = s.extractLogin(data) + } - if len(userInfo.Groups) == 0 { - groups, err := s.extractGroups(data) - if err != nil { - s.log.Warn("Failed to extract groups", "err", err) - } else if len(groups) > 0 { - s.log.Debug("Setting user info groups from extracted groups") - userInfo.Groups = groups + if userInfo.Email == "" { + userInfo.Email = s.extractEmail(data) + if userInfo.Email != "" { + s.log.Debug("Set user info email from extracted email", "email", userInfo.Email) + } + } +} + +// extractRoleAndOrgs extracts role and organization information from data +func (s *SocialGenericOAuth) extractRoleAndOrgs(userInfo *social.BasicUserInfo, externalOrgs *[]string, data *UserInfoJson) error { + if userInfo.Role == "" && !s.info.SkipOrgRoleSync { + role, grafanaAdmin, err := s.extractRoleAndAdminOptional(data.rawJSON, []string{}) + if err != nil { + s.log.Warn("Failed to extract role", "err", err) + } else { + userInfo.Role = role + if s.info.AllowAssignGrafanaAdmin { + userInfo.IsGrafanaAdmin = &grafanaAdmin } } } + if len(*externalOrgs) == 0 && !s.info.SkipOrgRoleSync { + orgs, err := s.extractOrgs(data.rawJSON) + if err != nil { + s.log.Warn("Failed to extract orgs", "err", err) + return err + } + *externalOrgs = orgs + } + + return nil +} + +// extractUserGroups extracts group information from data +func (s *SocialGenericOAuth) extractUserGroups(userInfo *social.BasicUserInfo, data *UserInfoJson) { + if len(userInfo.Groups) == 0 { + groups, err := s.extractGroups(data) + if err != nil { + s.log.Warn("Failed to extract groups", "err", err) + } else if len(groups) > 0 { + s.log.Debug("Setting user info groups from extracted groups") + userInfo.Groups = groups + } + } +} + +// postProcessUserInfo handles post-processing of user info (org roles, private email, etc.) +func (s *SocialGenericOAuth) postProcessUserInfo(ctx context.Context, client *http.Client, userInfo *social.BasicUserInfo, externalOrgs []string) error { if !s.info.SkipOrgRoleSync { userInfo.OrgRoles = s.orgRoleMapper.MapOrgRoles(s.orgMappingCfg, externalOrgs, userInfo.Role) if s.info.RoleAttributeStrict && len(userInfo.OrgRoles) == 0 { - // If no roles are found and role_attribute_strict is set, return an error. - // The s.info.RoleAttributeStrict is necessary, because there is a case when len(userInfo.OrgRoles) == 0, - // but strict role mapping is not enabled (when getAllOrgs fails). - return nil, errRoleAttributeStrictViolation.Errorf("could not evaluate any valid roles using IdP provided data") + return errRoleAttributeStrictViolation.Errorf("could not evaluate any valid roles using IdP provided data") } } @@ -325,11 +383,11 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client, } if s.canFetchPrivateEmail(userInfo) { - var err error - userInfo.Email, err = s.fetchPrivateEmail(ctx, client) + email, err := s.fetchPrivateEmail(ctx, client) if err != nil { - return nil, err + return err } + userInfo.Email = email s.log.Debug("Setting email from fetched private email", "email", userInfo.Email) } @@ -338,28 +396,32 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client, userInfo.Login = userInfo.Email } + return nil +} + +// validateUserAccess validates user access based on team, organization, and group membership +func (s *SocialGenericOAuth) validateUserAccess(ctx context.Context, client *http.Client, userInfo *social.BasicUserInfo) error { if !s.isTeamMember(ctx, client) { - return nil, &SocialError{"User not a member of one of the required teams"} + return &SocialError{"User not a member of one of the required teams"} } if !s.isOrganizationMember(ctx, client) { - return nil, &SocialError{"User not a member of one of the required organizations"} + return &SocialError{"User not a member of one of the required organizations"} } if !s.isGroupMember(userInfo.Groups) { - return nil, errMissingGroupMembership + return errMissingGroupMembership } - s.log.Debug("User info result", "result", userInfo) - return userInfo, nil + return nil } func (s *SocialGenericOAuth) canFetchPrivateEmail(userinfo *social.BasicUserInfo) bool { return s.info.ApiUrl != "" && userinfo.Email == "" } -func (s *SocialGenericOAuth) extractFromToken(token *oauth2.Token) *UserInfoJson { - s.log.Debug("Extracting user info from OAuth token") +func (s *SocialGenericOAuth) extractFromIDToken(token *oauth2.Token) *UserInfoJson { + s.log.Debug("Extracting user info from OAuth ID token") idTokenAttribute := "id_token" if s.idTokenAttributeName != "" { @@ -373,21 +435,44 @@ func (s *SocialGenericOAuth) extractFromToken(token *oauth2.Token) *UserInfoJson return nil } - rawJSON, err := s.retrieveRawIDToken(idToken) + rawJSON, err := s.retrieveRawJWTPayload(idToken) if err != nil { - s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", token)) + s.log.Warn("Error retrieving id_token payload", "error", err, "token", fmt.Sprintf("%+v", token)) return nil } + return s.parseUserInfoFromJSON(rawJSON, "id_token") +} + +func (s *SocialGenericOAuth) extractFromAccessToken(token *oauth2.Token) *UserInfoJson { + s.log.Debug("Extracting user info from OAuth access token") + + accessToken := token.AccessToken + if accessToken == "" { + s.log.Debug("No access token found") + return nil + } + + rawJSON, err := s.retrieveRawJWTPayload(accessToken) + if err != nil { + s.log.Warn("Error retrieving access token payload", "error", err) + return nil + } + + return s.parseUserInfoFromJSON(rawJSON, "access_token") +} + +// parseUserInfoFromJSON is a helper method to parse UserInfoJson from raw JSON and source +func (s *SocialGenericOAuth) parseUserInfoFromJSON(rawJSON []byte, source string) *UserInfoJson { var data UserInfoJson if err := json.Unmarshal(rawJSON, &data); err != nil { - s.log.Error("Error decoding id_token JSON", "raw_json", string(rawJSON), "error", err) + s.log.Error("Error decoding user info JSON", "raw_json", string(rawJSON), "error", err, "source", source) return nil } data.rawJSON = rawJSON - data.source = "token" - s.log.Debug("Received id_token", "raw_json", string(data.rawJSON), "data", data.String()) + data.source = source + s.log.Debug("Parsed user info from JSON", "raw_json", string(rawJSON), "data", data.String(), "source", source) return &data } @@ -404,18 +489,7 @@ func (s *SocialGenericOAuth) extractFromAPI(ctx context.Context, client *http.Cl return nil } - rawJSON := rawUserInfoResponse.Body - - var data UserInfoJson - if err := json.Unmarshal(rawJSON, &data); err != nil { - s.log.Error("Error decoding user info response", "raw_json", rawJSON, "error", err) - return nil - } - - data.rawJSON = rawJSON - data.source = "API" - s.log.Debug("Received user info response from API", "raw_json", string(rawJSON), "data", data.String()) - return &data + return s.parseUserInfoFromJSON(rawUserInfoResponse.Body, "API") } func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string { diff --git a/pkg/login/social/connectors/generic_oauth_test.go b/pkg/login/social/connectors/generic_oauth_test.go index 881ed9fd541..b4b7469cc05 100644 --- a/pkg/login/social/connectors/generic_oauth_test.go +++ b/pkg/login/social/connectors/generic_oauth_test.go @@ -31,6 +31,7 @@ func TestUserInfoSearchesForEmailAndOrgRoles(t *testing.T) { AllowAssignGrafanaAdmin bool ResponseBody any OAuth2Extra any + AccessToken string Setup func(*orgtest.FakeOrgService) RoleAttributePath string RoleAttributeStrict bool @@ -440,6 +441,62 @@ func TestUserInfoSearchesForEmailAndOrgRoles(t *testing.T) { ExpectedEmail: "john.doe@example.com", ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleViewer}, }, + // Access Token Test Cases + { + Name: "Given a valid access token with role, no ID token, no API response, use access token", + ResponseBody: map[string]any{}, + OAuth2Extra: map[string]any{}, + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiRWRpdG9yIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20ifQ.oVEMSJVqBwrGXOcwGgXL_8J-CZhgFVPjXXSqzPJQ5JU", // { "role": "Editor", "email": "access.token@example.com" } + RoleAttributePath: "role", + ExpectedEmail: "access.token@example.com", + ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleEditor}, + }, + { + Name: "Given a valid access token with org roles, no ID token, no API response, use access token", + ResponseBody: map[string]any{}, + OAuth2Extra: map[string]any{}, + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiVmlld2VyIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20iLCJpbmZvIjp7InJvbGVzIjpbImFjY2Vzcy1kZXYiLCJhY2Nlc3Mtb3BzIl19fQ.g8-mNJQDL9CJWgRTFdKBRRKbsHZfFhJrzPYQGXfxGIE", // { "role": "Viewer", "email": "access.token@example.com", "info": { "roles": [ "access-dev", "access-ops" ] }} + RoleAttributePath: "role", + OrgAttributePath: "info.roles", + OrgMapping: []string{"access-dev:org_dev:Admin", "access-ops:org_engineering:Editor"}, + ExpectedEmail: "access.token@example.com", + ExpectedOrgRoles: map[int64]org.RoleType{4: org.RoleAdmin, 5: org.RoleEditor}, + }, + { + Name: "Given a valid access token and ID token, prefer ID token", + ResponseBody: map[string]any{}, + OAuth2Extra: map[string]any{ + // { "role": "Admin", "email": "id.token@example.com" } + "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiQWRtaW4iLCJlbWFpbCI6ImlkLnRva2VuQGV4YW1wbGUuY29tIn0.T8wcoOOPQ_av9VsOFoYJZGNFGJgG0d3LPDvtxvgODkU", + }, + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiRWRpdG9yIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20ifQ.oVEMSJVqBwrGXOcwGgXL_8J-CZhgFVPjXXSqzPJQ5JU", // { "role": "Editor", "email": "access.token@example.com" } + RoleAttributePath: "role", + ExpectedEmail: "id.token@example.com", + ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin}, + }, + { + Name: "Given a valid access token with no email, ID token with no role, API response with no data, merge", + ResponseBody: map[string]any{}, + OAuth2Extra: map[string]any{ + // { "email": "id.token@example.com" } + "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImlkLnRva2VuQGV4YW1wbGUuY29tIn0.k5GwPcZvGe2BE_jgwN0ntz0nz4KlYhEd0hRRLApkTJ4", + }, + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiRWRpdG9yIn0.gfnKWZKNFNqrILhHFzabBVEWnJJIZBmQSBwLPCHhLUY", // { "role": "Editor" } + RoleAttributePath: "role", + ExpectedEmail: "id.token@example.com", + ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleEditor}, + }, + { + Name: "Given a valid access token with GrafanaAdmin role and AssignGrafanaAdmin enabled", + AllowAssignGrafanaAdmin: true, + ResponseBody: map[string]any{}, + OAuth2Extra: map[string]any{}, + AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiR3JhZmFuYUFkbWluIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20ifQ.fJPjMgZW9bOYXOLgOUekNQmNrVbUNhU1iqQJwqFWzUY", // { "role": "GrafanaAdmin", "email": "access.token@example.com" } + RoleAttributePath: "role", + ExpectedEmail: "access.token@example.com", + ExpectedGrafanaAdmin: trueBoolPtr(), + ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin}, + }, } cfg := &setting.Cfg{ @@ -479,8 +536,9 @@ func TestUserInfoSearchesForEmailAndOrgRoles(t *testing.T) { require.NoError(t, err) })) provider.info.ApiUrl = ts.URL + staticToken := oauth2.Token{ - AccessToken: "", + AccessToken: tc.AccessToken, TokenType: "", RefreshToken: "", Expiry: time.Now(), @@ -853,7 +911,7 @@ func TestPayloadCompression(t *testing.T) { } token := staticToken.WithExtra(test.OAuth2Extra) - userInfo := provider.extractFromToken(token) + userInfo := provider.extractFromIDToken(token) if test.ExpectedEmail == "" { require.Nil(t, userInfo, "Testing case %q", test.Name) diff --git a/pkg/login/social/connectors/gitlab_oauth.go b/pkg/login/social/connectors/gitlab_oauth.go index 917a4503218..2a2d2b7100b 100644 --- a/pkg/login/social/connectors/gitlab_oauth.go +++ b/pkg/login/social/connectors/gitlab_oauth.go @@ -275,7 +275,7 @@ func (s *SocialGitlab) extractFromToken(ctx context.Context, client *http.Client return nil, nil } - rawJSON, err := s.retrieveRawIDToken(idToken) + rawJSON, err := s.retrieveRawJWTPayload(idToken) if err != nil { s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", idToken)) return nil, nil diff --git a/pkg/login/social/connectors/google_oauth.go b/pkg/login/social/connectors/google_oauth.go index 2191a2c01e8..4e5d7a3f8f8 100644 --- a/pkg/login/social/connectors/google_oauth.go +++ b/pkg/login/social/connectors/google_oauth.go @@ -236,7 +236,7 @@ func (s *SocialGoogle) extractFromToken(_ context.Context, _ *http.Client, token return nil, nil } - rawJSON, err := s.retrieveRawIDToken(idToken) + rawJSON, err := s.retrieveRawJWTPayload(idToken) if err != nil { s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", idToken)) return nil, nil diff --git a/pkg/login/social/connectors/social_base.go b/pkg/login/social/connectors/social_base.go index 35bc67004c5..e6bda1c4f81 100644 --- a/pkg/login/social/connectors/social_base.go +++ b/pkg/login/social/connectors/social_base.go @@ -196,21 +196,21 @@ func (s *SocialBase) isGroupMember(groups []string) bool { return false } -func (s *SocialBase) retrieveRawIDToken(idToken any) ([]byte, error) { - tokenString, ok := idToken.(string) +func (s *SocialBase) retrieveRawJWTPayload(token any) ([]byte, error) { + tokenString, ok := token.(string) if !ok { - return nil, fmt.Errorf("id_token is not a string: %v", idToken) + return nil, fmt.Errorf("token is not a string: %v", token) } jwtRegexp := regexp.MustCompile("^([-_a-zA-Z0-9=]+)[.]([-_a-zA-Z0-9=]+)[.]([-_a-zA-Z0-9=]+)$") matched := jwtRegexp.FindStringSubmatch(tokenString) if matched == nil { - return nil, fmt.Errorf("id_token is not in JWT format: %s", tokenString) + return nil, fmt.Errorf("token is not in JWT format: %s", tokenString) } rawJSON, err := base64.RawURLEncoding.DecodeString(matched[2]) if err != nil { - return nil, fmt.Errorf("error base64 decoding id_token: %w", err) + return nil, fmt.Errorf("error base64 decoding token payload: %w", err) } headerBytes, err := base64.RawURLEncoding.DecodeString(matched[1]) From dbbd9f23d103b9e8a532a390b4dcc2d047110d80 Mon Sep 17 00:00:00 2001 From: Matt Cowley Date: Tue, 8 Jul 2025 14:51:30 +0100 Subject: [PATCH 02/21] Plugin Extensions: Expose PluginMeta generic in usePluginContext (#107577) * Plugin Extensions: Expose PluginMeta generic in usePluginContext * Plugin Extensions: Cast usePluginContext type on return * Plugin Extensions: Fix PluginContext export --- .../plugins/DataSourcePluginContextProvider.tsx | 4 ++-- .../src/context/plugins/PluginContext.tsx | 9 +++++---- .../src/context/plugins/PluginContextProvider.tsx | 4 ++-- packages/grafana-data/src/context/plugins/guards.ts | 6 +++++- .../src/context/plugins/usePluginContext.tsx | 11 +++++++---- packages/grafana-data/src/index.ts | 2 +- 6 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/grafana-data/src/context/plugins/DataSourcePluginContextProvider.tsx b/packages/grafana-data/src/context/plugins/DataSourcePluginContextProvider.tsx index 1464f1a6f6c..db5b5a5b610 100644 --- a/packages/grafana-data/src/context/plugins/DataSourcePluginContextProvider.tsx +++ b/packages/grafana-data/src/context/plugins/DataSourcePluginContextProvider.tsx @@ -2,7 +2,7 @@ import { PropsWithChildren, ReactElement, useMemo } from 'react'; import { DataSourceInstanceSettings } from '../../types/datasource'; -import { Context, DataSourcePluginContextType } from './PluginContext'; +import { PluginContext, DataSourcePluginContextType } from './PluginContext'; export type DataSourcePluginContextProviderProps = { instanceSettings: DataSourceInstanceSettings; @@ -16,5 +16,5 @@ export function DataSourcePluginContextProvider( return { instanceSettings, meta: instanceSettings.meta }; }, [instanceSettings]); - return {children}; + return {children}; } diff --git a/packages/grafana-data/src/context/plugins/PluginContext.tsx b/packages/grafana-data/src/context/plugins/PluginContext.tsx index f8748617541..7471109d93d 100644 --- a/packages/grafana-data/src/context/plugins/PluginContext.tsx +++ b/packages/grafana-data/src/context/plugins/PluginContext.tsx @@ -1,14 +1,15 @@ import { createContext } from 'react'; +import { KeyValue } from '../../types/data'; import { DataSourceInstanceSettings } from '../../types/datasource'; import { PluginMeta } from '../../types/plugin'; -export interface PluginContextType { - meta: PluginMeta; +export interface PluginContextType { + meta: PluginMeta; } -export interface DataSourcePluginContextType extends PluginContextType { +export interface DataSourcePluginContextType extends PluginContextType { instanceSettings: DataSourceInstanceSettings; } -export const Context = createContext(undefined); +export const PluginContext = createContext(undefined); diff --git a/packages/grafana-data/src/context/plugins/PluginContextProvider.tsx b/packages/grafana-data/src/context/plugins/PluginContextProvider.tsx index f60381c5aaa..c7698b18a34 100644 --- a/packages/grafana-data/src/context/plugins/PluginContextProvider.tsx +++ b/packages/grafana-data/src/context/plugins/PluginContextProvider.tsx @@ -2,7 +2,7 @@ import { PropsWithChildren, ReactElement } from 'react'; import { PluginMeta } from '../../types/plugin'; -import { Context } from './PluginContext'; +import { PluginContext } from './PluginContext'; export type PluginContextProviderProps = { meta: PluginMeta; @@ -10,5 +10,5 @@ export type PluginContextProviderProps = { export function PluginContextProvider(props: PropsWithChildren): ReactElement { const { children, ...rest } = props; - return {children}; + return {children}; } diff --git a/packages/grafana-data/src/context/plugins/guards.ts b/packages/grafana-data/src/context/plugins/guards.ts index 3a93c862f09..3b89b24dfed 100644 --- a/packages/grafana-data/src/context/plugins/guards.ts +++ b/packages/grafana-data/src/context/plugins/guards.ts @@ -1,5 +1,9 @@ +import { KeyValue } from '../../types/data'; + import { type DataSourcePluginContextType, type PluginContextType } from './PluginContext'; -export function isDataSourcePluginContext(context: PluginContextType): context is DataSourcePluginContextType { +export function isDataSourcePluginContext( + context: PluginContextType +): context is DataSourcePluginContextType { return 'instanceSettings' in context && 'meta' in context; } diff --git a/packages/grafana-data/src/context/plugins/usePluginContext.tsx b/packages/grafana-data/src/context/plugins/usePluginContext.tsx index 58190a7e4e1..b00875909d7 100644 --- a/packages/grafana-data/src/context/plugins/usePluginContext.tsx +++ b/packages/grafana-data/src/context/plugins/usePluginContext.tsx @@ -1,9 +1,11 @@ import { useContext } from 'react'; -import { Context, PluginContextType } from './PluginContext'; +import { KeyValue } from '../../types/data'; -export function usePluginContext(): PluginContextType | null { - const context = useContext(Context); +import { PluginContext, PluginContextType } from './PluginContext'; + +export function usePluginContext(): PluginContextType | null { + const context = useContext(PluginContext); // The extensions hooks (e.g. `usePluginLinks()`) are using this hook to check // if they are inside a plugin or not (core Grafana), so we should be able to return an empty state as well (`null`). @@ -11,5 +13,6 @@ export function usePluginContext(): PluginContextType | null { return null; } - return context; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return context as PluginContextType; } diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 80351af8bc2..bc9e4d245db 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -435,7 +435,7 @@ export { type GroupingToMatrixTransformerOptions } from './transformations/trans export { type PluginContextType, type DataSourcePluginContextType, - Context as PluginContext, + PluginContext, } from './context/plugins/PluginContext'; export { type PluginContextProviderProps, PluginContextProvider } from './context/plugins/PluginContextProvider'; export { From e920bd2d292364657c10f274b9890305124b2b55 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 13:54:06 +0000 Subject: [PATCH 03/21] Update dependency autoprefixer to v10.4.21 (#107781) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 46 +++++++++++++++++++++++----------------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index c9d80186f8f..86072a52f15 100644 --- a/package.json +++ b/package.json @@ -164,7 +164,7 @@ "@types/yargs": "17.0.33", "@typescript-eslint/eslint-plugin": "8.35.1", "@typescript-eslint/parser": "8.35.1", - "autoprefixer": "10.4.20", + "autoprefixer": "10.4.21", "babel-loader": "9.2.1", "blob-polyfill": "9.0.20240710", "browserslist": "^4.21.4", diff --git a/yarn.lock b/yarn.lock index 4039baa5714..c67d3321930 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11726,21 +11726,21 @@ __metadata: languageName: node linkType: hard -"autoprefixer@npm:10.4.20": - version: 10.4.20 - resolution: "autoprefixer@npm:10.4.20" +"autoprefixer@npm:10.4.21": + version: 10.4.21 + resolution: "autoprefixer@npm:10.4.21" dependencies: - browserslist: "npm:^4.23.3" - caniuse-lite: "npm:^1.0.30001646" + browserslist: "npm:^4.24.4" + caniuse-lite: "npm:^1.0.30001702" fraction.js: "npm:^4.3.7" normalize-range: "npm:^0.1.2" - picocolors: "npm:^1.0.1" + picocolors: "npm:^1.1.1" postcss-value-parser: "npm:^4.2.0" peerDependencies: postcss: ^8.1.0 bin: autoprefixer: bin/autoprefixer - checksum: 10/d3c4b562fc4af2393623a0207cc336f5b9f94c4264ae1c316376904c279702ce2b12dc3f27205f491195d1e29bb52ffc269970ceb0f271f035fadee128a273f7 + checksum: 10/5d7aeee78ef362a6838e12312908516a8ac5364414175273e5cff83bbff67612755b93d567f3aa01ce318342df48aeab4b291847b5800c780e58c458f61a98a6 languageName: node linkType: hard @@ -12287,17 +12287,17 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.0.0, browserslist@npm:^4.21.10, browserslist@npm:^4.21.4, browserslist@npm:^4.23.3, browserslist@npm:^4.24.0, browserslist@npm:^4.24.3": - version: 4.25.0 - resolution: "browserslist@npm:4.25.0" +"browserslist@npm:^4.0.0, browserslist@npm:^4.21.10, browserslist@npm:^4.21.4, browserslist@npm:^4.23.3, browserslist@npm:^4.24.0, browserslist@npm:^4.24.3, browserslist@npm:^4.24.4": + version: 4.25.1 + resolution: "browserslist@npm:4.25.1" dependencies: - caniuse-lite: "npm:^1.0.30001718" - electron-to-chromium: "npm:^1.5.160" + caniuse-lite: "npm:^1.0.30001726" + electron-to-chromium: "npm:^1.5.173" node-releases: "npm:^2.0.19" update-browserslist-db: "npm:^1.1.3" bin: browserslist: cli.js - checksum: 10/4a5442b1a0d09c4c64454f184b8fed17d8c3e202034bf39de28f74497d7bd28dddee121b2bab4e34825fe0ed4c166d84e32a39f576c76fce73c1f8f05e4b6ee6 + checksum: 10/bfb5511b425886279bbe2ea44d10e340c8aea85866c9d45083c13491d049b6362e254018c0afbf56d41ceeb64f994957ea8ae98dbba74ef1e54ef901c8732987 languageName: node linkType: hard @@ -12567,10 +12567,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001646, caniuse-lite@npm:^1.0.30001718": - version: 1.0.30001723 - resolution: "caniuse-lite@npm:1.0.30001723" - checksum: 10/edab89e84a2b257cf640f0bac1f25f92c699ade86143b2affc73403468f894023416a9f4a99e5345c933956990b005a2facfb87ac4517c8ccb588819bb62453b +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001702, caniuse-lite@npm:^1.0.30001726": + version: 1.0.30001727 + resolution: "caniuse-lite@npm:1.0.30001727" + checksum: 10/6155a4141332c337d6317325bea58a09036a65f45bd9bd834ec38978b40c27d214baa04d25b21a5661664f3fbd00cb830e2bdb7eee8df09970bdd98a71f4dabf languageName: node linkType: hard @@ -15384,10 +15384,10 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.160": - version: 1.5.167 - resolution: "electron-to-chromium@npm:1.5.167" - checksum: 10/078093a38e7295e575f381943f62914f49b53dd73506af2ce3e59332835c42b487ad02ff1207dfdcb33a5886d74a98e352c04431c0537366d9999a79c7d15c94 +"electron-to-chromium@npm:^1.5.173": + version: 1.5.180 + resolution: "electron-to-chromium@npm:1.5.180" + checksum: 10/8d7f68650427f6bcb107ee1dcbe18f68b5c582601653095bec653fa898bd8427be4b1836581ce74405dca7cb36ebfc265a85c186e750b4de6e6f3ac4cfeef71b languageName: node linkType: hard @@ -18238,7 +18238,7 @@ __metadata: "@visx/tooltip": "npm:3.12.0" "@welldone-software/why-did-you-render": "npm:8.0.3" ansicolor: "npm:2.0.3" - autoprefixer: "npm:10.4.20" + autoprefixer: "npm:10.4.21" babel-loader: "npm:9.2.1" baron: "npm:3.0.3" blob-polyfill: "npm:9.0.20240710" @@ -25105,7 +25105,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.1": +"picocolors@npm:^1.0.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10/e1cf46bf84886c79055fdfa9dcb3e4711ad259949e3565154b004b260cd356c5d54b31a1437ce9782624bf766272fe6b0154f5f0c744fb7af5d454d2b60db045 From 48309693058b4eab3dba68801c09bbaa6a458cf3 Mon Sep 17 00:00:00 2001 From: Alyssa Joyner <58453566+alyssajoyner@users.noreply.github.com> Date: Tue, 8 Jul 2025 08:14:47 -0600 Subject: [PATCH 04/21] Add influx DRBP mapping (#107744) --- .../editor/config-v2/UrlAndAuthenticationSection.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.tsx index 7b0ff3fcd69..cb3e632cba8 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.tsx @@ -41,7 +41,13 @@ export const UrlAndAuthenticationSection = (props: Props) => { const requiresDrbpMapping = options.jsonData.product && options.jsonData.version === InfluxVersion.InfluxQL && - ['InfluxDB OSS 1.x', 'InfluxDB OSS 2.x'].includes(options.jsonData.product); + [ + 'InfluxDB OSS 1.x', + 'InfluxDB OSS 2.x', + 'InfluxDB Enterprise 1.x', + 'InfluxDB Cloud (TSM)', + 'InfluxDB Cloud Serverless', + ].includes(options.jsonData.product); const onProductChange = ({ value }: ComboboxOption) => { trackInfluxDBConfigV2ProductSelected({ product: value }); From 0f239881326a60d51d3bc03d641b78c775121441 Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 8 Jul 2025 11:19:39 -0300 Subject: [PATCH 05/21] JWT.Authenticate: remove err != nil check for error that has already been checked (#107727) --- pkg/services/authn/clients/jwt.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go index 82323e21bfe..93036b87915 100644 --- a/pkg/services/authn/clients/jwt.go +++ b/pkg/services/authn/clients/jwt.go @@ -114,9 +114,6 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi if !s.cfg.JWTAuth.SkipOrgRoleSync { role, grafanaAdmin := s.extractRoleAndAdmin(claims) - if err != nil { - s.log.Warn("Failed to extract role", "err", err) - } if s.cfg.JWTAuth.AllowAssignGrafanaAdmin { id.IsGrafanaAdmin = &grafanaAdmin From 53537148e1bd3113cb3bd2c7e710430d5c1b634f Mon Sep 17 00:00:00 2001 From: maicon Date: Tue, 8 Jul 2025 11:26:45 -0300 Subject: [PATCH 06/21] Folders: reenable unit tests (#107751) * Folders: reenable unit test TestFoldersCreateAPIEndpointK8S Signed-off-by: Maicon Costa * reenable unit test TestFoldersGetAPIEndpointK8S Signed-off-by: Maicon Costa --------- Signed-off-by: Maicon Costa --- pkg/tests/apis/folder/folders_test.go | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index 35722cbc964..7bd8fda8e61 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -862,10 +862,6 @@ func TestIntegrationFolderGetPermissions(t *testing.T) { // TestFoldersCreateAPIEndpointK8S is the counterpart of pkg/api/folder_test.go TestFoldersCreateAPIEndpoint func TestFoldersCreateAPIEndpointK8S(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - folderWithoutParentInput := "{ \"uid\": \"uid\", \"title\": \"Folder\"}" folderWithTitleEmpty := "{ \"title\": \"\"}" folderWithInvalidUid := "{ \"uid\": \"::::::::::::\", \"title\": \"Another folder\"}" @@ -902,7 +898,7 @@ func TestFoldersCreateAPIEndpointK8S(t *testing.T) { description: "folder creation fails without permissions to create a folder", input: folderWithoutParentInput, expectedCode: http.StatusForbidden, - expectedMessage: dashboards.ErrFolderAccessDenied.Error(), + expectedMessage: fmt.Sprintf("You'll need additional permissions to perform this action. Permissions needed: %s", "folders:create"), permissions: []resourcepermissions.SetResourcePermissionCommand{}, }, { @@ -1023,10 +1019,6 @@ func testDescription(description string, expectedErr error) string { // There are no counterpart of TestFoldersGetAPIEndpointK8S in pkg/api/folder_test.go func TestFoldersGetAPIEndpointK8S(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - type testCase struct { description string expectedCode int @@ -1062,6 +1054,7 @@ func TestFoldersGetAPIEndpointK8S(t *testing.T) { expectedOutput: []dtos.FolderSearchHit{ {UID: "foo", Title: "Folder 1"}, {UID: "qux", Title: "Folder 3"}, + {UID: folder.SharedWithMeFolder.UID, Title: folder.SharedWithMeFolder.Title}, }, permissions: folderReadAndCreatePermission, }, @@ -1107,7 +1100,7 @@ func TestFoldersGetAPIEndpointK8S(t *testing.T) { } // test on all dualwriter modes - for mode := 1; mode <= 4; mode++ { + for mode := 0; mode <= 4; mode++ { for _, tc := range tcs { t.Run(fmt.Sprintf("Mode: %d, %s", mode, tc.description), func(t *testing.T) { modeDw := grafanarest.DualWriterMode(mode) @@ -1123,6 +1116,7 @@ func TestFoldersGetAPIEndpointK8S(t *testing.T) { }, EnableFeatureToggles: []string{ featuremgmt.FlagNestedFolders, + featuremgmt.FlagUnifiedStorageSearch, featuremgmt.FlagKubernetesClientDashboardsFolders, }, }) From 7728a5197291a4fb929b38daf8b97f8ea94e0caf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 14:28:26 +0000 Subject: [PATCH 07/21] Update dependency chance to v1.1.13 (#107785) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index c67d3321930..09dd1064a08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8902,13 +8902,20 @@ __metadata: languageName: node linkType: hard -"@types/chance@npm:1.1.6, @types/chance@npm:^1.1.3": +"@types/chance@npm:1.1.6": version: 1.1.6 resolution: "@types/chance@npm:1.1.6" checksum: 10/f4366f1b3144d143af3e6f0fad2ed1db7b9bdfa7d82d40944e9619d57fe7e6b60e8c1452f47a8ededa6b2188932879518628ecd9aac81c40384ded39c26338ba languageName: node linkType: hard +"@types/chance@npm:^1.1.3": + version: 1.1.7 + resolution: "@types/chance@npm:1.1.7" + checksum: 10/5b3bf4ef0b7a2f6554f7767d7f081b4f613b45e74e74450d4da9c10cd12162420fc6ff5bb0abea143d6f7c43b62776b7a0e75d242cbe25c729fe020a1735e16f + languageName: node + linkType: hard + "@types/common-tags@npm:^1.8.0": version: 1.8.4 resolution: "@types/common-tags@npm:1.8.4" @@ -12677,13 +12684,20 @@ __metadata: languageName: node linkType: hard -"chance@npm:1.1.12, chance@npm:^1.0.10": +"chance@npm:1.1.12": version: 1.1.12 resolution: "chance@npm:1.1.12" checksum: 10/8700d5a66e27b47f4bdcf68f48489e1a490f39cd8bc8e39cac67c089b3f8d04b4bbc6710db0e85e802ad698cf3440d92784861208d2c248820355404b85b3f30 languageName: node linkType: hard +"chance@npm:^1.0.10": + version: 1.1.13 + resolution: "chance@npm:1.1.13" + checksum: 10/968e31ce9b8ce554c8a84fb66d85f6d06a33517bf21355ab760e347a0aaaece0652a23c791406826d4efe619b1aa4e7b66fb42acbd07859ba36ec2498a167b8c + languageName: node + linkType: hard + "change-case@npm:^4.1.2": version: 4.1.2 resolution: "change-case@npm:4.1.2" From 56dcb6c08ac8611bb5609cf38960e12539393b22 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 15:13:54 +0000 Subject: [PATCH 08/21] Update dependency chance to v1.1.13 (#107790) * Update dependency chance to v1.1.13 * use same version of chance everywhere --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: joshhunt --- package.json | 4 ++-- packages/grafana-ui/package.json | 4 ++-- yarn.lock | 26 ++++++-------------------- 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 86072a52f15..536171776a7 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "@testing-library/user-event": "14.6.1", "@types/babel__core": "^7", "@types/babel__preset-env": "^7", - "@types/chance": "^1.1.3", + "@types/chance": "^1.1.7", "@types/common-tags": "^1.8.0", "@types/confusing-browser-globals": "^1", "@types/d3": "7.4.3", @@ -168,7 +168,7 @@ "babel-loader": "9.2.1", "blob-polyfill": "9.0.20240710", "browserslist": "^4.21.4", - "chance": "^1.0.10", + "chance": "^1.1.13", "chrome-remote-interface": "0.33.2", "codeowners": "^5.1.1", "confusing-browser-globals": "^1.0.11", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index f0e1b8692d1..e26cf46e5be 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -156,7 +156,7 @@ "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.2.0", "@testing-library/user-event": "14.6.1", - "@types/chance": "1.1.6", + "@types/chance": "^1.1.7", "@types/common-tags": "^1.8.0", "@types/d3": "7.4.3", "@types/hoist-non-react-statics": "3.3.6", @@ -176,7 +176,7 @@ "@types/slate-react": "0.22.9", "@types/tinycolor2": "1.4.6", "@types/uuid": "10.0.0", - "chance": "1.1.12", + "chance": "^1.1.13", "common-tags": "1.8.2", "core-js": "3.40.0", "css-loader": "7.1.2", diff --git a/yarn.lock b/yarn.lock index 09dd1064a08..acda0ef1cff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3714,7 +3714,7 @@ __metadata: "@testing-library/jest-dom": "npm:6.6.3" "@testing-library/react": "npm:16.2.0" "@testing-library/user-event": "npm:14.6.1" - "@types/chance": "npm:1.1.6" + "@types/chance": "npm:^1.1.7" "@types/common-tags": "npm:^1.8.0" "@types/d3": "npm:7.4.3" "@types/hoist-non-react-statics": "npm:3.3.6" @@ -3738,7 +3738,7 @@ __metadata: "@types/tinycolor2": "npm:1.4.6" "@types/uuid": "npm:10.0.0" calculate-size: "npm:1.1.1" - chance: "npm:1.1.12" + chance: "npm:^1.1.13" classnames: "npm:2.5.1" common-tags: "npm:1.8.2" core-js: "npm:3.40.0" @@ -8902,14 +8902,7 @@ __metadata: languageName: node linkType: hard -"@types/chance@npm:1.1.6": - version: 1.1.6 - resolution: "@types/chance@npm:1.1.6" - checksum: 10/f4366f1b3144d143af3e6f0fad2ed1db7b9bdfa7d82d40944e9619d57fe7e6b60e8c1452f47a8ededa6b2188932879518628ecd9aac81c40384ded39c26338ba - languageName: node - linkType: hard - -"@types/chance@npm:^1.1.3": +"@types/chance@npm:^1.1.7": version: 1.1.7 resolution: "@types/chance@npm:1.1.7" checksum: 10/5b3bf4ef0b7a2f6554f7767d7f081b4f613b45e74e74450d4da9c10cd12162420fc6ff5bb0abea143d6f7c43b62776b7a0e75d242cbe25c729fe020a1735e16f @@ -12684,14 +12677,7 @@ __metadata: languageName: node linkType: hard -"chance@npm:1.1.12": - version: 1.1.12 - resolution: "chance@npm:1.1.12" - checksum: 10/8700d5a66e27b47f4bdcf68f48489e1a490f39cd8bc8e39cac67c089b3f8d04b4bbc6710db0e85e802ad698cf3440d92784861208d2c248820355404b85b3f30 - languageName: node - linkType: hard - -"chance@npm:^1.0.10": +"chance@npm:^1.1.13": version: 1.1.13 resolution: "chance@npm:1.1.13" checksum: 10/968e31ce9b8ce554c8a84fb66d85f6d06a33517bf21355ab760e347a0aaaece0652a23c791406826d4efe619b1aa4e7b66fb42acbd07859ba36ec2498a167b8c @@ -18192,7 +18178,7 @@ __metadata: "@testing-library/user-event": "npm:14.6.1" "@types/babel__core": "npm:^7" "@types/babel__preset-env": "npm:^7" - "@types/chance": "npm:^1.1.3" + "@types/chance": "npm:^1.1.7" "@types/common-tags": "npm:^1.8.0" "@types/confusing-browser-globals": "npm:^1" "@types/d3": "npm:7.4.3" @@ -18259,7 +18245,7 @@ __metadata: brace: "npm:0.11.1" browserslist: "npm:^4.21.4" centrifuge: "npm:5.3.5" - chance: "npm:^1.0.10" + chance: "npm:^1.1.13" chrome-remote-interface: "npm:0.33.2" classnames: "npm:2.5.1" codeowners: "npm:^5.1.1" From 0459382b25bc70a70d2bae62f3263ff883cee452 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 15:54:26 +0000 Subject: [PATCH 09/21] Update dependency chrome-remote-interface to v0.33.3 (#107792) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 536171776a7..334ba022b4d 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,7 @@ "blob-polyfill": "9.0.20240710", "browserslist": "^4.21.4", "chance": "^1.1.13", - "chrome-remote-interface": "0.33.2", + "chrome-remote-interface": "0.33.3", "codeowners": "^5.1.1", "confusing-browser-globals": "^1.0.11", "copy-webpack-plugin": "12.0.2", diff --git a/yarn.lock b/yarn.lock index acda0ef1cff..723f3f8865b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12849,15 +12849,15 @@ __metadata: languageName: node linkType: hard -"chrome-remote-interface@npm:0.33.2": - version: 0.33.2 - resolution: "chrome-remote-interface@npm:0.33.2" +"chrome-remote-interface@npm:0.33.3": + version: 0.33.3 + resolution: "chrome-remote-interface@npm:0.33.3" dependencies: commander: "npm:2.11.x" ws: "npm:^7.2.0" bin: chrome-remote-interface: bin/client.js - checksum: 10/fa82c76c5af629f5fbccb22c383604650f8679571385b8610783fde6f006b899c48e1522be551fd9670de59c1a2d93beb152a55651f24ba9c91ca0f8df3f581c + checksum: 10/65d07afc8f97fad6326bd94f0c4ef004d76b16841015c72d09aefbfa4df62b47407067a01c886535a706c36b23070b40fc3edb96444665438efa1d4f65cc3db8 languageName: node linkType: hard @@ -18246,7 +18246,7 @@ __metadata: browserslist: "npm:^4.21.4" centrifuge: "npm:5.3.5" chance: "npm:^1.1.13" - chrome-remote-interface: "npm:0.33.2" + chrome-remote-interface: "npm:0.33.3" classnames: "npm:2.5.1" codeowners: "npm:^5.1.1" combokeys: "npm:^3.0.0" From 0fdcae4e264bd9bba70024d7505ee46eacd0f255 Mon Sep 17 00:00:00 2001 From: Tim Levett Date: Tue, 8 Jul 2025 10:56:39 -0500 Subject: [PATCH 10/21] Tables: Pills for Table Cells (#107485) * v2 of pills for tables * cleanup bettererrrrr * cleanup pretty * i18n changes * add in the option for value mapping * value mapping * change to just use the value mapping from the table component * tests fixed * betterer all better now * fix pretty * i18n * fix gen issue * i18n * fix merge issue * Refactor pillcell to an interface for said pill, cleanup tests * mind the space says prettier --- .../grafana-schema/src/common/common.gen.ts | 67 +++--- packages/grafana-schema/src/common/table.cue | 10 +- .../Table/TableNG/Cells/PillCell.test.tsx | 219 ++++++++++++++++++ .../Table/TableNG/Cells/PillCell.tsx | 185 +++++++++++++++ .../Table/TableNG/Cells/renderers.tsx | 4 + .../grafana-ui/src/components/Table/utils.ts | 2 + .../table/table-new/TableCellOptionEditor.tsx | 5 + .../table-new/cells/PillCellOptionsEditor.tsx | 66 ++++++ public/locales/en-US/grafana.json | 8 + 9 files changed, 534 insertions(+), 32 deletions(-) create mode 100644 packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx create mode 100644 packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx create mode 100644 public/app/plugins/panel/table/table-new/cells/PillCellOptionsEditor.tsx diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index 4c1fd34f48e..ff937656095 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -708,6 +708,7 @@ export enum TableCellDisplayMode { Image = 'image', JSONView = 'json-view', LcdGauge = 'lcd-gauge', + Pill = 'pill', Sparkline = 'sparkline', } @@ -838,7 +839,38 @@ export enum TableCellHeight { * Table cell options. Each cell has a display mode * and other potential options for that display. */ -export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions); +export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions); + +/** + * Field options for each field within a table (e.g 10, "The String", 64.20, etc.) + * Generally defines alignment, filtering capabilties, display options, etc. + */ +export interface TableFieldOptions { + align: FieldTextAlignment; + cellOptions: TableCellOptions; + /** + * This field is deprecated in favor of using cellOptions + */ + displayMode?: TableCellDisplayMode; + filterable?: boolean; + hidden?: boolean; // ?? default is missing or false ?? + /** + * Hides any header for a column, useful for columns that show some static content or buttons. + */ + hideHeader?: boolean; + inspect: boolean; + minWidth?: number; + width?: number; + /** + * Enables text wrapping for column headers + */ + wrapHeaderText?: boolean; +} + +export const defaultTableFieldOptions: Partial = { + align: 'auto', + inspect: false, +}; /** * Use UTC/GMT timezone @@ -944,37 +976,12 @@ export enum ComparisonOperation { NEQ = 'neq', } -/** - * Field options for each field within a table (e.g 10, "The String", 64.20, etc.) - * Generally defines alignment, filtering capabilties, display options, etc. - */ -export interface TableFieldOptions { - align: FieldTextAlignment; - cellOptions: TableCellOptions; - /** - * This field is deprecated in favor of using cellOptions - */ - displayMode?: TableCellDisplayMode; - filterable?: boolean; - hidden?: boolean; // ?? default is missing or false ?? - /** - * Hides any header for a column, useful for columns that show some static content or buttons. - */ - hideHeader?: boolean; - inspect: boolean; - minWidth?: number; - width?: number; - /** - * Enables text wrapping for column headers - */ - wrapHeaderText?: boolean; +export interface TablePillCellOptions { + color?: string; + colorMode?: ('auto' | 'fixed' | 'mapped'); + type: TableCellDisplayMode.Pill; } -export const defaultTableFieldOptions: Partial = { - align: 'auto', - inspect: false, -}; - /** * A specific timezone from https://en.wikipedia.org/wiki/Tz_database */ diff --git a/packages/grafana-schema/src/common/table.cue b/packages/grafana-schema/src/common/table.cue index ca61617aedb..0858150ae4c 100644 --- a/packages/grafana-schema/src/common/table.cue +++ b/packages/grafana-schema/src/common/table.cue @@ -4,7 +4,7 @@ package common // in the table such as colored text, JSON, gauge, etc. // The color-background-solid, gradient-gauge, and lcd-gauge // modes are deprecated in favor of new cell subOptions -TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" | "sparkline" | "data-links" | "custom" | "actions" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline|DataLinks|Custom|Actions") +TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" | "sparkline" | "data-links" | "custom" | "actions" | "pill" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline|DataLinks|Custom|Actions|Pill") // Display mode to the "Colored Background" display // mode for table cells. Either displays a solid color (basic mode) @@ -89,7 +89,7 @@ TableCellHeight: "sm" | "md" | "lg" | "auto" @cuetsy(kind="enum") // Table cell options. Each cell has a display mode // and other potential options for that display. -TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions @cuetsy(kind="type") +TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions @cuetsy(kind="type") // Field options for each field within a table (e.g 10, "The String", 64.20, etc.) // Generally defines alignment, filtering capabilties, display options, etc. @@ -109,3 +109,9 @@ TableFieldOptions: { wrapHeaderText?: bool } @cuetsy(kind="interface") +TablePillCellOptions: { + type: TableCellDisplayMode & "pill" + color?: string + colorMode?: "auto" | "fixed" | "mapped" +} @cuetsy(kind="interface") + diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx new file mode 100644 index 00000000000..a216a9221d1 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.test.tsx @@ -0,0 +1,219 @@ +import { render, screen } from '@testing-library/react'; + +import { DataFrame, Field, FieldType, GrafanaTheme2, MappingType, createTheme } from '@grafana/data'; +import { TableCellDisplayMode, TablePillCellOptions } from '@grafana/schema'; + +import { mockThemeContext } from '../../../../themes/ThemeContext'; + +import { PillCell, inferPills } from './PillCell'; + +describe('PillCell', () => { + let restoreThemeContext: () => void; + + beforeEach(() => { + restoreThemeContext = mockThemeContext(createTheme()); + }); + + afterEach(() => { + restoreThemeContext(); + }); + + const mockCellOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'auto', + }; + + const mockField: Field = { + name: 'test', + type: FieldType.string, + values: [], + config: {}, + }; + + const mockFrame: DataFrame = { + name: 'test', + fields: [mockField], + length: 1, + }; + + const defaultProps = { + value: 'test-value', + field: mockField, + justifyContent: 'flex-start' as const, + cellOptions: mockCellOptions, + rowIdx: 0, + frame: mockFrame, + height: 30, + width: 100, + theme: {} as GrafanaTheme2, + cellInspect: false, + showFilters: false, + }; + + describe('pill parsing', () => { + it('should render pills for single values', () => { + render(); + expect(screen.getByText('test-value')).toBeInTheDocument(); + }); + + it('should render pills for CSV values', () => { + render(); + expect(screen.getByText('value1')).toBeInTheDocument(); + expect(screen.getByText('value2')).toBeInTheDocument(); + expect(screen.getByText('value3')).toBeInTheDocument(); + }); + + it('should render pills for JSON array values', () => { + render(); + expect(screen.getByText('item1')).toBeInTheDocument(); + expect(screen.getByText('item2')).toBeInTheDocument(); + expect(screen.getByText('item3')).toBeInTheDocument(); + }); + + it('should show dash for empty values', () => { + render(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); + + it('should show dash for null values', () => { + render(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); + }); + + describe('color mapping', () => { + // These tests primarily ensure the color logic executes without throwing. + // For true color verification, visual regression tests would be needed. + + it('should use mapped colors when colorMode is mapped', () => { + const mappedOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'mapped', + }; + + render(); + + const successPill = screen.getByText('success'); + const errorPill = screen.getByText('error'); + const warningPill = screen.getByText('warning'); + const unknownPill = screen.getByText('unknown'); + + expect(successPill).toBeInTheDocument(); + expect(errorPill).toBeInTheDocument(); + expect(warningPill).toBeInTheDocument(); + expect(unknownPill).toBeInTheDocument(); + }); + + it('should use field-level value mappings when available', () => { + const mappedOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'mapped', + }; + + // Mock field with value mappings + const fieldWithMappings: Field = { + ...mockField, + config: { + ...mockField.config, + mappings: [ + { + type: MappingType.ValueToText, + options: { + success: { color: '#00FF00' }, + error: { color: '#FF0000' }, + warning: { color: '#FFFF00' }, + }, + }, + ], + }, + display: (value: unknown) => ({ + text: String(value), + color: + String(value) === 'success' + ? '#00FF00' + : String(value) === 'error' + ? '#FF0000' + : String(value) === 'warning' + ? '#FFFF00' + : '#FF780A', + numeric: 0, + }), + }; + + render( + + ); + + const successPill = screen.getByText('success'); + const errorPill = screen.getByText('error'); + const warningPill = screen.getByText('warning'); + const unknownPill = screen.getByText('unknown'); + + expect(successPill).toBeInTheDocument(); + expect(errorPill).toBeInTheDocument(); + expect(warningPill).toBeInTheDocument(); + expect(unknownPill).toBeInTheDocument(); + }); + + it('should use fixed color when colorMode is fixed', () => { + const fixedOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'fixed', + color: '#FF00FF', + }; + + render(); + expect(screen.getByText('test-value')).toBeInTheDocument(); + }); + + it('should use auto color when colorMode is auto', () => { + const autoOptions: TablePillCellOptions = { + type: TableCellDisplayMode.Pill, + colorMode: 'auto', + }; + + render(); + expect(screen.getByText('test-value')).toBeInTheDocument(); + }); + }); +}); + +describe('inferPills', () => { + // These tests verify the pill parsing logic handles various input formats correctly. + // They ensure the function can extract pill values from different data structures. + + it('should return empty array for null/undefined values', () => { + expect(inferPills(null)).toEqual([]); + expect(inferPills(undefined)).toEqual([]); + expect(inferPills('')).toEqual([]); + }); + + it('should parse single values', () => { + expect(inferPills('test')).toEqual(['test']); + expect(inferPills('"quoted"')).toEqual(['quoted']); + expect(inferPills("'quoted'")).toEqual(['quoted']); + }); + + it('should parse CSV strings', () => { + expect(inferPills('value1,value2,value3')).toEqual(['value1', 'value2', 'value3']); + expect(inferPills(' value1 , value2 , value3 ')).toEqual(['value1', 'value2', 'value3']); + expect(inferPills('value1, ,value3')).toEqual(['value1', 'value3']); + }); + + it('should parse JSON arrays', () => { + expect(inferPills('["item1","item2","item3"]')).toEqual(['item1', 'item2', 'item3']); + expect(inferPills('["item1", "item2", "item3"]')).toEqual(['item1', 'item2', 'item3']); + expect(inferPills('["item1", null, "item3"]')).toEqual(['item1', 'item3']); + }); + + it('should handle mixed content', () => { + // When JSON parsing fails, it falls back to CSV parsing + expect(inferPills('["item1", "item2"],extra')).toEqual(['["item1"', '"item2"]', 'extra']); + expect(inferPills('not-json,value')).toEqual(['not-json', 'value']); + }); +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx new file mode 100644 index 00000000000..aaa103c3dc7 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx @@ -0,0 +1,185 @@ +import { css } from '@emotion/css'; +import { Property } from 'csstype'; +import { useMemo } from 'react'; + +import { GrafanaTheme2, isDataFrame, classicColors, colorManipulator, Field } from '@grafana/data'; +import { TablePillCellOptions } from '@grafana/schema'; + +import { useStyles2 } from '../../../../themes/ThemeContext'; +import { TableCellRendererProps } from '../types'; + +const DEFAULT_PILL_BG_COLOR = '#FF780A'; + +interface Pill { + value: string; + key: string; + bgColor: string; + color: string; +} + +function createPills(pillValues: string[], cellOptions: TableCellRendererProps['cellOptions'], field: Field): Pill[] { + return pillValues.map((pill, index) => { + const bgColor = getPillColor(pill, cellOptions, field); + const textColor = colorManipulator.getContrastRatio('#FFFFFF', bgColor) >= 4.5 ? '#FFFFFF' : '#000000'; + return { + value: pill, + key: `${pill}-${index}`, + bgColor, + color: textColor, + }; + }); +} + +export function PillCell({ value, field, justifyContent, cellOptions }: TableCellRendererProps) { + const styles = useStyles2(getStyles, justifyContent); + + const pills: Pill[] = useMemo(() => { + const pillValues = inferPills(value); + return createPills(pillValues, cellOptions, field); + }, [value, cellOptions, field]); + + if (pills.length === 0) { + return
-
; + } + + return ( +
+
+ {pills.map((pill) => ( + + {pill.value} + + ))} +
+
+ ); +} + +export function inferPills(value: unknown): string[] { + if (!value) { + return []; + } + + // Handle DataFrame - not supported for pills + if (isDataFrame(value)) { + return []; + } + + // Handle different value types + const stringValue = String(value); + + // Try to parse as JSON first + try { + const parsed = JSON.parse(stringValue); + if (Array.isArray(parsed)) { + // JSON array of strings + return parsed + .filter((item) => item != null && item !== '') + .map(String) + .map((text) => text.trim()) + .filter((item) => item !== ''); + } + } catch { + // Not valid JSON, continue with other parsing + } + + // Handle CSV string + if (stringValue.includes(',')) { + return stringValue + .split(',') + .map((text) => text.trim()) + .filter((item) => item !== ''); + } + + // Single value - strip quotes + return [stringValue.replace(/["'`]/g, '').trim()]; +} + +function isPillCellOptions(cellOptions: TableCellRendererProps['cellOptions']): cellOptions is TablePillCellOptions { + return cellOptions?.type === 'pill'; +} + +function getPillColor(pill: string, cellOptions: TableCellRendererProps['cellOptions'], field: Field): string { + if (!isPillCellOptions(cellOptions)) { + return getDeterministicColor(pill); + } + + const colorMode = cellOptions.colorMode || 'auto'; + + // Fixed color mode (highest priority) + if (colorMode === 'fixed' && cellOptions.color) { + return cellOptions.color; + } + + // Mapped color mode - use field's value mappings + if (colorMode === 'mapped') { + // Check if field has value mappings + if (field.config.mappings && field.config.mappings.length > 0) { + // Use the field's display processor to get the mapped value + const displayValue = field.display!(pill); + if (displayValue.color) { + return displayValue.color; + } + } + // Fallback to default color for unmapped values + return cellOptions.color || DEFAULT_PILL_BG_COLOR; + } + + // Auto mode - deterministic color assignment based on string hash + if (colorMode === 'auto') { + return getDeterministicColor(pill); + } + + // Default color for unknown values or fallback + return DEFAULT_PILL_BG_COLOR; +} + +function getDeterministicColor(text: string): string { + // Create a simple hash of the string to get consistent colors + let hash = 0; + for (let i = 0; i < text.length; i++) { + const char = text.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + + // Use absolute value and modulo to get a consistent index + const colorValues = Object.values(classicColors); + const index = Math.abs(hash) % colorValues.length; + + return colorValues[index]; +} + +const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent | undefined) => ({ + cell: css({ + display: 'flex', + justifyContent: justifyContent || 'flex-start', + alignItems: 'center', + height: '100%', + padding: theme.spacing(0.5), + }), + pillsContainer: css({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(0.5), + maxWidth: '100%', + }), + pill: css({ + display: 'inline-block', + padding: theme.spacing(0.25, 0.75), + borderRadius: theme.shape.radius.default, + fontSize: theme.typography.bodySmall.fontSize, + lineHeight: theme.typography.bodySmall.lineHeight, + fontWeight: theme.typography.fontWeightMedium, + whiteSpace: 'nowrap', + textAlign: 'center', + minWidth: 'fit-content', + }), +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx index 7bb4b39f2cb..92cb1a5e36e 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx @@ -12,6 +12,7 @@ import { DataLinksCell } from './DataLinksCell'; import { GeoCell } from './GeoCell'; import { ImageCell } from './ImageCell'; import { JSONCell } from './JSONCell'; +import { PillCell } from './PillCell'; import { SparklineCell } from './SparklineCell'; export type TableCellRenderer = (props: TableCellRendererProps) => ReactNode; @@ -81,6 +82,8 @@ const DATA_LINKS_RENDERER: TableCellRenderer = (props) => ; +const PILL_RENDERER: TableCellRenderer = (props) => ; + function isCustomCellOptions(options: TableCellOptions): options is TableCustomCellOptions { return options.type === TableCellDisplayMode.Custom; } @@ -104,6 +107,7 @@ const CELL_RENDERERS: Record = { [TableCellDisplayMode.ColorText]: AUTO_RENDERER, [TableCellDisplayMode.ColorBackground]: AUTO_RENDERER, [TableCellDisplayMode.Auto]: AUTO_RENDERER, + [TableCellDisplayMode.Pill]: PILL_RENDERER, }; /** @internal */ diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index f4c9b072b16..16f5ccac9d2 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -196,6 +196,8 @@ export function getCellComponent(displayMode: TableCellDisplayMode, field: Field return DataLinksCell; case TableCellDisplayMode.Actions: return ActionsCell; + case TableCellDisplayMode.Pill: + return DefaultCell; // Legacy table doesn't support pill cells, fallback to default } if (field.type === FieldType.geo) { diff --git a/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx b/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx index ad5d9e400be..d8752c8e503 100644 --- a/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx +++ b/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx @@ -10,6 +10,7 @@ import { AutoCellOptionsEditor } from './cells/AutoCellOptionsEditor'; import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor'; import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor'; import { ImageCellOptionsEditor } from './cells/ImageCellOptionsEditor'; +import { PillCellOptionsEditor } from './cells/PillCellOptionsEditor'; import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor'; // The props that any cell type editor are expected @@ -77,6 +78,9 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => { {cellType === TableCellDisplayMode.Image && ( )} + {cellType === TableCellDisplayMode.Pill && ( + + )} ); }; @@ -91,6 +95,7 @@ let cellDisplayModeOptions: Array> = [ { value: { type: TableCellDisplayMode.JSONView }, label: 'JSON View' }, { value: { type: TableCellDisplayMode.Image }, label: 'Image' }, { value: { type: TableCellDisplayMode.Actions }, label: 'Actions' }, + { value: { type: TableCellDisplayMode.Pill }, label: 'Pill' }, ]; const getStyles = (theme: GrafanaTheme2) => ({ diff --git a/public/app/plugins/panel/table/table-new/cells/PillCellOptionsEditor.tsx b/public/app/plugins/panel/table/table-new/cells/PillCellOptionsEditor.tsx new file mode 100644 index 00000000000..0ff38f33ea9 --- /dev/null +++ b/public/app/plugins/panel/table/table-new/cells/PillCellOptionsEditor.tsx @@ -0,0 +1,66 @@ +import { t } from '@grafana/i18n'; +import { TablePillCellOptions } from '@grafana/schema'; +import { Field, ColorPicker, RadioButtonGroup, Stack } from '@grafana/ui'; + +import { TableCellEditorProps } from '../TableCellOptionEditor'; + +const colorModeOptions: Array<{ value: 'auto' | 'fixed' | 'mapped'; label: string }> = [ + { value: 'auto', label: 'Auto' }, + { value: 'fixed', label: 'Fixed color' }, + { value: 'mapped', label: 'Value mapping' }, +]; + +export const PillCellOptionsEditor = ({ cellOptions, onChange }: TableCellEditorProps) => { + const colorMode = cellOptions.colorMode || 'auto'; + + const onColorModeChange = (mode: 'auto' | 'fixed' | 'mapped') => { + const updatedOptions = { ...cellOptions, colorMode: mode }; + onChange(updatedOptions); + }; + + const onColorChange = (color: string) => { + const updatedOptions = { ...cellOptions, color }; + onChange(updatedOptions); + }; + + return ( + + + + + + {colorMode === 'fixed' && ( + + + + )} + + {colorMode === 'mapped' && ( + +
 
+
+ )} +
+ ); +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4f517a706ed..7d978590e2c 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11768,6 +11768,14 @@ "name-show-table-footer": "Show table footer", "name-show-table-header": "Show table header", "name-wrap-header-text": "Wrap header text", + "pill-cell-options-editor": { + "description-color-mode": "Choose how colors are assigned to pills", + "description-fixed-color": "All pills in this column will use this color", + "description-value-mappings-info": "For Value Mappings either use the global table Value Mappings or the Field overrides Value Mappings. The default will fall back to the Color Scheme. ", + "label-color-mode": "Color Mode", + "label-fixed-color": "Fixed Color", + "label-value-mappings-info": "Value Mappings" + }, "placeholder-column-width": "auto", "placeholder-fields": "All Numeric Fields" }, From 1eef358deb43deb2bdbaf3ac5a72e6dc92ee9d85 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:26:48 +0100 Subject: [PATCH 11/21] Update dependency css-minimizer-webpack-plugin to v7.0.2 (#107796) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 565 ++++++++++++++++++++++++++------------------------- 2 files changed, 289 insertions(+), 278 deletions(-) diff --git a/package.json b/package.json index 334ba022b4d..f2df53f9869 100644 --- a/package.json +++ b/package.json @@ -176,7 +176,7 @@ "core-js": "3.40.0", "crashme": "0.0.15", "css-loader": "7.1.2", - "css-minimizer-webpack-plugin": "7.0.0", + "css-minimizer-webpack-plugin": "7.0.2", "cypress": "14.3.2", "cypress-file-upload": "5.0.8", "cypress-recurse": "^1.35.3", diff --git a/yarn.lock b/yarn.lock index 723f3f8865b..ce94ea3ac4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12287,7 +12287,7 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.0.0, browserslist@npm:^4.21.10, browserslist@npm:^4.21.4, browserslist@npm:^4.23.3, browserslist@npm:^4.24.0, browserslist@npm:^4.24.3, browserslist@npm:^4.24.4": +"browserslist@npm:^4.0.0, browserslist@npm:^4.21.10, browserslist@npm:^4.21.4, browserslist@npm:^4.24.0, browserslist@npm:^4.24.3, browserslist@npm:^4.24.4, browserslist@npm:^4.24.5": version: 4.25.1 resolution: "browserslist@npm:4.25.1" dependencies: @@ -13933,14 +13933,14 @@ __metadata: languageName: node linkType: hard -"css-minimizer-webpack-plugin@npm:7.0.0": - version: 7.0.0 - resolution: "css-minimizer-webpack-plugin@npm:7.0.0" +"css-minimizer-webpack-plugin@npm:7.0.2": + version: 7.0.2 + resolution: "css-minimizer-webpack-plugin@npm:7.0.2" dependencies: "@jridgewell/trace-mapping": "npm:^0.3.25" - cssnano: "npm:^7.0.1" + cssnano: "npm:^7.0.4" jest-worker: "npm:^29.7.0" - postcss: "npm:^8.4.38" + postcss: "npm:^8.4.40" schema-utils: "npm:^4.2.0" serialize-javascript: "npm:^6.0.2" peerDependencies: @@ -13958,7 +13958,7 @@ __metadata: optional: true lightningcss: optional: true - checksum: 10/47d8f8a38c97496759f1676b5344231d48bfb205cc272e163a113d4cd40daddd17f8eb719d1f1071cf3ec4d62fd83537801cba598d7c653657aa97f1461e4a8b + checksum: 10/80ada5f059900a3b474e33ff0f10c0e5f5a37b7525fa7ef30812b6049bcbc28fbda1d703d8f76724a97449114306de5cd7a7af15003a16784e6c9cc374dfedc8 languageName: node linkType: hard @@ -14098,64 +14098,64 @@ __metadata: languageName: node linkType: hard -"cssnano-preset-default@npm:^7.0.6": - version: 7.0.6 - resolution: "cssnano-preset-default@npm:7.0.6" +"cssnano-preset-default@npm:^7.0.7": + version: 7.0.7 + resolution: "cssnano-preset-default@npm:7.0.7" dependencies: - browserslist: "npm:^4.23.3" + browserslist: "npm:^4.24.5" css-declaration-sorter: "npm:^7.2.0" - cssnano-utils: "npm:^5.0.0" - postcss-calc: "npm:^10.0.2" - postcss-colormin: "npm:^7.0.2" - postcss-convert-values: "npm:^7.0.4" - postcss-discard-comments: "npm:^7.0.3" - postcss-discard-duplicates: "npm:^7.0.1" - postcss-discard-empty: "npm:^7.0.0" - postcss-discard-overridden: "npm:^7.0.0" - postcss-merge-longhand: "npm:^7.0.4" - postcss-merge-rules: "npm:^7.0.4" - postcss-minify-font-values: "npm:^7.0.0" - postcss-minify-gradients: "npm:^7.0.0" - postcss-minify-params: "npm:^7.0.2" - postcss-minify-selectors: "npm:^7.0.4" - postcss-normalize-charset: "npm:^7.0.0" - postcss-normalize-display-values: "npm:^7.0.0" - postcss-normalize-positions: "npm:^7.0.0" - postcss-normalize-repeat-style: "npm:^7.0.0" - postcss-normalize-string: "npm:^7.0.0" - postcss-normalize-timing-functions: "npm:^7.0.0" - postcss-normalize-unicode: "npm:^7.0.2" - postcss-normalize-url: "npm:^7.0.0" - postcss-normalize-whitespace: "npm:^7.0.0" - postcss-ordered-values: "npm:^7.0.1" - postcss-reduce-initial: "npm:^7.0.2" - postcss-reduce-transforms: "npm:^7.0.0" - postcss-svgo: "npm:^7.0.1" - postcss-unique-selectors: "npm:^7.0.3" + cssnano-utils: "npm:^5.0.1" + postcss-calc: "npm:^10.1.1" + postcss-colormin: "npm:^7.0.3" + postcss-convert-values: "npm:^7.0.5" + postcss-discard-comments: "npm:^7.0.4" + postcss-discard-duplicates: "npm:^7.0.2" + postcss-discard-empty: "npm:^7.0.1" + postcss-discard-overridden: "npm:^7.0.1" + postcss-merge-longhand: "npm:^7.0.5" + postcss-merge-rules: "npm:^7.0.5" + postcss-minify-font-values: "npm:^7.0.1" + postcss-minify-gradients: "npm:^7.0.1" + postcss-minify-params: "npm:^7.0.3" + postcss-minify-selectors: "npm:^7.0.5" + postcss-normalize-charset: "npm:^7.0.1" + postcss-normalize-display-values: "npm:^7.0.1" + postcss-normalize-positions: "npm:^7.0.1" + postcss-normalize-repeat-style: "npm:^7.0.1" + postcss-normalize-string: "npm:^7.0.1" + postcss-normalize-timing-functions: "npm:^7.0.1" + postcss-normalize-unicode: "npm:^7.0.3" + postcss-normalize-url: "npm:^7.0.1" + postcss-normalize-whitespace: "npm:^7.0.1" + postcss-ordered-values: "npm:^7.0.2" + postcss-reduce-initial: "npm:^7.0.3" + postcss-reduce-transforms: "npm:^7.0.1" + postcss-svgo: "npm:^7.0.2" + postcss-unique-selectors: "npm:^7.0.4" peerDependencies: - postcss: ^8.4.31 - checksum: 10/686e7652d01ad4337dbad17b22fdb9cf132cf4664fddd05194da13a1f44f1177697745bbc6da73083941356280e89fe2cceacb6f422cc4522d70ff51db83cd63 + postcss: ^8.4.32 + checksum: 10/1ca9b739531acc2dff66347cc1b6195da0549058b5b00b9d36c3f241535ad67476218f61201cfb15e8b460357ec42414aa53bf78a7f01ee26ac26b7852e6c244 languageName: node linkType: hard -"cssnano-utils@npm:^5.0.0": - version: 5.0.0 - resolution: "cssnano-utils@npm:5.0.0" +"cssnano-utils@npm:^5.0.1": + version: 5.0.1 + resolution: "cssnano-utils@npm:5.0.1" peerDependencies: - postcss: ^8.4.31 - checksum: 10/89ed5b8ca554697b4ae285e0d3e134fccc9a0471adda57c8fba17a2bace2f062b9fcf7aeaf66fbd7fabddca8a15a6b1e5ccb70a2783421ae1ac164f779d9f24e + postcss: ^8.4.32 + checksum: 10/cdf37315d3cf9726e10ce842b18e148e4df1d1d18d292540e724d5a96994901abc631c8894328c39ab70c864449a8a83f8fc117114fdcbade204e5e65898af90 languageName: node linkType: hard -"cssnano@npm:^7.0.1": - version: 7.0.6 - resolution: "cssnano@npm:7.0.6" +"cssnano@npm:^7.0.4": + version: 7.0.7 + resolution: "cssnano@npm:7.0.7" dependencies: - cssnano-preset-default: "npm:^7.0.6" - lilconfig: "npm:^3.1.2" + cssnano-preset-default: "npm:^7.0.7" + lilconfig: "npm:^3.1.3" peerDependencies: - postcss: ^8.4.31 - checksum: 10/12b1e1f2b52ff2ba0ecb470e51f8fb3298d976bf91a51c7d2854793ea1e2af5d3c40385a85ad82d2117c84b9528d08f4bfecbb14949c6014d953dae34260952b + postcss: ^8.4.32 + checksum: 10/c5b3123757834537f818e0f3eb6b20da51a194fefed599632f7ddd600c9e25d38abe38a22582a579660a49368a146c294e2096b2837cbeeda51ddfc85b108601 languageName: node linkType: hard @@ -18258,7 +18258,7 @@ __metadata: crashme: "npm:0.0.15" croner: "npm:^9.0.0" css-loader: "npm:7.1.2" - css-minimizer-webpack-plugin: "npm:7.0.0" + css-minimizer-webpack-plugin: "npm:7.0.2" cypress: "npm:14.3.2" cypress-file-upload: "npm:5.0.8" cypress-recurse: "npm:^1.35.3" @@ -21787,7 +21787,7 @@ __metadata: languageName: node linkType: hard -"lilconfig@npm:^3.1.2, lilconfig@npm:^3.1.3": +"lilconfig@npm:^3.1.3": version: 3.1.3 resolution: "lilconfig@npm:3.1.3" checksum: 10/b932ce1af94985f0efbe8896e57b1f814a48c8dbd7fc0ef8469785c6303ed29d0090af3ccad7e36b626bfca3a4dc56cc262697e9a8dd867623cf09a39d54e4c3 @@ -23295,12 +23295,12 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.3.8": - version: 3.3.8 - resolution: "nanoid@npm:3.3.8" +"nanoid@npm:^3.3.11, nanoid@npm:^3.3.8": + version: 3.3.11 + resolution: "nanoid@npm:3.3.11" bin: nanoid: bin/nanoid.cjs - checksum: 10/2d1766606cf0d6f47b6f0fdab91761bb81609b2e3d367027aff45e6ee7006f660fb7e7781f4a34799fe6734f1268eeed2e37a5fdee809ade0c2d4eb11b0f9c40 + checksum: 10/73b5afe5975a307aaa3c95dfe3334c52cdf9ae71518176895229b8d65ab0d1c0417dd081426134eb7571c055720428ea5d57c645138161e7d10df80815527c48 languageName: node linkType: hard @@ -25303,79 +25303,79 @@ __metadata: languageName: node linkType: hard -"postcss-calc@npm:^10.0.2": - version: 10.0.2 - resolution: "postcss-calc@npm:10.0.2" +"postcss-calc@npm:^10.1.1": + version: 10.1.1 + resolution: "postcss-calc@npm:10.1.1" dependencies: - postcss-selector-parser: "npm:^6.1.2" + postcss-selector-parser: "npm:^7.0.0" postcss-value-parser: "npm:^4.2.0" peerDependencies: postcss: ^8.4.38 - checksum: 10/12d497e632b4a12f7d33507ed6f74db2dd01f9b9cc1f9986271af16b118d25f959dc255777a91d742e0431f400a90b8540d00533fc0513f34c1840a491cf2bee + checksum: 10/16a25ec594cfbbda439fd2939820f78ed4e7b8b5ab458aed7283b05fffabe68e1d4e1f4821fac798095f10539371676cd690bd27927adefab1911ff69b33d62c languageName: node linkType: hard -"postcss-colormin@npm:^7.0.2": - version: 7.0.2 - resolution: "postcss-colormin@npm:7.0.2" +"postcss-colormin@npm:^7.0.3": + version: 7.0.3 + resolution: "postcss-colormin@npm:7.0.3" dependencies: - browserslist: "npm:^4.23.3" + browserslist: "npm:^4.24.5" caniuse-api: "npm:^3.0.0" colord: "npm:^2.9.3" postcss-value-parser: "npm:^4.2.0" peerDependencies: - postcss: ^8.4.31 - checksum: 10/cb83d95d21668c770e5268f50ec6f8cd5d991d65123bafd3aa4a697580609c62d0078e704c4b7820db57638bf386084b253885b1e86263f580e8a393a687e973 + postcss: ^8.4.32 + checksum: 10/b9016d205eaf61a25efb187264a2ce35cb59aa1734b946268abcd747b5796e0d855c081b460ead4042a17c6806e011b57ee543b9e1f6312620f8daf661a7e40c languageName: node linkType: hard -"postcss-convert-values@npm:^7.0.4": - version: 7.0.4 - resolution: "postcss-convert-values@npm:7.0.4" +"postcss-convert-values@npm:^7.0.5": + version: 7.0.5 + resolution: "postcss-convert-values@npm:7.0.5" dependencies: - browserslist: "npm:^4.23.3" + browserslist: "npm:^4.24.5" postcss-value-parser: "npm:^4.2.0" peerDependencies: - postcss: ^8.4.31 - checksum: 10/077481cc98514965acf335cdacae4f604be86f4153ed3bcfdd2c4c54058182d0b472f859931d55d9aeb01600f08fff6a88a21539adbb6169019fda8b22f064ef + postcss: ^8.4.32 + checksum: 10/67920f9ba823a6f6aa3b46c3a098c2d4a7a2a32349971cfa6ce986e08e7cbae6badeb23de680d36d1439e7d3f2cdbf26f5ee080a66f2823931c1d3f8146bc2a6 languageName: node linkType: hard -"postcss-discard-comments@npm:^7.0.3": - version: 7.0.3 - resolution: "postcss-discard-comments@npm:7.0.3" +"postcss-discard-comments@npm:^7.0.4": + version: 7.0.4 + resolution: "postcss-discard-comments@npm:7.0.4" dependencies: - postcss-selector-parser: "npm:^6.1.2" + postcss-selector-parser: "npm:^7.1.0" peerDependencies: - postcss: ^8.4.31 - checksum: 10/f7c994df0d2de75d876f0db7ebd5b63718ef7ee5336a35f5f753f8ea115ecf8be26d5d2ad8800e833f18b33da6e018af82de7b5f0aa69e6338d3e0aff46348d4 + postcss: ^8.4.32 + checksum: 10/a09ac248bfbd6f2baa72b84873a876f4113df0fb5e9dd10808f6bbb310473fcd7905cc4639dbfd3ad8a5444053d42f7bb644a6934e95305820bdedc731d3c80a languageName: node linkType: hard -"postcss-discard-duplicates@npm:^7.0.1": +"postcss-discard-duplicates@npm:^7.0.2": + version: 7.0.2 + resolution: "postcss-discard-duplicates@npm:7.0.2" + peerDependencies: + postcss: ^8.4.32 + checksum: 10/2da841b5c0117528e56e1ccda28924339c03fdb93dab61b767cebb9a9e4a2a077498d00e0c97c9ec36a534f98d6f358e6236f30913c184f90d51f6d302f4f0f6 + languageName: node + linkType: hard + +"postcss-discard-empty@npm:^7.0.1": version: 7.0.1 - resolution: "postcss-discard-duplicates@npm:7.0.1" + resolution: "postcss-discard-empty@npm:7.0.1" peerDependencies: - postcss: ^8.4.31 - checksum: 10/0c757bb542caf017740157a2e29186ae83085bb42cd8e5ea3649fa039cc3d505ccaca739b1aed6c89e1f0a7f18440f77c3f49e4b99f45efd767c863d6647af94 + postcss: ^8.4.32 + checksum: 10/39977000657e78202da891ae6300593e40e1c8a756f1d9707087390e47a410739c394c35e902130556efb5808e6701b3b34b89facf7a9e56533d617dd9597049 languageName: node linkType: hard -"postcss-discard-empty@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-discard-empty@npm:7.0.0" +"postcss-discard-overridden@npm:^7.0.1": + version: 7.0.1 + resolution: "postcss-discard-overridden@npm:7.0.1" peerDependencies: - postcss: ^8.4.31 - checksum: 10/0c5cea198057727765855dbb43b5f16bd4d7da8c783fea8d18ad445ad3457681a7bc1696fda6bf16313e6fadaf86d519470aff68f02378b8b413e60023b70d57 - languageName: node - linkType: hard - -"postcss-discard-overridden@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-discard-overridden@npm:7.0.0" - peerDependencies: - postcss: ^8.4.31 - checksum: 10/e41c448305f96a93ec97a4a8ce2932a123283898041ff38ed2f7a35fcb76d937f448c2c8efb7d74d53d38b4ebf9163ae12935297bb99baec2f6751776b0ea29b + postcss: ^8.4.32 + checksum: 10/a0e67314b696591396e6bb371cdd57537e06f63e9fa0d742fe678decf600bed0cdcfa481487bce91b3732bdd7c46338f9102ccc8180c41032811e99962883715 languageName: node linkType: hard @@ -25406,78 +25406,78 @@ __metadata: languageName: node linkType: hard -"postcss-merge-longhand@npm:^7.0.4": - version: 7.0.4 - resolution: "postcss-merge-longhand@npm:7.0.4" +"postcss-merge-longhand@npm:^7.0.5": + version: 7.0.5 + resolution: "postcss-merge-longhand@npm:7.0.5" dependencies: postcss-value-parser: "npm:^4.2.0" - stylehacks: "npm:^7.0.4" + stylehacks: "npm:^7.0.5" peerDependencies: - postcss: ^8.4.31 - checksum: 10/b94b98a9b21bc8671aa0fba96491e8e2deea57c9bbfe9a74305400a36035b63764d8bfbdc6bda047887b665b92b91361a8bbc1cb9c14f80b3792feef19881005 + postcss: ^8.4.32 + checksum: 10/3378fc3a196082dfdb9acff94efbfa0de95ed86bf87f485285e775fd3c21218e5a243e363ad80b96237edb454776f7c1deea28c37afb8b96ddfaf5cfe8bd606b languageName: node linkType: hard -"postcss-merge-rules@npm:^7.0.4": - version: 7.0.4 - resolution: "postcss-merge-rules@npm:7.0.4" +"postcss-merge-rules@npm:^7.0.5": + version: 7.0.5 + resolution: "postcss-merge-rules@npm:7.0.5" dependencies: - browserslist: "npm:^4.23.3" + browserslist: "npm:^4.24.5" caniuse-api: "npm:^3.0.0" - cssnano-utils: "npm:^5.0.0" - postcss-selector-parser: "npm:^6.1.2" + cssnano-utils: "npm:^5.0.1" + postcss-selector-parser: "npm:^7.1.0" peerDependencies: - postcss: ^8.4.31 - checksum: 10/f67a4f6e814c5e7ce990a3c3d699e1c1dba7e79c5cb3a11795534d47b0fa257d27465e248546b45104d8278dfcbd07d9fbceb8046fcdfac86fe6340ca3c85f9a + postcss: ^8.4.32 + checksum: 10/fa490791ea5e907e4498701593252ce33df468a821e5f3acf5f126f73c8262189c13ca7a0c1645ae3d66a46a03cf930048e10d808182a3e9bec78af30a02893a languageName: node linkType: hard -"postcss-minify-font-values@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-minify-font-values@npm:7.0.0" +"postcss-minify-font-values@npm:^7.0.1": + version: 7.0.1 + resolution: "postcss-minify-font-values@npm:7.0.1" dependencies: postcss-value-parser: "npm:^4.2.0" peerDependencies: - postcss: ^8.4.31 - checksum: 10/8578c1d1d4d65ca34db5ac0cccc7b73500040e52a3abb8abc7e5b6e47e5f72c88bfe5f3b19847556a2a68082245009d693a7c098b8bc58e7f9640abba4e80194 + postcss: ^8.4.32 + checksum: 10/6578a1fd293e202e738ce38d91d71c08ba970f4a998edff48022cb21ec23ef26bf7d284ddb41d6e51bf20b5b5676fe142de1bd092a76d2ef982d5ee1d6b00190 languageName: node linkType: hard -"postcss-minify-gradients@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-minify-gradients@npm:7.0.0" +"postcss-minify-gradients@npm:^7.0.1": + version: 7.0.1 + resolution: "postcss-minify-gradients@npm:7.0.1" dependencies: colord: "npm:^2.9.3" - cssnano-utils: "npm:^5.0.0" + cssnano-utils: "npm:^5.0.1" postcss-value-parser: "npm:^4.2.0" peerDependencies: - postcss: ^8.4.31 - checksum: 10/9649e255ad954e67e0d7c2111b0f1681a93e8cba7179a547491eacf135d64596dfee9774b589d7a46ee3ace673a026113e56e734d6ab19297367f11dd3104c0e + postcss: ^8.4.32 + checksum: 10/4aa782331c5d1826e549b3940eefb54e2d51f5c5a2c5f5537384bfe6eac45bfe7ba4535c03cd1642d8a27ab088f56c3682b55f5dd2c3f7969b715692e0c1102b languageName: node linkType: hard -"postcss-minify-params@npm:^7.0.2": - version: 7.0.2 - resolution: "postcss-minify-params@npm:7.0.2" +"postcss-minify-params@npm:^7.0.3": + version: 7.0.3 + resolution: "postcss-minify-params@npm:7.0.3" dependencies: - browserslist: "npm:^4.23.3" - cssnano-utils: "npm:^5.0.0" + browserslist: "npm:^4.24.5" + cssnano-utils: "npm:^5.0.1" postcss-value-parser: "npm:^4.2.0" peerDependencies: - postcss: ^8.4.31 - checksum: 10/26b6ce4db3cdefcceb7a00b64dfbd27dee4194b55708937dddd5c4000c1f02013dc0659e62e799dc1ce1f1a697961cec55a2a746a4f59d54ccae4b68adf41768 + postcss: ^8.4.32 + checksum: 10/97de22d6ba0310685d33b530dbfeefa930f7ac48effe623fc8a4a59d2b98bed221d0d2edad4f2e1f4590322240d0e1e94bdb162069c40b5d7ae00c58637c90c9 languageName: node linkType: hard -"postcss-minify-selectors@npm:^7.0.4": - version: 7.0.4 - resolution: "postcss-minify-selectors@npm:7.0.4" +"postcss-minify-selectors@npm:^7.0.5": + version: 7.0.5 + resolution: "postcss-minify-selectors@npm:7.0.5" dependencies: cssesc: "npm:^3.0.0" - postcss-selector-parser: "npm:^6.1.2" + postcss-selector-parser: "npm:^7.1.0" peerDependencies: - postcss: ^8.4.31 - checksum: 10/54c74dcb098819417e95ec2b5ecdd33a2c6fdccea2346e110037c762d37644e11f83d67e6b0c93405f2b7cc28880ca0a07ad4d6618330436f7b8b84d719b85fb + postcss: ^8.4.32 + checksum: 10/12580d9a17c146c9e9bb604b4887085d897554317590cee91e0f28e2a4757c18e09299365a44eae25e848e65d53b845928dfa56a9d0199d0e159d525732fbf89 languageName: node linkType: hard @@ -25525,136 +25525,136 @@ __metadata: languageName: node linkType: hard -"postcss-normalize-charset@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-normalize-charset@npm:7.0.0" - peerDependencies: - postcss: ^8.4.31 - checksum: 10/a41043fb81a1d5b3b05e8b317de7fe123854a4535f9ce2904a16196a32b3565d2fd6ac59a9842e337cf1bb298dcc108cbdbc6a5d4a500aec3520d759e951a8de - languageName: node - linkType: hard - -"postcss-normalize-display-values@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-normalize-display-values@npm:7.0.0" - dependencies: - postcss-value-parser: "npm:^4.2.0" - peerDependencies: - postcss: ^8.4.31 - checksum: 10/55bbfb4dac3bf9bcc2aed30057c0bc968927b5337b372ee2dd825d6ec626c18d1481b0e8dd928d4cab70c3e8a2e6708d6115b14bebd34fe4462eb15aacff35f4 - languageName: node - linkType: hard - -"postcss-normalize-positions@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-normalize-positions@npm:7.0.0" - dependencies: - postcss-value-parser: "npm:^4.2.0" - peerDependencies: - postcss: ^8.4.31 - checksum: 10/a6b982e567ddf1ad4120aaf898056f2fdbe5f6cae1d475fef22cb1f025c9bfe37df5511a4353b9f13d01feae8b1d9638c1deb70537058312262647052d004f64 - languageName: node - linkType: hard - -"postcss-normalize-repeat-style@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-normalize-repeat-style@npm:7.0.0" - dependencies: - postcss-value-parser: "npm:^4.2.0" - peerDependencies: - postcss: ^8.4.31 - checksum: 10/f8ef8cf5ac6232f1d0615a97f21ea464a6930484b58421c87e0f9e626b1bb52916592f25e4f9874f424b1529807b170d8805d45878aa8293ea0608dd753230c8 - languageName: node - linkType: hard - -"postcss-normalize-string@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-normalize-string@npm:7.0.0" - dependencies: - postcss-value-parser: "npm:^4.2.0" - peerDependencies: - postcss: ^8.4.31 - checksum: 10/23ea7dd7b28880dfafd0880ab782d65186ab94a4cf789b8723f9666020c7f7c8b97546e0dc46d08da3f71a873bb6db41cd69a4cafb4fde4a85f97ef83ee38bae - languageName: node - linkType: hard - -"postcss-normalize-timing-functions@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-normalize-timing-functions@npm:7.0.0" - dependencies: - postcss-value-parser: "npm:^4.2.0" - peerDependencies: - postcss: ^8.4.31 - checksum: 10/f85870b3c8132b530fb8e5c8474f1eea1d0ef69a374d5867d0300f7501803bffa55f7fad34f662d88a747ce73d552ec0f818722d2d5157cf8e5dc45a98fa552b - languageName: node - linkType: hard - -"postcss-normalize-unicode@npm:^7.0.2": - version: 7.0.2 - resolution: "postcss-normalize-unicode@npm:7.0.2" - dependencies: - browserslist: "npm:^4.23.3" - postcss-value-parser: "npm:^4.2.0" - peerDependencies: - postcss: ^8.4.31 - checksum: 10/cb342f7507f28c8e9c500a2d6369c6b04a85f6c6f93aaa1ab6768d0e097453480834d3f7c5fad503f9fb9e178d9011df50ceaeebe2ac68d5daaa7c8a63ad3b3f - languageName: node - linkType: hard - -"postcss-normalize-url@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-normalize-url@npm:7.0.0" - dependencies: - postcss-value-parser: "npm:^4.2.0" - peerDependencies: - postcss: ^8.4.31 - checksum: 10/c5edca0646a13d76c5347fffaaa828184e035486d7eeb2a8b31781d30de6a90f7ad3f0cffe59e8fd4c31f1525fdb85b45777745685603ac533a151c42691f601 - languageName: node - linkType: hard - -"postcss-normalize-whitespace@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-normalize-whitespace@npm:7.0.0" - dependencies: - postcss-value-parser: "npm:^4.2.0" - peerDependencies: - postcss: ^8.4.31 - checksum: 10/c409362e3256ed66629fc48c63e834c9bfb598ca20587adb620bbc04fdccef4cd0d08b1f485eb8290d6a30e8dd836fecb0def38c3a49fe8503e2579e60f5bccf - languageName: node - linkType: hard - -"postcss-ordered-values@npm:^7.0.1": +"postcss-normalize-charset@npm:^7.0.1": version: 7.0.1 - resolution: "postcss-ordered-values@npm:7.0.1" - dependencies: - cssnano-utils: "npm:^5.0.0" - postcss-value-parser: "npm:^4.2.0" + resolution: "postcss-normalize-charset@npm:7.0.1" peerDependencies: - postcss: ^8.4.31 - checksum: 10/048082c09eee021d97def02eb8fc03fb0414402b1f6925af29a862f537b66b43d7a8e8d94c552ca67cd6172230873260f4ad44f1d5bac81c553afb054d80e6a8 + postcss: ^8.4.32 + checksum: 10/bcec822491e3421b009c688473433164b5c80bbef48af4e47f704bee68f0b7ba2009aaf46788e698dd233d5f4e1cf444a4f59a901623c73f8458c2227b15db57 languageName: node linkType: hard -"postcss-reduce-initial@npm:^7.0.2": - version: 7.0.2 - resolution: "postcss-reduce-initial@npm:7.0.2" +"postcss-normalize-display-values@npm:^7.0.1": + version: 7.0.1 + resolution: "postcss-normalize-display-values@npm:7.0.1" dependencies: - browserslist: "npm:^4.23.3" + postcss-value-parser: "npm:^4.2.0" + peerDependencies: + postcss: ^8.4.32 + checksum: 10/53f341c17a5487639e6f7c917ad695e059bf4aff66b3c971e008163f774337444753310def9f38dd26066ea96b136422592fc74077c38c40b3bfdfaa338d5b58 + languageName: node + linkType: hard + +"postcss-normalize-positions@npm:^7.0.1": + version: 7.0.1 + resolution: "postcss-normalize-positions@npm:7.0.1" + dependencies: + postcss-value-parser: "npm:^4.2.0" + peerDependencies: + postcss: ^8.4.32 + checksum: 10/72b23ab87c97c155d2ec475fba8a8b968f7c7b42d055a79b267449d570c328d5ea4cb0002428cf26e9daa70c58655e0b931d2a5801cc407554d3f03a21ac041b + languageName: node + linkType: hard + +"postcss-normalize-repeat-style@npm:^7.0.1": + version: 7.0.1 + resolution: "postcss-normalize-repeat-style@npm:7.0.1" + dependencies: + postcss-value-parser: "npm:^4.2.0" + peerDependencies: + postcss: ^8.4.32 + checksum: 10/db677bceec8c00a1860b64932b99af937e7674b3e5c5ac333c95efb090e9abd747eca4ad51855f0fe73fbe544c3d21e58d06b39e03fd525945309743e31ec235 + languageName: node + linkType: hard + +"postcss-normalize-string@npm:^7.0.1": + version: 7.0.1 + resolution: "postcss-normalize-string@npm:7.0.1" + dependencies: + postcss-value-parser: "npm:^4.2.0" + peerDependencies: + postcss: ^8.4.32 + checksum: 10/48df2eaca6f5365af31ad46fd60a32dc7b714cc5ec8ba80980e65855ddc47c03ac82077ce7ca04c90898f73d173410d1d6a104754ff487e7e5a59e3eae8325b3 + languageName: node + linkType: hard + +"postcss-normalize-timing-functions@npm:^7.0.1": + version: 7.0.1 + resolution: "postcss-normalize-timing-functions@npm:7.0.1" + dependencies: + postcss-value-parser: "npm:^4.2.0" + peerDependencies: + postcss: ^8.4.32 + checksum: 10/31fb88489244334295918fa7d6af2d76c310a83abd20be0a7f1c408c54ac0c0f81b0ae7877698bf66de1f76495766e159c8871387407dfcafa0cb1a53f5f0460 + languageName: node + linkType: hard + +"postcss-normalize-unicode@npm:^7.0.3": + version: 7.0.3 + resolution: "postcss-normalize-unicode@npm:7.0.3" + dependencies: + browserslist: "npm:^4.24.5" + postcss-value-parser: "npm:^4.2.0" + peerDependencies: + postcss: ^8.4.32 + checksum: 10/fc10205655f77d6467da811fbd26aa607c519cbf162ae2ba40821cf64227233445490881119c820c6988c0943cb2f4dc755abe94cb30637001ca35cce5d07b61 + languageName: node + linkType: hard + +"postcss-normalize-url@npm:^7.0.1": + version: 7.0.1 + resolution: "postcss-normalize-url@npm:7.0.1" + dependencies: + postcss-value-parser: "npm:^4.2.0" + peerDependencies: + postcss: ^8.4.32 + checksum: 10/975dd0d1b55b637d45756ec57e554b2134f77368dd3ae09be9fa6636f2f41e72422505409d7fca75c635b9b1b8ec8ec2607d84c6c85497bbfd4e7748a2992882 + languageName: node + linkType: hard + +"postcss-normalize-whitespace@npm:^7.0.1": + version: 7.0.1 + resolution: "postcss-normalize-whitespace@npm:7.0.1" + dependencies: + postcss-value-parser: "npm:^4.2.0" + peerDependencies: + postcss: ^8.4.32 + checksum: 10/05a0fa74f4c8e93243053b9cc865cbddddb309b2ccb08271ca9c38ea7ece2ff43d5faa12cce87f06e40cbcf22c94443c9fa2b74ed0c6b94d72a9e67ea0381626 + languageName: node + linkType: hard + +"postcss-ordered-values@npm:^7.0.2": + version: 7.0.2 + resolution: "postcss-ordered-values@npm:7.0.2" + dependencies: + cssnano-utils: "npm:^5.0.1" + postcss-value-parser: "npm:^4.2.0" + peerDependencies: + postcss: ^8.4.32 + checksum: 10/be8fb13639fb0e1ffd7d4e9bb4824d3a283c8a63a8b0dd1a654435fff1e019007c79be877940bb101bb9ebd8ba3ac18bcffd144e939890bedeb40044dcc2b9cc + languageName: node + linkType: hard + +"postcss-reduce-initial@npm:^7.0.3": + version: 7.0.3 + resolution: "postcss-reduce-initial@npm:7.0.3" + dependencies: + browserslist: "npm:^4.24.5" caniuse-api: "npm:^3.0.0" peerDependencies: - postcss: ^8.4.31 - checksum: 10/5a8260cbf7fa6ea12908debe23e191bb45109b29048d15e63c60df42c4ed62c860273ce9b37172d5f31c4bdb965e984962e4e6f506939a1fc49202dd7bf520c5 + postcss: ^8.4.32 + checksum: 10/8fd9ff4b49a2f7e1b7c51b7da637578e32a178363e3e932c80565241454dca306658dacd390ad3d73647d55dace8be8fe29278668afa32fd9d872ee7026bdbf7 languageName: node linkType: hard -"postcss-reduce-transforms@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-reduce-transforms@npm:7.0.0" +"postcss-reduce-transforms@npm:^7.0.1": + version: 7.0.1 + resolution: "postcss-reduce-transforms@npm:7.0.1" dependencies: postcss-value-parser: "npm:^4.2.0" peerDependencies: - postcss: ^8.4.31 - checksum: 10/1c369a1be820a80e8bf06376476190fe2ae5a0b5a7459257d7d9b5bc0c9aed79f46026e8558fca088f7a814e632c678f67749b246901a3839f2d50b7b9ec2d41 + postcss: ^8.4.32 + checksum: 10/a22d07559859b9d4313d579104a25aa254695bc37dec5134de1064d1bd52b9d1f33f050fbf330170ef1105ede9aad7741bbcf9cad2221a6a5c8d529fd3cf0259 languageName: node linkType: hard @@ -25705,36 +25705,36 @@ __metadata: languageName: node linkType: hard -"postcss-selector-parser@npm:^7.0.0": - version: 7.0.0 - resolution: "postcss-selector-parser@npm:7.0.0" +"postcss-selector-parser@npm:^7.0.0, postcss-selector-parser@npm:^7.1.0": + version: 7.1.0 + resolution: "postcss-selector-parser@npm:7.1.0" dependencies: cssesc: "npm:^3.0.0" util-deprecate: "npm:^1.0.2" - checksum: 10/0e92be7281e2b440a8be8cf207de40a24ca7bc765577916499614d5a47827a3e658206728cc559db96803e554270516104aad919a04f91bfa8914ccef1ba14ca + checksum: 10/2caf09e66e2be81d45538f8afdc5439298c89bea71e9943b364e69dce9443d9c5ab33f4dd8b237f1ed7d2f38530338dcc189c1219d888159e6afb5b0afe58b19 languageName: node linkType: hard -"postcss-svgo@npm:^7.0.1": - version: 7.0.1 - resolution: "postcss-svgo@npm:7.0.1" +"postcss-svgo@npm:^7.0.2": + version: 7.0.2 + resolution: "postcss-svgo@npm:7.0.2" dependencies: postcss-value-parser: "npm:^4.2.0" svgo: "npm:^3.3.2" peerDependencies: - postcss: ^8.4.31 - checksum: 10/4196d9b7ec37ea7c427b6d3d40fa75bdae6d1fdf5a814481202138fb9b074ecc1e442b8e0202aa8c76eaaff747e2f6bfec968cfe7bc774d8a58faf8bd945ff4e + postcss: ^8.4.32 + checksum: 10/8615877dffbac2bb2b971fb0e8c882ebff479c2529a0fc20937d09623fcaf35a2d934c4046188bae2534729aba1de5a1ba227630aaf96a800b6f2acdbfbf1d32 languageName: node linkType: hard -"postcss-unique-selectors@npm:^7.0.3": - version: 7.0.3 - resolution: "postcss-unique-selectors@npm:7.0.3" +"postcss-unique-selectors@npm:^7.0.4": + version: 7.0.4 + resolution: "postcss-unique-selectors@npm:7.0.4" dependencies: - postcss-selector-parser: "npm:^6.1.2" + postcss-selector-parser: "npm:^7.1.0" peerDependencies: - postcss: ^8.4.31 - checksum: 10/c38ca6b5f539cae1e0e8ef0efa338f91e4e054dbd9c619e26708d787e94ce788739bbe782103f2cf35c38819233897901038292255a1726905bd04433ac9e5f2 + postcss: ^8.4.32 + checksum: 10/b880f96fdb20037b16ae21b48f5240a4cf8585bf3133c7894dd869711b14f3a1a82bbdecd36adc78f8c34553a46fc2199ed3e92d5031b0267ff6f43894fc00f7 languageName: node linkType: hard @@ -25745,7 +25745,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:8.5.1, postcss@npm:^8.4.33, postcss@npm:^8.4.38, postcss@npm:^8.5.1": +"postcss@npm:8.5.1": version: 8.5.1 resolution: "postcss@npm:8.5.1" dependencies: @@ -25756,6 +25756,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.4.33, postcss@npm:^8.4.40, postcss@npm:^8.5.1": + version: 8.5.6 + resolution: "postcss@npm:8.5.6" + dependencies: + nanoid: "npm:^3.3.11" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10/9e4fbe97574091e9736d0e82a591e29aa100a0bf60276a926308f8c57249698935f35c5d2f4e80de778d0cbb8dcffab4f383d85fd50c5649aca421c3df729b86 + languageName: node + linkType: hard + "prefix-style@npm:2.0.1": version: 2.0.1 resolution: "prefix-style@npm:2.0.1" @@ -30048,15 +30059,15 @@ __metadata: languageName: node linkType: hard -"stylehacks@npm:^7.0.4": - version: 7.0.4 - resolution: "stylehacks@npm:7.0.4" +"stylehacks@npm:^7.0.5": + version: 7.0.5 + resolution: "stylehacks@npm:7.0.5" dependencies: - browserslist: "npm:^4.23.3" - postcss-selector-parser: "npm:^6.1.2" + browserslist: "npm:^4.24.5" + postcss-selector-parser: "npm:^7.1.0" peerDependencies: - postcss: ^8.4.31 - checksum: 10/fc9d6b1e0b996d139a77f391df6db49ee1ab7e8fdeb32a8fa6b4c11512e72eb072470c32080171e46ebe123c9c96d763b9e4421b09c9c428985077940b6ba085 + postcss: ^8.4.32 + checksum: 10/798ac0f92ff4489c251550d64b903f1aa8b5946e5b09b33ebf68290b5a345257cecf98c989526a5d462b560081194fead38c4f804ec016ceb8b1b3f17ec74fc5 languageName: node linkType: hard From fdc6a0d774dd268749021b10d1585e675073fdb8 Mon Sep 17 00:00:00 2001 From: Kristina Date: Tue, 8 Jul 2025 11:29:14 -0500 Subject: [PATCH 12/21] Transformations: For Convert Field Type, clone conversions as to not mutate defaultOptions (#107752) Clone conversions as to not mutate defaultOptions --- .../transformers/editors/ConvertFieldTypeTransformerEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx index 9508dfee1e7..7345c031eac 100644 --- a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx +++ b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx @@ -51,7 +51,7 @@ export const ConvertFieldTypeTransformerEditor = ({ const onSelectField = useCallback( (idx: number) => (value: string | undefined) => { - const conversions = options.conversions; + const conversions = [...options.conversions]; conversions[idx] = { ...conversions[idx], targetField: value ?? '', dateFormat: undefined }; onChange({ ...options, From 0b28a539238ffc52a2c60cd8e38c49e6240dacc8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 16:53:35 +0000 Subject: [PATCH 13/21] Update dependency @grafana/lezer-logql to v0.2.8 (#107797) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- public/app/plugins/datasource/loki/package.json | 2 +- yarn.lock | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index f2df53f9869..515bc414b97 100644 --- a/package.json +++ b/package.json @@ -281,7 +281,7 @@ "@grafana/flamegraph": "workspace:*", "@grafana/google-sdk": "0.3.2", "@grafana/i18n": "workspace:*", - "@grafana/lezer-logql": "0.2.7", + "@grafana/lezer-logql": "0.2.8", "@grafana/llm": "0.22.1", "@grafana/monaco-logql": "^0.0.8", "@grafana/o11y-ds-frontend": "workspace:*", diff --git a/public/app/plugins/datasource/loki/package.json b/public/app/plugins/datasource/loki/package.json index 0b4be6cada6..0fe5fbfdd7b 100644 --- a/public/app/plugins/datasource/loki/package.json +++ b/public/app/plugins/datasource/loki/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.5", "@grafana/data": "12.1.0-pre", - "@grafana/lezer-logql": "0.2.7", + "@grafana/lezer-logql": "0.2.8", "@grafana/llm": "0.22.1", "@grafana/monaco-logql": "^0.0.8", "@grafana/runtime": "12.1.0-pre", diff --git a/yarn.lock b/yarn.lock index ce94ea3ac4d..6f8069078d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2720,7 +2720,7 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "npm:12.1.0-pre" "@grafana/e2e-selectors": "npm:12.1.0-pre" - "@grafana/lezer-logql": "npm:0.2.7" + "@grafana/lezer-logql": "npm:0.2.8" "@grafana/llm": "npm:0.22.1" "@grafana/monaco-logql": "npm:^0.0.8" "@grafana/plugin-configs": "npm:12.1.0-pre" @@ -3283,12 +3283,12 @@ __metadata: languageName: unknown linkType: soft -"@grafana/lezer-logql@npm:0.2.7": - version: 0.2.7 - resolution: "@grafana/lezer-logql@npm:0.2.7" +"@grafana/lezer-logql@npm:0.2.8": + version: 0.2.8 + resolution: "@grafana/lezer-logql@npm:0.2.8" peerDependencies: "@lezer/lr": ^1.0.0 - checksum: 10/606a9dc77b3b3751e1f325d6b1a8994b1bafef7fe0f6f3980ee7d184244b373f828960f46a748746e765615352ed8928d10f22ff06cede588fcc9d32e70a68d8 + checksum: 10/56b31f9479037201b07d27602c023a2c2373758f232b02a4e5c501dbfabe6f708ddd2a4f8d059b5aad576297137bc2836ed3f520eceee6ffcd3001ad53c73f5c languageName: node linkType: hard @@ -18122,7 +18122,7 @@ __metadata: "@grafana/flamegraph": "workspace:*" "@grafana/google-sdk": "npm:0.3.2" "@grafana/i18n": "workspace:*" - "@grafana/lezer-logql": "npm:0.2.7" + "@grafana/lezer-logql": "npm:0.2.8" "@grafana/llm": "npm:0.22.1" "@grafana/monaco-logql": "npm:^0.0.8" "@grafana/o11y-ds-frontend": "workspace:*" From 412415ab3924145aa9b1c3ef471cd2a6a9e4e2ed Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Tue, 8 Jul 2025 14:00:51 -0400 Subject: [PATCH 14/21] NewProvisionedFolderForm: pass in empty title for new folder form (#107733) Co-authored-by: Clarity-89 --- .../browse-dashboards/components/NewProvisionedFolderForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx index 10a9e429099..291696baeeb 100644 --- a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx +++ b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx @@ -210,7 +210,7 @@ export function NewProvisionedFolderForm({ parentFolder, onDismiss }: Props) { const { workflowOptions, isGitHub, repository, folder, initialValues } = useProvisionedFolderFormData({ folderUid: parentFolder?.uid, action: 'create', - title: parentFolder?.title, + title: '', // Empty title for new folders }); if (!initialValues) { From 68ee251c5c94044a7e10882942177d54f8122ddd Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Tue, 8 Jul 2025 13:24:03 -0500 Subject: [PATCH 15/21] TableNG: Extract links and actions tooltip into cell root renderer (#107667) * TableNG: Extract links and actions tooltip into cell root renderer * make TS happy * fixes & tweaks * lint * skip datalinks and actions tooltip on those cell types * add todo * withTooltip lookup * fix * optional getActions * fix * kill cursor: 'context-menu' * stop event propagation from TableCellActions * update tests to move tooltip tests up to TableNG * remove safety assertion * add value back to DataLinksActionsTooltip --------- Co-authored-by: Paul Marbach --- .../src/selectors/components.ts | 5 - .../Table/DataLinksActionsTooltip.tsx | 33 +++-- .../Table/TableNG/Cells/ActionsCell.tsx | 9 +- .../Table/TableNG/Cells/AutoCell.test.tsx | 97 -------------- .../Table/TableNG/Cells/AutoCell.tsx | 37 +----- .../Table/TableNG/Cells/BarGaugeCell.tsx | 75 ++++------- .../Table/TableNG/Cells/ImageCell.tsx | 37 +----- .../Table/TableNG/Cells/JSONCell.tsx | 37 +----- .../Table/TableNG/Cells/TableCellActions.tsx | 5 +- .../Table/TableNG/Cells/renderers.tsx | 15 +-- .../components/Table/TableNG/TableNG.test.tsx | 63 +++++++++- .../src/components/Table/TableNG/TableNG.tsx | 118 ++++++++++++------ .../src/components/Table/TableNG/hooks.ts | 10 +- .../src/components/Table/TableNG/types.ts | 33 +++-- .../src/components/Table/TableNG/utils.ts | 10 ++ .../grafana-ui/src/components/Table/types.ts | 2 +- .../grafana-ui/src/components/Table/utils.ts | 10 +- .../panel/table/table-new/TablePanel.tsx | 57 +++++---- 18 files changed, 291 insertions(+), 362 deletions(-) delete mode 100644 packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 0ff43b6807e..088caf6dfa1 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -1179,11 +1179,6 @@ export const versionedComponents = { '12.1.0': 'data-testid Data links actions tooltip wrapper', }, }, - TablePanel: { - autoCell: { - '12.1.0': 'data-testid Table panel auto cell', - }, - }, CodeEditor: { container: { '10.2.3': 'data-testid Code editor container', diff --git a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx index 1bd8774479b..5b2e28c646f 100644 --- a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx +++ b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx @@ -74,8 +74,6 @@ export const DataLinksActionsTooltip = ({ links, actions, value, coords, onToolt const dismiss = useDismiss(context); - const hasMultipleLinksOrActions = links.length > 1 || Boolean(actions?.length); - const { getFloatingProps, getReferenceProps } = useInteractions([dismiss]); if (links.length === 0 && !Boolean(actions?.length)) { @@ -84,23 +82,22 @@ export const DataLinksActionsTooltip = ({ links, actions, value, coords, onToolt return ( <> + {/* TODO: we can remove `value` from this component when tableNextGen is fully rolled out */} {value} - {hasMultipleLinksOrActions && ( - -
- - - -
-
- )} + +
+ + + +
+
); }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx index 9616baa5a76..087efa47ecc 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import { useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; @@ -6,12 +7,16 @@ import { useStyles2 } from '../../../../themes/ThemeContext'; import { ActionButton } from '../../../Actions/ActionButton'; import { ActionCellProps } from '../types'; -export const ActionsCell = ({ actions }: ActionCellProps) => { +export const ActionsCell = ({ field, rowIdx, getActions }: ActionCellProps) => { const styles = useStyles2(getStyles); + const actions = useMemo(() => getActions(field, rowIdx), [getActions, field, rowIdx]); + return (
- {actions && actions.map((action, i) => )} + {actions.map((action, i) => ( + + ))}
); }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx deleted file mode 100644 index b65fa0e0297..00000000000 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { Field, FieldType, LinkModel } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; - -import { TableCellDisplayMode } from '../../types'; - -import AutoCell from './AutoCell'; - -describe('AutoCell', () => { - describe('Displays data Links', () => { - const getFieldWithLinks = (links: LinkModel[]): Field => { - return { - name: 'Category', - type: FieldType.string, - values: ['A', 'B', 'A', 'B', 'A'], - config: { - custom: { - cellOptions: { - type: TableCellDisplayMode.Auto, - wrapText: false, - }, - }, - }, - display: (value: unknown) => ({ - text: String(value), - numeric: 0, - color: undefined, - prefix: undefined, - suffix: undefined, - }), - state: {}, - getLinks: () => links, - }; - }; - - it('shows multiple datalinks in the tooltip', async () => { - const linksForField = [ - { href: 'http://asdasd.com', title: 'Test Title' } as LinkModel, - { href: 'http://asdasd2.com', title: 'Test Title2' } as LinkModel, - ]; - - jest.mock('../utils', () => ({ - getCellLinks: () => linksForField, - })); - - const field = getFieldWithLinks(linksForField); - - render( - - ); - - const cell = screen.getByTestId(selectors.components.TablePanel.autoCell); - await userEvent.click(cell); - - const tooltip = screen.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper); - expect(tooltip).toBeInTheDocument(); - expect(screen.getByText('Test Title')).toBeInTheDocument(); - expect(screen.getByText('Test Title2')).toBeInTheDocument(); - }); - - it('does not show tooltip for multiple links if one is invalid', async () => { - const linksForField = [ - { href: 'http://asdasd.com', title: 'Test Title' } as LinkModel, - { title: 'Test Title2' } as LinkModel, - ]; - - jest.mock('../utils', () => ({ - getCellLinks: () => linksForField, - })); - - const field = getFieldWithLinks(linksForField); - - render( - - ); - - const cell = screen.getByTestId(selectors.components.TablePanel.autoCell); - await userEvent.click(cell); - - expect(screen.queryByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).not.toBeInTheDocument(); - }); - }); -}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx index 3fdcfc85491..71293bbc06e 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx @@ -1,49 +1,24 @@ import { css } from '@emotion/css'; import { Property } from 'csstype'; -import { useState } from 'react'; import { GrafanaTheme2, formattedValueToString } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; import { useStyles2 } from '../../../../themes/ThemeContext'; -import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; +import { renderSingleLink } from '../../DataLinksActionsTooltip'; import { TableCellOptions, TableCellDisplayMode } from '../../types'; -import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils, tooltipOnClickHandler } from '../../utils'; +import { useSingleLink } from '../hooks'; import { AutoCellProps } from '../types'; -import { getCellLinks } from '../utils'; -export default function AutoCell({ value, field, justifyContent, rowIdx, cellOptions, actions }: AutoCellProps) { +export default function AutoCell({ value, field, justifyContent, rowIdx, cellOptions }: AutoCellProps) { const styles = useStyles2(getStyles, justifyContent); const displayValue = field.display!(value); const formattedValue = formattedValueToString(displayValue); - const links = getCellLinks(field, rowIdx) || []; - - const [tooltipCoords, setTooltipCoords] = useState(); - const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions); - const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; + const link = useSingleLink(field, rowIdx); return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions -
- {shouldShowLink ? ( - renderSingleLink(links[0], formattedValue, getLinkStyle(styles, cellOptions)) - ) : shouldShowTooltip ? ( - setTooltipCoords(undefined)} - /> - ) : ( - formattedValue - )} +
+ {link == null ? formattedValue : renderSingleLink(link, formattedValue, getLinkStyle(styles, cellOptions))}
); } diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx index 710a8c65726..69ebfac671c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx @@ -1,13 +1,11 @@ -import { useState } from 'react'; - import { ThresholdsConfig, ThresholdsMode, VizOrientation, getFieldConfigWithMinMax } from '@grafana/data'; import { BarGaugeDisplayMode, BarGaugeValueMode, TableCellDisplayMode } from '@grafana/schema'; import { BarGauge } from '../../../BarGauge/BarGauge'; -import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; -import { tooltipOnClickHandler, DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils'; +import { renderSingleLink } from '../../DataLinksActionsTooltip'; +import { useSingleLink } from '../hooks'; import { BarGaugeCellProps } from '../types'; -import { extractPixelValue, getCellOptions, getAlignmentFactor, getCellLinks } from '../utils'; +import { extractPixelValue, getCellOptions, getAlignmentFactor } from '../utils'; const defaultScale: ThresholdsConfig = { mode: ThresholdsMode.Absolute, @@ -23,7 +21,7 @@ const defaultScale: ThresholdsConfig = { ], }; -export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx, actions }: BarGaugeCellProps) => { +export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx }: BarGaugeCellProps) => { const displayValue = field.display!(value); const cellOptions = getCellOptions(field); const heightOffset = extractPixelValue(theme.spacing(1)); @@ -48,51 +46,26 @@ export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx, actio } const alignmentFactors = getAlignmentFactor(field, displayValue, rowIdx!); - const links = getCellLinks(field, rowIdx) || []; - const [tooltipCoords, setTooltipCoords] = useState(); - const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions); - const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; - - const renderComponent = () => { - return ( - - ); - }; - - return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions -
- {shouldShowLink ? ( - renderSingleLink(links[0], renderComponent()) - ) : shouldShowTooltip ? ( - setTooltipCoords(undefined)} - /> - ) : ( - renderComponent() - )} -
+ const barGaugeComponent = ( + ); + + const link = useSingleLink(field, rowIdx); + + return link == null ? barGaugeComponent : renderSingleLink(link, barGaugeComponent); }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx index bc62e266b12..e0c1050396a 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx @@ -1,55 +1,28 @@ import { css } from '@emotion/css'; import { Property } from 'csstype'; -import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../../../themes/ThemeContext'; -import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; +import { renderSingleLink } from '../../DataLinksActionsTooltip'; import { TableCellDisplayMode } from '../../types'; -import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils, tooltipOnClickHandler } from '../../utils'; +import { useSingleLink } from '../hooks'; import { ImageCellProps } from '../types'; -import { getCellLinks } from '../utils'; const DATALINKS_HEIGHT_OFFSET = 10; -export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx, actions }: ImageCellProps) => { +export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx }: ImageCellProps) => { const calculatedHeight = height - DATALINKS_HEIGHT_OFFSET; const styles = useStyles2(getStyles, calculatedHeight, justifyContent); - const links = getCellLinks(field, rowIdx) || []; - - const [tooltipCoords, setTooltipCoords] = useState(); - const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions); - const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; const { text } = field.display!(value); const { alt, title } = cellOptions.type === TableCellDisplayMode.Image ? cellOptions : { alt: undefined, title: undefined }; const img = {alt}; + const link = useSingleLink(field, rowIdx); - return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions -
- {shouldShowLink ? ( - renderSingleLink(links[0], img) - ) : shouldShowTooltip ? ( - setTooltipCoords(undefined)} - /> - ) : ( - img - )} -
- ); + return
{link == null ? img : renderSingleLink(link, img)}
; }; const getStyles = (theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({ diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx index 2506255cb03..dc195cbcd67 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx @@ -1,16 +1,14 @@ import { css } from '@emotion/css'; import { Property } from 'csstype'; -import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../../../themes/ThemeContext'; -import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; -import { tooltipOnClickHandler, DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils'; +import { renderSingleLink } from '../../DataLinksActionsTooltip'; +import { useSingleLink } from '../hooks'; import { JSONCellProps } from '../types'; -import { getCellLinks } from '../utils'; -export const JSONCell = ({ value, justifyContent, field, rowIdx, actions }: JSONCellProps) => { +export const JSONCell = ({ value, justifyContent, field, rowIdx }: JSONCellProps) => { const styles = useStyles2(getStyles, justifyContent); let displayValue = value; @@ -33,34 +31,9 @@ export const JSONCell = ({ value, justifyContent, field, rowIdx, actions }: JSON } } - const links = getCellLinks(field, rowIdx) || []; + const link = useSingleLink(field, rowIdx); - const [tooltipCoords, setTooltipCoords] = useState(); - const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions); - const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; - - return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions -
- {shouldShowLink ? ( - renderSingleLink(links[0], displayValue) - ) : shouldShowTooltip ? ( - setTooltipCoords(undefined)} - /> - ) : ( - displayValue - )} -
- ); + return
{link == null ? displayValue : renderSingleLink(link, displayValue)}
; }; const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) => ({ diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx index eb67a369cb2..558d1c7978d 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx @@ -24,7 +24,10 @@ export function TableCellActions(props: TableCellActionsProps) { } = props; return ( -
+ // stopping propagation to prevent clicks within the actions menu from triggering the cell click events + // for things like the data links tooltip. + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
ev.stopPropagation()}> {cellInspect && ( ( height={props.height} width={props.width} rowIdx={props.rowIdx} - actions={props.actions} /> ); @@ -36,7 +35,6 @@ const AUTO_RENDERER: TableCellRenderer = (props) => ( justifyContent={props.justifyContent} rowIdx={props.rowIdx} cellOptions={props.cellOptions} - actions={props.actions} /> ); @@ -53,13 +51,7 @@ const SPARKLINE_RENDERER: TableCellRenderer = (props) => ( ); const JSON_RENDERER: TableCellRenderer = (props) => ( - + ); const GEO_RENDERER: TableCellRenderer = (props) => ( @@ -74,13 +66,14 @@ const IMAGE_RENDERER: TableCellRenderer = (props) => ( justifyContent={props.justifyContent} value={props.value} rowIdx={props.rowIdx} - actions={props.actions} /> ); const DATA_LINKS_RENDERER: TableCellRenderer = (props) => ; -const ACTIONS_RENDERER: TableCellRenderer = (props) => ; +const ACTIONS_RENDERER: TableCellRenderer = ({ field, rowIdx, getActions = () => [] }) => ( + +); const PILL_RENDERER: TableCellRenderer = (props) => ; diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx index dd1d4364128..9dfe1a2719b 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx @@ -1,7 +1,17 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { applyFieldOverrides, createTheme, DataFrame, EventBus, FieldType, toDataFrame } from '@grafana/data'; +import { + applyFieldOverrides, + createTheme, + DataFrame, + DataLink, + EventBus, + FieldType, + LinkModel, + toDataFrame, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { TableCellBackgroundDisplayMode } from '@grafana/schema'; import { PanelContext, PanelContextProvider } from '../../../components/PanelChrome'; @@ -1682,4 +1692,55 @@ describe('TableNG', () => { expect(mockEventBus.publish).not.toHaveBeenCalled(); }); }); + + describe('Displays data Links', () => { + function toLinkModel(link: DataLink): LinkModel { + return { + href: link.url, + title: link.title, + target: link.targetBlank ? '_blank' : '_self', + origin: link.origin || 'panel', + }; + } + + it('shows multiple datalinks in the tooltip', async () => { + const dataFrame = createBasicDataFrame(); + const links: DataLink[] = [ + { url: 'http://asdasd.com', title: 'Test Title' }, + { url: 'http://asdasd2.com', title: 'Test Title2' }, + ]; + + dataFrame.fields[0].config.links = links; + dataFrame.fields[0].getLinks = () => links.map(toLinkModel); + + render(); + + const cell = screen.getByText('A1'); + await userEvent.click(cell); + + const tooltip = screen.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper); + expect(tooltip).toBeInTheDocument(); + + expect(screen.getByText('Test Title')).toBeInTheDocument(); + expect(screen.getByText('Test Title2')).toBeInTheDocument(); + }); + + it('does not show tooltip for a single link', async () => { + const dataFrame = createBasicDataFrame(); + + const links: DataLink[] = [{ url: 'http://asdasd.com', title: 'Test Title' }]; + + dataFrame.fields[0].config.links = links; + dataFrame.fields[0].getLinks = () => links.map(toLinkModel); + + render(); + + const cell = screen.getByText('A1'); + + // we need to click the parent since the cell itself is a link. + await userEvent.click(cell.parentElement!); + + expect(screen.queryByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 5a0a2fdc036..aa35b99ae9c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -1,7 +1,7 @@ import 'react-data-grid/lib/styles.css'; import { css, cx } from '@emotion/css'; import { Property } from 'csstype'; -import { Key, ReactNode, useLayoutEffect, useMemo, useState } from 'react'; +import { Key, ReactNode, useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { Cell, CellRendererProps, @@ -22,8 +22,10 @@ import { ContextMenu } from '../../ContextMenu/ContextMenu'; import { MenuItem } from '../../Menu/MenuItem'; import { Pagination } from '../../Pagination/Pagination'; import { PanelContext, usePanelContext } from '../../PanelChrome'; +import { DataLinksActionsTooltip } from '../DataLinksActionsTooltip'; import { TableCellInspector, TableCellInspectorMode } from '../TableCellInspector'; import { CellColors, TableCellDisplayMode } from '../types'; +import { DataLinksActionsTooltipState } from '../utils'; import { HeaderCell } from './Cells/HeaderCell'; import { RowExpander } from './Cells/RowExpander'; @@ -56,6 +58,8 @@ import { getCellOptions, shouldTextWrap, isCellInspectEnabled, + getCellLinks, + withDataLinksActionsTooltip, } from './utils'; type CellRootRenderer = (key: React.Key, props: CellRendererProps) => React.ReactNode; @@ -68,14 +72,13 @@ export function TableNG(props: TableNGProps) { enableSharedCrosshair = false, enableVirtualization, footerOptions, - getActions, + getActions = () => [], height, initialSortBy, noHeader, onCellFilterAdded, onColumnResize, onSortByChange, - replaceVariables, showTypeIcons, structureRev, width, @@ -88,6 +91,11 @@ export function TableNG(props: TableNGProps) { }); const panelContext = usePanelContext(); + const getCellActions = useCallback( + (field: Field, rowIdx: number) => getActions(data, field, rowIdx), + [getActions, data] + ); + const hasHeader = !noHeader; const hasFooter = Boolean(footerOptions?.show && footerOptions.reducer?.length); const isCountRowsSet = Boolean( @@ -256,13 +264,15 @@ export function TableNG(props: TableNGProps) { interface Schema { columns: TableColumn[]; cellRootRenderers: Record; + colsWithTooltip: Record; } - const { columns, cellRootRenderers } = useMemo(() => { + const { columns, cellRootRenderers, colsWithTooltip } = useMemo(() => { const fromFields = (f: Field[], widths: number[]) => { const result: Schema = { columns: [], cellRootRenderers: {}, + colsWithTooltip: {}, }; let lastRowIdx = -1; @@ -280,7 +290,6 @@ export function TableNG(props: TableNGProps) { const showFilters = Boolean(field.config.filterable && onCellFilterAdded != null); const showActions = cellInspect || showFilters; const width = widths[i]; - const frame = data; // helps us avoid string cx and emotion per-cell const cellActionClassName = showActions @@ -294,6 +303,9 @@ export function TableNG(props: TableNGProps) { const cellType = cellOptions.type; const shouldOverflow = shouldTextOverflow(field); const shouldWrap = shouldTextWrap(field); + const withTooltip = withDataLinksActionsTooltip(field, cellType); + + result.colsWithTooltip[displayName] = withTooltip; // this fires first const renderCellRoot = (key: Key, props: CellRendererProps): ReactNode => { @@ -317,7 +329,7 @@ export function TableNG(props: TableNGProps) { colors = {}; } - const cellStyle = getCellStyles(theme, field, _rowHeight, shouldWrap, shouldOverflow, colors); + const cellStyle = getCellStyles(theme, field, _rowHeight, shouldWrap, shouldOverflow, withTooltip, colors); return ( ): JSX.Element => { const rowIdx = props.row.__index; const value = props.row[props.column.key]; - - // TODO: defer until click? - const actions = getActions?.(frame, field, props.row.__index, replaceVariables); + const frame = data; return ( <> {renderFieldCell({ - actions, cellOptions, frame, field, @@ -354,6 +363,7 @@ export function TableNG(props: TableNGProps) { width, cellInspect, showFilters, + getActions: getCellActions, })} {showActions && ( (); + return ( <> @@ -541,6 +552,24 @@ export function TableNG(props: TableNGProps) { className={styles.grid} columns={structureRevColumns} rows={paginatedRows} + onCellClick={({ column, row }, { clientX, clientY, preventGridDefault }) => { + // Note: could be column.field; JS says yes, but TS says no! + const field = columns[column.idx].field; + + if (colsWithTooltip[getDisplayName(field)]) { + const rowIdx = row.__index; + setTooltipState({ + coords: { + clientX, + clientY, + }, + links: getCellLinks(field, rowIdx), + actions: getCellActions(field, rowIdx), + }); + + preventGridDefault(); + } + }} onCellKeyDown={ hasNestedFrames ? (_, event) => { @@ -577,6 +606,15 @@ export function TableNG(props: TableNGProps) {
)} + {tooltipState && ( + setTooltipState(undefined)} + /> + )} + {isContextMenuOpen && ( ({ - cell: css({ - textOverflow: 'initial', - background: colors.bgColor ?? 'inherit', - alignContent: 'center', - justifyContent: getTextAlign(field), - paddingInline: TABLE.CELL_PADDING, - height: '100%', - minHeight: rowHeight, // min height interacts with the fit-content property on the overflow container - ...(shouldWrap && { whiteSpace: 'pre-line' }), - '&:last-child': { - borderInlineEnd: 'none', - }, - '&:hover': { - background: colors.bgHoverColor, - '.table-cell-actions': { - display: 'flex', +) => { + return { + cell: css({ + textOverflow: 'initial', + background: colors.bgColor ?? 'inherit', + alignContent: 'center', + justifyContent: getTextAlign(field), + paddingInline: TABLE.CELL_PADDING, + height: '100%', + minHeight: rowHeight, // min height interacts with the fit-content property on the overflow container + ...(shouldWrap && { whiteSpace: 'pre-line' }), + ...(hasTooltip && { cursor: 'pointer' }), + '&:last-child': { + borderInlineEnd: 'none', }, - ...(shouldOverflow && { - zIndex: theme.zIndex.tooltip - 2, - whiteSpace: 'pre-line', - height: 'fit-content', - minWidth: 'fit-content', - }), - }, - }), -}); + '&:hover': { + background: colors.bgHoverColor, + '.table-cell-actions': { + display: 'flex', + }, + ...(shouldOverflow && { + zIndex: theme.zIndex.tooltip - 2, + whiteSpace: 'pre-line', + height: 'fit-content', + minWidth: 'fit-content', + }), + }, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts index 1540d1e0383..33c89162939 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect } fr import { Column, DataGridProps, SortColumn } from 'react-data-grid'; import { varPreLine } from 'uwrap'; -import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data'; +import { Field, fieldReducers, FieldType, formattedValueToString, LinkModel, reduceField } from '@grafana/data'; import { useTheme2 } from '../../../themes/ThemeContext'; import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types'; @@ -17,6 +17,7 @@ import { getColumnTypes, GetMaxWrapCellOptions, getMaxWrapCell, + getCellLinks, } from './utils'; // Helper function to get displayed value @@ -597,3 +598,10 @@ export function useColumnResize( return dataGridResizeHandler; } + +export function useSingleLink(field: Field, rowIdx: number): LinkModel | undefined { + const linksCount = field.config.links?.length ?? 0; + const actionsCount = field.config.actions?.length ?? 0; + const shouldShowLink = linksCount === 1 && actionsCount === 0; + return useMemo(() => (shouldShowLink ? (getCellLinks(field, rowIdx) ?? []) : [])[0], [field, shouldShowLink, rowIdx]); +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 2946923db8f..04caa62c85c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -10,7 +10,6 @@ import { TimeRange, FieldConfigSource, ActionModel, - InterpolateFunction, FieldType, DataFrameWithValue, SelectableValue, @@ -30,12 +29,9 @@ export type TableColumnResizeActionCallback = (fieldDisplayName: string, width: export type TableSortByActionCallback = (state: TableSortByFieldState[]) => void; export type FooterItem = Array> | string | undefined; -export type GetActionsFunction = ( - frame: DataFrame, - field: Field, - rowIndex: number, - replaceVariables?: InterpolateFunction -) => ActionModel[]; +export type GetActionsFunction = (frame: DataFrame, field: Field, rowIndex: number) => ActionModel[]; + +export type GetActionsFunctionLocal = (field: Field, rowIndex: number) => ActionModel[]; export type TableFieldOptionsType = Omit & { cellOptions: TableCellOptions; @@ -142,7 +138,6 @@ export interface BaseTableProps { initialRowIndex?: number; fieldConfig?: FieldConfigSource; getActions?: GetActionsFunction; - replaceVariables?: InterpolateFunction; // Used solely for testing as RTL can't correctly render the table otherwise enableVirtualization?: boolean; } @@ -151,7 +146,6 @@ export interface BaseTableProps { export interface TableNGProps extends BaseTableProps {} export interface TableCellRendererProps { - actions?: ActionModel[]; rowIdx: number; frame: DataFrame; timeRange?: TimeRange; @@ -165,6 +159,7 @@ export interface TableCellRendererProps { cellInspect: boolean; showFilters: boolean; justifyContent: Property.JustifyContent; + getActions?: GetActionsFunctionLocal; } export type ContextMenuProps = { @@ -205,7 +200,7 @@ export interface SparklineCellProps { width: number; } -export interface BarGaugeCellProps extends ActionCellProps { +export interface BarGaugeCellProps { field: Field; height: number; rowIdx: number; @@ -214,7 +209,7 @@ export interface BarGaugeCellProps extends ActionCellProps { width: number; } -export interface ImageCellProps extends ActionCellProps { +export interface ImageCellProps { cellOptions: TableCellOptions; field: Field; height: number; @@ -223,7 +218,7 @@ export interface ImageCellProps extends ActionCellProps { rowIdx: number; } -export interface JSONCellProps extends ActionCellProps { +export interface JSONCellProps { justifyContent: Property.JustifyContent; value: TableCellValue; field: Field; @@ -241,24 +236,26 @@ export interface GeoCellProps { height: number; } -export interface ActionCellProps { - actions?: ActionModel[]; -} - export interface CellColors { textColor?: string; bgColor?: string; bgHoverColor?: string; } -export interface AutoCellProps extends ActionCellProps { - value: TableCellValue; +export interface AutoCellProps { field: Field; + value: TableCellValue; justifyContent: Property.JustifyContent; rowIdx: number; cellOptions: TableCellOptions; } +export interface ActionCellProps { + field: Field; + rowIdx: number; + getActions: GetActionsFunctionLocal; +} + // Comparator for sorting table values export type Comparator = (a: TableCellValue, b: TableCellValue) => number; diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 78f5442f234..90004837874 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -172,6 +172,7 @@ const DEFAULT_CELL_OPTIONS = { type: TableCellDisplayMode.Auto } as const; /** * @internal * Returns the cell options for a field, migrating from legacy displayMode if necessary. + * TODO: remove live migration in favor of doing it in dashboard or panel migrator */ export function getCellOptions(field: Field): TableCellOptions { if (field.config.custom?.displayMode) { @@ -613,3 +614,12 @@ export function getApplyToRowBgFn(fields: Field[], theme: GrafanaTheme2): ((rowI } } } + +/** @internal */ +export function withDataLinksActionsTooltip(field: Field, cellType: TableCellDisplayMode) { + return ( + cellType !== TableCellDisplayMode.DataLinks && + cellType !== TableCellDisplayMode.Actions && + (field.config.links?.length ?? 0) + (field.config.actions?.length ?? 0) > 1 + ); +} diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index e2c5b5b8099..1d029ae7099 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -57,7 +57,7 @@ export interface TableCellProps extends CellProps { onCellFilterAdded?: TableFilterActionCallback; innerWidth: number; frame: DataFrame; - actions?: ActionModel[]; + actions?: ActionModel[]; // unused in NG setInspectCell?: TableInspectCellCallback; } diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index 16f5ccac9d2..77dc0cec078 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -766,10 +766,16 @@ export function guessLongestField(fieldConfig: FieldConfigSource, data: DataFram return longestField; } -export type DataLinksActionsTooltipCoords = { +export interface DataLinksActionsTooltipState { + coords: DataLinksActionsTooltipCoords; + links?: LinkModel[]; + actions?: ActionModel[]; +} + +export interface DataLinksActionsTooltipCoords { clientX: number; clientY: number; -}; +} export const getDataLinksActionsTooltipUtils = (links: LinkModel[], actions?: ActionModel[]) => { const hasMultipleLinksOrActions = links.length > 1 || Boolean(actions?.length); diff --git a/public/app/plugins/panel/table/table-new/TablePanel.tsx b/public/app/plugins/panel/table/table-new/TablePanel.tsx index 903a74f65c3..d5bf34dd869 100644 --- a/public/app/plugins/panel/table/table-new/TablePanel.tsx +++ b/public/app/plugins/panel/table/table-new/TablePanel.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { ActionModel, @@ -57,6 +57,11 @@ export function TablePanel(props: Props) { const enableSharedCrosshair = panelContext.sync && panelContext.sync() !== DashboardCursorSync.Off; + const _getActions = useCallback( + (frame: DataFrame, field: Field, rowIndex: number) => getCellActions(frame, field, rowIndex, replaceVariables), + [replaceVariables] + ); + const tableElement = ( ); @@ -159,28 +163,39 @@ const getCellActions = ( field: Field, rowIndex: number, replaceVariables: InterpolateFunction | undefined -) => { - const actions: Array> = []; - const actionLookup = new Set(); +): Array> => { + const numActions = field.config.actions?.length ?? 0; - const actionsModel = getActions( - dataFrame, - field, - field.state!.scopedVars!, - replaceVariables ?? replaceVars, - field.config.actions ?? [], - { valueRowIndex: rowIndex } - ); + if (numActions > 0) { + const actions = getActions( + dataFrame, + field, + field.state!.scopedVars!, + replaceVariables ?? replaceVars, + field.config.actions ?? [], + { valueRowIndex: rowIndex } + ); - actionsModel.forEach((action) => { - const key = `${action.title}`; - if (!actionLookup.has(key)) { - actions.push(action); - actionLookup.add(key); + if (actions.length === 1) { + return actions; + } else { + const actionsOut: Array> = []; + const actionLookup = new Set(); + + actions.forEach((action) => { + const key = action.title; + + if (!actionLookup.has(key)) { + actionsOut.push(action); + actionLookup.add(key); + } + }); + + return actionsOut; } - }); + } - return actions; + return []; }; const tableStyles = { From efe46c8aad661aa2a13b74b7c80eecc395836aa6 Mon Sep 17 00:00:00 2001 From: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:37:54 -0700 Subject: [PATCH 16/21] Limit: Invalidate field reducer calcs on applying limit transformation (#106723) * fix: clear state calcs when limit transformation is applied * chore: fix limit tests expected values --- .../src/transformations/transformers/limit.test.ts | 6 ++++++ .../grafana-data/src/transformations/transformers/limit.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/packages/grafana-data/src/transformations/transformers/limit.test.ts b/packages/grafana-data/src/transformations/transformers/limit.test.ts index 9fde97b0283..a042dc5f34e 100644 --- a/packages/grafana-data/src/transformations/transformers/limit.test.ts +++ b/packages/grafana-data/src/transformations/transformers/limit.test.ts @@ -35,18 +35,21 @@ describe('Limit transformer', () => { { name: 'time', type: FieldType.time, + state: { calcs: undefined }, values: [3000, 4000, 5000], config: {}, }, { name: 'message', type: FieldType.string, + state: { calcs: undefined }, values: ['one', 'two', 'two'], config: {}, }, { name: 'values', type: FieldType.number, + state: { calcs: undefined }, values: [1, 2, 2], config: {}, }, @@ -79,18 +82,21 @@ describe('Limit transformer', () => { { name: 'time', type: FieldType.time, + state: { calcs: undefined }, values: [6000, 7000, 8000], config: {}, }, { name: 'message', type: FieldType.string, + state: { calcs: undefined }, values: ['three', 'three', 'three'], config: {}, }, { name: 'values', type: FieldType.number, + state: { calcs: undefined }, values: [3, 3, 3], config: {}, }, diff --git a/packages/grafana-data/src/transformations/transformers/limit.ts b/packages/grafana-data/src/transformations/transformers/limit.ts index 230e66d9dd3..4361e93c385 100644 --- a/packages/grafana-data/src/transformations/transformers/limit.ts +++ b/packages/grafana-data/src/transformations/transformers/limit.ts @@ -37,6 +37,12 @@ export const limitTransformer: DataTransformerInfo = { fields: frame.fields.map((f) => { return { ...f, + // Clear cached field calculations since applying a limit changes the dataset + // and previously computed stats (min, max, mean, etc.) are no longer valid + state: { + ...f.state, + calcs: undefined, + }, values: limit >= 0 ? f.values.slice(0, limit) : f.values.slice(f.values.length + limit, f.values.length), }; From dd1fce5c8ad466109ad2faba77e6bfb06524c828 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Tue, 8 Jul 2025 14:14:00 -0500 Subject: [PATCH 17/21] Search: Use case-insensitive substring matching in fuzzySearch fallback (#107661) --- packages/grafana-data/src/utils/fuzzySearch.test.ts | 8 ++++++++ packages/grafana-data/src/utils/fuzzySearch.ts | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/grafana-data/src/utils/fuzzySearch.test.ts b/packages/grafana-data/src/utils/fuzzySearch.test.ts index 71d75b27558..b53c26e98aa 100644 --- a/packages/grafana-data/src/utils/fuzzySearch.test.ts +++ b/packages/grafana-data/src/utils/fuzzySearch.test.ts @@ -48,6 +48,14 @@ describe('fuzzySearch', () => { expect(result.map((idx) => haystack[idx])).toEqual(['A水']); }); + it('should do case-insensitive substring match when needle contains non-ascii characters', () => { + const haystack = ['Über']; + const needle = 'ü'; + const result = fuzzySearch(haystack, needle); + + expect(result.map((idx) => haystack[idx])).toEqual(['Über']); + }); + it('should handle multiple non-latin characters', () => { const haystack = ['台灣省', '台中市', '台北市', '台南市', '南投縣', '高雄市', '台中第一高級中學']; const needle = '南'; diff --git a/packages/grafana-data/src/utils/fuzzySearch.ts b/packages/grafana-data/src/utils/fuzzySearch.ts index 72d1540f7b9..09ca02b7bc8 100644 --- a/packages/grafana-data/src/utils/fuzzySearch.ts +++ b/packages/grafana-data/src/utils/fuzzySearch.ts @@ -1,5 +1,7 @@ import uFuzzy from '@leeoniya/ufuzzy'; +import { escapeRegex } from '../text/string'; + // https://catonmat.net/my-favorite-regex :) const REGEXP_NON_ASCII = /[^ -~]/m; // https://www.asciitable.com/ @@ -36,11 +38,13 @@ export function fuzzySearch(haystack: string[], needle: string): number[] { needle.length > maxNeedleLength || uf.split(needle).length > maxFuzzyTerms ) { + const needleRegex = new RegExp(escapeRegex(needle), 'i'); const indices: number[] = []; + for (let i = 0; i < haystack.length; i++) { let item = haystack[i]; - if (item.includes(needle)) { + if (needleRegex.test(item)) { indices.push(i); } } From 79ebe2dc10d1d4d262fc606fa61c46d2e56e066f Mon Sep 17 00:00:00 2001 From: maicon Date: Tue, 8 Jul 2025 16:32:41 -0300 Subject: [PATCH 18/21] Folders: Ensure all folder tests under `/pkg/tests/apis/folders` are handled as integrations tests (#107801) Folders: ensure integration tests are executed in our CI pipeline Signed-off-by: Maicon Costa --- pkg/tests/apis/folder/folders_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index 7bd8fda8e61..24459c08963 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -861,7 +861,11 @@ func TestIntegrationFolderGetPermissions(t *testing.T) { } // TestFoldersCreateAPIEndpointK8S is the counterpart of pkg/api/folder_test.go TestFoldersCreateAPIEndpoint -func TestFoldersCreateAPIEndpointK8S(t *testing.T) { +func TestIntegrationFoldersCreateAPIEndpointK8S(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + folderWithoutParentInput := "{ \"uid\": \"uid\", \"title\": \"Folder\"}" folderWithTitleEmpty := "{ \"title\": \"\"}" folderWithInvalidUid := "{ \"uid\": \"::::::::::::\", \"title\": \"Another folder\"}" @@ -1018,7 +1022,11 @@ func testDescription(description string, expectedErr error) string { } // There are no counterpart of TestFoldersGetAPIEndpointK8S in pkg/api/folder_test.go -func TestFoldersGetAPIEndpointK8S(t *testing.T) { +func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + type testCase struct { description string expectedCode int From 869094bb3713c236a497270714b27c4ff9ebf063 Mon Sep 17 00:00:00 2001 From: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com> Date: Tue, 8 Jul 2025 14:11:54 -0700 Subject: [PATCH 19/21] SQL Expressions: Reconfigure add expression button for improved UX (#106797) * feat: reconfigure expression button for improved UX * chore: fix broken test * chore: refactor to use improved UX + combine another UI PR. * chore: i18n * chore: memoize options + add data test ids for tracking * chore: common component for expression dropdown * chore: streamline common component * chore: add event tracking * chore: put event tracking in its own PR --- .../PanelDataPane/PanelDataQueriesTab.tsx | 27 +++-- .../expressions/ExpressionQueryEditor.tsx | 66 +++++++++--- .../components/ExpressionTypeDropdown.tsx | 100 ++++++++++++++++++ .../expressions/components/SqlExpr.tsx | 10 +- public/locales/en-US/grafana.json | 1 + 5 files changed, 174 insertions(+), 30 deletions(-) create mode 100644 public/app/features/expressions/components/ExpressionTypeDropdown.tsx diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx index f1660b56772..19a1e3af6c5 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx @@ -18,6 +18,9 @@ import { addQuery } from 'app/core/utils/query'; import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard'; import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils'; import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; +import { ExpressionTypeDropdown } from 'app/features/expressions/components/ExpressionTypeDropdown'; +import { ExpressionQueryType } from 'app/features/expressions/types'; +import { getDefaults } from 'app/features/expressions/utils/expressionTypes'; import { GroupActionComponents } from 'app/features/query/components/QueryActionComponent'; import { QueryEditorRows } from 'app/features/query/components/QueryEditorRows'; import { QueryGroupTopSection } from 'app/features/query/components/QueryGroup'; @@ -286,9 +289,15 @@ export class PanelDataQueriesTab extends SceneObjectBase { + public onAddExpressionOfType = (type: ExpressionQueryType) => { const queries = this.getQueries(); - this.onQueriesChange(addQuery(queries, expressionDatasource.newQuery())); + // Create base expression query with the specified type + const baseQuery = expressionDatasource.newQuery(); + const queryWithType = { ...baseQuery, type }; + // Apply defaults specific to the expression type + const queryWithDefaults = getDefaults(queryWithType); + + this.onQueriesChange(addQuery(queries, queryWithDefaults)); }; public renderExtraActions() { @@ -316,6 +325,7 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps { // ensure all queries explicitly define a datasource @@ -394,16 +404,11 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps )} {config.expressionsEnabled && model.isExpressionsSupported(dsSettings) && ( - + + )} {model.renderExtraActions()} diff --git a/public/app/features/expressions/ExpressionQueryEditor.tsx b/public/app/features/expressions/ExpressionQueryEditor.tsx index 75261904c60..b1f07c253bb 100644 --- a/public/app/features/expressions/ExpressionQueryEditor.tsx +++ b/public/app/features/expressions/ExpressionQueryEditor.tsx @@ -1,10 +1,12 @@ +import { css } from '@emotion/css'; import { useCallback, useEffect, useRef } from 'react'; -import { DataSourceApi, QueryEditorProps, SelectableValue } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { InlineField, Select } from '@grafana/ui'; +import { DataSourceApi, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; +import { Button, IconButton, InlineField, PopoverContent, useStyles2 } from '@grafana/ui'; import { ClassicConditions } from './components/ClassicConditions'; +import { ExpressionTypeDropdown } from './components/ExpressionTypeDropdown'; import { Math } from './components/Math'; import { Reduce } from './components/Reduce'; import { Resample } from './components/Resample'; @@ -20,6 +22,24 @@ const labelWidth = 15; type NonClassicExpressionType = Exclude; type ExpressionTypeConfigStorage = Partial>; +// Help text for each expression type - can be expanded with more detailed content +const getExpressionHelpText = (type: ExpressionQueryType): PopoverContent | string => { + const description = expressionTypes.find(({ value }) => value === type)?.description; + + switch (type) { + case ExpressionQueryType.sql: + return ( + + Run MySQL-dialect SQL against the tables returned from your data sources. Data source queries (ie "A", "B") + are available as tables and referenced by query-name. Fields are available as columns, as returned from the + data source. + + ); + default: + return description ?? ''; + } +}; + function useExpressionsCache() { const expressionCache = useRef({}); @@ -62,14 +82,16 @@ export function ExpressionQueryEditor(props: Props) { const { query, queries, onRunQuery, onChange, app } = props; const { getCachedExpression, setCachedExpression } = useExpressionsCache(); + const styles = useStyles2(getStyles); + useEffect(() => { setCachedExpression(query.type, query.expression); }, [query.expression, query.type, setCachedExpression]); const onSelectExpressionType = useCallback( - (item: SelectableValue) => { - const cachedExpression = getCachedExpression(item.value!); - const defaults = getDefaults({ ...query, type: item.value! }); + (value: ExpressionQueryType) => { + const cachedExpression = getCachedExpression(value!); + const defaults = getDefaults({ ...query, type: value! }); onChange({ ...defaults, expression: cachedExpression ?? defaults.expression }); }, @@ -100,17 +122,35 @@ export function ExpressionQueryEditor(props: Props) { } }; - const selected = expressionTypes.find((o) => o.value === query.type); + const helperText = getExpressionHelpText(query.type); return (
- -