Compare commits

..

4 Commits

Author SHA1 Message Date
Gaurav Tewari
d4cbbcf3f7 chore: more refactor 2026-09-11 20:26:15 +05:30
Gaurav Tewari
9130e14bf6 chore: test and code update 2026-09-11 20:21:44 +05:30
Gaurav Tewari
75eed0778d refactor: move things to constant 2026-09-11 19:44:52 +05:30
Gaurav Tewari
6862387501 refactor: getFieldKey and getFeildValue for qb 2026-09-11 18:16:26 +05:30
268 changed files with 1940 additions and 17938 deletions

View File

@@ -79,36 +79,6 @@ jobs:
run: |
cd frontend && pnpm generate:api
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run pnpm generate:api in frontend/ locally and commit."; exit 1)
storybook:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
timeout-minutes: 10
container:
image: mcr.microsoft.com/playwright:v1.57.0-noble
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
version: 10
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: install-frontend
run: cd frontend && pnpm install
- name: test-storybook
run: cd frontend && pnpm test:storybook --shard=${{ matrix.shard }}/${{ strategy.job-total }}
web-settings:
if: |
github.event_name == 'merge_group' ||

View File

@@ -296,17 +296,6 @@ components:
- jsmops
- incidentio
type: string
AlertmanagertypesChannelListOrder:
enum:
- asc
- desc
type: string
AlertmanagertypesChannelListSort:
enum:
- updated_at
- created_at
- name
type: string
AlertmanagertypesChannelMSTeamsConfig:
properties:
sendResolved:
@@ -587,43 +576,6 @@ components:
wont_fix_resolution:
type: string
type: object
AlertmanagertypesListableNotificationChannel:
properties:
channels:
items:
$ref: '#/components/schemas/AlertmanagertypesListedNotificationChannel'
type: array
total:
format: int64
type: integer
required:
- channels
- total
type: object
AlertmanagertypesListedNotificationChannel:
properties:
createdAt:
format: date-time
type: string
displayName:
type: string
id:
type: string
kind:
$ref: '#/components/schemas/AlertmanagertypesChannelKind'
name:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- displayName
- kind
- createdAt
- updatedAt
type: object
AlertmanagertypesMaintenanceKind:
enum:
- fixed
@@ -990,20 +942,6 @@ components:
- timezone
- startTime
type: object
AlertmanagertypesTestableNotificationChannel:
properties:
config:
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
required:
- config
type: object
AlertmanagertypesUpdatableNotificationChannel:
properties:
config:
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
required:
- config
type: object
AuthtypesAttributeMapping:
properties:
email:
@@ -3536,11 +3474,6 @@ components:
- tags
- spec
type: object
DashboardtypesHeaderOptions:
properties:
hide:
type: boolean
type: object
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -3898,7 +3831,6 @@ components:
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
propertyName: kind
oneOf:
@@ -3909,7 +3841,6 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -3920,7 +3851,6 @@ components:
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
@@ -3994,18 +3924,6 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec:
properties:
kind:
enum:
- signoz/TextPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTextPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec:
properties:
kind:
@@ -4297,37 +4215,6 @@ components:
- color
- columnName
type: object
DashboardtypesTextAlign:
enum:
- left
- center
- right
type: string
DashboardtypesTextMode:
enum:
- markdown
type: string
DashboardtypesTextPanelSpec:
properties:
headerOptions:
$ref: '#/components/schemas/DashboardtypesHeaderOptions'
mode:
$ref: '#/components/schemas/DashboardtypesTextMode'
presentation:
$ref: '#/components/schemas/DashboardtypesTextPresentation'
text:
type: string
type: object
DashboardtypesTextPresentation:
properties:
background:
nullable: true
type: string
textAlign:
$ref: '#/components/schemas/DashboardtypesTextAlign'
verticalAlign:
$ref: '#/components/schemas/DashboardtypesVerticalAlign'
type: object
DashboardtypesTextVariableSpec:
properties:
constant:
@@ -4537,12 +4424,6 @@ components:
- kind
- spec
type: object
DashboardtypesVerticalAlign:
enum:
- top
- center
- bottom
type: string
ErrorsJSON:
properties:
code:
@@ -7514,7 +7395,10 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
type: array
meta:
$ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
properties:
unit:
type: string
type: object
predictedSeries:
items:
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
@@ -7529,51 +7413,12 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
type: array
type: object
Querybuildertypesv5AggregationMeta:
Querybuildertypesv5Bucket:
properties:
buckets:
items:
format: double
type: number
type: array
unit:
type: string
step:
format: double
type: number
type: object
Querybuildertypesv5BucketOptions:
discriminator:
mapping:
linear: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
log: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
type: object
Querybuildertypesv5BucketOptionsLinear:
properties:
kind:
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
spec:
$ref: '#/components/schemas/Querybuildertypesv5LinearBucketsSpec'
required:
- kind
- spec
type: object
Querybuildertypesv5BucketOptionsLog:
properties:
kind:
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
spec:
$ref: '#/components/schemas/Querybuildertypesv5LogBucketsSpec'
required:
- kind
- spec
type: object
Querybuildertypesv5BucketsKind:
enum:
- linear
- log
type: string
Querybuildertypesv5BuilderQuerySpec:
discriminator:
mapping:
@@ -7754,16 +7599,6 @@ components:
value:
type: string
type: object
Querybuildertypesv5LinearBucketsSpec:
properties:
maxValue:
format: double
type: number
numBuckets:
type: integer
required:
- maxValue
type: object
Querybuildertypesv5LogAggregation:
properties:
alias:
@@ -7771,12 +7606,6 @@ components:
expression:
type: string
type: object
Querybuildertypesv5LogBucketsSpec:
properties:
scale:
nullable: true
type: integer
type: object
Querybuildertypesv5MetricAggregation:
properties:
comparisonSpaceAggregationParam:
@@ -7857,8 +7686,6 @@ components:
type: object
Querybuildertypesv5QueryBuilderFormula:
properties:
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
disabled:
type: boolean
expression:
@@ -7889,8 +7716,6 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5LogAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
@@ -7952,8 +7777,6 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5MetricAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
@@ -8015,8 +7838,6 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
@@ -8342,7 +8163,6 @@ components:
- raw
- raw_stream
- trace
- heatmap
type: string
Querybuildertypesv5ScalarData:
properties:
@@ -8417,6 +8237,8 @@ components:
type: object
Querybuildertypesv5TimeSeriesValue:
properties:
bucket:
$ref: '#/components/schemas/Querybuildertypesv5Bucket'
partial:
type: boolean
timestamp:
@@ -8527,8 +8349,6 @@ components:
$ref: '#/components/schemas/RuletypesAlertState'
overallStateChanged:
type: boolean
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -8572,8 +8392,6 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5Label'
nullable: true
type: array
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -8679,7 +8497,6 @@ components:
- TRACES_BASED_ALERT
- LOGS_BASED_ALERT
- EXCEPTIONS_BASED_ALERT
- AI_TRACES_BASED_ALERT
type: string
RuletypesBasicRuleThreshold:
properties:
@@ -19903,86 +19720,6 @@ paths:
tags:
- metrics
/api/v2/notification_channels:
get:
deprecated: false
description: Returns a page of notification channels for the org. Each entry
carries the channel's identity and kind but not its configuration; fetch a
channel by ID for that. Supports a case-insensitive display name search (`query`),
a kind filter (`kind`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`),
and offset-based pagination (`limit`/`offset`).
operationId: ListNotificationChannels
parameters:
- in: query
name: query
schema:
type: string
- in: query
name: kind
schema:
$ref: '#/components/schemas/AlertmanagertypesChannelKind'
- in: query
name: sort
schema:
$ref: '#/components/schemas/AlertmanagertypesChannelListSort'
- in: query
name: order
schema:
$ref: '#/components/schemas/AlertmanagertypesChannelListOrder'
- in: query
name: limit
schema:
type: integer
- in: query
name: offset
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesListableNotificationChannel'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:list
- tokenizer:
- notification-channel:list
summary: List notification channels
tags:
- channels
post:
deprecated: false
description: This endpoint creates a notification channel
@@ -20045,239 +19782,6 @@ paths:
summary: Create notification channel
tags:
- channels
/api/v2/notification_channels/{id}:
delete:
deprecated: false
description: This endpoint deletes a notification channel by ID
operationId: DeleteNotificationChannel
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:delete
- tokenizer:
- notification-channel:delete
summary: Delete notification channel
tags:
- channels
get:
deprecated: false
description: This endpoint returns a notification channel by ID. A channel written
by the v1 API can carry a configuration this API does not model.
operationId: GetNotificationChannel
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:read
- tokenizer:
- notification-channel:read
summary: Get notification channel by ID
tags:
- channels
put:
deprecated: false
description: 'This endpoint replaces a notification channel''s configuration
in full. Neither name is part of the request body: both are immutable. The
kind may change, which replaces the channel''s notifier configuration.'
operationId: UpdateNotificationChannel
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesUpdatableNotificationChannel'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:update
- tokenizer:
- notification-channel:update
summary: Update notification channel
tags:
- channels
/api/v2/notification_channels/test:
post:
deprecated: false
description: This endpoint sends a test notification for the configuration in
the request body. The channel need not exist and nothing is persisted, so
the body carries a configuration only.
operationId: TestNotificationChannel
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesTestableNotificationChannel'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:create
- tokenizer:
- notification-channel:create
summary: Test notification channel
tags:
- channels
/api/v2/orgs/me:
get:
deprecated: false

View File

@@ -21,5 +21,4 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
- [Service](service.md) - Managed service lifecycle with `factory.Service`
- [SQL](sql.md) - Database and SQL patterns
- [SQL Compiler](sqlcompiler.md) - Compiling the list filter DSL to relational-store WHERE clauses
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`

View File

@@ -1,84 +0,0 @@
# SQL Compiler
List pages (dashboards, alert rules) share one filter DSL in their search bars. [pkg/parser/filterquery/sqlcompiler](/pkg/parser/filterquery/sqlcompiler/compiler.go) compiles a DSL string into a WHERE clause for the relational store: `?`-placeholder SQL plus bind arguments, ready for bun on both SQLite and Postgres. Telemetry filters are a different pipeline. They stay on querybuilder's ClickHouse visitor.
The package owns everything generic about the language. A module adopting it writes exactly one thing: a `FieldResolver` that says which keys exist and what each maps to.
## What is the DSL?
The grammar lives at [grammar/FilterQuery.g4](/grammar/FilterQuery.g4), with the ANTLR-generated parser in [pkg/parser/filterquery/grammar](/pkg/parser/filterquery/grammar). It is the same grammar the telemetry search bars use, so the query language feels identical everywhere. The shapes that matter:
- Boolean structure: parentheses > `NOT` > `AND` > `OR`; adjacent terms with no connective are an implicit `AND`.
- Comparisons: `key OP value`, e.g. `name CONTAINS cpu`, `created_at > '2025-01-01T00:00:00Z'`, `labels.team IN ('infra', 'platform')`, `labels.env EXISTS`. See the `comparison` rule in the grammar for the full operator list.
- Free text: a bare or quoted token with no key. Quoting is the escape hatch for a phrase that looks like DSL.
- Values: bare tokens or quoted strings; `IN` accepts `in(...)` and `[...]` forms.
## What does the framework already cover?
```go
compiled, errs := sqlcompiler.Compile(query, formatter, resolver)
```
`Compile` returns either a non-nil `*Compiled` or a list of human-readable errors. An empty query compiles to an empty `Compiled`; callers gate on `IsEmpty()`, not nil. On top of parsing, the package handles:
- Syntax errors, collected with line/column positions instead of failing on the first one.
- The boolean tree: `AND`/`OR`/`NOT`, parentheses, implicit `AND`, and pruning of empty conditions.
- Operator extraction, including inversion of `NOT LIKE`, `NOT IN`, `NOT EXISTS` and friends.
- Predicate builders the resolver calls back into:
| Builder | Handles |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `BuildStringOperation` | `=`, `!=`, `LIKE`/`ILIKE`, `CONTAINS`, `IN` on a string column; escapes `%`/`_` for `CONTAINS`, rejects patterns ending in a dangling backslash, lowers both sides for `ILIKE` so SQLite and Postgres agree |
| `BuildTimestampComparison` | equality, ranges and `BETWEEN` on RFC3339 timestamps |
| `BuildBoolComparison` | `= true/false` |
| `BuildFreeTextContains` | case-insensitive substring match, `COALESCE`d so `NOT (...)` does not drop rows where the column is NULL |
- Typed value extraction (`ExtractSingleStringValue`, `ExtractStringValueList`, ...) with accumulated errors: the user sees every problem in the query at once.
- Argument binding through go-sqlbuilder; no value is ever interpolated into the SQL text.
## When do I write a FieldResolver?
Whenever a module adopts the DSL for its list page. The resolver is the per-module policy and the only code you write:
```go
type FieldResolver interface {
ResolveComparison(v *Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string
ResolveFreeText(v *Visitor, value string) string
}
```
Rules for implementing one:
- Declare the key namespace in `pkg/types/<domain>`: `DSLKey` constants plus a `ReservedOps` map of key to allowed operators. The list API advertises these as `reservedKeywords`, so the frontend suggestions never go stale.
- Reject a disallowed operator with `v.AddError(...)` and return `""`. Never panic, never fail fast; the compile fails at the end with all errors.
- Map a key to a column expression through `v.Formatter` (`JSONExtractString`, `LowerExpression`), never by hand, so the expression is valid on both SQLite and Postgres.
- Delegate the predicate to the `Build*` helpers above; do not hand-build SQL or manage arguments yourself.
- For a key that lives in a relation table (dashboard tags, rule labels), build an `EXISTS` subquery on a fresh `sqlbuilder.SelectBuilder` and pass that builder into `BuildStringOperation`, so its arguments thread through the compile. For a negative operator, build the positive predicate and toggle `NotExists` on the outer builder.
The reference implementation is the dashboards resolver, [pkg/modules/dashboard/impldashboard/listfilter_resolver.go](/pkg/modules/dashboard/impldashboard/listfilter_resolver.go): reserved keys backed by columns and JSON paths, tag keys via `EXISTS` subqueries, free text across name, description and tags.
## How to wire it in?
Give the module a thin `Compile` wrapper that maps the error list onto the module's error code, as in [pkg/modules/dashboard/impldashboard/listfilter.go](/pkg/modules/dashboard/impldashboard/listfilter.go):
```go
func Compile(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
compiled, errs := sqlcompiler.Compile(query, formatter, dashboardFieldResolver{})
if len(errs) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(errs, "; "))
}
return compiled, nil
}
```
The store then appends `compiled.SQL` with `compiled.Args` to its list query when `!compiled.IsEmpty()`.
## What should I remember?
- One DSL, one compiler; a new list page adds a `FieldResolver`, not a new parser or SQL layer.
- Keys and allowed operators live in `pkg/types/<domain>` and are advertised as `reservedKeywords`.
- Column expressions go through `v.Formatter`; predicates go through the `Build*` helpers.
- Report problems with `v.AddError` and return `""`; errors accumulate.
- Relation-table keys use `EXISTS` subqueries on their own builder; negation toggles `NotExists`.
- This package is for the relational store only; telemetry filtering stays in querybuilder.

View File

@@ -1,11 +1,9 @@
<!--
`index.html` links Inter from Google Fonts here. The link is parsed before the
msw worker has started, so that one request escapes the iframe while every
other font request the app makes is answered with an empty stylesheet; the
local faces stand in for both.
-->
<link rel="stylesheet" href="storybook-fonts.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="css/uPlot.min.css" />
<script>

View File

@@ -1,50 +0,0 @@
/*
* The five families the app pulls from Google Fonts, served from the files in
* `public/fonts` instead. `msw/appShellHandlers.ts` answers the CDN with an
* empty stylesheet so no request leaves the iframe, which without this left
* every story on a fallback for the four families `src/styles.scss` imports at
* runtime, and on whatever the Inter link in `index.html` happened to fetch
* before the worker had started.
*/
@font-face {
font-family: 'Inter';
/* The comma is part of the filename and the dev server serves it raw, so it
stays literal inside the quoted url rather than percent-encoded. */
src: url('fonts/Inter-VariableFont_opsz,wght.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Work Sans';
src: url('fonts/WorkSans-VariableFont_wght.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Space Mono';
src: url('fonts/SpaceMono-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Fira Code';
src: url('fonts/FiraCode-VariableFont_wght.ttf') format('truetype');
font-weight: 300 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('fonts/GeistMonoVF.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}

View File

@@ -1,85 +0,0 @@
import { getStoryContext } from '@storybook/test-runner';
import type { TestRunnerConfig } from '@storybook/test-runner';
import type { Page } from 'playwright';
const IGNORED_MESSAGES = [
// `preview-head.html` swaps a local stylesheet in for Google Fonts, but the
// browser still warns on the real cross-origin one it briefly requests
// before msw starts (no CORS headers), regardless of story content.
/Can't access cssRules/,
// Pre-existing dev-server noise, unrelated to any story.
/Couldn't load preload assets/,
// Fires because a Jest-driven browser sets a global testing flag React
// checks for; unrelated to anything a story does.
/current testing environment is not configured to support act/,
// React and antd route dev-only warnings (missing keys, DOM nesting, API
// deprecations) through `console.error` under this prefix; app-wide and
// tracked separately from story regressions.
/^Warning: /,
// msw's own warning when its response listener count grows across many
// story visits in one browser session; not a story defect.
/MaxListenersExceededWarning/,
// `preview-head.html`'s CSP intentionally blocks third-party iframes
// (YouTube embeds, the docs pane) so they hit the real network instead of
// an unanswered msw request; the block is the point, not a bug.
/violates the following Content Security Policy directive/,
];
const messagesByPage = new WeakMap<Page, string[]>();
/**
* Only `console.error` fails a story. `console.warn` is dev-time advice from
* app code (e.g. `aggregateData is null`) and from the runner itself; an
* unmocked `/api/` call is a `console.error` in `src/storybook/msw/handlers.ts`.
*/
const config: TestRunnerConfig = {
// msw logs every mocked request at `log`; keep it out of the failure dump
// unless the job is re-run with debug logging (GitHub sets RUNNER_DEBUG=1).
logLevel: process.env.RUNNER_DEBUG === '1' ? 'info' : 'warn',
async preVisit(page): Promise<void> {
const existing = messagesByPage.get(page);
if (existing) {
existing.length = 0;
return;
}
const messages: string[] = [];
messagesByPage.set(page, messages);
page.on('console', (message) => {
if (
message.type() === 'error' &&
!IGNORED_MESSAGES.some((pattern) => pattern.test(message.text()))
) {
messages.push(`[error] ${message.text()}`);
}
});
// The console message alone ("Failed to load resource") doesn't name the
// URL; pairing it with the response is what makes a missing mock
// actionable instead of just a status code.
page.on('response', (response) => {
if (response.status() >= 400) {
messages.push(`[response] ${response.status()} ${response.url()}`);
}
});
},
async postVisit(page, context): Promise<void> {
const messages = messagesByPage.get(page) ?? [];
if (messages.length === 0) {
return;
}
// A story that deliberately mocks a failure response (e.g. a 500 to test
// an error state) logs the error it's testing for; opt it out per-story
// with `parameters: { allowConsoleErrors: true }`.
const storyContext = await getStoryContext(page, context);
if (storyContext.parameters?.allowConsoleErrors) {
return;
}
throw new Error(
`Story "${context.name}" logged console error/warning:\n${messages.join('\n')}`,
);
},
};
export default config;

View File

@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -9,7 +9,6 @@
"dev": "vite",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build -o storybook-static",
"test:storybook": "bash scripts/test-storybook.sh",
"build": "vite build",
"preview": "vite preview",
"prettify": "oxfmt",
@@ -163,7 +162,6 @@
"@jest/types": "30.2.0",
"@storybook/addon-a11y": "10.5.9",
"@storybook/react-vite": "10.5.9",
"@storybook/test-runner": "0.24.5",
"@testing-library/dom": "8.20.0",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "13.4.0",
@@ -235,7 +233,7 @@
},
"overrides": {
"@babel/core@<=7.29.0": ">=7.29.6 <8",
"@istanbuljs/load-nyc-config>js-yaml": ">=4.3.1 <5",
"@istanbuljs/load-nyc-config>js-yaml": ">=4.2.0 <5",
"cookie@<0.7.0": ">=0.7.1 <1",
"dompurify@<=3.4.10": ">=3.4.11 <4",
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1 <0.29.0",
@@ -244,14 +242,6 @@
"prismjs@<1.30.0": ">=1.30.0 <2",
"react-router@>=6.7.0 <6.30.4": ">=6.30.4 <7",
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2",
"brace-expansion@<1.1.18": ">=1.1.18 <2",
"brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4 <3",
"brace-expansion@>=5.0.0 <5.0.9": ">=5.0.9 <6",
"fast-uri@<3.1.5": ">=3.1.5 <4",
"immutable@<5.1.8": ">=5.1.8 <6",
"js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5",
"less@<4.5.0": ">=4.5.0 <5",
"nanoid@<3.3.18": ">=3.3.18 <4"
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
}
}

1076
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
story_count=$(find src -name '*.stories.tsx' | wc -l)
if [ "$story_count" -eq 0 ]; then
echo "No *.stories.tsx found under src/" >&2
exit 1
fi
# jest splits the sorted story files into contiguous shards and exits 1 when a
# shard is empty, which happens on every shard above the file count. The runner
# rejects jest's own `--passWithNoTests`, so skip those shards here.
for arg in "$@"; do
if [[ $arg == --shard=* ]]; then
shard_index=${arg#--shard=}
if [ "${shard_index%%/*}" -gt "$story_count" ]; then
echo "Skipping ${arg#--shard=}: only ${story_count} story files"
exit 0
fi
fi
done
pnpm storybook --ci --quiet &
SB_PID=$!
trap 'kill "$SB_PID" 2>/dev/null || true' EXIT
until curl -sf http://127.0.0.1:6006/index.json >/dev/null 2>&1; do
sleep 1
done
pnpm exec test-storybook --ci --maxWorkers=2 "$@"

View File

@@ -1,4 +1,4 @@
import { AxiosError, AxiosResponse, isCancel } from 'axios';
import { AxiosError, AxiosResponse } from 'axios';
import { ErrorResponse } from 'types/api';
import { ErrorStatusCode } from 'types/common';
@@ -42,16 +42,6 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
};
}
if (request) {
// Avoid logging error when the request was just cancelled for whatever reason
if (isCancel(error)) {
return {
statusCode: 500,
payload: null,
error: 'Something went wrong',
message: null,
};
}
// client never received a response, or request never left
console.error('client never received a response, or request never left');

View File

@@ -21,23 +21,14 @@ import type {
AlertmanagertypesPostableChannelDTO,
AlertmanagertypesPostableNotificationChannelDTO,
AlertmanagertypesReceiverDTO,
AlertmanagertypesTestableNotificationChannelDTO,
AlertmanagertypesUpdatableNotificationChannelDTO,
CreateChannel201,
CreateNotificationChannel201,
DeleteChannelByIDPathParameters,
DeleteNotificationChannelPathParameters,
GetChannelByID200,
GetChannelByIDPathParameters,
GetNotificationChannel200,
GetNotificationChannelPathParameters,
ListChannels200,
ListNotificationChannels200,
ListNotificationChannelsParams,
RenderErrorResponseDTO,
UpdateChannelByIDPathParameters,
UpdateNotificationChannel200,
UpdateNotificationChannelPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -658,105 +649,6 @@ export const useTestChannelDeprecated = <
> => {
return useMutation(getTestChannelDeprecatedMutationOptions(options));
};
/**
* Returns a page of notification channels for the org. Each entry carries the channel's identity and kind but not its configuration; fetch a channel by ID for that. Supports a case-insensitive display name search (`query`), a kind filter (`kind`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
* @summary List notification channels
*/
export const listNotificationChannels = (
params?: ListNotificationChannelsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListNotificationChannels200>({
url: `/api/v2/notification_channels`,
method: 'GET',
params,
signal,
});
};
export const getListNotificationChannelsQueryKey = (
params?: ListNotificationChannelsParams,
) => {
return [`/api/v2/notification_channels`, ...(params ? [params] : [])] as const;
};
export const getListNotificationChannelsQueryOptions = <
TData = Awaited<ReturnType<typeof listNotificationChannels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListNotificationChannelsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listNotificationChannels>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListNotificationChannelsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listNotificationChannels>>
> = ({ signal }) => listNotificationChannels(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listNotificationChannels>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListNotificationChannelsQueryResult = NonNullable<
Awaited<ReturnType<typeof listNotificationChannels>>
>;
export type ListNotificationChannelsQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary List notification channels
*/
export function useListNotificationChannels<
TData = Awaited<ReturnType<typeof listNotificationChannels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListNotificationChannelsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listNotificationChannels>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListNotificationChannelsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List notification channels
*/
export const invalidateListNotificationChannels = async (
queryClient: QueryClient,
params?: ListNotificationChannelsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListNotificationChannelsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint creates a notification channel
* @summary Create notification channel
@@ -841,370 +733,3 @@ export const useCreateNotificationChannel = <
> => {
return useMutation(getCreateNotificationChannelMutationOptions(options));
};
/**
* This endpoint deletes a notification channel by ID
* @summary Delete notification channel
*/
export const deleteNotificationChannel = (
{ id }: DeleteNotificationChannelPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/notification_channels/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
TError,
{ pathParams: DeleteNotificationChannelPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
TError,
{ pathParams: DeleteNotificationChannelPathParameters },
TContext
> => {
const mutationKey = ['deleteNotificationChannel'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
{ pathParams: DeleteNotificationChannelPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteNotificationChannel(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteNotificationChannel>>
>;
export type DeleteNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete notification channel
*/
export const useDeleteNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
TError,
{ pathParams: DeleteNotificationChannelPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
TError,
{ pathParams: DeleteNotificationChannelPathParameters },
TContext
> => {
return useMutation(getDeleteNotificationChannelMutationOptions(options));
};
/**
* This endpoint returns a notification channel by ID. A channel written by the v1 API can carry a configuration this API does not model.
* @summary Get notification channel by ID
*/
export const getNotificationChannel = (
{ id }: GetNotificationChannelPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetNotificationChannel200>({
url: `/api/v2/notification_channels/${id}`,
method: 'GET',
signal,
});
};
export const getGetNotificationChannelQueryKey = ({
id,
}: GetNotificationChannelPathParameters) => {
return [`/api/v2/notification_channels/${id}`] as const;
};
export const getGetNotificationChannelQueryOptions = <
TData = Awaited<ReturnType<typeof getNotificationChannel>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetNotificationChannelPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getNotificationChannel>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetNotificationChannelQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getNotificationChannel>>
> = ({ signal }) => getNotificationChannel({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getNotificationChannel>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetNotificationChannelQueryResult = NonNullable<
Awaited<ReturnType<typeof getNotificationChannel>>
>;
export type GetNotificationChannelQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get notification channel by ID
*/
export function useGetNotificationChannel<
TData = Awaited<ReturnType<typeof getNotificationChannel>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetNotificationChannelPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getNotificationChannel>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetNotificationChannelQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get notification channel by ID
*/
export const invalidateGetNotificationChannel = async (
queryClient: QueryClient,
{ id }: GetNotificationChannelPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetNotificationChannelQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint replaces a notification channel's configuration in full. Neither name is part of the request body: both are immutable. The kind may change, which replaces the channel's notifier configuration.
* @summary Update notification channel
*/
export const updateNotificationChannel = (
{ id }: UpdateNotificationChannelPathParameters,
alertmanagertypesUpdatableNotificationChannelDTO?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateNotificationChannel200>({
url: `/api/v2/notification_channels/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesUpdatableNotificationChannelDTO,
signal,
});
};
export const getUpdateNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationChannel>>,
TError,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationChannel>>,
TError,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
},
TContext
> => {
const mutationKey = ['updateNotificationChannel'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateNotificationChannel>>,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateNotificationChannel(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof updateNotificationChannel>>
>;
export type UpdateNotificationChannelMutationBody =
| BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>
| undefined;
export type UpdateNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update notification channel
*/
export const useUpdateNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationChannel>>,
TError,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateNotificationChannel>>,
TError,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
},
TContext
> => {
return useMutation(getUpdateNotificationChannelMutationOptions(options));
};
/**
* This endpoint sends a test notification for the configuration in the request body. The channel need not exist and nothing is persisted, so the body carries a configuration only.
* @summary Test notification channel
*/
export const testNotificationChannel = (
alertmanagertypesTestableNotificationChannelDTO?: BodyType<AlertmanagertypesTestableNotificationChannelDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/notification_channels/test`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesTestableNotificationChannelDTO,
signal,
});
};
export const getTestNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
TContext
> => {
const mutationKey = ['testNotificationChannel'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof testNotificationChannel>>,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> }
> = (props) => {
const { data } = props ?? {};
return testNotificationChannel(data);
};
return { mutationFn, ...mutationOptions };
};
export type TestNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof testNotificationChannel>>
>;
export type TestNotificationChannelMutationBody =
| BodyType<AlertmanagertypesTestableNotificationChannelDTO>
| undefined;
export type TestNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Test notification channel
*/
export const useTestNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
TContext
> => {
return useMutation(getTestNotificationChannelMutationOptions(options));
};

View File

@@ -507,15 +507,6 @@ export enum AlertmanagertypesChannelKindDTO {
jsmops = 'jsmops',
incidentio = 'incidentio',
}
export enum AlertmanagertypesChannelListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum AlertmanagertypesChannelListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
}
export interface ModelLabelSetDTO {
[key: string]: string;
}
@@ -1009,44 +1000,6 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
wont_fix_resolution?: string;
}
export interface AlertmanagertypesListedNotificationChannelDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
displayName: string;
/**
* @type string
*/
id: string;
kind: AlertmanagertypesChannelKindDTO;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface AlertmanagertypesListableNotificationChannelDTO {
/**
* @type array
*/
channels: AlertmanagertypesListedNotificationChannelDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export enum AlertmanagertypesMaintenanceKindDTO {
fixed = 'fixed',
recurring = 'recurring',
@@ -2438,14 +2391,6 @@ export interface AlertmanagertypesReceiverDTO {
wechat_configs?: ConfigWechatConfigDTO[];
}
export interface AlertmanagertypesTestableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
}
export interface AlertmanagertypesUpdatableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
}
export interface AuthtypesAttributeMappingDTO {
/**
* @type string
@@ -4134,53 +4079,6 @@ export interface Querybuildertypesv5LogAggregationDTO {
expression?: string;
}
export enum Querybuildertypesv5BucketOptionsLinearDTOKind {
linear = 'linear',
}
export interface Querybuildertypesv5LinearBucketsSpecDTO {
/**
* @type number
* @format double
*/
maxValue: number;
/**
* @type integer
*/
numBuckets?: number;
}
export interface Querybuildertypesv5BucketOptionsLinearDTO {
/**
* @type string
* @enum linear
*/
kind: Querybuildertypesv5BucketOptionsLinearDTOKind;
spec: Querybuildertypesv5LinearBucketsSpecDTO;
}
export enum Querybuildertypesv5BucketOptionsLogDTOKind {
log = 'log',
}
export interface Querybuildertypesv5LogBucketsSpecDTO {
/**
* @type integer,null
*/
scale?: number | null;
}
export interface Querybuildertypesv5BucketOptionsLogDTO {
/**
* @type string
* @enum log
*/
kind: Querybuildertypesv5BucketOptionsLogDTOKind;
spec: Querybuildertypesv5LogBucketsSpecDTO;
}
export type Querybuildertypesv5BucketOptionsDTO =
| Querybuildertypesv5BucketOptionsLinearDTO
| Querybuildertypesv5BucketOptionsLogDTO;
export interface Querybuildertypesv5FilterDTO {
/**
* @type string
@@ -4374,7 +4272,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array,null
*/
aggregations?: Querybuildertypesv5LogAggregationDTO[] | null;
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
*/
@@ -4502,7 +4399,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array,null
*/
aggregations?: Querybuildertypesv5MetricAggregationDTO[] | null;
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
*/
@@ -4578,7 +4474,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array,null
*/
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
*/
@@ -5019,57 +4914,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesListPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind {
'signoz/TextPanel' = 'signoz/TextPanel',
}
export interface DashboardtypesHeaderOptionsDTO {
/**
* @type boolean
*/
hide?: boolean;
}
export enum DashboardtypesTextModeDTO {
markdown = 'markdown',
}
export enum DashboardtypesTextAlignDTO {
left = 'left',
center = 'center',
right = 'right',
}
export enum DashboardtypesVerticalAlignDTO {
top = 'top',
center = 'center',
bottom = 'bottom',
}
export interface DashboardtypesTextPresentationDTO {
/**
* @type string,null
*/
background?: string | null;
textAlign?: DashboardtypesTextAlignDTO;
verticalAlign?: DashboardtypesVerticalAlignDTO;
}
export interface DashboardtypesTextPanelSpecDTO {
headerOptions?: DashboardtypesHeaderOptionsDTO;
mode?: DashboardtypesTextModeDTO;
presentation?: DashboardtypesTextPresentationDTO;
/**
* @type string
*/
text?: string;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO {
/**
* @enum signoz/TextPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind;
spec: DashboardtypesTextPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5077,8 +4921,7 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -5086,7 +4929,6 @@ export enum Querybuildertypesv5RequestTypeDTO {
raw = 'raw',
raw_stream = 'raw_stream',
trace = 'trace',
heatmap = 'heatmap',
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind {
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
@@ -5133,7 +4975,6 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
}
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type boolean
*/
@@ -6002,7 +5843,6 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/TablePanel' = 'signoz/TablePanel',
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
}
/**
* @nullable
@@ -8702,7 +8542,16 @@ export interface Querybuildertypesv5LabelDTO {
value?: Querybuildertypesv5LabelDTOValue;
}
export interface Querybuildertypesv5BucketDTO {
/**
* @type number
* @format double
*/
step?: number;
}
export interface Querybuildertypesv5TimeSeriesValueDTO {
bucket?: Querybuildertypesv5BucketDTO;
/**
* @type boolean
*/
@@ -9253,16 +9102,12 @@ export interface PromotetypesPromotePathDTO {
promote?: boolean;
}
export interface Querybuildertypesv5AggregationMetaDTO {
/**
* @type array
*/
buckets?: number[];
export type Querybuildertypesv5AggregationBucketDTOMeta = {
/**
* @type string
*/
unit?: string;
}
};
export interface Querybuildertypesv5AggregationBucketDTO {
/**
@@ -9281,7 +9126,10 @@ export interface Querybuildertypesv5AggregationBucketDTO {
* @type array
*/
lowerBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
meta?: Querybuildertypesv5AggregationMetaDTO;
/**
* @type object
*/
meta?: Querybuildertypesv5AggregationBucketDTOMeta;
/**
* @type array
*/
@@ -9296,10 +9144,6 @@ export interface Querybuildertypesv5AggregationBucketDTO {
upperBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
}
export enum Querybuildertypesv5BucketsKindDTO {
linear = 'linear',
log = 'log',
}
export type Querybuildertypesv5ColumnDescriptorDTOMeta = {
/**
* @type string
@@ -9766,10 +9610,6 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @type boolean
*/
overallStateChanged: boolean;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9818,10 +9658,6 @@ export interface RulestatehistorytypesGettableRuleStateHistoryContributorDTO {
* @type array,null
*/
labels: Querybuildertypesv5LabelDTO[] | null;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9917,7 +9753,6 @@ export enum RuletypesAlertTypeDTO {
TRACES_BASED_ALERT = 'TRACES_BASED_ALERT',
LOGS_BASED_ALERT = 'LOGS_BASED_ALERT',
EXCEPTIONS_BASED_ALERT = 'EXCEPTIONS_BASED_ALERT',
AI_TRACES_BASED_ALERT = 'AI_TRACES_BASED_ALERT',
}
export enum RuletypesMatchTypeDTO {
at_least_once = 'at_least_once',
@@ -13327,44 +13162,6 @@ export type GetMetricsTreemap200 = {
status: string;
};
export type ListNotificationChannelsParams = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @description undefined
*/
kind?: AlertmanagertypesChannelKindDTO;
/**
* @description undefined
*/
sort?: AlertmanagertypesChannelListSortDTO;
/**
* @description undefined
*/
order?: AlertmanagertypesChannelListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListNotificationChannels200 = {
data: AlertmanagertypesListableNotificationChannelDTO;
/**
* @type string
*/
status: string;
};
export type CreateNotificationChannel201 = {
data: AlertmanagertypesGettableNotificationChannelDTO;
/**
@@ -13373,31 +13170,6 @@ export type CreateNotificationChannel201 = {
status: string;
};
export type DeleteNotificationChannelPathParameters = {
id: string;
};
export type GetNotificationChannelPathParameters = {
id: string;
};
export type GetNotificationChannel200 = {
data: AlertmanagertypesGettableNotificationChannelDTO;
/**
* @type string
*/
status: string;
};
export type UpdateNotificationChannelPathParameters = {
id: string;
};
export type UpdateNotificationChannel200 = {
data: AlertmanagertypesGettableNotificationChannelDTO;
/**
* @type string
*/
status: string;
};
export type GetMyOrganization200 = {
data: TypesOrganizationDTO;
/**

View File

@@ -0,0 +1,13 @@
import { getAIObservabilityFieldsKeys } from 'api/generated/services/ai-observability';
import { getFieldsKeys } from 'api/generated/services/fields';
import type { BuilderQueryType } from 'types/api/v5/queryRange';
import { FieldKeysFilterConfig, FieldKeysResponse } from './types';
export const getFieldKeySuggestions = (
filterConfig: FieldKeysFilterConfig,
builderQueryType?: BuilderQueryType,
): Promise<FieldKeysResponse> =>
builderQueryType === 'builder_ai_query'
? getAIObservabilityFieldsKeys(filterConfig)
: getFieldsKeys(filterConfig);

View File

@@ -0,0 +1,13 @@
import { getAIObservabilityFieldsValues } from 'api/generated/services/ai-observability';
import { getFieldsValues } from 'api/generated/services/fields';
import type { BuilderQueryType } from 'types/api/v5/queryRange';
import { FieldValuesFilterConfig, FieldValuesResponse } from './types';
export const getFieldValueSuggestions = (
filterConfig: FieldValuesFilterConfig,
builderQueryType?: BuilderQueryType,
): Promise<FieldValuesResponse> =>
builderQueryType === 'builder_ai_query'
? getAIObservabilityFieldsValues(filterConfig)
: getFieldsValues(filterConfig);

View File

@@ -0,0 +1,26 @@
import type {
GetAIObservabilityFieldsKeys200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValues200,
GetAIObservabilityFieldsValuesParams,
GetFieldsKeys200,
GetFieldsKeysParams,
GetFieldsValues200,
GetFieldsValuesParams,
} from 'api/generated/services/sigNoz.schemas';
export type FieldKeysFilterConfig =
| GetFieldsKeysParams
| GetAIObservabilityFieldsKeysParams;
export type FieldValuesFilterConfig =
| GetFieldsValuesParams
| GetAIObservabilityFieldsValuesParams;
export type FieldKeysResponse =
| GetFieldsKeys200
| GetAIObservabilityFieldsKeys200;
export type FieldValuesResponse =
| GetFieldsValues200
| GetAIObservabilityFieldsValues200;

View File

@@ -1,45 +0,0 @@
import type { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import type { CursorPosition } from './types';
import styles from './MarkdownEditor.module.scss';
interface EditorStatusBarProps {
cursor: CursorPosition;
length: number;
maxLength: number;
hint?: ReactNode;
}
function EditorStatusBar({
cursor,
length,
maxLength,
hint,
}: EditorStatusBarProps): JSX.Element {
const isOverLimit = length > maxLength;
return (
<div className={styles.statusBar} data-testid="markdown-editor-status">
<Typography.Text className={styles.statusPosition}>
{`Ln ${cursor.line}, Col ${cursor.column}`}
<span className={styles.statusSeparator}>·</span>
<span
className={cx(styles.statusCount, {
[styles.statusCountOverLimit]: isOverLimit,
})}
data-testid="markdown-editor-char-count"
>
{isOverLimit ? `${length} / ${maxLength} chars` : `${length} chars`}
</span>
</Typography.Text>
{hint && (
<Typography.Text className={styles.statusHint}>{hint}</Typography.Text>
)}
</div>
);
}
export default EditorStatusBar;

View File

@@ -1,93 +0,0 @@
import type { ReactNode } from 'react';
import {
Bold,
CodeXml,
Heading,
Italic,
Link,
List,
ListOrdered,
Table,
Type,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import InsertVariableMenu from './InsertVariableMenu';
import MarkdownHelp from './MarkdownHelp';
import type { EditorCommand, EditorVariable } from './types';
import styles from './MarkdownEditor.module.scss';
const COMMAND_ICONS: Record<string, ReactNode> = {
heading: <Heading size={14} />,
bold: <Bold size={14} />,
italic: <Italic size={14} />,
'bulleted-list': <List size={14} />,
'numbered-list': <ListOrdered size={14} />,
link: <Link size={14} />,
code: <CodeXml size={14} />,
table: <Table size={14} />,
};
interface EditorToolbarProps {
formatLabel: string;
commands: EditorCommand[];
onRunCommand: (command: EditorCommand) => void;
variables: EditorVariable[];
onInsertVariable: (name: string) => void;
disabled: boolean;
extra?: ReactNode;
}
function EditorToolbar({
formatLabel,
commands,
onRunCommand,
variables,
onInsertVariable,
disabled,
extra,
}: EditorToolbarProps): JSX.Element {
return (
<div className={styles.toolbar} data-testid="markdown-editor-toolbar">
<span className={styles.formatChip}>
<Type size={14} />
<Typography.Text className={styles.formatLabel}>
{formatLabel}
</Typography.Text>
</span>
<span className={styles.toolbarDivider} />
<div className={styles.commands}>
{commands.map((command) => (
<TooltipSimple key={command.id} title={command.label}>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
disabled={disabled}
aria-label={command.label}
data-testid={`markdown-command-${command.id}`}
onClick={(): void => onRunCommand(command)}
>
{COMMAND_ICONS[command.id]}
</Button>
</TooltipSimple>
))}
</div>
<div className={styles.toolbarEnd}>
{extra}
<InsertVariableMenu
variables={variables}
onSelect={onInsertVariable}
disabled={disabled}
/>
<MarkdownHelp />
</div>
</div>
);
}
export default EditorToolbar;

View File

@@ -1,91 +0,0 @@
import { useMemo, useState } from 'react';
import { ChevronDown, DollarSign, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import type { EditorVariable } from './types';
import styles from './MarkdownEditor.module.scss';
interface InsertVariableMenuProps {
variables: EditorVariable[];
/** Receives the variable name; the caller decides the token syntax. */
onSelect: (name: string) => void;
disabled: boolean;
}
function toMenuItems(
variables: EditorVariable[],
onSelect: (name: string) => void,
): MenuItem[] {
return variables.map((variable) => ({
key: variable.name,
label: (
<span
className={styles.variableRow}
data-testid={`markdown-variable-${variable.name}`}
>
<span className={styles.variableName}>{`$${variable.name}`}</span>
{variable.badge && (
<span className={styles.variableBadge}>{variable.badge}</span>
)}
</span>
),
onClick: (): void => onSelect(variable.name),
}));
}
/** Searchable variable picker; hidden entirely when there is nothing to insert. */
function InsertVariableMenu({
variables,
onSelect,
disabled,
}: InsertVariableMenuProps): JSX.Element | null {
const [search, setSearch] = useState('');
const matches = useMemo(() => {
const query = search.trim().toLowerCase();
return query
? variables.filter((variable) => variable.name.toLowerCase().includes(query))
: variables;
}, [variables, search]);
const items = useMemo(
() => toMenuItems(matches, onSelect),
[matches, onSelect],
);
if (variables.length === 0) {
return null;
}
return (
<DropdownMenuSimple
className={styles.variableMenu}
menu={{
items,
search: {
placeholder: 'Search variables',
searchIcon: <Search size={14} />,
onSearchChange: setSearch,
},
}}
>
<Button
type="button"
variant="outlined"
color="secondary"
size="sm"
disabled={disabled}
prefix={<DollarSign size={14} className={styles.insertVariableIcon} />}
suffix={<ChevronDown size={14} />}
className={styles.insertVariable}
data-testid="markdown-insert-variable"
>
Insert variable
</Button>
</DropdownMenuSimple>
);
}
export default InsertVariableMenu;

View File

@@ -1,253 +0,0 @@
@use '../../styles/scrollbar' as *;
.container {
// Read by the decoration theme in `markdownHighlight`, which can't see SCSS.
--md-syntax-heading: var(--text-vanilla-100);
--md-syntax-strong: var(--text-vanilla-100);
--md-syntax-emphasis: var(--text-vanilla-300);
--md-syntax-quote: var(--text-vanilla-400);
--md-syntax-marker: var(--text-robin-300);
--md-syntax-code: var(--text-forest-400);
--md-syntax-link: var(--text-robin-400);
--md-syntax-variable: var(--text-amber-400);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--l1-background);
}
:global(body.lightMode) .container {
--md-syntax-heading: var(--text-ink-400);
--md-syntax-strong: var(--text-ink-400);
--md-syntax-emphasis: var(--text-ink-200);
--md-syntax-quote: var(--text-neutral-light-100);
--md-syntax-marker: var(--text-robin-500);
--md-syntax-code: var(--text-forest-700);
--md-syntax-link: var(--text-robin-500);
--md-syntax-variable: var(--text-sienna-500);
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
}
.formatChip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--l1-border);
border-radius: 2px;
color: var(--text-sienna-400);
}
.formatLabel {
font-size: 12px;
font-weight: 500;
color: var(--l1-foreground);
}
.toolbarDivider {
width: 1px;
height: 16px;
flex-shrink: 0;
background: var(--l1-border);
}
.commands {
display: flex;
align-items: center;
gap: 2px;
}
.toolbarEnd {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.insertVariable {
white-space: nowrap;
}
.insertVariableIcon {
color: var(--text-amber-400);
}
// The ui library's dropdown assumes a global border-box reset this app doesn't
// have (`box-sizing` is set on `body` only and doesn't inherit): its items are
// `width: 100%` + padding, so in the portal they lay out content-box and
// overflow the popup by the padding — clipping the flush-right badge.
.variableMenu,
.variableMenu * {
box-sizing: border-box;
}
.variableMenu {
width: 320px;
}
// Shrinkable, so a clamped popup truncates the name instead of clipping the
// badge at the content's `overflow: hidden` edge.
.variableRow {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.variableName {
font-family: var(--font-family-sf-mono);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.variableBadge {
flex-shrink: 0;
margin-left: auto;
padding: 2px 6px;
border: 1px solid color-mix(in srgb, var(--text-amber-400) 40%, transparent);
border-radius: 4px;
font-family: var(--font-family-sf-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-amber-400);
}
.editorArea {
flex: 1;
min-height: 0;
overflow: hidden;
}
.codeMirror {
height: 100%;
font-family: var(--font-family-sf-mono);
font-size: 13px;
:global(.cm-editor) {
height: 100%;
background: transparent;
}
:global(.cm-editor.cm-focused) {
outline: none;
}
:global(.cm-gutters) {
background: transparent;
border-right: none;
color: var(--text-neutral-dark-200);
}
:global(.cm-scroller) {
line-height: 20px;
padding: 0 12px;
@include custom-scrollbar;
}
:global(.cm-content) {
padding: 8px 0;
}
}
.statusBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
padding: 6px 12px;
border-top: 1px solid var(--l1-border);
}
.statusPosition {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-family-sf-mono);
font-size: 11px;
color: var(--text-neutral-dark-200);
}
.statusSeparator {
color: var(--l1-border);
}
.statusCount {
color: inherit;
}
.statusCountOverLimit {
color: var(--text-cherry-400);
font-weight: 600;
}
.statusHint {
font-size: 11px;
color: var(--text-neutral-dark-200);
}
.helpContent {
width: 280px;
max-height: 320px;
overflow-y: auto;
@include custom-scrollbar;
}
.helpTitle {
display: block;
margin-bottom: 8px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-neutral-dark-200);
}
.helpList {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
}
.helpRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
dt {
margin: 0;
code {
font-family: var(--font-family-sf-mono);
font-size: 11px;
color: var(--text-forest-400);
}
}
dd {
margin: 0;
font-size: 11px;
color: var(--text-neutral-dark-200);
}
}
// The help popover portals out of `.container`, so it can't inherit its tokens.
:global(body.lightMode) .helpRow dt code {
color: var(--text-forest-700);
}

View File

@@ -1,245 +0,0 @@
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { copilot } from '@uiw/codemirror-theme-copilot';
import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, {
type BasicSetupOptions,
EditorView,
type ViewUpdate,
} from '@uiw/react-codemirror';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { formatVariableToken, MARKDOWN_MAX_LENGTH } from './constants';
import EditorStatusBar from './EditorStatusBar';
import EditorToolbar from './EditorToolbar';
import { applyTransform, replaceDocument } from './editorDocument';
import { insertText, MARKDOWN_COMMANDS } from './markdownCommands';
import { markdownHighlight } from './markdownHighlight';
import type {
CursorPosition,
EditorCommand,
EditorTransform,
EditorVariable,
} from './types';
import styles from './MarkdownEditor.module.scss';
/** What the status bar reports. */
type DocumentStatus = CursorPosition & { length: number };
// No language grammar is loaded, so bracket/indent/completion behaviour would only
// get in the way of prose. `indentWithTab` stays off so Tab keeps moving focus.
const BASIC_SETUP: BasicSetupOptions = {
lineNumbers: true,
highlightActiveLine: true,
highlightActiveLineGutter: true,
foldGutter: false,
autocompletion: false,
bracketMatching: false,
closeBrackets: false,
indentOnInput: false,
syntaxHighlighting: false,
highlightSelectionMatches: false,
rectangularSelection: false,
crosshairCursor: false,
searchKeymap: false,
foldKeymap: false,
lintKeymap: false,
completionKeymap: false,
closeBracketsKeymap: false,
};
const EMPTY_VARIABLES: EditorVariable[] = [];
export interface MarkdownEditorProps {
/** Seeds the document; replaced only from outside. See the sync effect. */
value: string;
onChange: (value: string) => void;
/** Offered by the "Insert variable" menu; the button disables when empty. */
variables?: EditorVariable[];
/** What the character counter reports against. */
maxLength?: number;
placeholder?: string;
readOnly?: boolean;
/** Shown on the toolbar chip. */
formatLabel?: string;
/** Rendered before the "Insert variable" menu. */
toolbarExtra?: ReactNode;
/** Right-hand status-bar note, e.g. "Preview updates as you type". */
statusHint?: ReactNode;
autoFocus?: boolean;
className?: string;
testId?: string;
}
/**
* Source editor for Markdown bodies. Source-only: it neither parses nor renders
* the body, so the preview surface and its sanitisation stay the caller's concern.
*/
function MarkdownEditor({
value,
onChange,
variables = EMPTY_VARIABLES,
maxLength = MARKDOWN_MAX_LENGTH,
placeholder = 'Write Markdown…',
readOnly = false,
formatLabel = 'Markdown',
toolbarExtra,
statusHint,
autoFocus = false,
className,
testId = 'markdown-editor',
}: MarkdownEditorProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const viewRef = useRef<EditorView | null>(null);
// Set while a programmatic replacement is in flight, so the caller isn't told
// about a change it asked for. `dispatch` runs listeners synchronously, so the
// window is exactly one call.
const isSyncingRef = useRef(false);
const previousValueRef = useRef(value);
const hasSeededRef = useRef(false);
const [isEditorReady, setIsEditorReady] = useState(false);
const [status, setStatus] = useState<DocumentStatus>(() => ({
line: 1,
column: 1,
length: value.length,
}));
const syncDocument = useCallback((view: EditorView, next: string): void => {
isSyncingRef.current = true;
replaceDocument(view, next);
isSyncingRef.current = false;
}, []);
const onCreateEditor = useCallback((view: EditorView): void => {
viewRef.current = view;
setIsEditorReady(true);
}, []);
/**
* Seeds the document, then applies external replacements — nothing else. Keeping
* keystrokes out of this round-trip is what stops a stale `value` from replacing
* the document and resetting the caret when typing outpaces React.
*
* The seed can't go in `onCreateEditor`: the wrapper defaults its own `value` to
* `''` and reconciles against it once the view exists, wiping anything written
* before that. `isEditorReady` puts this effect after that pass, since a parent's
* effects flush after its children's.
*
* Focus marks ownership: a replacement arriving mid-typing is dropped rather than
* applied over the author.
*/
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
const previous = previousValueRef.current;
previousValueRef.current = value;
const isSeeding = !hasSeededRef.current;
hasSeededRef.current = true;
if (!isSeeding && (value === previous || view.hasFocus)) {
return;
}
if (view.state.doc.toString() !== value) {
syncDocument(view, value);
}
}, [value, isEditorReady, syncDocument]);
const handleChange = useCallback(
(next: string): void => {
if (!isSyncingRef.current) {
onChange(next);
}
},
[onChange],
);
const runTransform = useCallback((transform: EditorTransform): void => {
const view = viewRef.current;
if (view) {
applyTransform(view, transform);
}
}, []);
const onRunCommand = useCallback(
(command: EditorCommand): void => runTransform(command.run),
[runTransform],
);
const onInsertVariable = useCallback(
(name: string): void =>
runTransform((snapshot) => insertText(snapshot, formatVariableToken(name))),
[runTransform],
);
const extensions = useMemo(
() => [markdownHighlight(), EditorView.lineWrapping],
[],
);
// From the document, not `value`: the caller may debounce or drop a change, and
// the counter has to match what the author sees.
const onUpdate = useCallback((update: ViewUpdate): void => {
if (!update.selectionSet && !update.docChanged) {
return;
}
const { head } = update.state.selection.main;
const line = update.state.doc.lineAt(head);
setStatus({
line: line.number,
column: head - line.from + 1,
length: update.state.doc.length,
});
}, []);
return (
<div className={cx(styles.container, className)} data-testid={testId}>
<EditorToolbar
formatLabel={formatLabel}
commands={MARKDOWN_COMMANDS}
onRunCommand={onRunCommand}
variables={variables}
onInsertVariable={onInsertVariable}
disabled={readOnly}
extra={toolbarExtra}
/>
<div className={styles.editorArea}>
<CodeMirror
className={styles.codeMirror}
// No `value`: passing it re-enables the wrapper's own reconciliation,
// and with it the caret reset.
onCreateEditor={onCreateEditor}
onChange={handleChange}
onUpdate={onUpdate}
theme={isDarkMode ? copilot : githubLight}
basicSetup={BASIC_SETUP}
placeholder={placeholder}
editable={!readOnly}
readOnly={readOnly}
indentWithTab={false}
autoFocus={autoFocus}
extensions={extensions}
height="100%"
/>
</div>
<EditorStatusBar
cursor={status}
length={status.length}
maxLength={maxLength}
hint={statusHint}
/>
</div>
);
}
export default MarkdownEditor;

View File

@@ -1,44 +0,0 @@
import { CircleHelp } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { Typography } from '@signozhq/ui/typography';
import { MARKDOWN_HELP_ITEMS } from './constants';
import styles from './MarkdownEditor.module.scss';
function MarkdownHelp(): JSX.Element {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
aria-label="Markdown syntax help"
data-testid="markdown-help-trigger"
>
<CircleHelp size={14} />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className={styles.helpContent}>
<Typography.Text className={styles.helpTitle}>
Markdown syntax
</Typography.Text>
<dl className={styles.helpList}>
{MARKDOWN_HELP_ITEMS.map((item) => (
<div key={item.syntax} className={styles.helpRow}>
<dt>
<code>{item.syntax}</code>
</dt>
<dd>{item.label}</dd>
</div>
))}
</dl>
</PopoverContent>
</Popover>
);
}
export default MarkdownHelp;

View File

@@ -1,270 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { EditorView } from '@uiw/react-codemirror';
import { mockCodeMirrorDomApis } from 'components/QueryBuilderV2/QueryV2/__tests__/codemirrorDomMocks';
import {
act,
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import MarkdownEditor from '../MarkdownEditor';
import type { EditorVariable } from '../types';
beforeAll(() => {
mockCodeMirrorDomApis();
});
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => true,
}));
const VARIABLES: EditorVariable[] = [
{ name: 'environment', badge: 'QUERY' },
{ name: 'service', badge: 'CUSTOM' },
];
/** A caller whose state trails the editor by one keystroke. */
function LaggingHarness(): JSX.Element {
const [value, setValue] = useState('');
const previousRef = useRef('');
const onChange = useCallback((next: string): void => {
setValue(previousRef.current);
previousRef.current = next;
}, []);
return <MarkdownEditor value={value} onChange={onChange} />;
}
/** Pushes a replacement in from outside the editor. */
function ExternalHarness(): JSX.Element {
const [value, setValue] = useState('before');
return (
<>
<button type="button" onClick={(): void => setValue('after')}>
push
</button>
<MarkdownEditor value={value} onChange={setValue} />
</>
);
}
function Harness({
initialValue = '',
maxLength,
variables = VARIABLES,
}: {
initialValue?: string;
maxLength?: number;
variables?: EditorVariable[];
}): JSX.Element {
const [value, setValue] = useState(initialValue);
return (
<MarkdownEditor
value={value}
onChange={setValue}
variables={variables}
maxLength={maxLength}
statusHint="Preview updates as you type"
/>
);
}
const getView = (): EditorView => {
const dom = document.querySelector('.cm-editor');
const view = dom ? EditorView.findFromDOM(dom as HTMLElement) : null;
if (!view) {
throw new Error('editor view not mounted');
}
return view;
};
const select = (from: number, to: number): void => {
act(() => {
getView().dispatch({ selection: { anchor: from, head: to } });
});
};
const documentText = (): string => getView().state.doc.toString();
describe('MarkdownEditor', () => {
it('reports the caret position and character count', async () => {
render(<Harness initialValue={'one\ntwo'} />);
select(5, 5);
await waitFor(() => {
expect(screen.getByTestId('markdown-editor-status')).toHaveTextContent(
'Ln 2, Col 2',
);
});
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
'7 chars',
);
});
it('flags a body over the character cap', async () => {
render(<Harness initialValue="123456" maxLength={5} />);
await waitFor(() => {
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
'6 / 5 chars',
);
});
});
it('applies a toolbar command to the selection', async () => {
render(<Harness initialValue="a word b" />);
select(2, 6);
await userEvent.click(screen.getByTestId('markdown-command-bold'));
await waitFor(() => {
expect(documentText()).toBe('a **word** b');
});
});
it('inserts a variable token at the caret', async () => {
render(<Harness initialValue="env: " />);
select(5, 5);
await userEvent.click(screen.getByTestId('markdown-insert-variable'));
// The row shows the name and kind badge.
const row = await screen.findByTestId('markdown-variable-environment');
expect(row).toHaveTextContent('$environment');
expect(row).toHaveTextContent('QUERY');
// fireEvent: userEvent's pointer-down path walks DOM selection APIs the
// CodeMirror mocks stub out.
fireEvent.click(row);
await waitFor(() => {
expect(documentText()).toBe('env: $environment');
});
});
it('colours Markdown syntax and variable tokens in the source', async () => {
render(<Harness initialValue={'## Runbook\nowner {{team}}'} />);
await waitFor(() => {
expect(document.querySelector('.cm-md-heading')).toBeInTheDocument();
});
expect(document.querySelector('.cm-md-variable')).toHaveTextContent(
'{{team}}',
);
});
describe('uncontrolled document', () => {
const type = (at: number, text: string): void => {
act(() => {
getView().dispatch({
changes: { from: at, insert: text },
selection: { anchor: at + text.length },
});
});
};
const focusEditor = (): void => {
act(() => {
getView().focus();
});
};
it('keeps the document and caret while the caller lags behind the typing', () => {
render(<LaggingHarness />);
focusEditor();
type(0, 'a');
type(1, 'b');
type(2, 'c');
expect(documentText()).toBe('abc');
expect(getView().state.selection.main.head).toBe(3);
});
it('reports every keystroke to the caller', () => {
const onChange = jest.fn();
render(<MarkdownEditor value="ab" onChange={onChange} />);
type(2, 'c');
expect(onChange).toHaveBeenLastCalledWith('abc');
});
it('does not report the seed back as a change', () => {
const onChange = jest.fn();
render(<MarkdownEditor value="seeded" onChange={onChange} />);
expect(documentText()).toBe('seeded');
expect(onChange).not.toHaveBeenCalled();
});
it('applies an external replacement while the editor is unfocused', async () => {
render(<ExternalHarness />);
await userEvent.click(screen.getByRole('button', { name: 'push' }));
expect(documentText()).toBe('after');
});
it('ignores a replacement that arrives while the author is still typing', () => {
render(<ExternalHarness />);
focusEditor();
// fireEvent: a real click would blur the editor first. This covers an update
// arriving on its own, while the author is still in the document.
fireEvent.click(screen.getByRole('button', { name: 'push' }));
expect(documentText()).toBe('before');
});
it('counts characters from the document, not from the lagging value', async () => {
render(<MarkdownEditor value="ab" onChange={jest.fn()} />);
type(2, 'cde');
await waitFor(() => {
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
'5 chars',
);
});
});
});
it('offers both list kinds in the toolbar', () => {
render(<Harness />);
expect(
screen.getByTestId('markdown-command-bulleted-list'),
).toBeInTheDocument();
expect(
screen.getByTestId('markdown-command-numbered-list'),
).toBeInTheDocument();
});
it('disables authoring affordances when read-only', () => {
render(
<MarkdownEditor
value="body"
onChange={jest.fn()}
variables={VARIABLES}
readOnly
/>,
);
expect(screen.getByTestId('markdown-command-bold')).toBeDisabled();
expect(screen.getByTestId('markdown-insert-variable')).toBeDisabled();
});
it('hides the insert-variable control when none are available', () => {
render(<Harness variables={[]} />);
expect(
screen.queryByTestId('markdown-insert-variable'),
).not.toBeInTheDocument();
});
});

View File

@@ -1,258 +0,0 @@
import { insertText, MARKDOWN_COMMANDS } from '../markdownCommands';
import type { EditorSnapshot, EditorTransform } from '../types';
const commandById = (id: string): EditorTransform => {
const command = MARKDOWN_COMMANDS.find((entry) => entry.id === id);
if (!command) {
throw new Error(`unknown command: ${id}`);
}
return command.run;
};
const heading = commandById('heading');
const bold = commandById('bold');
const italic = commandById('italic');
const bulletedList = commandById('bulleted-list');
const numberedList = commandById('numbered-list');
const link = commandById('link');
const code = commandById('code');
const table = commandById('table');
/** `|` marks a caret, `[...]` a range, so expectations read like the editor looks. */
const snapshot = (marked: string): EditorSnapshot => {
if (marked.includes('|')) {
const caret = marked.indexOf('|');
return {
text: marked.replace('|', ''),
selectionStart: caret,
selectionEnd: caret,
};
}
const start = marked.indexOf('[');
const end = marked.indexOf(']') - 1;
return {
text: marked.replace('[', '').replace(']', ''),
selectionStart: start,
selectionEnd: end,
};
};
const selectionOf = (result: EditorSnapshot): string =>
result.text.slice(result.selectionStart, result.selectionEnd);
describe('heading', () => {
it('prefixes the caret line and keeps the caret on the same character', () => {
const result = heading(snapshot('Chec|kout'));
expect(result.text).toBe('## Checkout');
expect(result.selectionStart).toBe(7);
});
it('strips the prefix when every selected line already has one', () => {
const result = heading({
text: '## one\n### two',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('one\ntwo');
});
it('adds the prefix when only some selected lines have one', () => {
const result = heading({
text: '## one\ntwo',
selectionStart: 0,
selectionEnd: 10,
});
expect(result.text).toBe('## ## one\n## two');
});
it('does not pull in the line after a selection ending on a line break', () => {
const result = heading({
text: 'one\ntwo',
selectionStart: 0,
selectionEnd: 4,
});
expect(result.text).toBe('## one\ntwo');
});
});
describe('bulleted list', () => {
it('bullets every line of a multi-line selection', () => {
const result = bulletedList({
text: 'one\ntwo',
selectionStart: 0,
selectionEnd: 7,
});
expect(result.text).toBe('- one\n- two');
expect(selectionOf(result)).toBe('- one\n- two');
});
it('unbullets a list written with a different marker', () => {
const result = bulletedList({
text: '* one\n+ two',
selectionStart: 0,
selectionEnd: 11,
});
expect(result.text).toBe('one\ntwo');
});
});
describe('numbered list', () => {
it('numbers each line of the selection in order', () => {
const result = numberedList({
text: 'one\ntwo\nthree',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('1. one\n2. two\n3. three');
});
it('unnumbers a list whose numbering is not sequential', () => {
const result = numberedList({
text: '1. one\n5. two',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('one\ntwo');
});
});
describe('switching between list kinds', () => {
it('converts bullets to numbers rather than marking them twice', () => {
const result = numberedList({
text: '- one\n- two',
selectionStart: 0,
selectionEnd: 11,
});
expect(result.text).toBe('1. one\n2. two');
});
it('converts numbers to bullets', () => {
const result = bulletedList({
text: '1. one\n2. two',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('- one\n- two');
});
it('keeps indentation so nested items stay nested', () => {
const result = numberedList({
text: 'one\n - nested',
selectionStart: 0,
selectionEnd: 16,
});
expect(result.text).toBe('1. one\n 2. nested');
});
});
describe('bold and italic', () => {
it('wraps the selection and keeps the original text selected', () => {
const result = bold(snapshot('a [word] b'));
expect(result.text).toBe('a **word** b');
expect(selectionOf(result)).toBe('word');
});
it('unwraps when the markers sit inside the selection', () => {
const result = bold({
text: 'a **word** b',
selectionStart: 2,
selectionEnd: 10,
});
expect(result.text).toBe('a word b');
expect(selectionOf(result)).toBe('word');
});
it('unwraps when the markers sit just outside the selection', () => {
const result = bold({
text: 'a **word** b',
selectionStart: 4,
selectionEnd: 8,
});
expect(result.text).toBe('a word b');
expect(selectionOf(result)).toBe('word');
});
it('leaves the caret between the markers when nothing is selected', () => {
const result = italic(snapshot('a |b'));
expect(result.text).toBe('a __b');
expect(result.selectionStart).toBe(3);
expect(result.selectionEnd).toBe(3);
});
it('does not mistake a leading document boundary for a marker', () => {
const result = bold(snapshot('[word] tail'));
expect(result.text).toBe('**word** tail');
});
});
describe('link', () => {
it('selects the url when the label came from the selection', () => {
const result = link(snapshot('see [docs] now'));
expect(result.text).toBe('see [docs](https://) now');
expect(selectionOf(result)).toBe('https://');
});
it('selects the label placeholder when nothing was selected', () => {
const result = link(snapshot('see |'));
expect(result.text).toBe('see [text](https://)');
expect(selectionOf(result)).toBe('text');
});
});
describe('code', () => {
it('uses backticks for a single-line selection', () => {
const result = code(snapshot('run [npm] here'));
expect(result.text).toBe('run `npm` here');
});
it('fences a multi-line selection and selects its content', () => {
const result = code({
text: 'one\ntwo',
selectionStart: 0,
selectionEnd: 7,
});
expect(result.text).toBe('```\none\ntwo\n```');
expect(selectionOf(result)).toBe('one\ntwo');
});
});
describe('table', () => {
it('starts the skeleton on its own line and selects the first header cell', () => {
const result = table(snapshot('intro|'));
expect(result.text).toBe(
'intro\n| Column | Column |\n| --- | --- |\n| | |',
);
expect(selectionOf(result)).toBe('Column');
});
});
describe('insertText', () => {
it('replaces the selection and leaves the caret after the insertion', () => {
const result = insertText(snapshot('env is [old]'), '{{env}}');
expect(result.text).toBe('env is {{env}}');
expect(result.selectionStart).toBe(14);
expect(result.selectionEnd).toBe(14);
});
});

View File

@@ -1,24 +0,0 @@
// The body is persisted inline in the dashboard JSON, so its length is capped.
export const MARKDOWN_MAX_LENGTH = 16000;
/** The canonical syntax; the renderer resolves the other three too. */
export const formatVariableToken = (name: string): string => `$${name}`;
export const MARKDOWN_HELP_ITEMS: { syntax: string; label: string }[] = [
// First: consecutive lines joining into one paragraph is the CommonMark rule
// authors trip over before any of the formatting syntax.
{ syntax: 'blank line', label: 'New paragraph' },
{ syntax: '2 spaces + ⏎', label: 'Line break' },
{ syntax: '# Heading', label: 'Heading (16 #)' },
{ syntax: '**bold**', label: 'Bold' },
{ syntax: '_italic_', label: 'Italic' },
{ syntax: '- item', label: 'Bulleted list' },
{ syntax: '1. item', label: 'Numbered list' },
{ syntax: '- [ ] task', label: 'Task list' },
{ syntax: '[label](url)', label: 'Link' },
{ syntax: '![alt](url)', label: 'Image' },
{ syntax: '`code`', label: 'Inline code' },
{ syntax: '```lang', label: 'Code block' },
{ syntax: '> quote', label: 'Blockquote' },
{ syntax: '| a | b |', label: 'Table' },
];

View File

@@ -1,69 +0,0 @@
import { EditorView } from '@uiw/react-codemirror';
import type { EditorSnapshot, EditorTransform } from './types';
// Narrows a whole-document replacement to the range that changed, so a toolbar
// action doesn't invalidate the document's decorations or scroll position.
function toChangeSpec(
previous: string,
next: string,
): { from: number; to: number; insert: string } | null {
if (previous === next) {
return null;
}
const shorter = Math.min(previous.length, next.length);
let start = 0;
while (start < shorter && previous[start] === next[start]) {
start += 1;
}
let previousEnd = previous.length;
let nextEnd = next.length;
while (
previousEnd > start &&
nextEnd > start &&
previous[previousEnd - 1] === next[nextEnd - 1]
) {
previousEnd -= 1;
nextEnd -= 1;
}
return { from: start, to: previousEnd, insert: next.slice(start, nextEnd) };
}
export function readSnapshot(view: EditorView): EditorSnapshot {
const range = view.state.selection.main;
return {
text: view.state.doc.toString(),
selectionStart: range.from,
selectionEnd: range.to,
};
}
/** Returns whether the transform ran, as CodeMirror's keymap contract expects. */
export function applyTransform(
view: EditorView,
transform: EditorTransform,
): boolean {
if (view.state.readOnly) {
return false;
}
const next = transform(readSnapshot(view));
const changes = toChangeSpec(view.state.doc.toString(), next.text);
view.dispatch({
...(changes ? { changes } : {}),
selection: { anchor: next.selectionStart, head: next.selectionEnd },
scrollIntoView: true,
});
view.focus();
return true;
}
/** Replaces the whole document, for seeding and external replacements. */
export function replaceDocument(view: EditorView, next: string): void {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: next },
});
}

View File

@@ -1,269 +0,0 @@
import type { EditorCommand, EditorSnapshot, EditorTransform } from './types';
const BOLD_MARKER = '**';
const ITALIC_MARKER = '_';
const INLINE_CODE_MARKER = '`';
const CODE_FENCE = '```';
const HEADING_PREFIX = '## ';
const BULLET_MARKER = '- ';
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
const BULLET_LIST_PATTERN = /^[ \t]*[-*+] /;
const ORDERED_LIST_PATTERN = /^[ \t]*\d+\. /;
// Either kind of marker, matched after the indent has been split off.
const LIST_MARKER_PATTERN = /^(?:[-*+]|\d+\.) /;
const INDENT_PATTERN = /^[ \t]*/;
const LINK_LABEL_PLACEHOLDER = 'text';
const LINK_URL_PLACEHOLDER = 'https://';
const TABLE_CELL_PLACEHOLDER = 'Column';
const TABLE_SNIPPET = [
`| ${TABLE_CELL_PLACEHOLDER} | ${TABLE_CELL_PLACEHOLDER} |`,
'| --- | --- |',
'| | |',
].join('\n');
interface LineRange {
start: number;
end: number;
}
// A selection ending exactly on a line break stops there rather than pulling in
// the next line, so "select the line, hit list" doesn't bullet the line below too.
function expandToLines(text: string, from: number, to: number): LineRange {
const end = to > from && text[to - 1] === '\n' ? to - 1 : to;
const breakBefore = from === 0 ? -1 : text.lastIndexOf('\n', from - 1);
const breakAfter = text.indexOf('\n', end);
return {
start: breakBefore + 1,
end: breakAfter === -1 ? text.length : breakAfter,
};
}
/** Pads `block` so it starts and ends on its own line. */
function replaceWithBlock(
snapshot: EditorSnapshot,
block: string,
): { text: string; blockStart: number } {
const { text, selectionStart, selectionEnd } = snapshot;
const before = text.slice(0, selectionStart);
const after = text.slice(selectionEnd);
const lead = before === '' || before.endsWith('\n') ? '' : '\n';
const trail = after === '' || after.startsWith('\n') ? '' : '\n';
return {
text: before + lead + block + trail + after,
blockStart: before.length + lead.length,
};
}
/** Rewrites every line the selection touches. */
function replaceLines(
snapshot: EditorSnapshot,
mapLines: (lines: string[]) => string[],
): EditorSnapshot {
const { text, selectionStart, selectionEnd } = snapshot;
const { start, end } = expandToLines(text, selectionStart, selectionEnd);
const lines = text.slice(start, end).split('\n');
const nextLines = mapLines(lines);
const block = nextLines.join('\n');
const nextText = text.slice(0, start) + block + text.slice(end);
if (selectionStart !== selectionEnd) {
return {
text: nextText,
selectionStart: start,
selectionEnd: start + block.length,
};
}
// Caret-only: the range covers one line, so shift by that line's delta.
const shifted = selectionStart + nextLines[0].length - lines[0].length;
const caret = Math.min(Math.max(shifted, start), start + nextLines[0].length);
return { text: nextText, selectionStart: caret, selectionEnd: caret };
}
/** Strips `prefix` when every selected line already matches `pattern`, else adds it. */
function toggleLinePrefix(prefix: string, pattern: RegExp): EditorTransform {
return (snapshot): EditorSnapshot =>
replaceLines(snapshot, (lines) => {
const isApplied = lines.every((line) => pattern.test(line));
return lines.map((line) =>
isApplied ? line.replace(pattern, '') : `${prefix}${line}`,
);
});
}
/**
* Toggles this kind of list marker. A line carrying the *other* kind is converted
* rather than marked twice, and indentation is preserved so nesting survives.
* `markerAt` takes the line's position, which is what lets an ordered list number.
*/
function toggleList(
pattern: RegExp,
markerAt: (index: number) => string,
): EditorTransform {
return (snapshot): EditorSnapshot =>
replaceLines(snapshot, (lines) => {
const isApplied = lines.every((line) => pattern.test(line));
return lines.map((line, index) => {
const indent = INDENT_PATTERN.exec(line)?.[0] ?? '';
const body = line.slice(indent.length).replace(LIST_MARKER_PATTERN, '');
return isApplied
? `${indent}${body}`
: `${indent}${markerAt(index)}${body}`;
});
});
}
/**
* Unwraps when the markers are already there, whether they sit inside the selection
* (`**bold**` selected whole) or just outside it (only `bold` selected).
*/
function toggleWrap(marker: string): EditorTransform {
return ({ text, selectionStart, selectionEnd }): EditorSnapshot => {
const selected = text.slice(selectionStart, selectionEnd);
const width = marker.length;
if (
selected.length >= width * 2 &&
selected.startsWith(marker) &&
selected.endsWith(marker)
) {
const inner = selected.slice(width, -width);
return {
text: text.slice(0, selectionStart) + inner + text.slice(selectionEnd),
selectionStart,
selectionEnd: selectionStart + inner.length,
};
}
if (
selectionStart >= width &&
text.slice(selectionStart - width, selectionStart) === marker &&
text.slice(selectionEnd, selectionEnd + width) === marker
) {
return {
text:
text.slice(0, selectionStart - width) +
selected +
text.slice(selectionEnd + width),
selectionStart: selectionStart - width,
selectionEnd: selectionStart - width + selected.length,
};
}
return {
text:
text.slice(0, selectionStart) +
marker +
selected +
marker +
text.slice(selectionEnd),
selectionStart: selectionStart + width,
selectionEnd: selectionStart + width + selected.length,
};
};
}
/** Lands the selection on whichever half is still a placeholder. */
const insertLink: EditorTransform = ({
text,
selectionStart,
selectionEnd,
}): EditorSnapshot => {
const selected = text.slice(selectionStart, selectionEnd);
const label = selected || LINK_LABEL_PLACEHOLDER;
const snippet = `[${label}](${LINK_URL_PLACEHOLDER})`;
const nextText =
text.slice(0, selectionStart) + snippet + text.slice(selectionEnd);
// `[` + label + `](` is label.length + 3 characters.
const target = selected
? {
from: selectionStart + label.length + 3,
length: LINK_URL_PLACEHOLDER.length,
}
: { from: selectionStart + 1, length: label.length };
return {
text: nextText,
selectionStart: target.from,
selectionEnd: target.from + target.length,
};
};
/** Backticks for a single-line selection, a fence for a multi-line one. */
const insertCode: EditorTransform = (snapshot): EditorSnapshot => {
const { text, selectionStart, selectionEnd } = snapshot;
const selected = text.slice(selectionStart, selectionEnd);
if (!selected.includes('\n')) {
return toggleWrap(INLINE_CODE_MARKER)(snapshot);
}
const { text: nextText, blockStart } = replaceWithBlock(
snapshot,
`${CODE_FENCE}\n${selected}\n${CODE_FENCE}`,
);
const contentStart = blockStart + CODE_FENCE.length + 1;
return {
text: nextText,
selectionStart: contentStart,
selectionEnd: contentStart + selected.length,
};
};
/** Selects the first header cell, for immediate typing. */
const insertTable: EditorTransform = (snapshot): EditorSnapshot => {
const { text, blockStart } = replaceWithBlock(snapshot, TABLE_SNIPPET);
const firstCell = blockStart + TABLE_SNIPPET.indexOf(TABLE_CELL_PLACEHOLDER);
return {
text,
selectionStart: firstCell,
selectionEnd: firstCell + TABLE_CELL_PLACEHOLDER.length,
};
};
/** Replaces the selection and leaves the caret after the insertion. */
export function insertText(
snapshot: EditorSnapshot,
value: string,
): EditorSnapshot {
const { text, selectionStart, selectionEnd } = snapshot;
const caret = selectionStart + value.length;
return {
text: text.slice(0, selectionStart) + value + text.slice(selectionEnd),
selectionStart: caret,
selectionEnd: caret,
};
}
/** Display order. A new action is an entry here plus an icon in `EditorToolbar`. */
export const MARKDOWN_COMMANDS: EditorCommand[] = [
{
id: 'heading',
label: 'Heading',
run: toggleLinePrefix(HEADING_PREFIX, HEADING_PATTERN),
},
{
id: 'bold',
label: 'Bold',
run: toggleWrap(BOLD_MARKER),
},
{
id: 'italic',
label: 'Italic',
run: toggleWrap(ITALIC_MARKER),
},
{
id: 'bulleted-list',
label: 'Bulleted list',
run: toggleList(BULLET_LIST_PATTERN, () => BULLET_MARKER),
},
{
id: 'numbered-list',
label: 'Numbered list',
run: toggleList(ORDERED_LIST_PATTERN, (index) => `${index + 1}. `),
},
{ id: 'link', label: 'Link', run: insertLink },
{ id: 'code', label: 'Code', run: insertCode },
{ id: 'table', label: 'Table', run: insertTable },
];

View File

@@ -1,149 +0,0 @@
import type { Extension, Line, Range } from '@codemirror/state';
import {
Decoration,
type DecorationSet,
EditorView,
ViewPlugin,
type ViewUpdate,
} from '@codemirror/view';
const FENCE_PATTERN = /^ {0,3}(```|~~~)/;
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
const QUOTE_PATTERN = /^ {0,3}> ?/;
const LIST_MARKER_PATTERN = /^ {0,3}([-*+]|\d+\.) /;
/**
* Convention: capture group 1, when present, is a left guard the token excludes —
* the token runs from the end of that group to the end of the match. Lookbehind is
* avoided for Safari compatibility, so guards are captured rather than asserted.
*/
const INLINE_PATTERNS: { pattern: RegExp; className: string }[] = [
{ pattern: /`[^`\n]+`/g, className: 'cm-md-code' },
{ pattern: /\*\*[^*\n]+\*\*/g, className: 'cm-md-strong' },
{ pattern: /(^|[^\w*_`])_[^_\n]+_(?![\w_])/g, className: 'cm-md-emphasis' },
{ pattern: /!?\[[^\]\n]*\]\([^)\n]*\)/g, className: 'cm-md-link' },
{
// The four variable syntaxes a dashboard body may carry.
pattern:
/\{\{\s*\.?[\w.-]+\s*\}\}|\[\[\s*[\w.-]+\s*\]\]|\$(?!__)[A-Za-z_]\w*(?:\.\w+)*/g,
className: 'cm-md-variable',
},
];
const MARKS = {
heading: Decoration.mark({ class: 'cm-md-heading' }),
quote: Decoration.mark({ class: 'cm-md-quote' }),
listMarker: Decoration.mark({ class: 'cm-md-list-marker' }),
code: Decoration.mark({ class: 'cm-md-code' }),
} as const;
const INLINE_MARKS = INLINE_PATTERNS.map(({ pattern, className }) => ({
pattern,
mark: Decoration.mark({ class: className }),
}));
function pushInlineMarks(
lineText: string,
lineFrom: number,
ranges: Range<Decoration>[],
): void {
INLINE_MARKS.forEach(({ pattern, mark }) => {
pattern.lastIndex = 0;
let match = pattern.exec(lineText);
while (match !== null) {
const guardLength = match[1]?.length ?? 0;
const from = lineFrom + match.index + guardLength;
const to = lineFrom + match.index + match[0].length;
if (to > from) {
ranges.push(mark.range(from, to));
}
match = pattern.exec(lineText);
}
});
}
function pushBlockMark(line: Line, ranges: Range<Decoration>[]): void {
if (HEADING_PATTERN.test(line.text)) {
ranges.push(MARKS.heading.range(line.from, line.to));
return;
}
if (QUOTE_PATTERN.test(line.text)) {
ranges.push(MARKS.quote.range(line.from, line.to));
return;
}
const listMarker = LIST_MARKER_PATTERN.exec(line.text);
if (listMarker) {
ranges.push(
MARKS.listMarker.range(line.from, line.from + listMarker[0].length),
);
}
}
// Scans the whole document rather than the viewport: fenced blocks opening above
// the visible range would otherwise be mis-detected. Bounded by the length cap.
function buildDecorations(view: EditorView): DecorationSet {
const { doc } = view.state;
const ranges: Range<Decoration>[] = [];
let isInsideFence = false;
for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
const line = doc.line(lineNumber);
const isFenceDelimiter = FENCE_PATTERN.test(line.text);
if (isFenceDelimiter || isInsideFence) {
if (line.to > line.from) {
ranges.push(MARKS.code.range(line.from, line.to));
}
isInsideFence = isFenceDelimiter ? !isInsideFence : isInsideFence;
} else {
pushBlockMark(line, ranges);
pushInlineMarks(line.text, line.from, ranges);
}
}
return Decoration.set(ranges, true);
}
// Colours come from custom properties so the SCSS module owns light/dark.
const syntaxTheme = EditorView.theme({
'.cm-md-heading': {
color: 'var(--md-syntax-heading)',
fontWeight: '600',
},
'.cm-md-quote': { color: 'var(--md-syntax-quote)', fontStyle: 'italic' },
'.cm-md-list-marker': { color: 'var(--md-syntax-marker)' },
'.cm-md-code': { color: 'var(--md-syntax-code)' },
'.cm-md-strong': { color: 'var(--md-syntax-strong)', fontWeight: '600' },
'.cm-md-emphasis': {
color: 'var(--md-syntax-emphasis)',
fontStyle: 'italic',
},
'.cm-md-link': { color: 'var(--md-syntax-link)' },
'.cm-md-variable': { color: 'var(--md-syntax-variable)' },
});
const highlightPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate): void {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
}
}
},
{ decorations: (plugin): DecorationSet => plugin.decorations },
);
/**
* Decorations rather than a grammar, so the editor stays on the CodeMirror packages
* already bundled — no `@codemirror/lang-markdown` / `@lezer` for what is only a
* colouring pass over a body the renderer parses for real.
*/
export function markdownHighlight(): Extension {
return [highlightPlugin, syntaxTheme];
}

View File

@@ -1,27 +0,0 @@
/** The value every editor command reads and returns. */
export interface EditorSnapshot {
text: string;
selectionStart: number;
selectionEnd: number;
}
export type EditorTransform = (snapshot: EditorSnapshot) => EditorSnapshot;
export interface EditorVariable {
name: string;
/** Short tag for the variable's kind, e.g. "QUERY". */
badge?: string;
}
export interface EditorCommand {
id: string;
/** Accessible name and tooltip for the toolbar button. */
label: string;
run: EditorTransform;
}
/** 1-based, as the status bar reports it. */
export interface CursorPosition {
line: number;
column: number;
}

View File

@@ -17,6 +17,7 @@ import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import cx from 'classnames';
import { DATA_SOURCE_TO_SIGNAL } from 'constants/fieldSuggestions';
import {
negationQueryOperatorSuggestions,
OPERATORS,
@@ -45,6 +46,13 @@ import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
import type {
TelemetrytypesGettableFieldKeysDTOKeysAnyOf,
TelemetrytypesSourceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getFieldKeySuggestions } from 'api/querySuggestions/getFieldKeySuggestions';
import { getFieldValueSuggestions } from 'api/querySuggestions/getFieldValueSuggestions';
import type { SignalType } from 'types/api/v5/queryRange';
import {
@@ -52,12 +60,6 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
SuggestedFieldKey,
SuggestedFieldKeysByName,
} from './fieldSuggestions';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -265,8 +267,10 @@ function QuerySearch({
const dashboardDynamicVariables = useDynamicVariableSuggestions();
// Add back the generateOptions function and useEffect
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
const generateOptions = (
keys: TelemetrytypesGettableFieldKeysDTOKeysAnyOf,
): any[] =>
Object.values(keys).flatMap((items: TelemetrytypesTelemetryFieldKeyDTO[]) =>
items.map(({ name, fieldDataType, fieldContext }) => ({
label: name,
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
@@ -319,17 +323,19 @@ function QuerySearch({
lastFetchedKeyRef.current = searchText || '';
const response = await fetchFieldKeysForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
metricNamespace,
});
const response = await getFieldKeySuggestions(
{
signal: DATA_SOURCE_TO_SIGNAL[dataSource],
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
source: signalSource as TelemetrytypesSourceDTO,
metricNamespace,
},
queryData.builderQueryType,
);
if (response.data.data) {
const { keys } = response.data.data;
if (response.data.keys) {
const { keys } = response.data;
const options = generateOptions(keys);
// Deduplicate by full variant identity (name + context + data type), NOT by
// label. deduping by label removes varient which is not expected. If we need
@@ -497,21 +503,23 @@ function QuerySearch({
try {
const values = valueSuggestionsOverride
? await valueSuggestionsOverride(key, sanitizedSearchText)
: await fetchFieldValuesForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
key,
searchText: sanitizedSearchText,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
}).then((response) => {
const responseData = response.data as any;
const data = responseData.data || {};
const values = data.values || {};
: await getFieldValueSuggestions(
{
signal: DATA_SOURCE_TO_SIGNAL[dataSource],
name: key,
searchText: sanitizedSearchText,
source: signalSource as TelemetrytypesSourceDTO,
metricName: debouncedMetricName ?? undefined,
},
queryData.builderQueryType,
).then((response) => {
const responseData = response.data;
const responseDataValues = responseData.values;
return {
stringValues: values.stringValues || [],
numberValues: values.numberValues || [],
complete: data.complete ?? false,
stringValues: responseDataValues.stringValues ?? [],
numberValues: responseDataValues.numberValues ?? [],
complete: responseData.complete ?? false,
};
});

View File

@@ -1,215 +0,0 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { DataSource } from 'types/common/queryBuilder';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
} from '../fieldSuggestions';
jest.mock('api/generated/services/ai-observability', () => ({
getAIObservabilityFieldsKeys: jest.fn(),
getAIObservabilityFieldsValues: jest.fn(),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn(),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn(),
}));
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
typeof getAIObservabilityFieldsKeys
>;
const mockedGenericKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
>;
const mockedAIValues = getAIObservabilityFieldsValues as jest.MockedFunction<
typeof getAIObservabilityFieldsValues
>;
const mockedGenericValues = getValueSuggestions as jest.MockedFunction<
typeof getValueSuggestions
>;
const aiValuesResponse = (
values: { stringValues?: string[]; numberValues?: number[] } | null,
complete = true,
): Awaited<ReturnType<typeof getAIObservabilityFieldsValues>> =>
({
status: 'success',
data: { complete, values },
}) as Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>;
describe('fetchFieldKeysForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: {
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
},
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const keys = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: 'llm',
});
expect(mockedAIKeys).toHaveBeenCalledWith({ searchText: 'llm' });
expect(mockedGenericKeys).not.toHaveBeenCalled();
expect(keys.data.data).toStrictEqual({
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
mockedGenericKeys.mockResolvedValue({
data: { status: 'success', data: { complete: true, keys: {} } },
} as Awaited<ReturnType<typeof getKeySuggestions>>);
await fetchFieldKeysForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
searchText: 'svc',
});
expect(mockedAIKeys).not.toHaveBeenCalled();
expect(mockedGenericKeys).toHaveBeenCalledWith(
expect.objectContaining({ signal: DataSource.TRACES, searchText: 'svc' }),
);
});
it('normalizes a null ai_observability keys payload to an empty map', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: { complete: false, keys: null },
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const response = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: '',
});
expect(response.data.data).toStrictEqual({ complete: false, keys: {} });
});
it('passes the generic response through untouched', async () => {
const genericResponse = {
data: { status: 'success', data: { complete: true, keys: {} } },
} as unknown as Awaited<ReturnType<typeof getKeySuggestions>>;
mockedGenericKeys.mockResolvedValue(genericResponse);
await expect(
fetchFieldKeysForQuery({
builderQueryType: 'builder_query',
dataSource: DataSource.TRACES,
searchText: '',
}),
).resolves.toBe(genericResponse);
});
});
describe('fetchFieldValuesForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIValues.mockResolvedValue(
aiValuesResponse({ stringValues: ['gpt-4o'], numberValues: [] }),
);
const response = await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'gen_ai.request.model',
searchText: 'gpt',
});
expect(mockedGenericValues).not.toHaveBeenCalled();
expect(response).toStrictEqual({
data: {
data: {
complete: true,
values: { stringValues: ['gpt-4o'], numberValues: [] },
},
},
});
});
it('forwards the key as the name the endpoint expects', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse({}));
await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'total_tokens',
searchText: '',
});
expect(mockedAIValues).toHaveBeenCalledWith({
name: 'total_tokens',
searchText: '',
});
});
it('wraps the ai_observability payload in the envelope the call site unwraps', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse(null, false));
await expect(
fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'llm_call_count',
searchText: '',
}),
).resolves.toStrictEqual({
data: { data: { complete: false, values: null } },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
const genericResponse = {
data: {
data: { complete: false, values: { stringValues: ['frontend'] } },
},
} as unknown as Awaited<ReturnType<typeof getValueSuggestions>>;
mockedGenericValues.mockResolvedValue(genericResponse);
const response = await fetchFieldValuesForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
});
expect(mockedAIValues).not.toHaveBeenCalled();
expect(mockedGenericValues).toHaveBeenCalledWith(
expect.objectContaining({
signal: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
}),
);
expect(response).toBe(genericResponse);
});
});

View File

@@ -1,111 +0,0 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export interface SuggestedFieldKey {
name: string;
fieldContext?: string;
fieldDataType?: string;
}
export type SuggestedFieldKeysByName = Record<string, SuggestedFieldKey[]>;
export interface SuggestedFieldKeysPayload {
complete: boolean;
keys: SuggestedFieldKeysByName;
}
export interface SuggestedFieldKeysResponse {
data: { data?: SuggestedFieldKeysPayload };
}
export interface SuggestedFieldValuesPayload {
complete?: boolean;
values?: {
stringValues?: string[] | null;
numberValues?: number[] | null;
} | null;
}
export interface SuggestedFieldValuesResponse {
data: { data?: SuggestedFieldValuesPayload };
}
interface FetchFieldKeysParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
metricNamespace?: string;
}
interface FetchFieldValuesParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
key: string;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
}
export const fetchFieldKeysForQuery = async ({
builderQueryType,
dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
}: FetchFieldKeysParams): Promise<SuggestedFieldKeysResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsKeys({ searchText });
return {
data: {
data: response.data
? { complete: response.data.complete, keys: response.data.keys ?? {} }
: undefined,
},
};
}
return getKeySuggestions({
signal: dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
});
};
export const fetchFieldValuesForQuery = async ({
builderQueryType,
dataSource,
key,
searchText,
metricName,
signalSource,
}: FetchFieldValuesParams): Promise<SuggestedFieldValuesResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsValues({
name: key,
searchText,
});
return { data: { data: response.data } };
}
// getValueSuggestions' declared response type does not match what the endpoint returns.
return getValueSuggestions({
signal: dataSource,
key,
searchText,
signalSource,
metricName,
}) as unknown as Promise<SuggestedFieldValuesResponse>;
};

View File

@@ -1,10 +1,15 @@
import { EditorView } from '@uiw/react-codemirror';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { getFieldKeySuggestions } from 'api/querySuggestions/getFieldKeySuggestions';
import { getFieldValueSuggestions } from 'api/querySuggestions/getFieldValueSuggestions';
import { initialQueriesMap } from 'constants/queryBuilder';
import { fireEvent, render, userEvent, waitFor } from 'tests/test-utils';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
import QuerySearch from '../QuerySearch/QuerySearch';
@@ -30,17 +35,25 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
};
});
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: {
data: { keys: {} as Record<string, QueryKeyDataSuggestionsProps[]> },
},
jest.mock('api/querySuggestions/getFieldKeySuggestions', () => ({
getFieldKeySuggestions: jest.fn().mockResolvedValue({
status: 'success',
data: { complete: true, keys: {} },
}),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
jest.mock('api/querySuggestions/getFieldValueSuggestions', () => ({
getFieldValueSuggestions: jest.fn().mockResolvedValue({
status: 'success',
data: {
complete: true,
values: {
stringValues: [],
numberValues: [],
boolValues: [],
relatedValues: [],
},
},
}),
}));
@@ -68,8 +81,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
it('fetches key suggestions when typing a key (debounced)', async () => {
// Use real timers for CodeMirror integration tests
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
const mockedGetKeys = getFieldKeySuggestions as jest.MockedFunction<
typeof getFieldKeySuggestions
>;
mockedGetKeys.mockClear();
@@ -102,10 +115,22 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
it('fetches value suggestions when editing value context', async () => {
// Use real timers for CodeMirror integration tests
const mockedGetValues = getValueSuggestions as jest.MockedFunction<
typeof getValueSuggestions
const mockedGetValues = getFieldValueSuggestions as jest.MockedFunction<
typeof getFieldValueSuggestions
>;
mockedGetValues.mockClear();
mockedGetValues.mockResolvedValueOnce({
status: 'success',
data: {
complete: true,
values: {
stringValues: ['payment-service'],
numberValues: [200],
boolValues: [],
relatedValues: [],
},
},
});
render(
<QuerySearch
@@ -129,12 +154,18 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
timeout: 2000,
});
// the string and number values off the response both reach the dropdown
await expect(
screen.findByText('payment-service'),
).resolves.toBeInTheDocument();
await expect(screen.findByText('200')).resolves.toBeInTheDocument();
});
it('fetches key suggestions on mount for LOGS', async () => {
// Use real timers for CodeMirror integration tests
const mockedGetKeysOnMount = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
const mockedGetKeysOnMount = getFieldKeySuggestions as jest.MockedFunction<
typeof getFieldKeySuggestions
>;
mockedGetKeysOnMount.mockClear();
@@ -153,6 +184,7 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
() =>
expect(mockedGetKeysOnMount).toHaveBeenCalledWith(
expect.objectContaining({ signal: DataSource.LOGS, searchText: '' }),
undefined,
),
{ timeout: 2000 },
);
@@ -357,8 +389,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
});
it('fetches key suggestions for metrics even without aggregateAttribute.key when showFilterSuggestionsWithoutMetric is true', async () => {
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
const mockedGetKeys = getFieldKeySuggestions as jest.MockedFunction<
typeof getFieldKeySuggestions
>;
mockedGetKeys.mockClear();

View File

@@ -31,15 +31,25 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: { data: { keys: {} } },
jest.mock('api/querySuggestions/getFieldKeySuggestions', () => ({
getFieldKeySuggestions: jest.fn().mockResolvedValue({
status: 'success',
data: { complete: true, keys: {} },
}),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
jest.mock('api/querySuggestions/getFieldValueSuggestions', () => ({
getFieldValueSuggestions: jest.fn().mockResolvedValue({
status: 'success',
data: {
complete: true,
values: {
stringValues: [],
numberValues: [],
boolValues: [],
relatedValues: [],
},
},
}),
}));

View File

@@ -1,14 +1,11 @@
import { useMemo } from 'react';
import { useGetFieldsValues } from 'api/generated/services/fields';
import {
TelemetrytypesSignalDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { DataSource } from 'types/common/queryBuilder';
import { DATA_SOURCE_TO_SIGNAL } from 'constants/fieldSuggestions';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
interface UseFieldValuesProps {
@@ -29,15 +26,6 @@ interface UseFieldValuesReturn {
isFetching: boolean;
}
export const DATA_SOURCE_TO_SIGNAL: Record<
DataSource,
TelemetrytypesSignalDTO
> = {
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
};
const QUICK_FILTERS_SOURCE_TO_SOURCE: Partial<
Record<QuickFiltersSource, TelemetrytypesSourceDTO>
> = {

View File

@@ -3,9 +3,9 @@ import { Button, Skeleton } from 'antd';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { DATA_SOURCE_TO_SIGNAL } from 'components/QuickFilters/FilterRenderers/Checkbox/v2/useFieldValues';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { DATA_SOURCE_TO_SIGNAL } from 'constants/fieldSuggestions';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import {
FieldContext,

View File

@@ -0,0 +1,11 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
export const DATA_SOURCE_TO_SIGNAL: Record<
DataSource,
TelemetrytypesSignalDTO
> = {
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
};

View File

@@ -31,8 +31,6 @@ export const getComponentForPanelType = (
[PANEL_TYPES.BAR]: Uplot,
[PANEL_TYPES.PIE]: null,
[PANEL_TYPES.HISTOGRAM]: Uplot,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -376,7 +376,6 @@ export enum PANEL_TYPES {
BAR = 'bar',
PIE = 'pie',
HISTOGRAM = 'histogram',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

@@ -376,9 +376,7 @@ export default function BillingContainer(): JSX.Element {
</Typography.Link>
</AuthZTooltip>
{` if your payment information has changed. Email us at `}
<Typography.Text as="span" color="muted">
cloud-support@signoz.io
</Typography.Text>
<Typography.Text color="muted">cloud-support@signoz.io</Typography.Text>
{` otherwise. Be sure to provide this information immediately to avoid interruption to your service.`}
</Typography>
);

View File

@@ -174,7 +174,6 @@ export default function ServiceTraces({
columns={columns}
dataSource={top5Services}
pagination={false}
rowKey="serviceName"
className="services-table"
onRow={(record: ServicesList): Record<string, unknown> => ({
onClick: (event: React.MouseEvent): void => {

View File

@@ -216,7 +216,7 @@ function K8sOptionsSidePanel<TData>({
);
return (
<div className={styles.columnItem} key={column.id}>
<Typography.Text as="span" size="sm" className={styles.columnLabel}>
<Typography.Text size="sm" className={styles.columnLabel}>
{column.label}
</Typography.Text>
{column.canBeHidden ? (

View File

@@ -92,17 +92,25 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: {
data: { keys: {} },
},
jest.mock('api/querySuggestions/getFieldKeySuggestions', () => ({
getFieldKeySuggestions: jest.fn().mockResolvedValue({
status: 'success',
data: { complete: true, keys: {} },
}),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
jest.mock('api/querySuggestions/getFieldValueSuggestions', () => ({
getFieldValueSuggestions: jest.fn().mockResolvedValue({
status: 'success',
data: {
complete: true,
values: {
stringValues: [],
numberValues: [],
boolValues: [],
relatedValues: [],
},
},
}),
}));

View File

@@ -179,7 +179,10 @@ const setupServer = (capturedPayloads: QueryRangePayloadV5[]): void => {
),
// Add handler for the fields endpoint that's causing warnings
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, async (req, res, ctx) =>
res(ctx.status(200), ctx.json([])),
res(
ctx.status(200),
ctx.json({ status: 'success', data: { complete: true, keys: {} } }),
),
),
);
};

View File

@@ -29,6 +29,5 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
BAR: true,
PIE: false,
HISTOGRAM: false,
TEXT: false,
EMPTY_WIDGET: false,
};

View File

@@ -14,8 +14,6 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.LIST]: ListPanelWrapper,
[PANEL_TYPES.VALUE]: ValuePanelWrapper,
[PANEL_TYPES.TRACE]: null,
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,

View File

@@ -1,57 +0,0 @@
@use '../../../../styles/scrollbar' as *;
.container {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
// Let the flex children shrink below their content height so the series list
// scrolls within the capped legend height instead of overflowing the wrapper
// (the default min-height:auto would block the shrink).
min-height: 0;
}
.scroller {
// flex:1 + min-height:0 pins the scroller to the space left after the
// toolbar instead of growing to fit every row.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
padding-right: var(--spacing-2);
overflow-x: hidden;
overscroll-behavior: contain;
@include custom-scrollbar;
}
.gridItem {
// Or the item keeps its content width and the label never ellipsizes.
min-width: 0;
max-width: 100%;
}
.gridList {
min-width: 0;
display: grid;
grid-auto-flow: row;
// min() keeps the column inside a narrow panel, where a wider one would push
// the row's actions out of the clipped area.
grid-template-columns: repeat(
auto-fill,
minmax(min(var(--legend-item-width, 240px), 100%), 1fr)
);
gap: var(--spacing-1) var(--spacing-4);
}
.container.isRight .gridList {
grid-template-columns: 1fr;
}
.emptyState {
padding: var(--spacing-16) 0;
font-size: var(--font-size-xs);
color: var(--l3-foreground);
text-align: center;
}

View File

@@ -0,0 +1,204 @@
@use '../../../../styles/scrollbar' as *;
.legend-search-container {
flex-shrink: 0;
width: 100%;
padding-right: 8px;
.legend-search-input {
font-size: 12px;
}
}
.legend-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
height: 100%;
width: 100%;
// Allow the flex children to shrink below their content height so the
// virtualized grid scrolls within the capped legend height instead of
// overflowing the wrapper (default min-height:auto would block the shrink).
min-height: 0;
&:has(.legend-item-focused) .legend-item {
opacity: 0.3;
}
&:has(.legend-item-focused) .legend-item.legend-item-focused {
opacity: 1;
}
.legend-empty-state {
font-size: 12px;
color: var(--l2-foreground);
text-align: center;
padding: 12px;
padding: 2rem 0;
}
.legend-virtuoso-container {
// flex:1 + min-height:0 pins the scroller to the space left after the
// search box (RIGHT legend) and lets it scroll instead of growing to fit
// every row — without this the grid overflows a BOTTOM legend's fixed height.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
.virtuoso-grid-list {
min-width: 0;
display: grid;
grid-auto-flow: row;
grid-template-columns: repeat(
auto-fill,
minmax(var(--legend-average-width, 240px), 1fr)
);
column-gap: 12px;
}
.virtuoso-grid-item {
min-width: 0;
}
&.legend-virtuoso-container-right {
.virtuoso-grid-list {
grid-template-columns: 1fr;
}
}
&.legend-virtuoso-container-single-row {
.virtuoso-grid-list {
grid-template-columns: repeat(
auto-fit,
minmax(var(--legend-average-width, 240px), max-content)
);
justify-content: center;
}
}
@include custom-scrollbar;
}
}
.legend-row {
padding: 4px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
&.legend-single-row {
justify-content: center;
}
&.legend-row-right {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
&.legend-row-bottom {
flex-direction: row;
}
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
// Include padding within the width so a full-width row (legend-item-right) fits its
// column instead of overflowing by the 16px horizontal padding — there is no global
// border-box reset, so the default content-box would make it overflow.
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
border-radius: 4px;
cursor: pointer;
&.legend-item-right {
width: 100%;
}
&.legend-item-off {
opacity: 0.3;
text-decoration: line-through;
text-decoration-thickness: 1px;
}
&.legend-item-focused {
opacity: 1;
}
.legend-item-label-trigger {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
cursor: pointer;
}
.legend-marker {
border-width: 2px;
border-style: solid;
border-radius: 50%;
min-width: 11px;
min-height: 11px;
width: 11px;
height: 11px;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
position: relative;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
}
&:active {
transform: scale(0.9);
}
}
.legend-label {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
user-select: none;
}
.legend-copy-button {
// Always laid out (space reserved) but transparent, so revealing it on
// hover fades the icon in without reflowing the row / shifting the label.
// Shrink the shared icon Button (defaults to a 2rem square) to the
// compact legend row via its size tokens.
--button-height: auto;
--button-width: auto;
--button-padding: 2px;
opacity: 0;
flex-shrink: 0;
color: var(--l2-foreground);
border-radius: 4px;
transition:
opacity 0.15s ease,
color 0.15s ease;
&:hover {
color: var(--l1-foreground);
}
}
&:hover {
background: var(--l3-background);
.legend-copy-button {
opacity: 1;
}
}
}

View File

@@ -1,106 +1,139 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input } from 'antd';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendItem } from 'lib/uPlotV2/config/types';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { LegendAction, LegendPosition, LegendProps } from '../types';
import { LegendPosition, LegendProps } from '../types';
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
import LegendRow from './LegendRow';
import LegendToolbar from './LegendToolbar';
import { filterLegendItems, getShownSeriesState } from './utils';
import './Legend.styles.scss';
import styles from './Legend.module.scss';
export const MAX_LEGEND_WIDTH = 240;
/**
* Presentational legend, source-agnostic: the uPlot charts feed it via
* UPlotLegend, Pie feeds it directly. Every state change is delegated.
* Presentational legend. Renders the supplied `items` (markers + labels, an
* optional copy button, and a search box for the RIGHT position) and delegates
* all interaction to the container handlers. Source-agnostic — the uPlot
* charts feed it via UPlotLegend; Pie feeds it directly.
*/
export default function Legend({
items,
position,
averageLegendWidth = MAX_LEGEND_WIDTH,
focusedSeriesIndex,
onAction,
onClick,
onMouseMove,
onMouseLeave,
showCopy = true,
}: LegendProps): JSX.Element {
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [filterQuery, setFilterQuery] = useState('');
const [legendSearchQuery, setLegendSearchQuery] = useState('');
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
const isRightPosition = position === LegendPosition.RIGHT;
// Search is intrinsic to the right-positioned legend.
const searchEnabled = position === LegendPosition.RIGHT;
const { width: containerWidth } = useResizeObserver(legendContainerRef);
const { visibleCount, soleShownSeriesIndex } = useMemo(
() => getShownSeriesState(items),
[items],
);
const isSingleRow = useMemo(() => {
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
return false;
}
const totalLegendWidth = items.length * (averageLegendWidth + 16);
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
return totalRows <= 1;
}, [averageLegendWidth, items.length, position, containerWidth]);
// A bottom legend gets two rows; spending one on chrome costs more chart than
// the readout is worth.
const showToolbar = isRightPosition && items.length > 0;
const showFilter = showToolbar;
const visibleLegendItems = useMemo(() => {
if (!searchEnabled || !legendSearchQuery.trim()) {
return items;
}
const effectiveQuery = showFilter ? filterQuery : '';
const visibleLegendItems = useMemo(
() => filterLegendItems(items, effectiveQuery),
[items, effectiveQuery],
);
const isEmptyState =
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
const isAllShown = visibleCount === items.length;
// A row that unmounts under the pointer never fires its own mouseleave.
const handleMouseLeave = useCallback(
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
[onAction],
);
const query = legendSearchQuery.trim().toLowerCase();
return items.filter((item) => item.label?.toLowerCase().includes(query));
}, [searchEnabled, legendSearchQuery, items]);
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => (
<LegendRow
key={item.seriesIndex}
item={item}
isSoleShown={soleShownSeriesIndex === item.seriesIndex}
isAllShown={isAllShown}
isFocused={focusedSeriesIndex === item.seriesIndex}
showCopy={showCopy}
onAction={onAction}
/>
),
[soleShownSeriesIndex, isAllShown, focusedSeriesIndex, showCopy, onAction],
(item: LegendItem): JSX.Element => {
// `color` is uPlot's stroke union (string | fn | gradient); only a string
// is a usable CSS colour for the marker.
const markerColor = typeof item.color === 'string' ? item.color : undefined;
return (
<div
key={item.seriesIndex}
data-legend-item-id={item.seriesIndex}
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<TooltipSimple title={item.label} arrow side="top" disableHoverableContent>
<div className="legend-item-label-trigger">
<div
className="legend-marker"
style={{ borderColor: markerColor }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</TooltipSimple>
{showCopy && (
<CopyButton
value={item.label ?? ''}
size={12}
className="legend-copy-button"
ariaLabel={`Copy ${item.label}`}
testId="legend-copy"
/>
)}
</div>
);
},
[focusedSeriesIndex, position, showCopy],
);
const isEmptyState = useMemo(() => {
if (!searchEnabled || !legendSearchQuery.trim()) {
return false;
}
return visibleLegendItems.length === 0;
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
return (
<div
ref={legendContainerRef}
className={cx(styles.container, {
[styles.isRight]: isRightPosition,
})}
style={{ ['--legend-item-width' as string]: `${itemWidth}px` }}
onMouseLeave={handleMouseLeave}
data-testid="legend-container"
className="legend-container"
onClick={onClick}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
style={{
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
}}
>
{showToolbar && (
<LegendToolbar
visibleCount={visibleCount}
totalCount={items.length}
showFilter={showFilter}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
/>
{searchEnabled && (
<div className="legend-search-container">
<Input
allowClear
placeholder="Search..."
value={legendSearchQuery}
onChange={(e): void => setLegendSearchQuery(e.target.value)}
data-testid="legend-search-input"
className="legend-search-input"
/>
</div>
)}
{isEmptyState ? (
<div className={styles.emptyState}>
No series found matching &quot;{effectiveQuery}&quot;
<div className="legend-empty-state">
No series found matching &quot;{legendSearchQuery}&quot;
</div>
) : (
<VirtuosoGrid
className={styles.scroller}
listClassName={styles.gridList}
itemClassName={styles.gridItem}
className={cx(
'legend-virtuoso-container',
`legend-virtuoso-container-${position.toLowerCase()}`,
{ 'legend-virtuoso-container-single-row': isSingleRow },
)}
data={visibleLegendItems}
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
/>

View File

@@ -1,170 +0,0 @@
.row {
// Width of the revealed actions, given up by the label on hover only.
--legend-actions-reserve: 78px;
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-4);
height: 28px;
padding: 0 var(--spacing-3) 0 var(--spacing-4);
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-width: 0;
border-radius: var(--radius);
cursor: pointer;
transition: background 160ms linear;
&:hover,
&:focus-visible {
background: var(--l3-background);
}
}
.isFocused {
background: var(--l3-background);
}
.marker {
// Reads as a checkbox without being one: filled when shown, hollow when
// hidden, deliberately not a check glyph.
flex: 0 0 auto;
box-sizing: border-box;
position: relative;
// Above the actions, so a narrow row's chip never covers the series colour.
z-index: 4;
width: 12px;
height: 12px;
padding: 0;
appearance: none;
border-width: 1.5px;
border-style: solid;
border-radius: var(--radius);
cursor: pointer;
transition:
transform 200ms ease,
box-shadow 200ms ease,
background-color 160ms linear,
opacity 160ms linear;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--l1-foreground) 30%, transparent);
}
&:active {
transform: scale(0.9);
}
&:disabled {
cursor: default;
}
&:disabled:hover {
transform: none;
box-shadow: none;
}
}
// Series names run long and have no spaces to break on, so they need both a
// cap and a break rule or the tooltip becomes one panel-wide line.
.rowTooltip {
max-width: 420px;
white-space: normal;
overflow-wrap: anywhere;
}
.label {
flex: 1 1 auto;
box-sizing: border-box;
width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
color: var(--l2-foreground);
user-select: none;
}
.isHidden .marker {
opacity: 0.45;
}
.isHidden .label {
color: var(--l3-foreground);
text-decoration: line-through;
text-decoration-thickness: 1px;
}
/* Row actions */
.actions {
position: absolute;
top: var(--spacing-2);
right: var(--spacing-3);
z-index: 3;
display: flex;
align-items: center;
gap: var(--spacing-2);
padding-left: var(--spacing-5, 10px);
// Sits on the row's hover background and masks the label's tail behind it.
background: var(--l3-background);
box-shadow: -8px 0 8px var(--l3-background);
opacity: 0;
transform: translateX(10px);
pointer-events: none;
transition:
opacity 180ms cubic-bezier(0.08, 0.52, 0.52, 1),
transform 180ms cubic-bezier(0.08, 0.52, 0.52, 1);
}
// :focus-visible, not :focus-within — the latter also matches the click that
// just toggled the series, leaving the actions stuck open.
.row:hover .actions,
.row:focus-visible .actions,
.row:has(:focus-visible) .actions {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
// The cap spares rows sized to their actions, not their label.
.row:hover .label,
.row:focus-visible .label,
.row:has(:focus-visible) .label {
padding-right: min(var(--legend-actions-reserve), 50%);
}
.actionTrigger {
display: inline-flex;
}
.actionButton {
--button-height: 20px;
--button-width: 20px;
--button-padding: 0;
--button-variant-ghost-color: var(--l3-foreground);
--button-variant-ghost-hover-color: var(--l1-foreground);
flex-shrink: 0;
}
.actionButton.scopeButton {
--button-width: auto;
--button-padding: 0 var(--spacing-4);
--button-font-size: var(--font-size-xs);
--button-border-radius: calc(var(--radius) * 4);
--button-base-border-width: 1px;
// --l2-border is the actions bar's own background: one step up reads.
--button-base-border-color: var(--l3-border);
border-style: solid;
}
.actionButton.scopeButton:hover {
border-color: var(--l2-border);
}

View File

@@ -1,182 +0,0 @@
import { KeyboardEvent, memo, MouseEvent, useCallback } from 'react';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { LegendItem } from 'lib/uPlotV2/config/types';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { LegendAction, OnLegendAction } from '../types';
import { LEGEND_TOOLTIP_DELAY_MS } from './constants';
import styles from './LegendRow.module.scss';
export interface LegendRowProps {
item: LegendItem;
/** The only series currently shown, so hiding it is refused. */
isSoleShown: boolean;
/** Nothing is hidden, so the row's action can only narrow the selection. */
isAllShown: boolean;
isFocused: boolean;
showCopy: boolean;
onAction: OnLegendAction;
}
/**
* One legend row. The marker is its own target for excluding a single series —
* the one thing the row click can't do while everything is showing. The actions
* overlay the label's tail rather than taking layout width, and their reveal is
* pure CSS.
*/
function LegendRow({
item,
isSoleShown,
isAllShown,
isFocused,
showCopy,
onAction,
}: LegendRowProps): JSX.Element {
const { seriesIndex, show } = item;
const label = item.label ?? '';
const isShowAllAction = show && !isAllShown;
const scopeActionLabel = isShowAllAction
? 'Show all series'
: 'Show only current series';
// `color` is uPlot's stroke union (string | fn | gradient); only a string is
// a usable CSS colour for the marker.
const seriesColor = typeof item.color === 'string' ? item.color : undefined;
/** Everything showing -> isolate; showing alone -> restore all. */
const handleRowClick = useCallback((): void => {
if (isSoleShown) {
onAction({ type: LegendAction.SHOW_ALL });
return;
}
onAction({
type: isAllShown ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
seriesIndex,
});
}, [isSoleShown, isAllShown, onAction, seriesIndex]);
const handleMarkerClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation();
onAction({ type: LegendAction.TOGGLE, seriesIndex });
},
[onAction, seriesIndex],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
// Let the row actions handle their own keys.
if (event.target !== event.currentTarget) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleRowClick();
}
},
[handleRowClick],
);
const handleScopeClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation();
if (isShowAllAction) {
onAction({ type: LegendAction.SHOW_ALL });
return;
}
onAction({ type: LegendAction.SHOW_ONLY, seriesIndex });
},
[isShowAllAction, onAction, seriesIndex],
);
const handleMouseEnter = useCallback(
(): void => onAction({ type: LegendAction.HOVER, seriesIndex }),
[onAction, seriesIndex],
);
const handleMouseLeave = useCallback(
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
[onAction],
);
return (
<div
className={cx(styles.row, {
[styles.isHidden]: !show,
[styles.isFocused]: isFocused,
})}
data-legend-item-id={seriesIndex}
data-testid={`legend-item-${seriesIndex}`}
role="switch"
tabIndex={0}
aria-checked={show}
aria-label={label}
onClick={handleRowClick}
onKeyDown={handleKeyDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
type="button"
className={styles.marker}
style={{
borderColor: seriesColor,
backgroundColor: show ? seriesColor : 'transparent',
}}
onClick={handleMarkerClick}
disabled={isSoleShown}
aria-label={`${show ? 'Hide' : 'Show'} ${label}`}
data-is-legend-marker={true}
data-testid={`legend-marker-${seriesIndex}`}
/>
<TooltipSimple
title={label}
arrow
side="top"
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
disableHoverableContent
tooltipContentProps={{ className: styles.rowTooltip }}
>
<span className={styles.label}>{label}</span>
</TooltipSimple>
<div className={styles.actions}>
<TooltipSimple
title={scopeActionLabel}
arrow
side="top"
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
disableHoverableContent
tooltipContentProps={{ className: styles.rowTooltip }}
>
{/* Radix's asChild merge strips the button's own data-testid. */}
<span className={styles.actionTrigger}>
<Button
variant="ghost"
color="secondary"
size="sm"
className={cx(styles.actionButton, styles.scopeButton)}
onClick={handleScopeClick}
aria-label={scopeActionLabel}
testId={`legend-scope-${seriesIndex}`}
>
{isShowAllAction ? 'All' : 'Only'}
</Button>
</span>
</TooltipSimple>
{showCopy && (
<CopyButton
value={label}
size={13}
className={styles.actionButton}
ariaLabel={`Copy ${label}`}
testId={`legend-copy-${seriesIndex}`}
/>
)}
</div>
</div>
);
}
export default memo(LegendRow);

View File

@@ -1,35 +0,0 @@
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-5, 10px);
padding: 0 var(--spacing-4) var(--spacing-5, 10px);
flex-shrink: 0;
> * {
flex: 0 1 auto;
}
}
.status {
// Wraps rather than losing the count at its end.
min-width: 0;
font-family: var(--font-mono);
font-size: var(--periscope-font-size-small);
color: var(--l3-foreground);
}
.searchContainer {
flex-shrink: 0;
width: 100%;
padding-right: var(--spacing-4);
padding-bottom: var(--spacing-5, 10px);
}
.searchInput {
font-size: var(--font-size-xs);
}
.searchIcon {
color: var(--l3-foreground);
}

View File

@@ -1,56 +0,0 @@
import { ChangeEvent, useCallback } from 'react';
import { Input } from 'antd';
import { Search } from '@signozhq/icons';
import styles from './LegendToolbar.module.scss';
export interface LegendToolbarProps {
visibleCount: number;
totalCount: number;
/** Search is intrinsic to the right-positioned legend. */
showFilter: boolean;
filterQuery: string;
onFilterQueryChange: (query: string) => void;
}
/** Legend chrome: the series search box and the "Showing N of M" readout. */
export default function LegendToolbar({
visibleCount,
totalCount,
showFilter,
filterQuery,
onFilterQueryChange,
}: LegendToolbarProps): JSX.Element {
const handleFilterChange = useCallback(
(event: ChangeEvent<HTMLInputElement>): void =>
onFilterQueryChange(event.target.value),
[onFilterQueryChange],
);
return (
<>
{showFilter && (
<div className={styles.searchContainer}>
<Input
allowClear
prefix={<Search size={12} className={styles.searchIcon} />}
placeholder="Search..."
value={filterQuery}
onChange={handleFilterChange}
className={styles.searchInput}
data-testid="legend-search-input"
/>
</div>
)}
<div className={styles.toolbar}>
<span
className={styles.status}
aria-live="polite"
data-testid="legend-status"
>
{`Showing ${visibleCount} of ${totalCount} series`}
</span>
</div>
</>
);
}

View File

@@ -8,8 +8,8 @@ import Legend from './Legend';
/**
* uPlot legend controller. Derives the legend items + focus/visibility state
* from the chart config (useLegendsSync) and the series interactions from the
* plot context (useLegendActions), then renders the presentational Legend.
* from the chart config (useLegendsSync) and the toggle/focus interactions from
* the plot context (useLegendActions), then renders the presentational Legend.
* Must be rendered inside a PlotContextProvider.
*/
export default function UPlotLegend({
@@ -17,8 +17,13 @@ export default function UPlotLegend({
config,
averageLegendWidth,
}: UPlotLegendProps): JSX.Element {
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
const onAction = useLegendActions();
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
useLegendsSync({ config });
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
});
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
@@ -28,7 +33,9 @@ export default function UPlotLegend({
position={position}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onAction}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
/>
);
}

View File

@@ -1,419 +0,0 @@
import React from 'react';
import { render, RenderResult, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../../hooks/useLegendActions';
import UPlotLegend from '../UPlotLegend';
import { LegendAction, LegendActionPayload, LegendPosition } from '../../types';
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
className,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
className?: string;
}): JSX.Element => (
<div data-testid="virtuoso-grid" className={className}>
{data.map((item, index) => (
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
{itemContent(index, item)}
</div>
))}
</div>
),
}));
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
jest.mock('lib/uPlotV2/hooks/useLegendActions');
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
typeof useLegendsSync
>;
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
typeof useLegendActions
>;
/** The payloads of one action type, in dispatch order. */
const dispatched = (
onAction: jest.Mock,
type: LegendAction,
): LegendActionPayload[] =>
onAction.mock.calls
.map(([payload]) => payload as LegendActionPayload)
.filter((payload) => payload.type === type);
describe('UPlotLegend', () => {
const baseLegendItemsMap = {
0: {
seriesIndex: 0,
label: 'A',
show: true,
color: '#ff0000',
},
1: {
seriesIndex: 1,
label: 'B',
show: false,
color: '#00ff00',
},
2: {
seriesIndex: 2,
label: 'C',
show: true,
color: '#0000ff',
},
};
let onAction: jest.Mock;
beforeEach(() => {
onAction = jest.fn();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: baseLegendItemsMap,
focusedSeriesIndex: 1,
setFocusedSeriesIndex: jest.fn(),
});
mockUseLegendActions.mockReturnValue(onAction);
});
afterEach(() => {
jest.clearAllMocks();
});
const renderLegend = (position?: LegendPosition): RenderResult =>
render(
<TooltipProvider>
<UPlotLegend
position={position}
// config is consumed by the mocked useLegendsSync hook, not directly
config={{} as any}
/>
</TooltipProvider>,
);
describe('layout and position', () => {
it('renders the search input on a RIGHT legend', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
});
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
renderLegend();
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
expect(screen.queryByTestId('legend-status')).not.toBeInTheDocument();
// The row interactions are the same in both placements.
expect(screen.getByTestId('legend-item-0')).toBeInTheDocument();
expect(screen.getByTestId('legend-scope-0')).toBeInTheDocument();
});
it('renders the marker with the series colour, filled only when shown', () => {
renderLegend(LegendPosition.RIGHT);
expect(
document.querySelector(
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
),
).toHaveStyle({
'border-color': '#ff0000',
'background-color': '#ff0000',
});
// Hidden series read as an empty checkbox.
expect(
document.querySelector(
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
),
).toHaveStyle({ 'background-color': 'transparent' });
});
it('renders all legend items in the grid by default', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('status readout', () => {
it('reports how many series are showing', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-status')).toHaveTextContent(
'Showing 2 of 3 series',
);
});
});
describe('filter behavior', () => {
it('filters legend items based on the query (case-insensitive)', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.type(screen.getByTestId('legend-search-input'), 'a');
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('C')).not.toBeInTheDocument();
});
it('shows the empty state when nothing matches', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.type(screen.getByTestId('legend-search-input'), 'network');
expect(
screen.getByText(/No series found matching "network"/i),
).toBeInTheDocument();
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
});
it('ignores a whitespace-only query', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.type(screen.getByTestId('legend-search-input'), ' ');
expect(
screen.queryByText(/No series found matching/i),
).not.toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('row interactions', () => {
const allShownItemsMap = {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1], show: true },
2: { ...baseLegendItemsMap[2] },
};
const mockAllShown = (): void => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: allShownItemsMap,
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
};
it('isolates the series when everything is showing', async () => {
const user = userEvent.setup();
mockAllShown();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
// Nothing the user can see is there to exclude, so the click means Only.
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
{ type: LegendAction.SHOW_ONLY, seriesIndex: 0 },
]);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
});
it('toggles the series once something is already hidden', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
]);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('excludes just that series when its marker is clicked', async () => {
const user = userEvent.setup();
mockAllShown();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-marker-0'));
// The marker is the one way to exclude a single series while
// everything is showing — the row click isolates instead.
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
]);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('stops the marker offering to hide the last series showing', () => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
},
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-marker-0')).toBeDisabled();
expect(screen.getByTestId('legend-marker-1')).toBeEnabled();
});
it('labels the marker with what clicking it does', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-marker-0')).toHaveAttribute(
'aria-label',
'Hide A',
);
expect(screen.getByTestId('legend-marker-1')).toHaveAttribute(
'aria-label',
'Show B',
);
});
it('adds the clicked series to the selection while one is alone', async () => {
const user = userEvent.setup();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
},
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
renderLegend(LegendPosition.RIGHT);
// Series 0 is showing alone; clicking another row builds the selection
// up rather than moving the isolation.
await user.click(screen.getByText('B'));
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 1 },
]);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('toggles the series on Enter and Space', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const row = screen.getByTestId('legend-item-0');
row.focus();
await user.keyboard('{Enter}');
await user.keyboard(' ');
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
]);
});
it('reflects visibility on the row for assistive tech', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-item-0')).toHaveAttribute(
'aria-checked',
'true',
);
expect(screen.getByTestId('legend-item-1')).toHaveAttribute(
'aria-checked',
'false',
);
});
it('restores every series from All without also toggling the row', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
// Series 0 is shown while B is hidden, so its action is All.
await user.click(screen.getByTestId('legend-scope-0'));
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('isolates the series from Only on a hidden row', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-scope-1'));
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
{ type: LegendAction.SHOW_ONLY, seriesIndex: 1 },
]);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
});
it('highlights the hovered series and clears it on leave', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const row = screen.getByTestId('legend-item-0');
await user.hover(row);
expect(onAction).toHaveBeenCalledWith({
type: LegendAction.HOVER,
seriesIndex: 0,
});
await user.unhover(row);
expect(onAction).toHaveBeenCalledWith({
type: LegendAction.HOVER,
seriesIndex: null,
});
});
});
describe('one-series state', () => {
const soleShownItemsMap = {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
};
beforeEach(() => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: soleShownItemsMap,
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
});
it('offers All on the shown row and Only on the hidden ones', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-scope-0')).toHaveTextContent('All');
expect(screen.getByTestId('legend-scope-1')).toHaveTextContent('Only');
expect(screen.getByTestId('legend-scope-2')).toHaveTextContent('Only');
});
it('restores everything from All', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-scope-0'));
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
});
it('restores everything when the row showing alone is clicked again', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
});
});

View File

@@ -1,45 +0,0 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import { filterLegendItems, getShownSeriesState } from '../utils';
const items = (shown: boolean[]): LegendItem[] =>
shown.map((show, index) => ({
seriesIndex: index + 1,
label: `series-${index}`,
color: '#000',
show,
}));
describe('getShownSeriesState', () => {
it('counts the shown series', () => {
expect(getShownSeriesState(items([true, false, true]))).toStrictEqual({
visibleCount: 2,
soleShownSeriesIndex: null,
});
});
it('names the series when exactly one is shown', () => {
expect(getShownSeriesState(items([false, true, false]))).toStrictEqual({
visibleCount: 1,
soleShownSeriesIndex: 2,
});
});
it('reports nothing shown', () => {
expect(getShownSeriesState(items([false, false]))).toStrictEqual({
visibleCount: 0,
soleShownSeriesIndex: null,
});
});
});
describe('filterLegendItems', () => {
it('matches case-insensitively on the label', () => {
const filtered = filterLegendItems(items([true, true, true]), 'SERIES-1');
expect(filtered.map((item) => item.label)).toStrictEqual(['series-1']);
});
it('returns every item for a blank query', () => {
expect(filterLegendItems(items([true, true]), ' ')).toHaveLength(2);
});
});

View File

@@ -1,20 +0,0 @@
/** Widest a single legend item is allowed to get when sizing the legend grid. */
export const MAX_LEGEND_WIDTH = 240;
/**
* Enough for a row to contain its own hover actions, which a short label would
* otherwise size a column too narrow for. Little room for the label is intended.
*/
export const MIN_LEGEND_ITEM_WIDTH = 110;
/** Marker + row padding, on top of the estimated label width. */
export const LEGEND_ITEM_EXTRA_WIDTH = 16;
/** Must match `.row`'s height and the grid's row gap, or the reserved
* rectangle clips a row. */
export const LEGEND_ROW_HEIGHT = 28;
export const LEGEND_ROW_GAP = 2;
export const LEGEND_MAX_BOTTOM_ROWS = 2;
/** Hover delay before a row's full-name tooltip opens. */
export const LEGEND_TOOLTIP_DELAY_MS = 500;

View File

@@ -1,34 +0,0 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
export interface ShownSeriesState {
visibleCount: number;
/** The series index when exactly one series is shown, else null. */
soleShownSeriesIndex: number | null;
}
/**
* Driven by what is actually shown, never a remembered isolation: hiding series
* one at a time down to a single one is the same state as "Only".
*/
export function getShownSeriesState(items: LegendItem[]): ShownSeriesState {
const shown = items.filter((item) => item.show);
return {
visibleCount: shown.length,
soleShownSeriesIndex: shown.length === 1 ? shown[0].seriesIndex : null,
};
}
export function filterLegendItems(
items: LegendItem[],
query: string,
): LegendItem[] {
const normalisedQuery = query.trim().toLowerCase();
if (!normalisedQuery) {
return items;
}
return items.filter((item) =>
item.label?.toLowerCase().includes(normalisedQuery),
);
}

View File

@@ -12,11 +12,10 @@
}
}
// Matches the legend row's marker.
.uplotTooltipItemMarker {
border-radius: var(--radius);
border-radius: 50%;
border-style: solid;
border-width: 1.5px;
border-width: 2px;
width: 12px;
height: 12px;
box-sizing: border-box;
@@ -31,23 +30,11 @@
justify-content: space-between;
}
// The legend's mono type; the container's Inter stays for the header.
.uplotTooltipItemLabel,
.uplotTooltipItemValue {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
}
.uplotTooltipItemLabel {
white-space: normal;
overflow-wrap: anywhere;
}
.uplotTooltipItemValue {
white-space: nowrap;
}
.uplotTooltipItemContentSeparator {
flex: 1;
border-width: 0.5px;

View File

@@ -25,7 +25,7 @@ export default function TooltipItem({
>
<div
className={Styles.uplotTooltipItemMarker}
style={{ borderColor: item.color, backgroundColor: item.color }}
style={{ borderColor: item.color }}
data-is-legend-marker={true}
data-testid={markerTestId}
/>
@@ -39,7 +39,7 @@ export default function TooltipItem({
className={Styles.uplotTooltipItemContentSeparator}
style={{ borderColor: item.color }}
/>
<span className={Styles.uplotTooltipItemValue}>{item.tooltipValue}</span>
<span>{item.tooltipValue}</span>
</div>
</div>
);

View File

@@ -0,0 +1,216 @@
import React from 'react';
import { render, RenderResult, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../hooks/useLegendActions';
import UPlotLegend from '../Legend/UPlotLegend';
import { LegendPosition } from '../types';
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
className,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
className?: string;
}): JSX.Element => (
<div data-testid="virtuoso-grid" className={className}>
{data.map((item, index) => (
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
{itemContent(index, item)}
</div>
))}
</div>
),
}));
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
jest.mock('lib/uPlotV2/hooks/useLegendActions');
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
typeof useLegendsSync
>;
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
typeof useLegendActions
>;
describe('UPlotLegend', () => {
const baseLegendItemsMap = {
0: {
seriesIndex: 0,
label: 'A',
show: true,
color: '#ff0000',
},
1: {
seriesIndex: 1,
label: 'B',
show: false,
color: '#00ff00',
},
2: {
seriesIndex: 2,
label: 'C',
show: true,
color: '#0000ff',
},
};
let onLegendClick: jest.Mock;
let onLegendMouseMove: jest.Mock;
let onLegendMouseLeave: jest.Mock;
let onFocusSeries: jest.Mock;
beforeEach(() => {
onLegendClick = jest.fn();
onLegendMouseMove = jest.fn();
onLegendMouseLeave = jest.fn();
onFocusSeries = jest.fn();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: baseLegendItemsMap,
focusedSeriesIndex: 1,
setFocusedSeriesIndex: jest.fn(),
});
mockUseLegendActions.mockReturnValue({
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
onFocusSeries,
});
});
afterEach(() => {
jest.clearAllMocks();
});
const renderLegend = (position?: LegendPosition): RenderResult =>
render(
<TooltipProvider>
<UPlotLegend
position={position}
// config is consumed by the mocked useLegendsSync hook, not directly
config={{} as any}
/>
</TooltipProvider>,
);
describe('layout and position', () => {
it('renders search input when legend position is RIGHT', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
});
it('does not render search input when legend position is BOTTOM (default)', () => {
renderLegend();
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
});
it('renders the marker with the correct border color', () => {
renderLegend(LegendPosition.RIGHT);
const legendMarker = document.querySelector(
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
) as HTMLElement;
expect(legendMarker).toHaveStyle({
'border-color': '#ff0000',
});
});
it('renders all legend items in the grid by default', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('search behavior (RIGHT position)', () => {
it('filters legend items based on search query (case-insensitive)', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, 'A');
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('C')).not.toBeInTheDocument();
});
it('shows empty state when no legend items match the search query', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, 'network');
expect(
screen.getByText(/No series found matching "network"/i),
).toBeInTheDocument();
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
});
it('does not filter or show empty state when search query is empty or only whitespace', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, ' ');
expect(
screen.queryByText(/No series found matching/i),
).not.toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('legend actions', () => {
it('calls onLegendClick when a legend item is clicked', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(onLegendClick).toHaveBeenCalledTimes(1);
});
it('calls mouseMove when the mouse moves over a legend item', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const legendItem = document.querySelector(
'[data-legend-item-id="0"]',
) as HTMLElement;
await user.hover(legendItem);
expect(onLegendMouseMove).toHaveBeenCalledTimes(1);
});
it('calls onLegendMouseLeave when the mouse leaves the legend container', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const container = document.querySelector('.legend-container') as HTMLElement;
await user.hover(container);
await user.unhover(container);
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import { MouseEventHandler, ReactNode } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
@@ -115,39 +115,26 @@ export enum LegendPosition {
export interface LegendConfig {
position: LegendPosition;
}
export enum LegendAction {
TOGGLE = 'toggle',
SHOW_ONLY = 'showOnly',
SHOW_ALL = 'showAll',
HOVER = 'hover',
}
/** Everything the legend can ask of its container, as one dispatch. */
export type LegendActionPayload =
/** Row click / Space / Enter / marker click: hide or show that one series. */
| { type: LegendAction.TOGGLE; seriesIndex: number }
/** Show that series alone. */
| { type: LegendAction.SHOW_ONLY; seriesIndex: number }
/** Leave the narrowed selection and show every series. */
| { type: LegendAction.SHOW_ALL }
/** Row hover, for the chart-side highlight; null on leave. */
| { type: LegendAction.HOVER; seriesIndex: number | null };
export type OnLegendAction = (payload: LegendActionPayload) => void;
/**
* Presentational legend props. Source-agnostic: it renders whatever `items`
* it's given and delegates interaction to the container handlers, so it serves
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie). The search
* box is intrinsic to the RIGHT position (derived from `position`, not a flag).
*/
export interface LegendProps {
items: LegendItem[];
/** Legend placement; always supplied by the container. */
position: LegendPosition;
averageLegendWidth?: number;
/** Series index highlighted by the chart cursor. */
/** Series index to highlight (hovered/focused). */
focusedSeriesIndex: number | null;
onAction: OnLegendAction;
/**
* Container-delegated handlers. Items carry `data-legend-item-id`, so the
* handler reads the target's id rather than binding per item.
*/
onClick: MouseEventHandler<HTMLDivElement>;
onMouseMove: MouseEventHandler<HTMLDivElement>;
onMouseLeave: () => void;
/** Show the per-item copy button. Default true. */
showCopy?: boolean;
}

View File

@@ -6,11 +6,6 @@ export const DEFAULT_HOVER_PROXIMITY_VALUE = 30; // only snap if within 30px hor
export const DEFAULT_FOCUS_PROXIMITY_VALUE = 1e6;
export const STEP_INTERVAL_MULTIPLIER = 3; // multiply the width computed by STEP_INTERVAL_MULTIPLIER to get the hover prox value
/** Opacity applied to the series that are NOT highlighted while a legend row is hovered. */
export const LEGEND_HIGHLIGHT_DIM_ALPHA = 0.16;
/** Stroke-width multiplier applied to the series highlighted from the legend. */
export const LEGEND_HIGHLIGHT_WIDTH_RATIO = 1.6;
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
focus: {
alpha: 0.3,

View File

@@ -8,10 +8,6 @@ import {
useMemo,
useRef,
} from 'react';
import {
LEGEND_HIGHLIGHT_DIM_ALPHA,
LEGEND_HIGHLIGHT_WIDTH_RATIO,
} from 'lib/uPlotV2/constants';
import type { SeriesVisibilityItem } from 'lib/visualization/panels/types';
import { updateSeriesVisibilityToLocalStorage } from 'lib/visualization/panels/utils/legendVisibilityUtils';
import type uPlot from 'uplot';
@@ -24,26 +20,12 @@ export interface IPlotContext {
setPlotContextInitialState: (state: PlotContextInitialState) => void;
onToggleSeriesVisibility: (seriesIndex: number) => void;
onToggleSeriesOnOff: (seriesIndex: number) => void;
/** Show this series alone. */
onShowOnlySeries: (seriesIndex: number) => void;
/** Show every series again. */
onShowAllSeries: () => void;
onFocusSeries: (seriesIndex: number | null) => void;
/** Lift one series above the rest (dim + thicken) without changing visibility. */
onHighlightSeries: (seriesIndex: number | null) => void;
syncSeriesVisibilityToLocalStorage: () => void;
}
export const PlotContext = createContext<IPlotContext | null>(null);
/** Data series (index 0 is the x-axis) currently drawn. */
const countShownSeries = (plot: uPlot): number =>
plot.series.reduce(
(count, series, index) =>
index > 0 && series.show !== false ? count + 1 : count,
0,
);
export const PlotContextProvider = ({
children,
}: PropsWithChildren): JSX.Element => {
@@ -51,9 +33,6 @@ export const PlotContextProvider = ({
const activeSeriesIndex = useRef<number | undefined>(undefined);
const idRef = useRef<string | undefined>(undefined);
const shouldSavePreferencesRef = useRef<boolean>(false);
/** Pre-highlight stroke widths, captured on the first highlight so it can be undone. */
const baseSeriesWidthsRef = useRef<Map<number, number | undefined>>(new Map());
const highlightedSeriesIndexRef = useRef<number | null>(null);
const setPlotContextInitialState = useCallback(
({
@@ -64,8 +43,6 @@ export const PlotContextProvider = ({
uPlotInstanceRef.current = uPlotInstance;
idRef.current = id;
activeSeriesIndex.current = undefined;
baseSeriesWidthsRef.current = new Map();
highlightedSeriesIndexRef.current = null;
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
},
[],
@@ -87,54 +64,6 @@ export const PlotContextProvider = ({
updateSeriesVisibilityToLocalStorage(idRef.current, seriesVisibility);
}, []);
const onHighlightSeries = useCallback((seriesIndex: number | null): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
highlightedSeriesIndexRef.current = seriesIndex;
plot.series.forEach((series, index) => {
if (index === 0) {
return;
}
if (!baseSeriesWidthsRef.current.has(index)) {
baseSeriesWidthsRef.current.set(index, series.width);
}
const baseWidth = baseSeriesWidthsRef.current.get(index);
const isHighlighted = index === seriesIndex;
/* eslint-disable no-param-reassign */
series.alpha =
seriesIndex === null || isHighlighted ? 1 : LEGEND_HIGHLIGHT_DIM_ALPHA;
series.width =
isHighlighted && baseWidth !== undefined
? baseWidth * LEGEND_HIGHLIGHT_WIDTH_RATIO
: baseWidth;
/* eslint-enable no-param-reassign */
});
// Only the stroke style changed, so the cached paths stay valid.
plot.redraw(false);
}, []);
/**
* Leaving the dim on a hidden series leaves every other one faded, which
* reads as an isolation rather than as one series being excluded.
*/
const clearHighlightIfHidden = useCallback((): void => {
const plot = uPlotInstanceRef.current;
const highlightedIndex = highlightedSeriesIndexRef.current;
if (!plot || highlightedIndex === null) {
return;
}
if (plot.series[highlightedIndex]?.show === false) {
onHighlightSeries(null);
}
}, [onHighlightSeries]);
const onToggleSeriesVisibility = useCallback(
(seriesIndex: number): void => {
const plot = uPlotInstanceRef.current;
@@ -174,61 +103,14 @@ export const PlotContextProvider = ({
if (!series) {
return;
}
// An empty chart is never worth reaching.
const isHiding = series.show !== false;
if (isHiding && countShownSeries(plot) <= 1) {
return;
}
plot.setSeries(seriesIndex, { show: !series.show });
if (idRef.current && shouldSavePreferencesRef.current) {
syncSeriesVisibilityToLocalStorage();
}
clearHighlightIfHidden();
},
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
[syncSeriesVisibilityToLocalStorage],
);
/** Applies `resolveShow` to every data series in one batch, then persists. */
const setSeriesVisibility = useCallback(
(resolveShow: (seriesIndex: number) => boolean): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
activeSeriesIndex.current = undefined;
plot.batch(() => {
plot.series.forEach((_, index) => {
if (index === 0) {
return;
}
plot.setSeries(index, { show: resolveShow(index) });
});
if (idRef.current && shouldSavePreferencesRef.current) {
syncSeriesVisibilityToLocalStorage();
}
});
clearHighlightIfHidden();
},
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
);
const onShowOnlySeries = useCallback(
(seriesIndex: number): void => {
setSeriesVisibility((index) => index === seriesIndex);
},
[setSeriesVisibility],
);
const onShowAllSeries = useCallback((): void => {
setSeriesVisibility(() => true);
}, [setSeriesVisibility]);
const onFocusSeries = useCallback((seriesIndex: number | null): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
@@ -249,20 +131,14 @@ export const PlotContextProvider = ({
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
syncSeriesVisibilityToLocalStorage,
}),
[
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
syncSeriesVisibilityToLocalStorage,
],
);

View File

@@ -26,7 +26,6 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
series,
batch: jest.fn((fn: () => void) => fn()),
setSeries: jest.fn(),
redraw: jest.fn(),
}) as unknown as uPlot;
interface TestComponentProps {
@@ -45,10 +44,7 @@ const TestComponent = ({
syncSeriesVisibilityToLocalStorage,
onToggleSeriesVisibility,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
} = usePlotContext();
const handleInit = (): void => {
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
@@ -88,13 +84,6 @@ const TestComponent = ({
>
Toggle on/off 1
</button>
<button
type="button"
data-testid="toggle-on-off-2"
onClick={(): void => onToggleSeriesOnOff(2)}
>
Toggle on/off 2
</button>
<button
type="button"
data-testid="toggle-on-off-5"
@@ -109,34 +98,6 @@ const TestComponent = ({
>
Focus series
</button>
<button
type="button"
data-testid="show-only-1"
onClick={(): void => onShowOnlySeries(1)}
>
Show only 1
</button>
<button
type="button"
data-testid="show-all"
onClick={(): void => onShowAllSeries()}
>
Show all
</button>
<button
type="button"
data-testid="highlight-1"
onClick={(): void => onHighlightSeries(1)}
>
Highlight 1
</button>
<button
type="button"
data-testid="clear-highlight"
onClick={(): void => onHighlightSeries(null)}
>
Clear highlight
</button>
</div>
);
};
@@ -312,7 +273,6 @@ describe('PlotContext', () => {
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
];
const plot = createMockPlot(series);
@@ -364,7 +324,6 @@ describe('PlotContext', () => {
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
];
const plot = createMockPlot(series);
@@ -384,48 +343,6 @@ describe('PlotContext', () => {
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('refuses to hide the last series showing', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('toggle-on-off-1'));
// An empty chart is never a state worth reaching.
expect(plot.setSeries).not.toHaveBeenCalled();
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('still shows a hidden series when only one is left showing', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: false },
{ label: 'Memory', show: true },
]);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('toggle-on-off-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
});
});
describe('onFocusSeries', () => {
@@ -464,193 +381,4 @@ describe('PlotContext', () => {
expect(plot.setSeries).toHaveBeenCalledWith(1, { focus: true }, false);
});
});
describe('onShowOnlySeries', () => {
const renderWithSeries = (
series: MockSeries[],
): { plot: uPlot; user: ReturnType<typeof userEvent.setup> } => {
const user = userEvent.setup();
const plot = createMockPlot(series);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
return { plot, user };
};
it('hides every other series, leaving the x-axis alone', async () => {
const { plot, user } = renderWithSeries([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
]);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('show-only-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalled();
});
it('keeps isolating the series that is already the only one shown', async () => {
const { plot, user } = renderWithSeries([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('show-only-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
});
});
describe('onShowAllSeries', () => {
it('shows every hidden series again', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('show-all'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: true });
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
});
});
describe('onHighlightSeries', () => {
const series = (): MockSeries[] => [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true, width: 2 },
{ label: 'Memory', show: true, width: 2 },
];
it('dims the other series and thickens the highlighted one', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
expect(plot.series[1].alpha).toBe(1);
expect(plot.series[1].width).toBe(3.2);
expect(plot.series[2].alpha).toBe(0.16);
expect(plot.series[2].width).toBe(2);
// Only the stroke changed, so the cached paths are reused.
expect(plot.redraw).toHaveBeenCalledWith(false);
});
it('restores every series when the highlight is cleared', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
await user.click(screen.getByTestId('clear-highlight'));
expect(plot.series[1].alpha).toBe(1);
expect(plot.series[1].width).toBe(2);
expect(plot.series[2].alpha).toBe(1);
expect(plot.series[2].width).toBe(2);
});
it('drops the dim when the highlighted series is hidden', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
// The mock's setSeries doesn't mutate, so mirror what uPlot would do.
(plot.setSeries as jest.Mock).mockImplementation(
(index: number, opts: { show?: boolean }) => {
if (typeof opts.show === 'boolean') {
(plot.series[index] as MockSeries).show = opts.show;
}
},
);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
await user.click(screen.getByTestId('toggle-on-off-1'));
// Otherwise every remaining series stays faded and the panel reads as
// an isolation instead of one series being excluded.
expect(plot.series[2].alpha).toBe(1);
expect(plot.series[2].width).toBe(2);
});
it('keeps the dim when a different series is hidden', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
(plot.setSeries as jest.Mock).mockImplementation(
(index: number, opts: { show?: boolean }) => {
if (typeof opts.show === 'boolean') {
(plot.series[index] as MockSeries).show = opts.show;
}
},
);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
await user.click(screen.getByTestId('toggle-on-off-2'));
expect(plot.series[1].alpha).toBe(1);
expect(plot.series[2].alpha).toBe(0.16);
});
it('leaves visibility untouched', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
expect(plot.setSeries).not.toHaveBeenCalled();
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,5 +1,4 @@
import { renderHook } from '@testing-library/react';
import { LegendAction } from 'lib/uPlotV2/components/types';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import { useLegendActions } from 'lib/uPlotV2/hooks/useLegendActions';
@@ -12,12 +11,10 @@ const mockUsePlotContext = usePlotContext as jest.MockedFunction<
describe('useLegendActions', () => {
let onToggleSeriesVisibility: jest.Mock;
let onToggleSeriesOnOff: jest.Mock;
let onShowOnlySeries: jest.Mock;
let onShowAllSeries: jest.Mock;
let onFocusSeries: jest.Mock;
let onHighlightSeries: jest.Mock;
let onFocusSeriesPlot: jest.Mock;
let setPlotContextInitialState: jest.Mock;
let syncSeriesVisibilityToLocalStorage: jest.Mock;
let setFocusedSeriesIndexMock: jest.Mock;
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
beforeAll(() => {
@@ -40,20 +37,15 @@ describe('useLegendActions', () => {
beforeEach(() => {
onToggleSeriesVisibility = jest.fn();
onToggleSeriesOnOff = jest.fn();
onShowOnlySeries = jest.fn();
onShowAllSeries = jest.fn();
onFocusSeries = jest.fn();
onHighlightSeries = jest.fn();
onFocusSeriesPlot = jest.fn();
setPlotContextInitialState = jest.fn();
syncSeriesVisibilityToLocalStorage = jest.fn();
setFocusedSeriesIndexMock = jest.fn();
mockUsePlotContext.mockReturnValue({
onToggleSeriesVisibility,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
onFocusSeries: onFocusSeriesPlot,
setPlotContextInitialState,
syncSeriesVisibilityToLocalStorage,
});
@@ -61,65 +53,149 @@ describe('useLegendActions', () => {
cancelAnimationFrameSpy.mockClear();
});
describe('visibility actions', () => {
it('toggles a single series on row click', () => {
const { result } = renderHook(() => useLegendActions());
const createMouseEvent = (options: {
legendItemId?: number;
isMarker?: boolean;
}): any => {
const { legendItemId, isMarker = false } = options;
result.current({ type: LegendAction.TOGGLE, seriesIndex: 2 });
return {
target: {
dataset: {
...(isMarker ? { isLegendMarker: 'true' } : {}),
},
closest: jest.fn(() =>
legendItemId !== undefined
? { dataset: { legendItemId: String(legendItemId) } }
: null,
),
},
};
};
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(2);
// The row must never isolate — that is what "Only" is for.
describe('onLegendClick', () => {
it('toggles series visibility when clicking on legend label', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendClick(createMouseEvent({ legendItemId: 0 }));
expect(onToggleSeriesVisibility).toHaveBeenCalledTimes(1);
expect(onToggleSeriesVisibility).toHaveBeenCalledWith(0);
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
});
it('toggles series on/off when clicking on marker', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendClick(
createMouseEvent({ legendItemId: 0, isMarker: true }),
);
expect(onToggleSeriesOnOff).toHaveBeenCalledTimes(1);
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(0);
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
});
it('forwards the Only and All actions to the plot', () => {
const { result } = renderHook(() => useLegendActions());
it('does nothing when click target is not inside a legend item', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current({ type: LegendAction.SHOW_ONLY, seriesIndex: 1 });
result.current({ type: LegendAction.SHOW_ALL });
result.current.onLegendClick(createMouseEvent({}));
expect(onShowOnlySeries).toHaveBeenCalledWith(1);
expect(onShowAllSeries).toHaveBeenCalled();
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
});
});
describe('hover highlight', () => {
it('highlights the hovered series', () => {
const { result } = renderHook(() => useLegendActions());
describe('onFocusSeries', () => {
it('schedules focus update and calls plot focus handler via mouse move', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current({ type: LegendAction.HOVER, seriesIndex: 2 });
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
expect(onHighlightSeries).toHaveBeenCalledWith(2);
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(0);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(0);
});
it('clears the highlight on leave', () => {
const { result } = renderHook(() => useLegendActions());
it('cancels previous animation frame before scheduling new one on subsequent mouse moves', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current({ type: LegendAction.HOVER, seriesIndex: null });
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
expect(onHighlightSeries).toHaveBeenCalledWith(null);
});
it('coalesces rapid hovers into one frame', () => {
const { result } = renderHook(() => useLegendActions());
result.current({ type: LegendAction.HOVER, seriesIndex: 1 });
result.current({ type: LegendAction.HOVER, seriesIndex: 2 });
// Each new hover cancels the frame the previous one queued.
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
});
});
it('cancels a pending highlight frame on unmount', () => {
jest
.spyOn(global, 'requestAnimationFrame')
.mockImplementation((): number => 7);
describe('onLegendMouseMove', () => {
it('focuses new series when hovering over different legend item', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: 0,
}),
);
const { result, unmount } = renderHook(() => useLegendActions());
result.current({ type: LegendAction.HOVER, seriesIndex: 1 });
unmount();
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(7);
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(1);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(1);
});
it('does nothing when hovering over already focused series', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: 1,
}),
);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
expect(setFocusedSeriesIndexMock).not.toHaveBeenCalled();
expect(onFocusSeriesPlot).not.toHaveBeenCalled();
});
});
describe('onLegendMouseLeave', () => {
it('cancels pending animation frame and clears focus state', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
result.current.onLegendMouseLeave();
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(null);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(null);
});
});
});

View File

@@ -1,66 +1,117 @@
import { useCallback, useEffect, useRef } from 'react';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
} from 'react';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import {
LegendAction,
LegendActionPayload,
OnLegendAction,
} from '../components/types';
/**
* Legend interactions, bound to the plot through PlotContext. Hover is coalesced
* to one chart redraw per frame.
*/
export function useLegendActions(): OnLegendAction {
export function useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
}: {
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
focusedSeriesIndex: number | null;
}): {
onLegendClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onFocusSeries: (seriesIndex: number | null) => void;
onLegendMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
} {
const {
onFocusSeries: onFocusSeriesPlot,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onHighlightSeries,
onToggleSeriesVisibility,
} = usePlotContext();
const rafIdRef = useRef<number | null>(null);
const rafId = useRef<number | null>(null); // requestAnimationFrame id
const cancelPendingHighlight = useCallback((): void => {
if (rafIdRef.current != null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
const getLegendItemIdFromEvent = useCallback(
(e: React.MouseEvent<HTMLDivElement>): string | undefined => {
const target = e.target as HTMLElement | null;
if (!target) {
return undefined;
}
const legendItemElement = target.closest<HTMLElement>(
'[data-legend-item-id]',
);
return legendItemElement?.dataset.legendItemId;
},
[],
);
const onLegendClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
if (!legendItemId) {
return;
}
const isLegendMarker = (e.target as HTMLElement).dataset.isLegendMarker;
const seriesIndex = Number(legendItemId);
if (isLegendMarker) {
onToggleSeriesOnOff(seriesIndex);
return;
}
onToggleSeriesVisibility(seriesIndex);
},
[onToggleSeriesVisibility, onToggleSeriesOnOff, getLegendItemIdFromEvent],
);
const onFocusSeries = useCallback(
(seriesIndex: number | null): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
rafId.current = requestAnimationFrame(() => {
setFocusedSeriesIndex(seriesIndex);
onFocusSeriesPlot(seriesIndex);
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeriesPlot],
);
const onLegendMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
const seriesIndex = legendItemId ? Number(legendItemId) : null;
if (seriesIndex === focusedSeriesIndex) {
return;
}
}, []);
onFocusSeries(seriesIndex);
};
useEffect(() => cancelPendingHighlight, [cancelPendingHighlight]);
const onLegendMouseLeave = useCallback(
(): void => {
// Cancel any pending RAF from handleFocusSeries to prevent race condition
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
setFocusedSeriesIndex(null);
onFocusSeries(null);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeries],
);
return useCallback(
(payload: LegendActionPayload): void => {
switch (payload.type) {
case LegendAction.TOGGLE:
onToggleSeriesOnOff(payload.seriesIndex);
break;
case LegendAction.SHOW_ONLY:
onShowOnlySeries(payload.seriesIndex);
break;
case LegendAction.SHOW_ALL:
onShowAllSeries();
break;
case LegendAction.HOVER: {
const { seriesIndex } = payload;
cancelPendingHighlight();
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
onHighlightSeries(seriesIndex);
});
break;
}
default:
break;
// Cleanup pending animation frames on unmount
useEffect(
() => (): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
},
[
cancelPendingHighlight,
onHighlightSeries,
onShowAllSeries,
onShowOnlySeries,
onToggleSeriesOnOff,
],
[],
);
return {
onLegendClick,
onFocusSeries,
onLegendMouseMove,
onLegendMouseLeave,
};
}

View File

@@ -45,7 +45,9 @@ export default function Pie({
visibleData,
legendItems,
focusedSeriesIndex,
onLegendAction,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
} = usePieInteractions(data, id);
const {
@@ -225,7 +227,9 @@ export default function Pie({
position={position}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onLegendAction}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
/>
</div>
</div>

View File

@@ -100,29 +100,17 @@ describe('Pie', () => {
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
});
it('isolates a slice when its legend row is clicked with everything showing', () => {
it('hides a slice when its legend marker is clicked', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
expect(svg.querySelectorAll('path')).toHaveLength(3);
fireEvent.click(screen.getByTestId('legend-item-1'));
// Nothing visible to exclude, so the click isolates: one arc left.
expect(svg.querySelectorAll('path')).toHaveLength(1);
});
it('excludes a slice when its legend row is clicked with others already hidden', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
// Isolate, then add a second slice back, so nothing is isolated any more.
fireEvent.click(screen.getByTestId('legend-item-1'));
fireEvent.click(screen.getByTestId('legend-item-0'));
expect(svg.querySelectorAll('path')).toHaveLength(2);
fireEvent.click(screen.getByTestId('legend-item-0'));
const marker = document.querySelector(
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
) as HTMLElement;
fireEvent.click(marker);
// One slice hidden → one fewer arc drawn.
expect(svg.querySelectorAll('path')).toHaveLength(1);
expect(svg.querySelectorAll('path')).toHaveLength(2);
});
});

View File

@@ -1,9 +1,6 @@
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
calculateAverageLegendWidth,
calculateChartDimensions,
} from 'lib/visualization/charts/utils';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
const labels = (count: number, length = 20): string[] =>
Array.from({ length: count }, (_, i) =>
@@ -52,104 +49,63 @@ describe('calculateChartDimensions', () => {
expect(dims.width).toBe(784);
});
it('RIGHT: never shrinks the column below the floor that fits its chrome', () => {
it('RIGHT: never shrinks the column below the 150px floor', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(3, 3),
});
expect(dims.legendWidth).toBe(190);
expect(dims.width).toBe(810);
expect(dims.legendWidth).toBe(150);
expect(dims.width).toBe(850);
});
it('RIGHT: on a narrow container the legend keeps its chrome, up to half the width', () => {
it('RIGHT: on a narrow container the legend never takes more than 40% of the width', () => {
const dims = calculateChartDimensions({
containerWidth: 300,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
// 40% is 120px, too narrow for the column's own toolbar.
expect(dims.legendWidth).toBe(150);
expect(dims.width).toBe(150);
expect(dims.legendWidth).toBe(120);
expect(dims.width).toBe(180);
});
it('RIGHT: stops widening the column once the panel is narrower than its chrome', () => {
const dims = calculateChartDimensions({
containerWidth: 200,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
expect(dims.legendWidth).toBe(100);
expect(dims.width).toBe(100);
});
it('BOTTOM: items that fit one row reserve exactly one row', () => {
it('BOTTOM: a single row of items reserves one legend row', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
});
// One 28px row + the wrapper's 12px bottom padding.
// One row = line height (28) + padding (12).
expect(dims.legendHeight).toBe(40);
expect(dims.height).toBe(460);
expect(dims.legendWidth).toBe(1000);
});
it('BOTTOM: more items than one row reserve exactly two rows', () => {
it('BOTTOM: many items cap at two rows on a tall container', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Two 28px rows + the 2px row gap + 12px bottom padding — no room for a
// clipped third row, and none left over.
expect(dims.legendHeight).toBe(70);
expect(dims.height).toBe(430);
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
expect(dims.legendHeight).toBe(68);
expect(dims.height).toBe(432);
});
it('BOTTOM: items one past a row still reserve two rows', () => {
// 1000px wide fits 5 of these per row, so 6 items need a second row.
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(6),
});
expect(dims.legendHeight).toBe(70);
});
it('BOTTOM: drops to a single row rather than take half a short panel', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 120,
containerHeight: 160,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// A whole row goes rather than a clipped one being reserved.
expect(dims.legendHeight).toBe(40);
expect(dims.height).toBe(80);
});
});
describe('calculateAverageLegendWidth', () => {
it('scales with the label length', () => {
// 16px of chrome + 30 chars at 8px.
expect(calculateAverageLegendWidth(labels(4, 30))).toBe(256);
});
it('never drops below what a row needs to contain its hover actions', () => {
// Short or unnamed series would otherwise size a column the actions
// escape, spilling over the item beside it.
expect(calculateAverageLegendWidth(['cpu'])).toBe(110);
expect(calculateAverageLegendWidth([''])).toBe(110);
});
it('keeps the default estimate when there are no labels to measure', () => {
expect(calculateAverageLegendWidth([])).toBe(120);
// Without the height-relative cap the legend would take 68px of a 160px
// panel and the chart (pie especially) collapses to a sliver.
expect(dims.legendHeight).toBe(48); // 30% of 160
expect(dims.height).toBe(112);
});
});

View File

@@ -1,10 +1,4 @@
import {
LEGEND_MAX_BOTTOM_ROWS,
MIN_LEGEND_ITEM_WIDTH,
LEGEND_ROW_GAP,
LEGEND_ROW_HEIGHT,
MAX_LEGEND_WIDTH,
} from 'lib/uPlotV2/components/Legend/constants';
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
export interface ChartDimensions {
width: number;
@@ -19,31 +13,22 @@ const LEGEND_WIDTH_PERCENTILE = 0.85;
const DEFAULT_AVG_LABEL_LENGTH = 15;
const BASE_LEGEND_WIDTH = 16;
const LEGEND_PADDING = 12;
// Two rows are worth having, but not at the cost of half the panel.
const MAX_SHORT_PANEL_LEGEND_RATIO = 0.5;
const LEGEND_LINE_HEIGHT = 28;
// RIGHT legend is a vertical column with its own width budget (cap protects the donut).
const MAX_RIGHT_LEGEND_WIDTH = 320;
const RIGHT_LEGEND_WIDTH_RATIO = 0.4;
// Column padding + copy button, not covered by the text-length estimate.
const RIGHT_LEGEND_RESERVED_WIDTH = 40;
// Fits the toolbar's "Showing N of M series" readout plus the wrapper padding.
const MIN_RIGHT_LEGEND_WIDTH = 190;
// Past this the split inverts and the chart becomes the smaller half.
const RIGHT_LEGEND_FLOOR_RATIO = 0.5;
/**
* Calculates the average width of the legend items based on the labels of the series.
* Never returns less than a legend row needs to hold its own hover actions.
* @param legends - The labels of the series.
* @returns The average width of the legend items.
*/
export function calculateAverageLegendWidth(legends: string[]): number {
if (legends.length === 0) {
return Math.max(
MIN_LEGEND_ITEM_WIDTH,
DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH,
);
return DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
}
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
@@ -51,10 +36,7 @@ export function calculateAverageLegendWidth(legends: string[]): number {
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
const percentileLength = lengths[Math.max(0, index)];
return Math.max(
MIN_LEGEND_ITEM_WIDTH,
BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH,
);
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
}
/**
@@ -70,9 +52,7 @@ export function calculateAverageLegendWidth(legends: string[]): number {
* - Chart width is `containerWidth - legendWidth`.
* - BOTTOM legend:
* - Computes how many items fit per row, then uses at most 2 rows.
* - `legendHeight` is exactly those rows plus the wrapper's bottom padding, so
* the rectangle never clips a row or reserves space for half of one. Two
* rows that would take half a short panel fall back to one row.
* - `legendHeight` is derived from row count, capped by both a fixed pixel max and a % of container height.
* - Chart height is `containerHeight - legendHeight`, never below 0.
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
*
@@ -120,14 +100,9 @@ export function calculateChartDimensions({
MAX_RIGHT_LEGEND_WIDTH,
containerWidth * RIGHT_LEGEND_WIDTH_RATIO,
);
// The column's chrome outranks the 40% share on a narrow panel.
const floorWidth = Math.min(
MIN_RIGHT_LEGEND_WIDTH,
containerWidth * RIGHT_LEGEND_FLOOR_RATIO,
);
const rightLegendWidth = Math.min(
Math.max(MIN_RIGHT_LEGEND_WIDTH, desiredLegendWidth),
Math.max(floorWidth, maxRightLegendWidth),
Math.max(150, desiredLegendWidth),
maxRightLegendWidth,
);
return {
@@ -140,6 +115,8 @@ export function calculateChartDimensions({
};
}
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
const legendItemWidth = Math.ceil(
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
);
@@ -148,30 +125,30 @@ export function calculateChartDimensions({
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
);
// The wrapper's bottom padding is inside this height (border-box).
const heightForRows = (rowCount: number): number =>
rowCount * LEGEND_ROW_HEIGHT +
(rowCount - 1) * LEGEND_ROW_GAP +
LEGEND_PADDING;
const neededRowCount = Math.max(
1,
Math.min(
LEGEND_MAX_BOTTOM_ROWS,
Math.ceil(legendItemCount / legendItemsPerRow),
),
const legendRowCount = Math.min(
2,
Math.ceil(legendItemCount / legendItemsPerRow),
);
// Without this, short grid panels hand most of their area to the legend and
// the chart — the pie donut especially — collapses to a sliver. Dropping a
// whole row beats clipping one.
const legendRowCount =
neededRowCount > 1 &&
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO
? 1
: neededRowCount;
const idealBottomLegendHeight =
legendRowCount > 1
? legendRowCount * legendRowHeight - LEGEND_PADDING
: legendRowHeight;
const bottomLegendHeight = heightForRows(legendRowCount);
// Cap at two rows / 80px, and never more than 30% of the container height
// (the doc above always promised the %-cap; without it, short grid panels
// hand most of their area to the legend and the chart — the pie donut
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
const maxAllowedLegendHeight = Math.min(
2 * legendRowHeight,
80,
Math.floor(containerHeight * 0.3),
);
const bottomLegendHeight = Math.min(
idealBottomLegendHeight,
maxAllowedLegendHeight,
);
return {
width: containerWidth,

View File

@@ -1,9 +1,9 @@
import { act, renderHook } from '@testing-library/react';
import { LegendAction } from 'lib/uPlotV2/components/types';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
import type { MouseEvent } from 'react';
import { PieSlice } from 'lib/visualization/charts/types';
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
@@ -24,6 +24,22 @@ const DATA: PieSlice[] = [
{ label: 'checkout', value: 40, color: '#c' },
];
// Builds a fake legend click/move event: `e.target.closest('[data-legend-item-id]')`
// resolves to the item at `index`, and `e.target.dataset.isLegendMarker` flags marker clicks.
function legendEvent(
index: number | null,
isMarker = false,
): MouseEvent<HTMLDivElement> {
const itemEl =
index == null ? null : { dataset: { legendItemId: String(index) } };
return {
target: {
closest: (): unknown => itemEl,
dataset: { isLegendMarker: isMarker ? 'true' : undefined },
},
} as unknown as MouseEvent<HTMLDivElement>;
}
describe('usePieInteractions', () => {
beforeEach(() => {
mockGetStored.mockReturnValue(null);
@@ -43,16 +59,11 @@ describe('usePieInteractions', () => {
expect(result.current.active).toBeNull();
});
describe('row toggle', () => {
describe('marker click (toggle one)', () => {
it('hides then unhides the clicked slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
act(() => result.current.onLegendClick(legendEvent(1, true)));
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
@@ -62,50 +73,18 @@ describe('usePieInteractions', () => {
{ label: 'checkout', show: true },
]);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
act(() => result.current.onLegendClick(legendEvent(1, true)));
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems[1].show).toBe(true);
});
});
describe('the last slice showing', () => {
it('cannot be hidden', () => {
describe('label click (isolate / reset)', () => {
it('isolates the clicked slice, then resets on a second click', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 0,
}),
);
// An empty donut is never a state worth reaching.
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
});
});
describe('Only', () => {
it('isolates the slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() => result.current.onLegendClick(legendEvent(0, false)));
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
@@ -113,39 +92,8 @@ describe('usePieInteractions', () => {
false,
false,
]);
});
it('switches the isolation to another slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 2,
}),
);
expect(result.current.visibleData).toStrictEqual([DATA[2]]);
});
});
describe('All', () => {
it('brings every hidden slice back', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
act(() => result.current.onLegendClick(legendEvent(0, false)));
expect(result.current.visibleData).toStrictEqual(DATA);
});
@@ -155,37 +103,11 @@ describe('usePieInteractions', () => {
it('focuses the hovered slice and clears on leave', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 2 }),
);
act(() => result.current.onLegendMouseMove(legendEvent(2)));
expect(result.current.active).toStrictEqual(DATA[2]);
expect(result.current.focusedSeriesIndex).toBe(2);
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: null,
}),
);
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('drops the focus when the focused slice is hidden', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
// Otherwise every remaining arc stays dimmed and the donut reads as an
// isolation instead of one slice being excluded.
act(() => result.current.onLegendMouseLeave());
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
@@ -193,15 +115,8 @@ describe('usePieInteractions', () => {
it('does not focus a hidden slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
);
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
act(() => result.current.onLegendMouseMove(legendEvent(1)));
expect(result.current.active).toBeNull();
});
@@ -210,12 +125,7 @@ describe('usePieInteractions', () => {
describe('persistence', () => {
it('does not write to storage when no id is provided', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 0,
}),
);
act(() => result.current.onLegendClick(legendEvent(0, true)));
expect(mockUpdateStored).not.toHaveBeenCalled();
});

View File

@@ -1,11 +1,6 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import {
LegendAction,
LegendActionPayload,
OnLegendAction,
} from 'lib/uPlotV2/components/types';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
getStoredSeriesVisibility,
@@ -23,15 +18,27 @@ export interface UsePieInteractionsResult {
legendItems: LegendItem[];
/** Index of the active slice for the legend's focus highlight, or null. */
focusedSeriesIndex: number | null;
/** Every legend interaction, dispatched by type. */
onLegendAction: OnLegendAction;
onLegendClick: (e: MouseEvent<HTMLDivElement>) => void;
onLegendMouseMove: (e: MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
}
// Reads the slice index off the nearest `[data-legend-item-id]` ancestor of the
// event target (the shared Legend tags each item with its seriesIndex).
function getLegendIndex(e: MouseEvent<HTMLDivElement>): number | null {
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(
'[data-legend-item-id]',
);
const id = el?.dataset.legendItemId;
return id != null ? Number(id) : null;
}
/**
* Pie interaction + derived state: hover/focus, slice hide/show driven by the
* shared legend's actions, and persistence of the hidden set to localStorage
* (keyed by `id`, matched by label) so it survives reloads. Returns the visible
* slices, legend items, focus index, and the legend action dispatch.
* Pie interaction + derived state: hover/focus, slice hide/unhide (mirroring the
* uPlot legend — marker toggles one, label isolates), and persistence of the
* hidden set to localStorage (keyed by `id`, matched by label) so it survives
* reloads. Returns the visible slices, legend items, focus index, and the
* legend container handlers.
*/
export function usePieInteractions(
data: PieSlice[],
@@ -41,6 +48,7 @@ export function usePieInteractions(
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
() => new Set(),
);
const isolatedIndexRef = useRef<number | null>(null);
const legendItems = useMemo<LegendItem[]>(
() =>
@@ -96,88 +104,65 @@ export function usePieInteractions(
[id, data],
);
const hoverSeries = useCallback(
(sliceIndex: number | null): void => {
const onLegendMouseMove = useCallback(
(e: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(e);
// Don't focus/dim for hidden slices — they aren't on the donut.
setActive(
sliceIndex != null && !hiddenIndices.has(sliceIndex)
? data[sliceIndex]
: null,
);
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
},
[data, hiddenIndices],
);
const toggleSeries = useCallback(
(sliceIndex: number): void => {
const next = new Set(hiddenIndices);
if (next.has(sliceIndex)) {
next.delete(sliceIndex);
} else {
// An empty donut is never worth reaching.
if (data.length - next.size <= 1) {
return;
}
next.add(sliceIndex);
// Marker click toggles just that slice on/off; label click isolates it
// (clicking the isolated one again resets to all) — mirrors the uPlot legend.
const onLegendClick = useCallback(
(e: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(e);
if (index == null) {
return;
}
applyHidden(next);
},
[data.length, hiddenIndices, applyHidden],
);
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
const showOnlySeries = useCallback(
(sliceIndex: number): void => {
const next = new Set<number>();
data.forEach((_, index) => {
if (index !== sliceIndex) {
if (isMarker) {
const next = new Set(hiddenIndices);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
applyHidden(next);
return;
}
const isReset = isolatedIndexRef.current === index;
isolatedIndexRef.current = isReset ? null : index;
if (isReset) {
applyHidden(new Set());
return;
}
const next = new Set<number>();
data.forEach((_, i) => {
if (i !== index) {
next.add(i);
}
});
applyHidden(next);
},
[data, applyHidden],
[data, hiddenIndices, applyHidden],
);
const showAllSeries = useCallback(
(): void => applyHidden(new Set()),
[applyHidden],
);
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
const onLegendAction = useCallback(
(payload: LegendActionPayload): void => {
switch (payload.type) {
case LegendAction.TOGGLE:
toggleSeries(payload.seriesIndex);
break;
case LegendAction.SHOW_ONLY:
showOnlySeries(payload.seriesIndex);
break;
case LegendAction.SHOW_ALL:
showAllSeries();
break;
case LegendAction.HOVER:
hoverSeries(payload.seriesIndex);
break;
default:
break;
}
},
[toggleSeries, showOnlySeries, showAllSeries, hoverSeries],
);
const activeIndex = active ? data.indexOf(active) : -1;
// Left active, a hidden slice keeps every other arc dimmed, which reads as an
// isolation rather than as one slice being excluded.
const effectiveActive =
activeIndex >= 0 && !hiddenIndices.has(activeIndex) ? active : null;
const focusedIndex = effectiveActive ? activeIndex : -1;
const focusedIndex = active ? data.indexOf(active) : -1;
return {
active: effectiveActive,
active,
setActive,
visibleData,
legendItems,
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
onLegendAction,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
};
}

View File

@@ -29,6 +29,7 @@
box-sizing: border-box;
min-height: 0;
overflow: hidden;
padding: 0 12px 12px 12px;
padding-left: 12px;
padding-bottom: 12px;
}
}

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/constants';
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';

View File

@@ -19,7 +19,6 @@ const KIND_LABEL: Record<VariableUsage['kind'], string> = {
promql: 'PromQL',
clickhouse: 'ClickHouse',
variable: 'Variable',
text: 'Markdown body',
};
interface VariableImpactDialogProps {

View File

@@ -1,49 +0,0 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { buildVariableImpactPatch } from '../utils/variableImpactPatch';
import type { VariableUsage } from '../utils/variableUsages';
jest.mock('../variableAdapters', () => ({
formModelToDto: (model: unknown): unknown => model,
}));
const dashboard = {
spec: {
panels: {
runbook: {
spec: {
display: { name: 'Runbook' },
plugin: { kind: 'signoz/TextPanel', spec: { text: 'env {{svc}}' } },
queries: [],
},
},
},
variables: [],
},
} as unknown as DashboardtypesGettableDashboardV2DTO;
const textUsage: VariableUsage = {
id: 'panel:runbook:0',
sourceType: 'panel',
sourceId: 'runbook',
sourceLabel: 'Runbook',
kind: 'text',
envelopeIndex: 0,
currentText: 'env {{svc}}',
resultingText: 'env {{zone}}',
};
describe('buildVariableImpactPatch — text panel bodies', () => {
it('replaces the plugin-spec text, never the (empty) queries', () => {
const ops = buildVariableImpactPatch(dashboard, [], [textUsage]);
const panelOps = ops.filter((op) => op.path.includes('/panels/'));
expect(panelOps).toStrictEqual([
{
op: 'replace',
path: '/spec/panels/runbook/spec/plugin/spec/text',
value: 'env {{zone}}',
},
]);
});
});

View File

@@ -45,16 +45,6 @@ function promqlPanel(name: string, query: string): unknown {
};
}
function textPanel(name: string, text: string): unknown {
return {
spec: {
display: { name },
plugin: { kind: 'signoz/TextPanel', spec: { text } },
queries: [],
},
};
}
function dashboard(
panels: Record<string, unknown>,
variables: VariableFormModel[],
@@ -109,38 +99,6 @@ describe('findVariableUsages', () => {
it('returns nothing for an unreferenced variable', () => {
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
});
describe('text panel bodies (TDD D5)', () => {
const textDash = dashboard(
{
runbook: textPanel(
'Runbook',
'env {{svc}} / {{.svc}} / [[svc]] / $svc / {{svcx}}',
),
unrelated: textPanel('Plain', 'no tokens here'),
},
[variable({ name: 'svc', type: 'QUERY' })],
);
it('finds the body usage and skips bodies without the token', () => {
const usages = findVariableUsages(textDash, 'svc', 'rename', 'zone');
expect(usages.map((u) => u.id)).toStrictEqual(['panel:runbook:0']);
expect(usages[0].kind).toBe('text');
expect(usages[0].sourceLabel).toBe('Runbook');
});
it('rewrites all four syntaxes on rename, leaving other names alone', () => {
const [usage] = findVariableUsages(textDash, 'svc', 'rename', 'zone');
expect(usage.resultingText).toBe(
'env {{zone}} / {{.zone}} / [[zone]] / $zone / {{svcx}}',
);
});
it('leaves the body for review on delete', () => {
const [usage] = findVariableUsages(textDash, 'svc', 'delete');
expect(usage.resultingText).toBe(usage.currentText);
});
});
});
describe('findApplyUsages', () => {

View File

@@ -1,16 +0,0 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { isStaticPanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
/**
* The markdown body of a static (query-less) panel, or null for query kinds.
* One localized cast: the plugin-spec union can't be narrowed by a dynamic kind.
*/
export function getTextPanelBody(
spec: DashboardtypesPanelSpecDTO | undefined,
): string | null {
if (!spec?.plugin || !isStaticPanelKind(spec.plugin.kind)) {
return null;
}
const { text } = spec.plugin.spec as { text?: string };
return typeof text === 'string' ? text : null;
}

View File

@@ -107,18 +107,6 @@ export function buildVariableImpactPatch(
byPanel.forEach((list, panelId) => {
const panel = panels[panelId];
// A static kind's usage edits its markdown body, not a query.
const textUsage = list.find((usage) => usage.kind === 'text');
if (textUsage) {
ops.push({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: `/spec/panels/${panelId}/spec/plugin/spec/text`,
value: textUsage.resultingText,
});
return;
}
if (!panel?.spec?.queries?.length) {
return;
}

View File

@@ -12,7 +12,6 @@ import {
} from 'lib/dashboardVariables/variableReference';
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
import { getTextPanelBody } from './getTextPanelBody';
import { dtoToFormModel } from '../variableAdapters';
/** The kind of query text a variable is referenced from. */
@@ -20,8 +19,7 @@ export type VariableUsageKind =
| 'builder'
| 'promql'
| 'clickhouse'
| 'variable'
| 'text';
| 'variable';
export type VariableImpactMode = 'rename' | 'delete' | 'apply';
@@ -83,7 +81,7 @@ function computeResultingText(
return rewriteVariableReferences(text, variableName, newName);
}
// delete: only builder filter clauses can be safely auto-stripped; raw PromQL/
// ClickHouse, markdown bodies and variable queries are left for the user to edit.
// ClickHouse and variable queries are left for the user to edit.
return kind === 'builder'
? removeVariableFromExpression(text, variableName)
: text;
@@ -108,31 +106,6 @@ export function findVariableUsages(
const spec = dashboard.spec;
Object.entries(spec.panels ?? {}).forEach(([panelId, panel]) => {
// A static kind references variables from its body, not a query (TDD D5 —
// rename must rewrite text bodies too, or it silently orphans the tokens).
const textBody = getTextPanelBody(panel?.spec);
if (typeof textBody === 'string') {
if (textContainsVariableReference(textBody, variableName)) {
usages.push({
id: `panel:${panelId}:0`,
sourceType: 'panel',
sourceId: panelId,
sourceLabel: panel.spec?.display?.name || panelId,
kind: 'text',
envelopeIndex: 0,
currentText: textBody,
resultingText: computeResultingText(
'text',
textBody,
variableName,
mode,
newName,
),
});
}
return;
}
const queries = panel?.spec?.queries;
if (!queries?.length) {
return;

View File

@@ -5,7 +5,6 @@ import type {
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
@@ -67,14 +66,7 @@ function ConfigPane({
}: ConfigPaneProps): JSX.Element {
const panelKind = spec.plugin.kind;
const definition = getPanelDefinition(panelKind);
// The header toggle belongs with the title and description it hides, so the kind's
// declaration still gates it but it renders above, out of the display options.
const headerSection = definition.sections.find(
(config) => config.kind === SectionKind.PanelHeader,
);
const sections = definition.sections.filter(
(config) => config.kind !== SectionKind.PanelHeader,
);
const sections = definition.sections;
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
@@ -113,23 +105,6 @@ function ConfigPane({
onChange={(e): void => setDisplayField('description', e.target.value)}
/>
</div>
{headerSection && (
<SectionSlot
bare
config={headerSection}
spec={spec}
onChangeSpec={onChangeSpec}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
)}
</div>
{sections.length > 0 && (

View File

@@ -2,7 +2,6 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import {
isStaticPanelKind,
isQueryTypeSupportedByPanelKind,
isSignalSupported,
} from '../../../Panels/capabilities';
@@ -38,12 +37,6 @@ export function getPanelTypeDisabledReason({
signal?: TelemetrytypesSignalDTO;
label: string;
}): string | undefined {
// A kind that renders without a query pairs with anything — it declares no
// query types or signals, and the checks below would read that as "supports
// nothing" and disable it everywhere.
if (isStaticPanelKind(kind)) {
return undefined;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
}

View File

@@ -16,8 +16,6 @@ type SectionSlotProps = {
config: SectionConfig;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Renders the editor alone, for a section promoted into the Panel Details fields. */
bare?: boolean;
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
// Per-section header content; `trigger` expands the section and runs the editor's handler.
@@ -52,7 +50,6 @@ function SectionSlot({
config,
spec,
onChangeSpec,
bare,
legendSeries,
tableColumns,
signal,
@@ -113,28 +110,6 @@ function SectionSlot({
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
const editorElement = (
<Component
value={get(spec)}
controls={controls}
onChange={(next): void => onChangeSpec(update(spec, next))}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
registerHeaderAction={registerHeaderAction}
/>
);
if (bare) {
return editorElement;
}
return (
<SettingsSection
title={title}
@@ -143,7 +118,21 @@ function SectionSlot({
onOpenChange={setOpen}
headerSlot={headerSlot}
>
{editorElement}
<Component
value={get(spec)}
controls={controls}
onChange={(next): void => onChangeSpec(update(spec, next))}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
registerHeaderAction={registerHeaderAction}
/>
</SettingsSection>
);
}

View File

@@ -23,14 +23,6 @@ jest.mock(
}),
);
function textSpec(): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Runbook', description: 'steps' },
plugin: { kind: 'signoz/TextPanel', spec: { text: '' } },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
function spec(unit?: string): DashboardtypesPanelSpecDTO {
return {
display: { name: 'CPU', description: 'usage' },
@@ -101,24 +93,6 @@ describe('ConfigPane', () => {
);
});
// It hides the title strip, so it sits with the title rather than under the
// display options — and only a kind whose spec accepts `headerOptions` shows it.
it('renders the hide-header toggle among the Panel Details fields', () => {
renderConfigPane({ spec: textSpec() });
const toggle = screen.getByTestId('panel-header-hide');
expect(toggle).toBeInTheDocument();
expect(screen.getByText('Hide header')).toBeInTheDocument();
// No collapsible wrapper of its own.
expect(screen.queryByText('Panel header')).not.toBeInTheDocument();
});
it('omits the hide-header toggle for a kind that has no header options', () => {
renderConfigPane();
expect(screen.queryByTestId('panel-header-hide')).not.toBeInTheDocument();
});
it('renders the Formatting section for a kind that declares it', () => {
renderConfigPane();
// The TimeSeries kind declares a Formatting section; its collapsible header shows.

View File

@@ -1,76 +0,0 @@
.row {
display: flex;
align-items: center;
gap: 6px;
}
.swatch {
flex: none;
width: 26px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: none;
cursor: pointer;
position: relative;
// The input carries focus, so the ring is drawn on the swatch around it.
&:has(.input:focus-visible) {
outline: 2px solid var(--bg-robin-400);
outline-offset: 1px;
}
}
// The real control, sized to the swatch and invisible over it: clicks and focus
// land on the radio, the swatch is what the user sees.
.input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.selected {
box-shadow: 0 0 0 2px var(--bg-robin-500);
}
// Transparency has no colour to show, so it reads as the conventional checkerboard.
.checkerboard {
background-color: var(--l2-background);
background-image:
linear-gradient(
45deg,
var(--l2-border) 25%,
transparent 25%,
transparent 75%,
var(--l2-border) 75%
),
linear-gradient(
45deg,
var(--l2-border) 25%,
transparent 25%,
transparent 75%,
var(--l2-border) 75%
);
background-size: 8px 8px;
background-position:
0 0,
4px 4px;
}
.defaultSurface {
background: var(--l2-background);
}
.divider {
flex: none;
width: 1px;
height: 18px;
margin: 0 2px;
background: var(--l2-border);
}

View File

@@ -1,119 +0,0 @@
import { Fragment } from 'react';
import { Check } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import {
TEXT_BACKGROUND_PAIRS,
TEXT_BACKGROUND_PRESETS,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import type {
PanelTheme,
TextBackgroundPreset,
TextBackgroundSelection,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import { TextBackgroundKind } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import styles from './BackgroundSwatches.module.scss';
const PRESET_TITLES: Record<TextBackgroundPreset, string> = {
robin: 'Robin',
purple: 'Purple',
sakura: 'Sakura',
cherry: 'Cherry',
amber: 'Amber',
forest: 'Forest',
sienna: 'Sienna',
slate: 'Slate',
};
type BaseSelection = TextBackgroundKind.None | TextBackgroundKind.Default;
const BASE_TITLES: Record<BaseSelection, string> = {
none: 'Transparent',
default: 'Default panel',
};
/** Neither base swatch shows a colour, so its tooltip says what it does. */
const BASE_TOOLTIPS: Record<BaseSelection, string> = {
none: 'Transparent — no card, border or title bar',
default: 'Default panel colour',
};
const OPTIONS: TextBackgroundSelection[] = [
TextBackgroundKind.None,
TextBackgroundKind.Default,
...TEXT_BACKGROUND_PRESETS,
];
const DIVIDER_AFTER = 1;
interface BackgroundSwatchesProps {
testId: string;
/** Names the group for assistive tech — the row carries no visible label. */
label: string;
/** `undefined` while a custom colour is active: no swatch is selected. */
value: TextBackgroundSelection | undefined;
/** Swatches paint in this theme's pair, so what the user picks is what they see. */
theme: PanelTheme;
onChange: (value: TextBackgroundSelection) => void;
}
/**
* The Text panel's background choices as one radio group. Native radios sharing a
* `name`, so arrow-key movement, the single tab stop and selection-follows-focus
* are the platform's; each input is transparent and fills its swatch.
*/
function BackgroundSwatches({
testId,
label,
value,
theme,
onChange,
}: BackgroundSwatchesProps): JSX.Element {
return (
<div
className={styles.row}
role="radiogroup"
aria-label={label}
data-testid={testId}
>
{OPTIONS.map((option, index) => {
const isBase =
option === TextBackgroundKind.None ||
option === TextBackgroundKind.Default;
const pair = isBase ? undefined : TEXT_BACKGROUND_PAIRS[option][theme];
const title = isBase ? BASE_TITLES[option] : PRESET_TITLES[option];
return (
<Fragment key={option}>
<TooltipSimple title={isBase ? BASE_TOOLTIPS[option] : title} arrow>
<label
className={cx(styles.swatch, {
[styles.checkerboard]: option === TextBackgroundKind.None,
[styles.defaultSurface]: option === TextBackgroundKind.Default,
[styles.selected]: option === value,
})}
style={pair ? { background: pair.surface, color: pair.ink } : undefined}
data-testid={`${testId}-${option}`}
>
<input
type="radio"
className={styles.input}
name={testId}
value={option}
checked={option === value}
aria-label={title}
onChange={(): void => onChange(option)}
/>
{option === value && <Check size={14} />}
</label>
</TooltipSimple>
{index === DIVIDER_AFTER && <span className={styles.divider} />}
</Fragment>
);
})}
</div>
);
}
export default BackgroundSwatches;

View File

@@ -1,67 +0,0 @@
.row {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: transparent;
cursor: pointer;
text-align: left;
}
.active {
border-color: var(--bg-robin-500);
}
.chip {
flex: none;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--l2-border);
border-radius: 4px;
}
// No colour to show yet, so the chip advertises that it opens a picker.
.chipEmpty {
background: conic-gradient(
from 0deg,
var(--bg-cherry-400),
var(--bg-amber-400),
var(--bg-forest-400),
var(--bg-robin-400),
var(--bg-sakura-400),
var(--bg-cherry-400)
);
}
.label {
flex: 1;
font-size: 12px;
color: var(--l2-foreground);
}
.hex {
font-family: var(--font-family-sf-mono);
font-size: 12px;
color: var(--text-vanilla-400);
letter-spacing: 0.02em;
}
// Appended under the picker's own panel.
.contrast {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 4px 2px;
font-size: 12px;
color: var(--text-vanilla-400);
}
.warning {
color: var(--bg-amber-400);
}

View File

@@ -1,91 +0,0 @@
import type { ReactNode } from 'react';
import { Check, ChevronDown, TriangleAlert } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { ColorPicker } from 'antd';
import cx from 'classnames';
import {
contrastRatio,
inkForSurface,
MIN_CONTRAST_RATIO,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/contrast';
import styles from './CustomBackgroundRow.module.scss';
const HEX_PLACEHOLDER = '#______';
/** What the picker opens on before a colour is chosen. */
const INITIAL_COLOR = '#3A2A63';
interface CustomBackgroundRowProps {
testId: string;
/** The stored hex while a custom colour is active; `undefined` otherwise. */
value: string | undefined;
onChange: (hex: string) => void;
}
/**
* The custom colour, as a row rather than a swatch: it opens a picker instead of
* committing a value in one click. The picker warns below the contrast floor but
* never blocks the choice.
*/
function CustomBackgroundRow({
testId,
value,
onChange,
}: CustomBackgroundRowProps): JSX.Element {
const color = value ?? INITIAL_COLOR;
const ratio = contrastRatio(inkForSurface(color), color);
const isLegible = ratio >= MIN_CONTRAST_RATIO;
const contrastMessage = isLegible
? `Contrast ${ratio.toFixed(1)}:1`
: `Contrast ${ratio.toFixed(1)}:1 — below ${MIN_CONTRAST_RATIO}:1`;
function renderPanel(panel: ReactNode): ReactNode {
return (
<>
{panel}
<div
className={cx(styles.contrast, { [styles.warning]: !isLegible })}
data-testid={`${testId}-contrast`}
>
{!isLegible && <TriangleAlert size={12} />}
<span className="translate-safe">{contrastMessage}</span>
</div>
</>
);
}
return (
<ColorPicker
value={color}
size="small"
showText={false}
trigger="click"
panelRender={renderPanel}
onChangeComplete={(next): void => onChange(next.toHexString())}
>
<button
type="button"
className={cx(styles.row, { [styles.active]: value !== undefined })}
data-testid={testId}
>
<span
className={cx(styles.chip, { [styles.chipEmpty]: value === undefined })}
style={
value ? { background: value, color: inkForSurface(value) } : undefined
}
>
{value !== undefined && <Check size={14} />}
</span>
<Typography.Text className={styles.label}>Custom</Typography.Text>
<span className={cx(styles.hex, 'translate-safe')}>
{value ?? HEX_PLACEHOLDER}
</span>
<ChevronDown size={14} />
</button>
</ColorPicker>
);
}
export default CustomBackgroundRow;

View File

@@ -1,118 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { TEXT_BACKGROUND_PAIRS } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import {
PanelTheme,
TextBackgroundKind,
TextBackgroundPreset,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import BackgroundSwatches from '../BackgroundSwatches';
function renderRow(
props: Partial<React.ComponentProps<typeof BackgroundSwatches>> = {},
): jest.Mock {
const onChange = jest.fn();
render(
<TooltipProvider>
<BackgroundSwatches
testId="background"
label="Panel background"
theme={PanelTheme.Dark}
value={TextBackgroundKind.Default}
onChange={onChange}
{...props}
/>
</TooltipProvider>,
);
return onChange;
}
describe('BackgroundSwatches', () => {
it('offers transparent, the default surface and the eight presets in order', () => {
renderRow();
expect(
screen
.getAllByRole('radio')
.map((swatch) => swatch.getAttribute('aria-label')),
).toStrictEqual([
'Transparent',
'Default panel',
'Robin',
'Purple',
'Sakura',
'Cherry',
'Amber',
'Forest',
'Sienna',
'Slate',
]);
});
it('is one labelled group', () => {
renderRow();
expect(
screen.getByRole('radiogroup', { name: 'Panel background' }),
).toBeInTheDocument();
});
it.each([
['background-none', 'Transparent — no card, border or title bar'],
['background-default', 'Default panel colour'],
['background-sakura', 'Sakura'],
])('explains %s on hover', async (swatchId, copy) => {
renderRow();
fireEvent.focus(screen.getByTestId(swatchId));
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(copy);
});
});
it('paints each preset in the given theme', () => {
renderRow({ theme: PanelTheme.Light });
expect(screen.getByTestId('background-amber')).toHaveStyle({
background: TEXT_BACKGROUND_PAIRS.amber.light.surface,
color: TEXT_BACKGROUND_PAIRS.amber.light.ink,
});
});
it('marks only the selected swatch, and checks it', () => {
renderRow({ value: TextBackgroundPreset.Forest });
expect(screen.getByRole('radio', { name: 'Forest' })).toBeChecked();
expect(
screen.getByRole('radio', { name: 'Default panel' }),
).not.toBeChecked();
expect(
screen.getByTestId('background-forest').querySelector('svg'),
).toBeInTheDocument();
expect(
screen.getByTestId('background-default').querySelector('svg'),
).not.toBeInTheDocument();
});
it('reports the swatch that was clicked', () => {
const onChange = renderRow();
fireEvent.click(screen.getByRole('radio', { name: 'Sienna' }));
expect(onChange).toHaveBeenCalledWith('sienna');
});
// jsdom does not implement radio arrow navigation, so the shared name — what
// makes them one group — is what there is to assert.
it('groups every swatch under one radio name', () => {
renderRow();
const names = new Set(
screen.getAllByRole('radio').map((swatch) => swatch.getAttribute('name')),
);
expect(names).toStrictEqual(new Set(['background']));
});
});

View File

@@ -1,77 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import CustomBackgroundRow from '../CustomBackgroundRow';
function renderRow(value?: string): jest.Mock {
const onChange = jest.fn();
render(
<CustomBackgroundRow testId="custom" value={value} onChange={onChange} />,
);
return onChange;
}
describe('CustomBackgroundRow', () => {
it('stands in for the hex while no custom colour is set', () => {
renderRow();
expect(screen.getByTestId('custom')).toHaveTextContent('#______');
});
it('shows the stored hex once one is set', () => {
renderRow('#3A2A63');
expect(screen.getByTestId('custom')).toHaveTextContent('#3A2A63');
});
it('checks the chip only while the custom colour is the selection', () => {
renderRow('#3A2A63');
expect(screen.getByTestId('custom').querySelector('svg')).toBeInTheDocument();
});
it('leaves the chip unchecked while no custom colour is set', () => {
renderRow();
expect(screen.getByTestId('custom').querySelectorAll('svg')).toHaveLength(1);
});
describe('the picker', () => {
it('opens on the row', () => {
renderRow('#3A2A63');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).toBeInTheDocument();
});
it('reports the contrast the colour achieves', () => {
renderRow('#3A2A63');
fireEvent.click(screen.getByTestId('custom'));
// The derived ink is pure white, not purple's paired ink.
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
'Contrast 12.4:1',
);
});
it('warns when no ink clears the floor, without disabling anything', () => {
renderRow('#808080');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
'below 4.5:1',
);
expect(screen.getByTestId('custom')).toBeEnabled();
});
it('says nothing about the floor when the colour clears it', () => {
renderRow('#111111');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).not.toHaveTextContent('below');
});
});
});

View File

@@ -23,8 +23,6 @@ import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppea
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import LegendSection from './sections/LegendSection/LegendSection';
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
@@ -119,23 +117,6 @@ export const SECTION_REGISTRY: {
update: (spec, buckets): PanelSpec =>
updatePluginSlice(spec, 'histogramBuckets', buckets),
},
[SectionKind.TextLayout]: {
Component: TextLayoutSection,
get: (spec): SectionSpecMap[SectionKind.TextLayout] | undefined =>
getPluginSlice<SectionSpecMap[SectionKind.TextLayout]>(spec, 'presentation'),
update: (spec, presentation): PanelSpec =>
updatePluginSlice(spec, 'presentation', presentation),
},
[SectionKind.PanelHeader]: {
Component: PanelHeaderSection,
get: (spec): SectionSpecMap[SectionKind.PanelHeader] | undefined =>
getPluginSlice<SectionSpecMap[SectionKind.PanelHeader]>(
spec,
'headerOptions',
),
update: (spec, headerOptions): PanelSpec =>
updatePluginSlice(spec, 'headerOptions', headerOptions),
},
[SectionKind.ContextLinks]: {
Component: ContextLinksSection,
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.

View File

@@ -1,24 +0,0 @@
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
/** Edits the Text panel's `headerOptions` slice: the panel card's title strip. */
function PanelHeaderSection({
value,
onChange,
}: SectionEditorProps<SectionKind.PanelHeader>): JSX.Element {
return (
<ConfigSwitch
testId="panel-header-hide"
title="Hide header"
description="Drop the title strip on the dashboard; hovering the panel shows controls for drag and actions."
value={value?.hide === true}
onChange={(hide): void => onChange({ ...value, hide })}
/>
);
}
export default PanelHeaderSection;

View File

@@ -1,29 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import PanelHeaderSection from '../PanelHeaderSection';
describe('PanelHeaderSection', () => {
it('toggles hide on', () => {
const onChange = jest.fn();
render(<PanelHeaderSection value={undefined} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-header-hide'));
expect(onChange).toHaveBeenCalledWith({ hide: true });
});
it('toggles hide back off', () => {
const onChange = jest.fn();
render(<PanelHeaderSection value={{ hide: true }} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-header-hide'));
expect(onChange).toHaveBeenCalledWith({ hide: false });
});
it('shows the header by default when the slice is empty', () => {
render(<PanelHeaderSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByTestId('panel-header-hide')).not.toBeChecked();
});
});

View File

@@ -1,11 +0,0 @@
.section {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -1,99 +0,0 @@
import {
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Typography } from '@signozhq/ui/typography';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
resolveTextBackground,
selectionFromResolved,
storedFromSelection,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/resolveTextBackground';
import type { TextBackgroundSelection } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import {
PanelTheme,
TextBackgroundKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import BackgroundSwatches from '../../controls/BackgroundSwatches/BackgroundSwatches';
import CustomBackgroundRow from '../../controls/BackgroundSwatches/CustomBackgroundRow';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import styles from './TextLayoutSection.module.scss';
const HORIZONTAL_OPTIONS = [
{ value: DashboardtypesTextAlignDTO.left, label: 'Left' },
{ value: DashboardtypesTextAlignDTO.center, label: 'Center' },
{ value: DashboardtypesTextAlignDTO.right, label: 'Right' },
];
const VERTICAL_OPTIONS = [
{ value: DashboardtypesVerticalAlignDTO.top, label: 'Top' },
{ value: DashboardtypesVerticalAlignDTO.center, label: 'Middle' },
{ value: DashboardtypesVerticalAlignDTO.bottom, label: 'Bottom' },
];
/**
* Edits the Text panel's `presentation` slice: body alignment and the card
* background (TDD D7 — scoped to the text spec, not the panel envelope).
*/
function TextLayoutSection({
value,
onChange,
}: SectionEditorProps<SectionKind.TextLayout>): JSX.Element {
const theme = useIsDarkMode() ? PanelTheme.Dark : PanelTheme.Light;
const background = resolveTextBackground(value?.background, theme);
return (
<div className={styles.section}>
<div className={styles.field}>
<Typography.Text>Horizontal alignment</Typography.Text>
<ConfigSegmented
testId="text-layout-horizontal-align"
items={HORIZONTAL_OPTIONS}
value={value?.textAlign ?? DashboardtypesTextAlignDTO.left}
onChange={(textAlign): void => onChange({ ...value, textAlign })}
/>
</div>
<div className={styles.field}>
<Typography.Text>Vertical alignment</Typography.Text>
<ConfigSegmented
testId="text-layout-vertical-align"
items={VERTICAL_OPTIONS}
value={value?.verticalAlign ?? DashboardtypesVerticalAlignDTO.top}
onChange={(verticalAlign): void => onChange({ ...value, verticalAlign })}
/>
</div>
<div className={styles.field}>
<Typography.Text>Background</Typography.Text>
<BackgroundSwatches
testId="text-layout-background"
label="Panel background"
theme={theme}
value={selectionFromResolved(background)}
onChange={(selection: TextBackgroundSelection): void =>
onChange({
...value,
background: storedFromSelection(selection, theme),
})
}
/>
<CustomBackgroundRow
testId="text-layout-background-custom"
value={
background.kind === TextBackgroundKind.Custom
? background.surface
: undefined
}
onChange={(hex): void => onChange({ ...value, background: hex })}
/>
</div>
</div>
);
}
export default TextLayoutSection;

View File

@@ -1,144 +0,0 @@
import type { ReactElement } from 'react';
import {
fireEvent,
render as rtlRender,
type RenderResult,
screen,
} from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import {
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
TEXT_BACKGROUND_PAIRS,
TRANSPARENT_BACKGROUND,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import TextLayoutSection from '../TextLayoutSection';
const value = {
textAlign: DashboardtypesTextAlignDTO.left,
verticalAlign: DashboardtypesVerticalAlignDTO.top,
};
// The swatch tooltips need a provider; AppLayout supplies one at runtime.
function render(ui: ReactElement): RenderResult {
return rtlRender(<TooltipProvider>{ui}</TooltipProvider>);
}
// The theme context defaults to dark, so the swatches paint the dark pairs.
describe('TextLayoutSection', () => {
it('changes horizontal alignment', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByText('Center'));
expect(onChange).toHaveBeenCalledWith({
...value,
textAlign: DashboardtypesTextAlignDTO.center,
});
});
it('changes vertical alignment', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByText('Bottom'));
expect(onChange).toHaveBeenCalledWith({
...value,
verticalAlign: DashboardtypesVerticalAlignDTO.bottom,
});
});
it('stores the surface of the theme a preset was picked in', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByRole('radio', { name: 'Amber' }));
expect(onChange).toHaveBeenCalledWith({
...value,
background: TEXT_BACKGROUND_PAIRS.amber.dark.surface,
});
});
it('stores a zero-alpha colour for transparent', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByRole('radio', { name: 'Transparent' }));
expect(onChange).toHaveBeenCalledWith({
...value,
background: TRANSPARENT_BACKGROUND,
});
});
it('unsets the background for the default panel surface', () => {
const onChange = jest.fn();
render(
<TextLayoutSection
value={{ ...value, background: TRANSPARENT_BACKGROUND }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByRole('radio', { name: 'Default panel' }));
expect(onChange).toHaveBeenCalledWith({ ...value, background: undefined });
});
it('lights up the swatch the stored surface belongs to', () => {
render(
<TextLayoutSection
value={{ ...value, background: TEXT_BACKGROUND_PAIRS.sakura.light.surface }}
onChange={jest.fn()}
/>,
);
expect(screen.getByRole('radio', { name: 'Sakura' })).toBeChecked();
});
it('stores a custom colour straight from the picker', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByTestId('text-layout-background-custom'));
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '3A2A64' },
});
expect(onChange).toHaveBeenCalledWith({
...value,
background: '#3a2a64',
});
});
it('shows a stored custom colour on the custom row alone', () => {
render(
<TextLayoutSection
value={{ ...value, background: '#3A2A64' }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('text-layout-background-custom')).toHaveTextContent(
'#3A2A64',
);
expect(
screen
.getAllByRole<HTMLInputElement>('radio')
.filter((swatch) => swatch.checked),
).toHaveLength(0);
});
it('selects the default surface when nothing is stored', () => {
render(<TextLayoutSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByRole('radio', { name: 'Default panel' })).toBeChecked();
expect(screen.getByRole('radio', { name: 'Transparent' })).not.toBeChecked();
});
});

View File

@@ -26,3 +26,16 @@
background: var(--l2-border);
}
}
// The static editor's preview: the panel card the grid shows, minus actions.
.staticPreviewSurface {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
margin: 12px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
overflow: hidden;
}

View File

@@ -23,7 +23,7 @@ import { EQueryType } from 'types/common/dashboard';
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { toPanelType } from '../../Panels/types/panelKind';
import { PANEL_KIND_TO_PANEL_TYPE } from '../../Panels/types/panelKind';
import styles from './PanelEditorQueryBuilder.module.scss';
@@ -60,7 +60,7 @@ function PanelEditorQueryBuilder({
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
// builder offers for this kind comes from the kind's own declaration.
const panelType = toPanelType(panelDefinition.kind);
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelDefinition.kind];
// Raw rows: the builder drops its aggregation controls, and with them the trace
// operator that combines aggregated trace queries (V1 parity).
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';

Some files were not shown because too many files have changed in this diff Show More