mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-16 00:10:42 +01:00
Compare commits
8 Commits
feat/heatm
...
ns/enable-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47a9fce310 | ||
|
|
130d168642 | ||
|
|
099e81202d | ||
|
|
9c886be120 | ||
|
|
861e5f75cf | ||
|
|
755cca13cf | ||
|
|
e0c0ac4ea6 | ||
|
|
c8e9e362f7 |
30
.github/workflows/jsci.yaml
vendored
30
.github/workflows/jsci.yaml
vendored
@@ -79,6 +79,36 @@ 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' ||
|
||||
|
||||
@@ -202,7 +202,6 @@ telemetrystore:
|
||||
max_bytes_to_read: 0
|
||||
max_result_rows: 0
|
||||
ignore_data_skipping_indices: ""
|
||||
secondary_indices_enable_bulk_filtering: false
|
||||
|
||||
##################### Prometheus #####################
|
||||
prometheus:
|
||||
|
||||
@@ -3536,6 +3536,11 @@ components:
|
||||
- tags
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesHeaderOptions:
|
||||
properties:
|
||||
hide:
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -3893,6 +3898,7 @@ 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:
|
||||
@@ -3903,6 +3909,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -3913,6 +3920,7 @@ components:
|
||||
- signoz/TablePanel
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
@@ -3986,6 +3994,18 @@ 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:
|
||||
@@ -4277,6 +4297,37 @@ 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:
|
||||
@@ -4486,6 +4537,12 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVerticalAlign:
|
||||
enum:
|
||||
- top
|
||||
- center
|
||||
- bottom
|
||||
type: string
|
||||
ErrorsJSON:
|
||||
properties:
|
||||
code:
|
||||
|
||||
@@ -54,50 +54,93 @@ The `fieldContexts` map includes aliases (`tag` -> `attribute`, `spanfield` -> `
|
||||
|
||||
## The Abstraction Stack
|
||||
|
||||
The query pipeline is built from four interfaces that compose vertically. Each layer has a single responsibility. Each layer depends only on the layers below it. This layering is intentional and must be preserved.
|
||||
The query pipeline has three layers. The generic layer is written one time, in `pkg/querybuilder`. A storage is written one time per signal. The statement builders compose them. Each layer depends only on the layer below it. This layering is intentional and must be preserved.
|
||||
|
||||
```
|
||||
StatementBuilder <- Orchestrates everything into executable SQL
|
||||
├── AggExprRewriter <- Rewrites aggregation expressions (maps field refs to columns)
|
||||
├── ConditionBuilder <- Builds WHERE predicates (field + operator + value -> SQL)
|
||||
└── FieldMapper <- Maps TelemetryFieldKey -> ClickHouse column expression
|
||||
StatementBuilder <- Composes one query into executable SQL
|
||||
├── AggExprRewriter <- Rewrites aggregation expressions through the generic layer
|
||||
├── filter visitor <- Parses the filter expression and compiles it one term at a time
|
||||
└── querybuilder (generic) <- Resolution, the filter condition, the column expression
|
||||
└── Storage <- What one signal's tables can answer about one field key
|
||||
```
|
||||
|
||||
### FieldMapper
|
||||
### Storage
|
||||
|
||||
**Contract:** Given a `TelemetryFieldKey`, return a ClickHouse column expression that yields the value for that field when used in a SELECT.
|
||||
**Contract:** `qbtypes.Storage` in `pkg/types/querybuildertypes/querybuildertypesv5/qb.go`. One implementation per signal: traces, logs, metrics, audit, rule state history, the resource fingerprint sub-query, and the related-values metadata.
|
||||
|
||||
**Principle:** This is the *only* place where field-to-column translation happens. No other layer should contain knowledge of how fields map to storage. If you need a column expression, go through the FieldMapper.
|
||||
A storage answers four questions and nothing else:
|
||||
|
||||
**Why:** The user says `http.request.method`. ClickHouse might store it as `attributes_string['http.request.method']`, or as a materialized column `` `attribute_string_http$$request$$method` ``, or via a JSON access path in a body column. This variation is entirely contained within the FieldMapper. Everything above it is storage-agnostic.
|
||||
- `Read(key)`: one call for one field key. It returns the bare SQL read, with no alias, no guard, and no cast. It returns the membership test (`Presence`), which binds no args so it can sit inside guards, and its negation in the storage's own form (`Absence`). It returns what a row without the key reads (`Absent`, below). It tells whether the group by, order by, and aggregation cast must leave the native type alone (`KeepType`: time columns, metrics labels). It tells whether the key filters but cannot be selected (`FilterOnly`: the legacy string body). It honors the materialization and the evolutions the key carries.
|
||||
- `Fallback(key, operator, value)`: the field keys that could hold a key metadata does not report: column aliases, the type variants of a map read, body paths, and virtual keys that compile to structural predicates (a span search scope, a full-text search over a scope).
|
||||
- `Traits()`: the storage's part in the resource fingerprint split, whether it supports body functions, what it does with an unknown key, and which contexts mean "this signal's own record".
|
||||
- `Compile`: the one override, for a storage with its own condition language. Logs have the body JSON language. The resource fingerprint has index hints. The related values have a polarity form. Metrics have String-typed labels. Every other storage returns `querybuilder.SharedCondition`.
|
||||
|
||||
### ConditionBuilder
|
||||
**Principle:** A storage describes its field keys. It never decides a guard, an ambiguity, a warning, or the shape of a fold. Those decisions are derived one time, in the generic layer, from those descriptions.
|
||||
|
||||
**Contract:** Given a field key, an operator, and a value, produce a valid SQL predicate for a WHERE clause.
|
||||
### Absent
|
||||
|
||||
**Dependency:** Uses FieldMapper for the left-hand side of the condition.
|
||||
`Read` returns the field's `Absent`: what a row without the field reads. It is a property of the read, not of the column. `resource.x::String` reads the empty string for an absent row. The multi-era fold `multiIf(..., NULL)` reads NULL. A table column always reads a real value. Every guard derives from it:
|
||||
|
||||
**Principle:** The ConditionBuilder owns all the complexity of operator semantics, i.e type casting, array operators (`hasAny`/`hasAll` vs `=`), existence checks, and negative operator behavior. This complexity must not leak upward into the StatementBuilder.
|
||||
| WhenAbsent | Absent row reads | Positive filter | Raw select | Multi-candidate column | Field keys |
|
||||
|---|---|---|---|---|---|
|
||||
| `AlwaysPresent` | a real value | no guard | no guard | no branch, ends the candidate list | table columns |
|
||||
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families |
|
||||
| `AbsentIsNull` | NULL | no guard | no guard | presence branch | multi-era folds, body JSON paths, numeric families |
|
||||
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels |
|
||||
|
||||
### AggExprRewriter
|
||||
### The generic layer
|
||||
|
||||
**Contract:** Given a user-facing aggregation expression like `sum(duration_nano)`, resolve field references within it and produce valid ClickHouse SQL.
|
||||
`pkg/querybuilder` needs two inputs, made one time per request:
|
||||
|
||||
**Dependency:** Uses FieldMapper to resolve field names within expressions.
|
||||
- The metadata keys: `keys := metadataStore.GetKeysMulti(...)`, the field keys the metadata store reports for the query's names, as `map[name][]*TelemetryFieldKey`.
|
||||
- `q := querybuilder.NewQueryInfo(ctx, orgID, fl, signal, metric, startNs, endNs)`: the time range every read needs, the signal and the queried metric that family admission needs, and the query-path flags (`FamiliesOn`, `BodyJSONOn`). The flags are evaluated one time.
|
||||
|
||||
**Principle:** Aggregation expressions are user-authored strings that contain field references. The rewriter parses them, identifies field references, resolves each through the FieldMapper, and reassembles the expression.
|
||||
The functions, from the outside in:
|
||||
|
||||
### StatementBuilder
|
||||
| Function | Does |
|
||||
|---|---|
|
||||
| `PrepareWhereClause(query, opts)` | The filter visitor. Parses the filter grammar and compiles each term through `RejectsBodyFunction`, `Resolve`, and `Condition`. Returns the WHERE clause, the warnings, and the cost-guard flag. |
|
||||
| `NewAggExprRewriter(settings, fullTextColumn, storage, fl, signal)` | Parses an aggregation expression such as `sum(duration_nano)` and resolves each field reference through `ResolveColumn`. |
|
||||
| `ResolveColumn(ctx, q, storage, key, target, metadata)` | `Resolve` with `FilterOperatorUnknown`, then `Column`. Every select field, order by, group by, and aggregation argument calls it. |
|
||||
| `Resolve(ctx, q, storage, key, operator, value, metadata)` | One requested key to its meanings in this storage (see "A resolved key"). |
|
||||
| `Condition(ctx, q, storage, resolved, dropResourceFields, operator, value, sb)` | A resolved key to the conditions of one filter term: the split narrows the fields, and each field compiles through `storage.Compile`. |
|
||||
| `Conditions(...)` | `RejectsBodyFunction`, `Resolve`, and `Condition` in one call, for callers outside the visitor: the related-values metadata, the scoped traces predicate resolver, tests. |
|
||||
| `Column(ctx, q, storage, resolved, target)` | A resolved key to one bare column expression. The caller aliases. |
|
||||
| `RejectsBodyFunction(traits, operator)` | Runs before resolution. A storage without body functions (`has`, `hasAny`, `hasAll`, `hasToken`, `search`) errors. The fingerprint side of a split skips the term, because the main query evaluates it. After resolution, `Condition` errors when `has`, `hasAny`, `hasAll`, or `hasToken` lands on a map-backed key (resource, attribute, scope), before the split can drop it. |
|
||||
| `SharedCondition(...)` | The `Compile` of every storage without its own condition language: `LogicalRead`, the shared data-type collision cast, `OperatorCondition`, then the guard rule. |
|
||||
| `OperatorCondition(...)` | The operator switch over an already cast read. A storage with its own cast policy composes with it. |
|
||||
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
|
||||
|
||||
**Contract:** Given a complete `QueryBuilderQuery`, a time range, and a request type, produces an executable SQL statement.
|
||||
### A resolved key
|
||||
|
||||
**Dependency:** Uses all three abstractions above.
|
||||
`Resolve` turns one requested key into a `Resolved` value. It is the only thing the filter condition (`Condition`) and the column expression (`Column`) receive. Compile it with the operator and value it was resolved with. The operand tells the use: a nil value means a select field, group by, order by, aggregation, or presence test.
|
||||
|
||||
**Principle:** This is the composition layer. It does not contain field mapping logic, condition building logic, or expression rewriting logic. It orchestrates the other abstractions. If you find storage-specific logic creeping into the StatementBuilder, push it down into the appropriate abstraction.
|
||||
```go
|
||||
type Resolved struct {
|
||||
Key *TelemetryFieldKey // the spelling the request used
|
||||
Fields []*LogicalField // its meanings in this storage, one per interpretation
|
||||
FromFallback bool // the fields came from the storage's Fallback, not from metadata matches
|
||||
Ambiguous bool // the matches held several interpretations
|
||||
Skipped bool // the storage contributes nothing for this key
|
||||
Warnings []string // the warnings to surface: ambiguity, not-found
|
||||
}
|
||||
```
|
||||
|
||||
A `LogicalField` is one meaning: one name, context, and data type, backed by one or more physical members. A family (one field with several spellings) is one logical field with several members, current spelling first. Ambiguity (one name, different fields) is several logical fields.
|
||||
|
||||
The resolution order is the same for every storage, in the filter and in every select field, group by, order by, and aggregation:
|
||||
|
||||
1. **Own context.** A key under one of the storage's own contexts (`span.x`, `log.x`) matches its own context first, then a column the storage knows under that context. Only when both miss does it look up as if it had no context. `span.http.method` then corrects to the attribute. Strict contexts (`resource.`, `attribute.`, `scope.`, `body.`) are kept as written.
|
||||
2. **Matches.** The metadata keys under the key's spellings, grouped into families when the flag is on. Each combination of context and data type is one interpretation.
|
||||
3. **Ambiguity.** A filter settles several interpretations by resource over attribute, with a warning. A select field, group by, order by, or aggregation keeps every interpretation in metadata order and folds them, so it shows the value wherever it is.
|
||||
4. **Intrinsic column first**, bare keys only. A column every row has leads the list. Metadata can report the column, or the storage's `Fallback` can. When only `Fallback` knows the column, its key carries the data type the column reads as (`querybuilder.ColumnDataType`). A same-named metadata key of a contradicting type then drops, and a time column merges none. When metadata reports the column too, every match reads. A metadata gap degrades to the correct column, never to a corrupt metadata key.
|
||||
5. **Fallback.** With no match, the storage's fallback keys for the key. When the storage ignores unknown keys (a side query whose main query owns the error), the key is `Skipped`. Otherwise a key nothing can serve is an error with suggestions. The not-found warning fires only when every fallback key is a guess, that is, none of them is always present.
|
||||
|
||||
`Condition` then applies the fingerprint split. `MainOfSplit` drops the resource fields the sub-query serves and keeps fallback keys. `FingerprintOfSplit` keeps resource fields only. `Condition` compiles each field, and the visitor joins the per-field conditions by the operator's polarity. `Column` reads each field through `LogicalRead`. Group by, order by, and aggregation cast the read unless it keeps its type. `Column` then guards by `Absent`, and renders one candidate bare or several as `multiIf(..., NULL)`. A filter-only candidate drops. The error surfaces only when no candidate remains.
|
||||
|
||||
### Invariant: No layer skipping
|
||||
|
||||
The StatementBuilder must not call FieldMapper directly to build conditions, it goes through the ConditionBuilder. The AggExprRewriter must not hardcode column names, it goes through the FieldMapper. Skipping layers creates hidden coupling and makes the system fragile to storage changes.
|
||||
A statement builder must not spell a column or a condition. It calls `ResolveColumn` and the filter visitor. A storage must not decide a guard or an ambiguity. It declares `Absent` and answers the four questions. Skipping layers recreates the per-signal copies the contract removed.
|
||||
|
||||
---
|
||||
|
||||
@@ -119,14 +162,15 @@ Only additive/counting aggregations (`count`, `count_distinct`, `sum`, `rate`) d
|
||||
|
||||
**Enforcement:** `GetQueriesSupportingZeroDefault` determines which queries can default to zero. The `FormulaEvaluator` consumes this via `canDefaultZero`. Changes to aggregation handling must preserve this distinction.
|
||||
|
||||
### Constraint: Existence semantics differ for positive vs negative operators
|
||||
### Constraint: The exists guard derives from the operator and from the field
|
||||
|
||||
- **Positive operators** (`=`, `>`, `LIKE`, `IN`, etc.) implicitly assert field existence. `http.method = GET` means "the field exists AND equals GET".
|
||||
- **Negative operators** (`!=`, `NOT IN`, `NOT LIKE`, etc.) do **not** add an existence check. `http.method != GET` includes records where the field doesn't exist at all.
|
||||
- **Positive operators** (`=`, `>`, `LIKE`, `IN`, etc.) implicitly assert field existence for a field that reads a sentinel when absent. `http.method = GET` on a map attribute means "the field exists AND equals GET".
|
||||
- **Negative operators** (`!=`, `NOT IN`, `NOT LIKE`, etc.) never add an existence check. `http.method != GET` includes records where the field doesn't exist at all.
|
||||
- A field that reads NULL when absent, and a table column, take no guard on any operator: the comparison already excludes the absent row, or there is no absent row.
|
||||
|
||||
**Why:** The user's intent with negative operators is ambiguous. Rather than guess, we take the broader interpretation. Users can add an explicit `EXISTS` filter if they want the narrower one. This is documented in `AddDefaultExistsFilter`.
|
||||
**Why:** The user's intent with negative operators is ambiguous. Rather than guess, we take the broader interpretation. Users can add an explicit `EXISTS` filter if they want the narrower one. The operator side is declared in `AddDefaultExistsFilter`. The field side is the `Absent` a storage returns from `Read`.
|
||||
|
||||
**Consequence:** Any new operator must declare its existence behavior in `AddDefaultExistsFilter`. Do not add operators without considering this.
|
||||
**Consequence:** Any new operator must declare its existence behavior in `AddDefaultExistsFilter`. Any new read must declare what an absent row reads. Never add a guard by hand in a storage.
|
||||
|
||||
### Constraint: Post-processing functions operate on result sets, not in SQL
|
||||
|
||||
@@ -188,11 +232,11 @@ The `MetadataStore` interface provides runtime field discovery and type resoluti
|
||||
|
||||
The same name can map to multiple `TelemetryFieldKey` variants (different contexts, different types). The metadata store returns *all* variants. Resolution to a single field happens during query building, using the query's signal and any explicit context/type hints from the user.
|
||||
|
||||
**Consequence:** Code that calls `GetKey` or `GetKeys` must handle multiple results. Do not assume a name maps to a single field.
|
||||
**Consequence:** Code that calls `GetKey` or `GetKeys` must handle multiple results. Do not assume a name maps to a single field. `querybuilder.Resolve` is where the variants settle: it returns every interpretation as a `LogicalField`, marks the result `Ambiguous`, and carries the warning.
|
||||
|
||||
### Principle: Materialized fields are a performance optimization, not a semantic distinction
|
||||
|
||||
A materialized field and its non-materialized equivalent represent the same logical field. The `Materialized` flag tells the FieldMapper to generate a simpler column expression. The user should never need to know whether a field is materialized.
|
||||
A materialized field and its non-materialized equivalent represent the same logical field. The `Materialized` flag tells the storage's `Read` to generate a simpler column expression. The user should never need to know whether a field is materialized.
|
||||
|
||||
### Principle: JSON body fields require access plans
|
||||
|
||||
@@ -203,14 +247,14 @@ Fields inside JSON body columns (`body.response.errors[].code`) need pre-compute
|
||||
## Summary of Inviolable Rules
|
||||
|
||||
1. **User-facing types never contain ClickHouse column names or SQL fragments.**
|
||||
2. **Field-to-column translation only happens in FieldMapper.**
|
||||
2. **Field-to-column translation only happens in a Storage (`Read`, `Fallback`).**
|
||||
3. **Normalization happens once at the API boundary, never deeper.**
|
||||
4. **Historical aliases in fieldContexts and fieldDataTypes must not be removed.**
|
||||
5. **Formula evaluation stays in Go — do not push it into ClickHouse JOINs.**
|
||||
6. **Zero-defaulting is aggregation-type-dependent — do not universally default to zero.**
|
||||
7. **Positive operators imply existence, negative operators do not.**
|
||||
7. **The exists guard derives from `AddDefaultExistsFilter` and `Absent`. Positive operators guard sentinel reads. Negative operators never guard.**
|
||||
8. **Post-processing functions operate on Go result sets, not in SQL.**
|
||||
9. **All user-facing types reject unknown JSON fields with suggestions.**
|
||||
10. **Validation rules are gated by request type.**
|
||||
11. **Query names must be unique within a composite query.**
|
||||
12. **The four-layer abstraction stack (FieldMapper -> ConditionBuilder -> AggExprRewriter -> StatementBuilder) must not be bypassed or flattened.**
|
||||
12. **The three-layer abstraction stack (Storage -> querybuilder generic layer -> StatementBuilder) must not be bypassed or flattened. A storage describes its field keys. The generic layer decides.**
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<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"
|
||||
/>
|
||||
<!--
|
||||
`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="stylesheet" href="css/uPlot.min.css" />
|
||||
|
||||
<script>
|
||||
|
||||
50
frontend/.storybook/public/storybook-fonts.css
Normal file
50
frontend/.storybook/public/storybook-fonts.css
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
85
frontend/.storybook/test-runner.ts
Normal file
85
frontend/.storybook/test-runner.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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;
|
||||
@@ -9,6 +9,7 @@
|
||||
"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",
|
||||
@@ -162,6 +163,7 @@
|
||||
"@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",
|
||||
@@ -233,7 +235,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@babel/core@<=7.29.0": ">=7.29.6 <8",
|
||||
"@istanbuljs/load-nyc-config>js-yaml": ">=4.2.0 <5",
|
||||
"@istanbuljs/load-nyc-config>js-yaml": ">=4.3.1 <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",
|
||||
@@ -242,6 +244,14 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
1076
frontend/pnpm-lock.yaml
generated
1076
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
31
frontend/scripts/test-storybook.sh
Executable file
31
frontend/scripts/test-storybook.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { AxiosError, AxiosResponse, isCancel } from 'axios';
|
||||
import { ErrorResponse } from 'types/api';
|
||||
import { ErrorStatusCode } from 'types/common';
|
||||
|
||||
@@ -42,6 +42,16 @@ 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');
|
||||
|
||||
|
||||
@@ -5019,6 +5019,57 @@ 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
|
||||
@@ -5026,7 +5077,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -5950,6 +6002,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TablePanel' = 'signoz/TablePanel',
|
||||
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
|
||||
@@ -31,6 +31,8 @@ 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,
|
||||
};
|
||||
|
||||
|
||||
@@ -376,6 +376,7 @@ export enum PANEL_TYPES {
|
||||
BAR = 'bar',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
|
||||
@@ -376,7 +376,9 @@ export default function BillingContainer(): JSX.Element {
|
||||
</Typography.Link>
|
||||
</AuthZTooltip>
|
||||
{` if your payment information has changed. Email us at `}
|
||||
<Typography.Text color="muted">cloud-support@signoz.io</Typography.Text>
|
||||
<Typography.Text as="span" color="muted">
|
||||
cloud-support@signoz.io
|
||||
</Typography.Text>
|
||||
{` otherwise. Be sure to provide this information immediately to avoid interruption to your service.`}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
@@ -174,6 +174,7 @@ 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 => {
|
||||
|
||||
@@ -216,7 +216,7 @@ function K8sOptionsSidePanel<TData>({
|
||||
);
|
||||
return (
|
||||
<div className={styles.columnItem} key={column.id}>
|
||||
<Typography.Text size="sm" className={styles.columnLabel}>
|
||||
<Typography.Text as="span" size="sm" className={styles.columnLabel}>
|
||||
{column.label}
|
||||
</Typography.Text>
|
||||
{column.canBeHidden ? (
|
||||
|
||||
@@ -131,7 +131,7 @@ export function AboutSigNozQuestions({
|
||||
|
||||
<AntdInput.TextArea
|
||||
className="discover-signoz-input"
|
||||
placeholder={`e.g., googling "datadog alternative", a post on r/devops, from a friend/colleague, a LinkedIn post, ChatGPT, etc.`}
|
||||
placeholder="e.g., I asked ChatGPT for Datadog alternatives, searched Google for “OpenTelemetry tools,” saw a Reddit or LinkedIn post, or heard about it from a colleague."
|
||||
value={discoverSignoz}
|
||||
autoFocus
|
||||
rows={4}
|
||||
|
||||
@@ -204,10 +204,12 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByPlaceholderText(/e\.g\., googling/i, {}),
|
||||
screen.findByPlaceholderText(/e\.g\., I asked ChatGPT/i, {}),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const discoverInput = screen.getByPlaceholderText(/e\.g\., googling/i);
|
||||
const discoverInput = screen.getByPlaceholderText(
|
||||
/e\.g\., I asked ChatGPT/i,
|
||||
);
|
||||
await user.type(discoverInput, 'Found via Google search');
|
||||
|
||||
const interestCheckbox = screen.getByLabelText(
|
||||
@@ -256,11 +258,11 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByPlaceholderText(/e\.g\., googling/i, {}),
|
||||
screen.findByPlaceholderText(/e\.g\., I asked ChatGPT/i, {}),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/e\.g\., googling/i),
|
||||
screen.getByPlaceholderText(/e\.g\., I asked ChatGPT/i),
|
||||
'Found via Google',
|
||||
);
|
||||
await user.click(screen.getByLabelText(/lowering observability costs/i));
|
||||
@@ -297,7 +299,7 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
await user.type(
|
||||
await screen.findByPlaceholderText(/e\.g\., googling/i),
|
||||
await screen.findByPlaceholderText(/e\.g\., I asked ChatGPT/i),
|
||||
'Found via Google',
|
||||
);
|
||||
await user.click(screen.getByLabelText(/lowering observability costs/i));
|
||||
@@ -329,11 +331,11 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByPlaceholderText(/e\.g\., googling/i, {}),
|
||||
screen.findByPlaceholderText(/e\.g\., I asked ChatGPT/i, {}),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/e\.g\., googling/i),
|
||||
screen.getByPlaceholderText(/e\.g\., I asked ChatGPT/i),
|
||||
'Found via Google',
|
||||
);
|
||||
await user.click(screen.getByLabelText(/lowering observability costs/i));
|
||||
|
||||
@@ -29,5 +29,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
BAR: true,
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
TEXT: false,
|
||||
EMPTY_WIDGET: false,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ 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,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
@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;
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,106 @@
|
||||
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 { LegendPosition, LegendProps } from '../types';
|
||||
import { LegendAction, LegendPosition, LegendProps } from '../types';
|
||||
|
||||
import './Legend.styles.scss';
|
||||
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
|
||||
import LegendRow from './LegendRow';
|
||||
import LegendToolbar from './LegendToolbar';
|
||||
import { filterLegendItems, getShownSeriesState } from './utils';
|
||||
|
||||
export const MAX_LEGEND_WIDTH = 240;
|
||||
import styles from './Legend.module.scss';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Presentational legend, source-agnostic: the uPlot charts feed it via
|
||||
* UPlotLegend, Pie feeds it directly. Every state change is delegated.
|
||||
*/
|
||||
export default function Legend({
|
||||
items,
|
||||
position,
|
||||
averageLegendWidth = MAX_LEGEND_WIDTH,
|
||||
focusedSeriesIndex,
|
||||
onClick,
|
||||
onMouseMove,
|
||||
onMouseLeave,
|
||||
onAction,
|
||||
showCopy = true,
|
||||
}: LegendProps): JSX.Element {
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [legendSearchQuery, setLegendSearchQuery] = useState('');
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
|
||||
// Search is intrinsic to the right-positioned legend.
|
||||
const searchEnabled = position === LegendPosition.RIGHT;
|
||||
const { width: containerWidth } = useResizeObserver(legendContainerRef);
|
||||
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
|
||||
const isRightPosition = position === LegendPosition.RIGHT;
|
||||
|
||||
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]);
|
||||
|
||||
const visibleLegendItems = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const query = legendSearchQuery.trim().toLowerCase();
|
||||
return items.filter((item) => item.label?.toLowerCase().includes(query));
|
||||
}, [searchEnabled, legendSearchQuery, items]);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(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 { visibleCount, soleShownSeriesIndex } = useMemo(
|
||||
() => getShownSeriesState(items),
|
||||
[items],
|
||||
);
|
||||
|
||||
const isEmptyState = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return false;
|
||||
}
|
||||
return visibleLegendItems.length === 0;
|
||||
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
|
||||
// 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 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 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],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={legendContainerRef}
|
||||
className="legend-container"
|
||||
onClick={onClick}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={{
|
||||
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
|
||||
}}
|
||||
className={cx(styles.container, {
|
||||
[styles.isRight]: isRightPosition,
|
||||
})}
|
||||
style={{ ['--legend-item-width' as string]: `${itemWidth}px` }}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-testid="legend-container"
|
||||
>
|
||||
{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>
|
||||
{showToolbar && (
|
||||
<LegendToolbar
|
||||
visibleCount={visibleCount}
|
||||
totalCount={items.length}
|
||||
showFilter={showFilter}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
/>
|
||||
)}
|
||||
{isEmptyState ? (
|
||||
<div className="legend-empty-state">
|
||||
No series found matching "{legendSearchQuery}"
|
||||
<div className={styles.emptyState}>
|
||||
No series found matching "{effectiveQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
className={cx(
|
||||
'legend-virtuoso-container',
|
||||
`legend-virtuoso-container-${position.toLowerCase()}`,
|
||||
{ 'legend-virtuoso-container-single-row': isSingleRow },
|
||||
)}
|
||||
className={styles.scroller}
|
||||
listClassName={styles.gridList}
|
||||
itemClassName={styles.gridItem}
|
||||
data={visibleLegendItems}
|
||||
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
|
||||
/>
|
||||
|
||||
170
frontend/src/lib/uPlotV2/components/Legend/LegendRow.module.scss
Normal file
170
frontend/src/lib/uPlotV2/components/Legend/LegendRow.module.scss
Normal file
@@ -0,0 +1,170 @@
|
||||
.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);
|
||||
}
|
||||
182
frontend/src/lib/uPlotV2/components/Legend/LegendRow.tsx
Normal file
182
frontend/src/lib/uPlotV2/components/Legend/LegendRow.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
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);
|
||||
@@ -0,0 +1,35 @@
|
||||
.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);
|
||||
}
|
||||
56
frontend/src/lib/uPlotV2/components/Legend/LegendToolbar.tsx
Normal file
56
frontend/src/lib/uPlotV2/components/Legend/LegendToolbar.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 toggle/focus interactions from
|
||||
* the plot context (useLegendActions), then renders the presentational Legend.
|
||||
* from the chart config (useLegendsSync) and the series interactions from the
|
||||
* plot context (useLegendActions), then renders the presentational Legend.
|
||||
* Must be rendered inside a PlotContextProvider.
|
||||
*/
|
||||
export default function UPlotLegend({
|
||||
@@ -17,13 +17,8 @@ export default function UPlotLegend({
|
||||
config,
|
||||
averageLegendWidth,
|
||||
}: UPlotLegendProps): JSX.Element {
|
||||
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
|
||||
useLegendsSync({ config });
|
||||
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex,
|
||||
focusedSeriesIndex,
|
||||
});
|
||||
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
|
||||
const onAction = useLegendActions();
|
||||
|
||||
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
|
||||
|
||||
@@ -33,9 +28,7 @@ export default function UPlotLegend({
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/uPlotV2/components/Legend/constants.ts
Normal file
20
frontend/src/lib/uPlotV2/components/Legend/constants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** 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;
|
||||
34
frontend/src/lib/uPlotV2/components/Legend/utils.ts
Normal file
34
frontend/src/lib/uPlotV2/components/Legend/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Matches the legend row's marker.
|
||||
.uplotTooltipItemMarker {
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius);
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
border-width: 1.5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
@@ -30,11 +31,23 @@
|
||||
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;
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function TooltipItem({
|
||||
>
|
||||
<div
|
||||
className={Styles.uplotTooltipItemMarker}
|
||||
style={{ borderColor: item.color }}
|
||||
style={{ borderColor: item.color, backgroundColor: 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>{item.tooltipValue}</span>
|
||||
<span className={Styles.uplotTooltipItemValue}>{item.tooltipValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,216 +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 '../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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MouseEventHandler, ReactNode } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import uPlot from 'uplot';
|
||||
@@ -115,26 +115,39 @@ 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). The search
|
||||
* box is intrinsic to the RIGHT position (derived from `position`, not a flag).
|
||||
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
|
||||
*/
|
||||
export interface LegendProps {
|
||||
items: LegendItem[];
|
||||
/** Legend placement; always supplied by the container. */
|
||||
position: LegendPosition;
|
||||
averageLegendWidth?: number;
|
||||
/** Series index to highlight (hovered/focused). */
|
||||
/** Series index highlighted by the chart cursor. */
|
||||
focusedSeriesIndex: number | null;
|
||||
/**
|
||||
* 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;
|
||||
onAction: OnLegendAction;
|
||||
/** Show the per-item copy button. Default true. */
|
||||
showCopy?: boolean;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ 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,
|
||||
|
||||
@@ -8,6 +8,10 @@ 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';
|
||||
@@ -20,12 +24,26 @@ 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 => {
|
||||
@@ -33,6 +51,9 @@ 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(
|
||||
({
|
||||
@@ -43,6 +64,8 @@ export const PlotContextProvider = ({
|
||||
uPlotInstanceRef.current = uPlotInstance;
|
||||
idRef.current = id;
|
||||
activeSeriesIndex.current = undefined;
|
||||
baseSeriesWidthsRef.current = new Map();
|
||||
highlightedSeriesIndexRef.current = null;
|
||||
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
|
||||
},
|
||||
[],
|
||||
@@ -64,6 +87,54 @@ 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;
|
||||
@@ -103,14 +174,61 @@ 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],
|
||||
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
|
||||
);
|
||||
|
||||
/** 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) {
|
||||
@@ -131,14 +249,20 @@ export const PlotContextProvider = ({
|
||||
onToggleSeriesVisibility,
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
}),
|
||||
[
|
||||
onToggleSeriesVisibility,
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
|
||||
series,
|
||||
batch: jest.fn((fn: () => void) => fn()),
|
||||
setSeries: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
}) as unknown as uPlot;
|
||||
|
||||
interface TestComponentProps {
|
||||
@@ -44,7 +45,10 @@ const TestComponent = ({
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
} = usePlotContext();
|
||||
const handleInit = (): void => {
|
||||
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
|
||||
@@ -84,6 +88,13 @@ 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"
|
||||
@@ -98,6 +109,34 @@ 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>
|
||||
);
|
||||
};
|
||||
@@ -273,6 +312,7 @@ describe('PlotContext', () => {
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
@@ -324,6 +364,7 @@ describe('PlotContext', () => {
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
@@ -343,6 +384,48 @@ 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', () => {
|
||||
@@ -381,4 +464,193 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -11,10 +12,12 @@ const mockUsePlotContext = usePlotContext as jest.MockedFunction<
|
||||
describe('useLegendActions', () => {
|
||||
let onToggleSeriesVisibility: jest.Mock;
|
||||
let onToggleSeriesOnOff: jest.Mock;
|
||||
let onFocusSeriesPlot: jest.Mock;
|
||||
let onShowOnlySeries: jest.Mock;
|
||||
let onShowAllSeries: jest.Mock;
|
||||
let onFocusSeries: jest.Mock;
|
||||
let onHighlightSeries: jest.Mock;
|
||||
let setPlotContextInitialState: jest.Mock;
|
||||
let syncSeriesVisibilityToLocalStorage: jest.Mock;
|
||||
let setFocusedSeriesIndexMock: jest.Mock;
|
||||
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -37,15 +40,20 @@ describe('useLegendActions', () => {
|
||||
beforeEach(() => {
|
||||
onToggleSeriesVisibility = jest.fn();
|
||||
onToggleSeriesOnOff = jest.fn();
|
||||
onFocusSeriesPlot = jest.fn();
|
||||
onShowOnlySeries = jest.fn();
|
||||
onShowAllSeries = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
onHighlightSeries = jest.fn();
|
||||
setPlotContextInitialState = jest.fn();
|
||||
syncSeriesVisibilityToLocalStorage = jest.fn();
|
||||
setFocusedSeriesIndexMock = jest.fn();
|
||||
|
||||
mockUsePlotContext.mockReturnValue({
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onFocusSeries: onFocusSeriesPlot,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
setPlotContextInitialState,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
});
|
||||
@@ -53,149 +61,65 @@ describe('useLegendActions', () => {
|
||||
cancelAnimationFrameSpy.mockClear();
|
||||
});
|
||||
|
||||
const createMouseEvent = (options: {
|
||||
legendItemId?: number;
|
||||
isMarker?: boolean;
|
||||
}): any => {
|
||||
const { legendItemId, isMarker = false } = options;
|
||||
describe('visibility actions', () => {
|
||||
it('toggles a single series on row click', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
return {
|
||||
target: {
|
||||
dataset: {
|
||||
...(isMarker ? { isLegendMarker: 'true' } : {}),
|
||||
},
|
||||
closest: jest.fn(() =>
|
||||
legendItemId !== undefined
|
||||
? { dataset: { legendItemId: String(legendItemId) } }
|
||||
: null,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
result.current({ type: LegendAction.TOGGLE, seriesIndex: 2 });
|
||||
|
||||
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(onToggleSeriesOnOff).toHaveBeenCalledWith(2);
|
||||
// The row must never isolate — that is what "Only" is for.
|
||||
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when click target is not inside a legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
it('forwards the Only and All actions to the plot', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendClick(createMouseEvent({}));
|
||||
result.current({ type: LegendAction.SHOW_ONLY, seriesIndex: 1 });
|
||||
result.current({ type: LegendAction.SHOW_ALL });
|
||||
|
||||
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
|
||||
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
expect(onShowOnlySeries).toHaveBeenCalledWith(1);
|
||||
expect(onShowAllSeries).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFocusSeries', () => {
|
||||
it('schedules focus update and calls plot focus handler via mouse move', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
describe('hover highlight', () => {
|
||||
it('highlights the hovered series', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 2 });
|
||||
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(0);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(0);
|
||||
expect(onHighlightSeries).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('cancels previous animation frame before scheduling new one on subsequent mouse moves', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
it('clears the highlight on leave', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: null });
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLegendMouseMove', () => {
|
||||
it('focuses new series when hovering over different legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
it('cancels a pending highlight frame on unmount', () => {
|
||||
jest
|
||||
.spyOn(global, 'requestAnimationFrame')
|
||||
.mockImplementation((): number => 7);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
const { result, unmount } = renderHook(() => useLegendActions());
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 1 });
|
||||
unmount();
|
||||
|
||||
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);
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,117 +1,66 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
|
||||
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;
|
||||
} {
|
||||
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 {
|
||||
const {
|
||||
onFocusSeries: onFocusSeriesPlot,
|
||||
onToggleSeriesOnOff,
|
||||
onToggleSeriesVisibility,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHighlightSeries,
|
||||
} = usePlotContext();
|
||||
|
||||
const rafId = useRef<number | null>(null); // requestAnimationFrame id
|
||||
const rafIdRef = useRef<number | null>(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;
|
||||
const cancelPendingHighlight = useCallback((): void => {
|
||||
if (rafIdRef.current != null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
onFocusSeries(seriesIndex);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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],
|
||||
);
|
||||
useEffect(() => cancelPendingHighlight, [cancelPendingHighlight]);
|
||||
|
||||
// Cleanup pending animation frames on unmount
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
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;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[
|
||||
cancelPendingHighlight,
|
||||
onHighlightSeries,
|
||||
onShowAllSeries,
|
||||
onShowOnlySeries,
|
||||
onToggleSeriesOnOff,
|
||||
],
|
||||
);
|
||||
return {
|
||||
onLegendClick,
|
||||
onFocusSeries,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,9 +45,7 @@ export default function Pie({
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onLegendAction,
|
||||
} = usePieInteractions(data, id);
|
||||
|
||||
const {
|
||||
@@ -227,9 +225,7 @@ export default function Pie({
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,17 +100,29 @@ describe('Pie', () => {
|
||||
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
|
||||
});
|
||||
|
||||
it('hides a slice when its legend marker is clicked', () => {
|
||||
it('isolates a slice when its legend row is clicked with everything showing', () => {
|
||||
renderPie();
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(3);
|
||||
|
||||
const marker = document.querySelector(
|
||||
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(marker);
|
||||
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'));
|
||||
|
||||
// One slice hidden → one fewer arc drawn.
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(2);
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
|
||||
import {
|
||||
calculateAverageLegendWidth,
|
||||
calculateChartDimensions,
|
||||
} from 'lib/visualization/charts/utils';
|
||||
|
||||
const labels = (count: number, length = 20): string[] =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
@@ -49,63 +52,104 @@ describe('calculateChartDimensions', () => {
|
||||
expect(dims.width).toBe(784);
|
||||
});
|
||||
|
||||
it('RIGHT: never shrinks the column below the 150px floor', () => {
|
||||
it('RIGHT: never shrinks the column below the floor that fits its chrome', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(3, 3),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(150);
|
||||
expect(dims.width).toBe(850);
|
||||
expect(dims.legendWidth).toBe(190);
|
||||
expect(dims.width).toBe(810);
|
||||
});
|
||||
|
||||
it('RIGHT: on a narrow container the legend never takes more than 40% of the width', () => {
|
||||
it('RIGHT: on a narrow container the legend keeps its chrome, up to half the width', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 300,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(10, 40),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(120);
|
||||
expect(dims.width).toBe(180);
|
||||
// 40% is 120px, too narrow for the column's own toolbar.
|
||||
expect(dims.legendWidth).toBe(150);
|
||||
expect(dims.width).toBe(150);
|
||||
});
|
||||
|
||||
it('BOTTOM: a single row of items reserves one legend row', () => {
|
||||
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', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(3),
|
||||
});
|
||||
// One row = line height (28) + padding (12).
|
||||
// One 28px row + the wrapper's 12px bottom padding.
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
expect(dims.height).toBe(460);
|
||||
expect(dims.legendWidth).toBe(1000);
|
||||
});
|
||||
|
||||
it('BOTTOM: many items cap at two rows on a tall container', () => {
|
||||
it('BOTTOM: more items than one row reserve exactly two rows', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
|
||||
expect(dims.legendHeight).toBe(68);
|
||||
expect(dims.height).toBe(432);
|
||||
// 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);
|
||||
});
|
||||
|
||||
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
|
||||
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.
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 160,
|
||||
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,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// 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);
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
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 { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
export interface ChartDimensions {
|
||||
width: number;
|
||||
@@ -13,22 +19,31 @@ const LEGEND_WIDTH_PERCENTILE = 0.85;
|
||||
const DEFAULT_AVG_LABEL_LENGTH = 15;
|
||||
const BASE_LEGEND_WIDTH = 16;
|
||||
const LEGEND_PADDING = 12;
|
||||
const LEGEND_LINE_HEIGHT = 28;
|
||||
// Two rows are worth having, but not at the cost of half the panel.
|
||||
const MAX_SHORT_PANEL_LEGEND_RATIO = 0.5;
|
||||
|
||||
// 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 DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
|
||||
return Math.max(
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
|
||||
@@ -36,7 +51,10 @@ export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
|
||||
const percentileLength = lengths[Math.max(0, index)];
|
||||
|
||||
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
|
||||
return Math.max(
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +70,9 @@ 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 derived from row count, capped by both a fixed pixel max and a % of container height.
|
||||
* - `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.
|
||||
* - 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.
|
||||
*
|
||||
@@ -100,9 +120,14 @@ 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(150, desiredLegendWidth),
|
||||
maxRightLegendWidth,
|
||||
Math.max(MIN_RIGHT_LEGEND_WIDTH, desiredLegendWidth),
|
||||
Math.max(floorWidth, maxRightLegendWidth),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -115,8 +140,6 @@ export function calculateChartDimensions({
|
||||
};
|
||||
}
|
||||
|
||||
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
|
||||
|
||||
const legendItemWidth = Math.ceil(
|
||||
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
|
||||
);
|
||||
@@ -125,30 +148,30 @@ export function calculateChartDimensions({
|
||||
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
|
||||
);
|
||||
|
||||
const legendRowCount = Math.min(
|
||||
2,
|
||||
Math.ceil(legendItemCount / legendItemsPerRow),
|
||||
// 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 idealBottomLegendHeight =
|
||||
legendRowCount > 1
|
||||
? legendRowCount * legendRowHeight - LEGEND_PADDING
|
||||
: legendRowHeight;
|
||||
// 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;
|
||||
|
||||
// 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,
|
||||
);
|
||||
const bottomLegendHeight = heightForRows(legendRowCount);
|
||||
|
||||
return {
|
||||
width: containerWidth,
|
||||
|
||||
@@ -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,22 +24,6 @@ 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);
|
||||
@@ -59,11 +43,16 @@ describe('usePieInteractions', () => {
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
|
||||
describe('marker click (toggle one)', () => {
|
||||
describe('row toggle', () => {
|
||||
it('hides then unhides the clicked slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
|
||||
expect(result.current.legendItems[1].show).toBe(false);
|
||||
@@ -73,18 +62,50 @@ describe('usePieInteractions', () => {
|
||||
{ label: 'checkout', show: true },
|
||||
]);
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
expect(result.current.legendItems[1].show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('label click (isolate / reset)', () => {
|
||||
it('isolates the clicked slice, then resets on a second click', () => {
|
||||
describe('the last slice showing', () => {
|
||||
it('cannot be hidden', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
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,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
|
||||
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
|
||||
@@ -92,8 +113,39 @@ describe('usePieInteractions', () => {
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, 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 }));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
});
|
||||
@@ -103,11 +155,37 @@ describe('usePieInteractions', () => {
|
||||
it('focuses the hovered slice and clears on leave', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(2)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 2 }),
|
||||
);
|
||||
expect(result.current.active).toStrictEqual(DATA[2]);
|
||||
expect(result.current.focusedSeriesIndex).toBe(2);
|
||||
|
||||
act(() => result.current.onLegendMouseLeave());
|
||||
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.
|
||||
expect(result.current.active).toBeNull();
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
@@ -115,8 +193,15 @@ describe('usePieInteractions', () => {
|
||||
it('does not focus a hidden slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(1)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
|
||||
);
|
||||
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
@@ -125,7 +210,12 @@ describe('usePieInteractions', () => {
|
||||
describe('persistence', () => {
|
||||
it('does not write to storage when no id is provided', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
act(() => result.current.onLegendClick(legendEvent(0, true)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
expect(mockUpdateStored).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
LegendAction,
|
||||
LegendActionPayload,
|
||||
OnLegendAction,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
@@ -18,27 +23,15 @@ export interface UsePieInteractionsResult {
|
||||
legendItems: LegendItem[];
|
||||
/** Index of the active slice for the legend's focus highlight, or null. */
|
||||
focusedSeriesIndex: number | null;
|
||||
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;
|
||||
/** Every legend interaction, dispatched by type. */
|
||||
onLegendAction: OnLegendAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function usePieInteractions(
|
||||
data: PieSlice[],
|
||||
@@ -48,7 +41,6 @@ export function usePieInteractions(
|
||||
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const isolatedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
@@ -104,65 +96,88 @@ export function usePieInteractions(
|
||||
[id, data],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
const hoverSeries = useCallback(
|
||||
(sliceIndex: number | null): void => {
|
||||
// Don't focus/dim for hidden slices — they aren't on the donut.
|
||||
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
|
||||
setActive(
|
||||
sliceIndex != null && !hiddenIndices.has(sliceIndex)
|
||||
? data[sliceIndex]
|
||||
: null,
|
||||
);
|
||||
},
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
|
||||
|
||||
if (isMarker) {
|
||||
const next = new Set(hiddenIndices);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
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;
|
||||
}
|
||||
applyHidden(next);
|
||||
return;
|
||||
next.add(sliceIndex);
|
||||
}
|
||||
applyHidden(next);
|
||||
},
|
||||
[data.length, hiddenIndices, applyHidden],
|
||||
);
|
||||
|
||||
const isReset = isolatedIndexRef.current === index;
|
||||
isolatedIndexRef.current = isReset ? null : index;
|
||||
if (isReset) {
|
||||
applyHidden(new Set());
|
||||
return;
|
||||
}
|
||||
const showOnlySeries = useCallback(
|
||||
(sliceIndex: number): void => {
|
||||
const next = new Set<number>();
|
||||
data.forEach((_, i) => {
|
||||
if (i !== index) {
|
||||
next.add(i);
|
||||
data.forEach((_, index) => {
|
||||
if (index !== sliceIndex) {
|
||||
next.add(index);
|
||||
}
|
||||
});
|
||||
applyHidden(next);
|
||||
},
|
||||
[data, hiddenIndices, applyHidden],
|
||||
[data, applyHidden],
|
||||
);
|
||||
|
||||
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
|
||||
const showAllSeries = useCallback(
|
||||
(): void => applyHidden(new Set()),
|
||||
[applyHidden],
|
||||
);
|
||||
|
||||
const focusedIndex = active ? data.indexOf(active) : -1;
|
||||
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;
|
||||
|
||||
return {
|
||||
active,
|
||||
active: effectiveActive,
|
||||
setActive,
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onLegendAction,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding: 0 12px 12px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/Legend';
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/constants';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const KIND_LABEL: Record<VariableUsage['kind'], string> = {
|
||||
promql: 'PromQL',
|
||||
clickhouse: 'ClickHouse',
|
||||
variable: 'Variable',
|
||||
text: 'Markdown body',
|
||||
};
|
||||
|
||||
interface VariableImpactDialogProps {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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}}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,16 @@ 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[],
|
||||
@@ -99,6 +109,38 @@ 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', () => {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
}
|
||||
@@ -107,6 +107,18 @@ 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;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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. */
|
||||
@@ -19,7 +20,8 @@ export type VariableUsageKind =
|
||||
| 'builder'
|
||||
| 'promql'
|
||||
| 'clickhouse'
|
||||
| 'variable';
|
||||
| 'variable'
|
||||
| 'text';
|
||||
|
||||
export type VariableImpactMode = 'rename' | 'delete' | 'apply';
|
||||
|
||||
@@ -81,7 +83,7 @@ function computeResultingText(
|
||||
return rewriteVariableReferences(text, variableName, newName);
|
||||
}
|
||||
// delete: only builder filter clauses can be safely auto-stripped; raw PromQL/
|
||||
// ClickHouse and variable queries are left for the user to edit.
|
||||
// ClickHouse, markdown bodies and variable queries are left for the user to edit.
|
||||
return kind === 'builder'
|
||||
? removeVariableFromExpression(text, variableName)
|
||||
: text;
|
||||
@@ -106,6 +108,31 @@ 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;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -66,7 +67,14 @@ function ConfigPane({
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
const panelKind = spec.plugin.kind;
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
const sections = definition.sections;
|
||||
// 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 signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
|
||||
|
||||
@@ -105,6 +113,23 @@ 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 && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
isStaticPanelKind,
|
||||
isQueryTypeSupportedByPanelKind,
|
||||
isSignalSupported,
|
||||
} from '../../../Panels/capabilities';
|
||||
@@ -37,6 +38,12 @@ 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`;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ 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.
|
||||
@@ -50,6 +52,7 @@ function SectionSlot({
|
||||
config,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
bare,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
signal,
|
||||
@@ -110,6 +113,28 @@ 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}
|
||||
@@ -118,21 +143,7 @@ function SectionSlot({
|
||||
onOpenChange={setOpen}
|
||||
headerSlot={headerSlot}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
{editorElement}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ 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' },
|
||||
@@ -93,6 +101,24 @@ 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.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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;
|
||||
@@ -0,0 +1,67 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
@@ -0,0 +1,118 @@
|
||||
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']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,8 @@ 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';
|
||||
|
||||
@@ -117,6 +119,23 @@ 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.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
@@ -0,0 +1,29 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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;
|
||||
@@ -0,0 +1,144 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -26,16 +26,3 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../../Panels/types/panelKind';
|
||||
import { toPanelType } 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 = PANEL_KIND_TO_PANEL_TYPE[panelDefinition.kind];
|
||||
const panelType = toPanelType(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';
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
// A static pane never scrolls — the panel card clips, and the renderer scrolls its
|
||||
// own body when the content outgrows it, as on the grid.
|
||||
.previewStatic {
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -56,6 +63,14 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
// A static kind's card takes its colours from the background the panel declares,
|
||||
// falling back to the same tokens the query surface uses.
|
||||
.surfaceStatic {
|
||||
border-color: var(--text-panel-border, var(--l2-border));
|
||||
background: var(--text-panel-surface, var(--l2-background));
|
||||
color: var(--text-panel-ink, inherit);
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -5,10 +5,16 @@ import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
|
||||
import { useTextBackground } from 'pages/DashboardPage/DashboardContainer/Panels/hooks/useTextBackground';
|
||||
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type {
|
||||
RenderableQueryPanelDefinition,
|
||||
RenderableStaticPanelDefinition,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import { getPanelQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import { isPanelHeaderHidden } from 'pages/DashboardPage/DashboardContainer/Panels/utils/isPanelHeaderHidden';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
@@ -17,9 +23,15 @@ import type {
|
||||
import PlotTag from './PlotTag';
|
||||
import styles from './PreviewPane.module.scss';
|
||||
|
||||
interface PreviewPaneProps {
|
||||
interface PreviewPaneBaseProps {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
|
||||
panelMode?: PanelMode;
|
||||
}
|
||||
|
||||
interface QueryPreviewPaneProps extends PreviewPaneBaseProps {
|
||||
mode: 'query';
|
||||
/** The kind's definition, narrowed to the query arm — this preview is the query render path. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
data: PanelQueryData;
|
||||
@@ -34,8 +46,6 @@ interface PreviewPaneProps {
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
|
||||
pagination?: PanelPagination;
|
||||
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
|
||||
panelMode?: PanelMode;
|
||||
/** Hide the preview's top row entirely (query-type badge + time picker) — the View modal has its own header. */
|
||||
hideHeader?: boolean;
|
||||
/** Dashboard-wide preferences (cursor sync, …) forwarded to the body; the modal isolates cursor-sync. */
|
||||
@@ -48,41 +58,43 @@ interface PreviewPaneProps {
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
interface StaticPreviewPaneProps extends PreviewPaneBaseProps {
|
||||
mode: 'static';
|
||||
/** The kind's definition, narrowed to the static arm — no query, no Run step. */
|
||||
panelDefinition: RenderableStaticPanelDefinition;
|
||||
/** Saves an edit made from the rendered body into the draft; absent = read-only. */
|
||||
onChangeText?: (text: string) => void;
|
||||
}
|
||||
|
||||
type PreviewPaneProps = QueryPreviewPaneProps | StaticPreviewPaneProps;
|
||||
|
||||
/**
|
||||
* Live preview for the panel editor: renders the draft through the same `PanelBody`
|
||||
* the dashboard grid uses (only `panelMode` differs), so the preview is the
|
||||
* production render path. The query result is owned by the editor root.
|
||||
* Live preview for the panel editor and the View modal: the draft rendered through
|
||||
* the same body the dashboard grid uses (only `panelMode` differs), so the preview
|
||||
* is the production render path. A query draft's result is owned by the editor
|
||||
* root; a static draft re-renders straight from the spec on every edit.
|
||||
*/
|
||||
function PreviewPane({
|
||||
panelId,
|
||||
panel,
|
||||
panelDefinition,
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
pagination,
|
||||
panelMode = PanelMode.DASHBOARD_EDIT,
|
||||
hideHeader = false,
|
||||
dashboardPreference,
|
||||
onCloseStandaloneView,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const queryType = getPanelQueryType(panel);
|
||||
function PreviewPane(props: PreviewPaneProps): JSX.Element {
|
||||
const { panelId, panel, panelMode = PanelMode.DASHBOARD_EDIT } = props;
|
||||
const query = props.mode === 'query' ? props : null;
|
||||
const staticDraft = props.mode === 'static' ? props : null;
|
||||
const background = useTextBackground(panel.spec);
|
||||
|
||||
// Search term is ephemeral preview state, threaded to header + renderer but
|
||||
// not persisted to the draft spec. Only kinds that declare it render the box.
|
||||
const searchable = !!panelDefinition.actions.search;
|
||||
const searchable = !!query?.panelDefinition.actions.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
{!hideHeader && (
|
||||
<div
|
||||
className={cx(styles.preview, { [styles.previewStatic]: !!staticDraft })}
|
||||
>
|
||||
{query && !query.hideHeader && (
|
||||
<div className={styles.header}>
|
||||
<PlotTag queryType={queryType} className={styles.queryType} />
|
||||
<PlotTag
|
||||
queryType={getPanelQueryType(panel)}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
@@ -91,39 +103,67 @@ function PreviewPane({
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={cx(styles.surface, {
|
||||
[styles.surfaceStacked]: panelMode === PanelMode.STANDALONE_VIEW,
|
||||
[styles.surfaceStacked]:
|
||||
!!query && panelMode === PanelMode.STANDALONE_VIEW,
|
||||
[styles.surfaceStatic]: !!staticDraft,
|
||||
})}
|
||||
style={background.style}
|
||||
>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
searchable={searchable}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
Renderer={panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={panelMode}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
onClick={onClick}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
{query ? (
|
||||
<>
|
||||
<PanelHeader
|
||||
mode="query"
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={query.data}
|
||||
isFetching={query.isFetching}
|
||||
error={query.error}
|
||||
warning={query.data.response?.data?.warning}
|
||||
searchable={searchable}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
Renderer={query.panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={query.data}
|
||||
isFetching={query.isFetching}
|
||||
isPreviousData={query.isPreviousData}
|
||||
error={query.error}
|
||||
refetch={query.refetch}
|
||||
onDragSelect={query.onDragSelect}
|
||||
panelMode={panelMode}
|
||||
dashboardPreference={query.dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={query.pagination}
|
||||
onCloseStandaloneView={query.onCloseStandaloneView}
|
||||
onClick={query.onClick}
|
||||
enableDrillDown={query.enableDrillDown}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
staticDraft && (
|
||||
<>
|
||||
{!isPanelHeaderHidden(panel.spec) && (
|
||||
<PanelHeader
|
||||
mode="static"
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
hideActions
|
||||
/>
|
||||
)}
|
||||
<StaticPanelBody
|
||||
Renderer={staticDraft.panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
panelMode={panelMode}
|
||||
onChangeText={staticDraft.onChangeText}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
@@ -217,7 +217,7 @@ function QueryEditorBody({
|
||||
const onSwitchToView = useSwitchToViewMode({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
|
||||
panelType: toPanelType(panelKind),
|
||||
query: currentQuery,
|
||||
spec: draft.spec,
|
||||
});
|
||||
@@ -286,6 +286,7 @@ function QueryEditorBody({
|
||||
}
|
||||
preview={
|
||||
<PreviewPane
|
||||
mode="query"
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
|
||||
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
|
||||
@@ -16,12 +13,12 @@ import Header from './Header/Header';
|
||||
import PanelEditorLayout, {
|
||||
PANE_SPLIT,
|
||||
} from './PanelEditorLayout/PanelEditorLayout';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import type { PanelEditorContainerProps } from './index';
|
||||
import type { PanelEditorDraftApi } from './types';
|
||||
import { withPanelText } from '../Panels/utils/withPanelText';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
|
||||
import styles from './PanelEditor.module.scss';
|
||||
|
||||
interface StaticEditorBodyProps extends PanelEditorContainerProps {
|
||||
draftApi: PanelEditorDraftApi;
|
||||
panelDefinition: RenderableStaticPanelDefinition;
|
||||
@@ -51,7 +48,7 @@ function StaticEditorBody({
|
||||
useDashboardEditContext();
|
||||
|
||||
const { draft, spec, setSpec, isSpecDirty } = draftApi;
|
||||
const { EditorPane, Renderer } = panelDefinition;
|
||||
const { EditorPane } = panelDefinition;
|
||||
|
||||
const { save, isSaving } = usePanelEditorSave({
|
||||
dashboardId,
|
||||
@@ -80,6 +77,11 @@ function StaticEditorBody({
|
||||
}
|
||||
}, [isEditable, save, draft.spec, setScrollTargetId, onSaved, showErrorModal]);
|
||||
|
||||
const onChangeText = useCallback(
|
||||
(text: string): void => setSpec(withPanelText(spec, text)),
|
||||
[spec, setSpec],
|
||||
);
|
||||
|
||||
const onCloseEditor = useCallback((): void => {
|
||||
if (!isNew) {
|
||||
setScrollTargetId(panelId);
|
||||
@@ -103,22 +105,14 @@ function StaticEditorBody({
|
||||
/>
|
||||
}
|
||||
preview={
|
||||
<div className={styles.staticPreviewSurface}>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
data={EMPTY_PANEL_QUERY_DATA}
|
||||
isFetching={false}
|
||||
error={null}
|
||||
hideActions
|
||||
/>
|
||||
<StaticPanelBody
|
||||
Renderer={Renderer}
|
||||
panel={draft}
|
||||
panelId={panelId}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
/>
|
||||
</div>
|
||||
<PreviewPane
|
||||
mode="static"
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
onChangeText={isEditable ? onChangeText : undefined}
|
||||
/>
|
||||
}
|
||||
editor={<EditorPane spec={spec} onChangeSpec={setSpec} />}
|
||||
config={
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
|
||||
import StaticEditorBody from '../StaticEditorBody';
|
||||
import type { PanelEditorDraftApi } from '../types';
|
||||
import { usePanelEditorSave } from '../hooks/usePanelEditorSave';
|
||||
|
||||
jest.mock('../hooks/usePanelEditorSave', () => ({
|
||||
usePanelEditorSave: jest.fn(),
|
||||
}));
|
||||
// Chrome + collaborators stubbed: this suite asserts the static body's wiring —
|
||||
// the save shape above all — not their internals.
|
||||
jest.mock('../Header/Header', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onSave }: { onSave: () => void }): JSX.Element => (
|
||||
<button type="button" data-testid="header-save" onClick={onSave}>
|
||||
Save
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
jest.mock('../ConfigPane/ConfigPane', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="config-pane" />,
|
||||
}));
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader',
|
||||
() => ({ __esModule: true, default: (): null => null }),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="static-preview-body" />,
|
||||
}),
|
||||
);
|
||||
jest.mock('@signozhq/ui/sonner', () => ({ toast: { success: jest.fn() } }));
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
useErrorModal: (): unknown => ({ showErrorModal: jest.fn() }),
|
||||
}));
|
||||
// The derivation has its own suite (useDashboardEditContext.authz); these cases are
|
||||
// about what the static body does with a given edit context, so control it directly.
|
||||
let editContext = { isEditable: true, editChecks: [], editDisabledTooltip: '' };
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): typeof editContext => editContext,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockUseSave = usePanelEditorSave as jest.Mock;
|
||||
|
||||
// The draft deliberately carries a stray query: Save must strip it — the API
|
||||
// rejects anything but [] for a static kind.
|
||||
const draft = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Runbook' },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text: '# hi' } },
|
||||
queries: [{ spec: {} }],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const draftApi: PanelEditorDraftApi = {
|
||||
draft,
|
||||
spec: draft.spec,
|
||||
setSpec: jest.fn(),
|
||||
isSpecDirty: false,
|
||||
reset: jest.fn(),
|
||||
};
|
||||
|
||||
const definition = {
|
||||
kind: 'signoz/TextPanel',
|
||||
displayName: 'Text',
|
||||
sections: [],
|
||||
actions: {},
|
||||
mode: 'static',
|
||||
Renderer: (): null => null,
|
||||
EditorPane: (): JSX.Element => <div data-testid="editor-pane" />,
|
||||
} as unknown as RenderableStaticPanelDefinition;
|
||||
|
||||
function renderBody(): void {
|
||||
render(
|
||||
<StaticEditorBody
|
||||
dashboardId="d1"
|
||||
panelId="p1"
|
||||
panel={draft}
|
||||
onClose={jest.fn()}
|
||||
onSaved={jest.fn()}
|
||||
draftApi={draftApi}
|
||||
panelDefinition={definition}
|
||||
onChangePanelKind={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('StaticEditorBody', () => {
|
||||
beforeEach(() => {
|
||||
mockUseSave.mockReset();
|
||||
editContext = { isEditable: true, editChecks: [], editDisabledTooltip: '' };
|
||||
mockUseSave.mockReturnValue({
|
||||
save: jest.fn().mockResolvedValue('p1'),
|
||||
isSaving: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the editor pane and the live preview, no query builder', () => {
|
||||
renderBody();
|
||||
|
||||
expect(screen.getByTestId('editor-pane')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('static-preview-body')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-query-builder'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves the spec with queries forced to [] — the only shape the API accepts', async () => {
|
||||
const save = jest.fn().mockResolvedValue('p1');
|
||||
mockUseSave.mockReturnValue({ save, isSaving: false });
|
||||
renderBody();
|
||||
|
||||
fireEvent.click(screen.getByTestId('header-save'));
|
||||
|
||||
await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
||||
expect(save).toHaveBeenCalledWith({ ...draft.spec, queries: [] });
|
||||
});
|
||||
|
||||
it('does not save when the dashboard is not editable', () => {
|
||||
const save = jest.fn();
|
||||
mockUseSave.mockReturnValue({ save, isSaving: false });
|
||||
editContext = {
|
||||
isEditable: false,
|
||||
editChecks: [],
|
||||
editDisabledTooltip: 'Dashboard is locked',
|
||||
};
|
||||
renderBody();
|
||||
|
||||
fireEvent.click(screen.getByTestId('header-save'));
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -115,3 +115,15 @@ describe('newPanelRoute', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseNewPanelKind — kinds without a legacy panel type', () => {
|
||||
it('accepts a registered static kind', () => {
|
||||
expect(parseNewPanelKind('new', '?panelKind=signoz%2FTextPanel')).toBe(
|
||||
'signoz/TextPanel',
|
||||
);
|
||||
});
|
||||
|
||||
it('still rejects a kind that is not registered', () => {
|
||||
expect(parseNewPanelKind('new', '?panelKind=signoz%2FNopePanel')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
usePanelQuery,
|
||||
type PanelQueryTimeOverride,
|
||||
@@ -90,7 +90,7 @@ export function usePanelEditSession({
|
||||
// Hosts fork on `definition.mode` before mounting this session (the editor and
|
||||
// View modal shells) — asserted rather than assumed.
|
||||
const panelDefinition = requireQueryPanelDefinition(panelKind);
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
const panelType = toPanelType(panelKind);
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
|
||||
const query = usePanelQuery({
|
||||
|
||||
@@ -19,10 +19,7 @@ import type {
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../../Panels/types/panelKind';
|
||||
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
|
||||
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
import {
|
||||
@@ -110,7 +107,7 @@ export function usePanelTypeSwitch({
|
||||
builderQuery: query,
|
||||
});
|
||||
|
||||
const newPanelType = PANEL_KIND_TO_PANEL_TYPE[newKind];
|
||||
const newPanelType = toPanelType(newKind);
|
||||
|
||||
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
|
||||
// dynamically-chosen kind can't be correlated with its spec statically (as in
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
import QueryEditorBody from './QueryEditorBody';
|
||||
import StaticEditorBody from './StaticEditorBody';
|
||||
@@ -41,7 +41,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
|
||||
panelType: toPanelType(panelKind),
|
||||
setSpec: draftApi.setSpec,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { PANELS } from '../Panels/registry';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
PANEL_TYPE_TO_PANEL_KIND,
|
||||
type PanelKind,
|
||||
} from '../Panels/types/panelKind';
|
||||
@@ -40,7 +40,9 @@ export function parseNewPanelKind(
|
||||
return null;
|
||||
}
|
||||
const kind = new URLSearchParams(search).get(PANEL_KIND_PARAM);
|
||||
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
|
||||
// Gated on the registry, not the legacy map — a static kind has no legacy
|
||||
// panel type, and the map would reject its route as a stale link.
|
||||
return kind && kind in PANELS ? (kind as PanelKind) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,8 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
|
||||
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/ListPanel': [QUERY_BUILDER],
|
||||
// Static kind: no query surface at all.
|
||||
'signoz/TextPanel': [],
|
||||
};
|
||||
|
||||
const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
|
||||
@@ -45,11 +47,16 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
|
||||
'signoz/TablePanel': [metrics, logs, traces],
|
||||
// List renders raw rows; metrics produce no row data.
|
||||
'signoz/ListPanel': [logs, traces],
|
||||
'signoz/TextPanel': [],
|
||||
};
|
||||
|
||||
// Exhaustive over PanelKind, so a new kind can't ship without stating how its request is
|
||||
// shaped — the check that used to be implicit in a legacy PANEL_TYPES switch.
|
||||
const EXPECTED_QUERY_CAPABILITIES: Record<PanelKind, PanelQueryCapabilities> = {
|
||||
// Partial: a static kind declares no query capabilities — the lookup below
|
||||
// resolves undefined on both sides for it.
|
||||
const EXPECTED_QUERY_CAPABILITIES: Partial<
|
||||
Record<PanelKind, PanelQueryCapabilities>
|
||||
> = {
|
||||
'signoz/TimeSeriesPanel': {
|
||||
requestType: time_series,
|
||||
formatTableResultForUI: false,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
selectViewPanelExtendWindow,
|
||||
useViewPanelStore,
|
||||
} from '../../../store/useViewPanelStore';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../../types/panelKind';
|
||||
import { toPanelType } from '../../types/panelKind';
|
||||
import PanelLoader from '../PanelLoader/PanelLoader';
|
||||
import PanelMessage, { PanelMessageAction } from '../PanelMessage/PanelMessage';
|
||||
import { useExtendTimeWindow } from './useExtendTimeWindow';
|
||||
@@ -57,7 +57,7 @@ function NoData({
|
||||
// `panelType` stays on the event so existing reports keep resolving; `panelKind` is the
|
||||
// V2 identity, and the only one that can tell two kinds sharing a panel type apart.
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
const panelType = toPanelType(panelKind);
|
||||
|
||||
const extendAction: PanelMessageAction | undefined =
|
||||
activeExtend?.canExtend && activeExtend.actionLabel
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { rgbaFromHex } from '../../kinds/TextPanel/background/contrast';
|
||||
import {
|
||||
INK_ALPHAS,
|
||||
SECONDARY_INK_OPACITY,
|
||||
TEXT_BACKGROUND_PAIRS,
|
||||
TRANSPARENT_BACKGROUND,
|
||||
} from '../../kinds/TextPanel/background/presets';
|
||||
import { useTextBackground } from '../useTextBackground';
|
||||
|
||||
const isDarkMode = jest.fn<boolean, []>(() => true);
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => isDarkMode(),
|
||||
}));
|
||||
|
||||
function textPanel(background?: string): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Panel' },
|
||||
plugin: {
|
||||
kind: 'signoz/TextPanel',
|
||||
spec: { text: '', presentation: { background } },
|
||||
},
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
describe('useTextBackground', () => {
|
||||
beforeEach(() => {
|
||||
isDarkMode.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('sets no custom properties for the default surface', () => {
|
||||
const { result } = renderHook(() => useTextBackground(textPanel()));
|
||||
|
||||
expect(result.current).toStrictEqual({ kind: 'default', style: {} });
|
||||
});
|
||||
|
||||
// The card, its border and the header's divider all read these.
|
||||
it('paints a zero-alpha background transparent rather than dropping the card', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBackground(textPanel(TRANSPARENT_BACKGROUND)),
|
||||
);
|
||||
|
||||
expect(result.current).toStrictEqual({
|
||||
kind: 'none',
|
||||
style: {
|
||||
'--text-panel-surface': 'transparent',
|
||||
'--text-panel-border': 'transparent',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes the preset pair for the current theme', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBackground(textPanel(TEXT_BACKGROUND_PAIRS.amber.dark.surface)),
|
||||
);
|
||||
|
||||
const { ink } = TEXT_BACKGROUND_PAIRS.amber.dark;
|
||||
|
||||
expect(result.current.style).toStrictEqual({
|
||||
'--text-panel-surface': TEXT_BACKGROUND_PAIRS.amber.dark.surface,
|
||||
'--text-panel-ink': ink,
|
||||
'--text-panel-border': 'rgba(255, 255, 255, 0.09)',
|
||||
'--text-panel-link-decoration': 'underline',
|
||||
'--text-panel-ink-secondary': rgbaFromHex(ink, SECONDARY_INK_OPACITY),
|
||||
'--text-panel-grip': rgbaFromHex(ink, INK_ALPHAS['--text-panel-grip']),
|
||||
'--scrollbar-thumb': rgbaFromHex(ink, INK_ALPHAS['--scrollbar-thumb']),
|
||||
'--scrollbar-thumb-hover': rgbaFromHex(
|
||||
ink,
|
||||
INK_ALPHAS['--scrollbar-thumb-hover'],
|
||||
),
|
||||
'--text-panel-pill-surface': rgbaFromHex(
|
||||
ink,
|
||||
INK_ALPHAS['--text-panel-pill-surface'],
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('draws the surface chrome from the ink', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBackground(textPanel(TEXT_BACKGROUND_PAIRS.amber.light.surface)),
|
||||
);
|
||||
|
||||
Object.keys(INK_ALPHAS).forEach((name) => {
|
||||
expect(result.current.style).toHaveProperty(name);
|
||||
});
|
||||
});
|
||||
|
||||
// Stored light, read in dark: the ink is the dark pair's.
|
||||
it('carries the secondary ink and the link underline', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBackground(textPanel(TEXT_BACKGROUND_PAIRS.sakura.light.surface)),
|
||||
);
|
||||
|
||||
expect(result.current.style).toMatchObject({
|
||||
'--text-panel-ink-secondary': `rgba(253, 232, 242, ${SECONDARY_INK_OPACITY})`,
|
||||
'--text-panel-link-decoration': 'underline',
|
||||
});
|
||||
});
|
||||
|
||||
it('re-resolves a stored surface when the theme changes', () => {
|
||||
const spec = textPanel(TEXT_BACKGROUND_PAIRS.forest.dark.surface);
|
||||
const { result, rerender } = renderHook(() => useTextBackground(spec));
|
||||
|
||||
expect(result.current.style).toMatchObject({
|
||||
'--text-panel-surface': TEXT_BACKGROUND_PAIRS.forest.dark.surface,
|
||||
});
|
||||
|
||||
isDarkMode.mockReturnValue(false);
|
||||
rerender();
|
||||
|
||||
expect(result.current.style).toMatchObject({
|
||||
'--text-panel-surface': TEXT_BACKGROUND_PAIRS.forest.light.surface,
|
||||
'--text-panel-ink': TEXT_BACKGROUND_PAIRS.forest.light.ink,
|
||||
'--text-panel-border': 'rgba(0, 0, 0, 0.07)',
|
||||
});
|
||||
});
|
||||
|
||||
it('paints a custom colour the same in both themes', () => {
|
||||
const spec = textPanel('#3A2A64');
|
||||
const { result, rerender } = renderHook(() => useTextBackground(spec));
|
||||
const inDark = result.current.style;
|
||||
|
||||
isDarkMode.mockReturnValue(false);
|
||||
rerender();
|
||||
|
||||
expect(result.current.style).toMatchObject({
|
||||
'--text-panel-surface': '#3A2A64',
|
||||
'--text-panel-ink': inDark['--text-panel-ink' as keyof typeof inDark],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a kind without a presentation slice alone', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBackground({
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO),
|
||||
);
|
||||
|
||||
expect(result.current).toStrictEqual({ kind: 'default', style: {} });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useUpdatePanelText } from '../useUpdatePanelText';
|
||||
|
||||
const patchAsync = jest.fn<Promise<unknown>, [unknown]>(() =>
|
||||
Promise.resolve(undefined),
|
||||
);
|
||||
const showErrorModal = jest.fn();
|
||||
let store = { dashboardId: 'dash-1' };
|
||||
let editContext = { isEditable: true };
|
||||
|
||||
jest.mock('../../../hooks/useOptimisticPatch', () => ({
|
||||
useOptimisticPatch: (): unknown => ({ patchAsync }),
|
||||
}));
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
useErrorModal: (): unknown => ({ showErrorModal }),
|
||||
}));
|
||||
jest.mock('../../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (select: (s: typeof store) => unknown): unknown =>
|
||||
select(store),
|
||||
}));
|
||||
jest.mock('../../../hooks/useDashboardEditContext', () => ({
|
||||
useDashboardEditContext: (): typeof editContext => editContext,
|
||||
}));
|
||||
|
||||
describe('useUpdatePanelText', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
store = { dashboardId: 'dash-1' };
|
||||
editContext = { isEditable: true };
|
||||
});
|
||||
|
||||
it('patches the panel body', () => {
|
||||
const { result } = renderHook(() => useUpdatePanelText('p1'));
|
||||
|
||||
result.current?.('- [x] done');
|
||||
|
||||
expect(patchAsync).toHaveBeenCalledWith([
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/p1/spec/plugin/spec/text',
|
||||
value: '- [x] done',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives no callback when the viewer cannot edit', () => {
|
||||
editContext = { isEditable: false };
|
||||
|
||||
const { result } = renderHook(() => useUpdatePanelText('p1'));
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives no callback outside a dashboard', () => {
|
||||
store = { dashboardId: '' };
|
||||
|
||||
const { result } = renderHook(() => useUpdatePanelText('p1'));
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces a failed save', async () => {
|
||||
const failure = new Error('locked');
|
||||
patchAsync.mockRejectedValueOnce(failure);
|
||||
const { result } = renderHook(() => useUpdatePanelText('p1'));
|
||||
|
||||
result.current?.('- [x] done');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(showErrorModal).toHaveBeenCalledWith(failure);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
// Within this distance of the end counts as "at the bottom", so the pill isn't
|
||||
// kept alive by sub-pixel rounding or a trailing margin.
|
||||
const BOTTOM_EPSILON_PX = 16;
|
||||
|
||||
interface UseOverflowBelowResult<T extends HTMLElement> {
|
||||
scrollRef: RefObject<T>;
|
||||
/** Content extends below the fold and the user isn't at the bottom yet. */
|
||||
hasMoreBelow: boolean;
|
||||
scrollToBottom: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether a scroll container has unseen content below the fold. Re-measures
|
||||
* on scroll, on container resize, and on every commit — the cheap way to follow
|
||||
* content growth (a live preview re-rendering as the body is typed) without
|
||||
* observing the subtree.
|
||||
*/
|
||||
export function useOverflowBelow<
|
||||
T extends HTMLElement,
|
||||
>(): UseOverflowBelowResult<T> {
|
||||
const scrollRef = useRef<T>(null);
|
||||
const [hasMoreBelow, setHasMoreBelow] = useState(false);
|
||||
|
||||
const measure = useCallback((): void => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const remaining = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
setHasMoreBelow(remaining > BOTTOM_EPSILON_PX);
|
||||
}, []);
|
||||
|
||||
// No deps on purpose: runs after every commit. setState bails on unchanged
|
||||
// values, so this settles instead of looping.
|
||||
useEffect(() => {
|
||||
measure();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) {
|
||||
return undefined;
|
||||
}
|
||||
el.addEventListener('scroll', measure, { passive: true });
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return (): void => {
|
||||
el.removeEventListener('scroll', measure);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [measure]);
|
||||
|
||||
const scrollToBottom = useCallback((): void => {
|
||||
const el = scrollRef.current;
|
||||
el?.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
return { scrollRef, hasMoreBelow, scrollToBottom };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isLanguageRegistered, loadLanguage } from './syntaxLanguages';
|
||||
import { isLanguageRegistered, loadLanguage } from '../utils/syntaxLanguages';
|
||||
|
||||
/**
|
||||
* Registers `language` with Prism on demand, reporting when it is ready to
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { rgbaFromHex } from '../kinds/TextPanel/background/contrast';
|
||||
import {
|
||||
INK_ALPHAS,
|
||||
PRESET_BORDER,
|
||||
} from '../kinds/TextPanel/background/presets';
|
||||
import { resolveTextBackground } from '../kinds/TextPanel/background/resolveTextBackground';
|
||||
import {
|
||||
PanelTheme,
|
||||
TextBackgroundKind,
|
||||
} from '../kinds/TextPanel/background/types';
|
||||
|
||||
export interface TextBackground {
|
||||
kind: TextBackgroundKind;
|
||||
/**
|
||||
* Custom properties for the card root. Empty for `default`, so the stylesheet's
|
||||
* own fallbacks decide — nothing here hardcodes a surface.
|
||||
*/
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const NO_STYLE: CSSProperties = {};
|
||||
|
||||
// D7: `None` drops the card so the body sits on the dashboard canvas. It rides the
|
||||
// same properties as a colour, which takes the header's divider with it.
|
||||
const CARDLESS_STYLE = {
|
||||
'--text-panel-surface': 'transparent',
|
||||
'--text-panel-border': 'transparent',
|
||||
} as CSSProperties;
|
||||
|
||||
function inkShares(ink: string): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(INK_ALPHAS).map(([name, alpha]) => [
|
||||
name,
|
||||
rgbaFromHex(ink, alpha) ?? ink,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The card is an ancestor of the renderer, so the host owns these properties and
|
||||
* everything below inherits them.
|
||||
*
|
||||
* Reading one plugin-spec field off the kind union is the accepted smell (TDD
|
||||
* D7): a dynamic kind can't narrow it, hence one localized cast per host.
|
||||
*/
|
||||
export function useTextBackground(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
): TextBackground {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const background = (
|
||||
spec.plugin.spec as {
|
||||
presentation?: { background?: string | null };
|
||||
}
|
||||
).presentation?.background;
|
||||
|
||||
return useMemo(() => {
|
||||
const theme = isDarkMode ? PanelTheme.Dark : PanelTheme.Light;
|
||||
const resolved = resolveTextBackground(background, theme);
|
||||
|
||||
if (resolved.kind === TextBackgroundKind.None) {
|
||||
return { kind: resolved.kind, style: CARDLESS_STYLE };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: resolved.kind,
|
||||
style:
|
||||
resolved.surface && resolved.ink
|
||||
? ({
|
||||
'--text-panel-surface': resolved.surface,
|
||||
'--text-panel-ink': resolved.ink,
|
||||
'--text-panel-border': PRESET_BORDER[theme],
|
||||
'--text-panel-link-decoration': 'underline',
|
||||
...inkShares(resolved.ink),
|
||||
} as CSSProperties)
|
||||
: NO_STYLE,
|
||||
};
|
||||
}, [background, isDarkMode]);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import type APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import { setPanelTextOp } from '../../patchOps';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* Saves a panel's authored body, or `undefined` when the viewer cannot edit it —
|
||||
* the absent callback is the read-only gate, so nothing downstream re-checks.
|
||||
* The patch is optimistic: the edit shows at once and rolls back if it fails.
|
||||
*/
|
||||
export function useUpdatePanelText(
|
||||
panelId: string,
|
||||
): ((text: string) => void) | undefined {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const { isEditable } = useDashboardEditContext();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const save = useCallback(
|
||||
(text: string): void => {
|
||||
patchAsync([setPanelTextOp(panelId, text)]).catch((error) => {
|
||||
showErrorModal(error as APIError);
|
||||
});
|
||||
},
|
||||
[panelId, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return dashboardId && isEditable ? save : undefined;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 8px 12px;
|
||||
overflow: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
// Horizontal alignment inherits into the rendered body, which deliberately leaves
|
||||
// `text-align` alone so the panel can own it.
|
||||
.alignLeft {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.alignCenter {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alignRight {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// `text-align` moves only inline content: the table is a block box inside its
|
||||
// scroll wrapper and stays put, and list markers hang at the list's left edge.
|
||||
// (0,2,1) beats the body reset at (0,2,0); `list-style-position` goes on the
|
||||
// `li` directly because the body's `list-style` shorthand on `ul`/`ol` resets
|
||||
// the inherited position.
|
||||
.panel.alignRight table {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.panel.alignCenter table {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.panel.alignRight li,
|
||||
.panel.alignCenter li {
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.alignTop {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
// Auto margins, not `justify-content`: when the body overflows, an auto margin
|
||||
// resolves to zero so the content's top stays scrollable — `center`/`flex-end`
|
||||
// push the overflow above the scrollport, where no scroll position reaches it.
|
||||
// Specificity (0,3,0): the body root's `all: revert` reset sits at (0,2,0) and
|
||||
// would strip a tied margin rule.
|
||||
.panel.alignMiddle > *:first-child {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.panel.alignBottom > *:first-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
// Positioning context for the scroll-to-bottom pill floating over the body.
|
||||
.host {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Pencil } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
DashboardtypesTextAlignDTO,
|
||||
DashboardtypesVerticalAlignDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
|
||||
import PanelMessage from '../../components/PanelMessage/PanelMessage';
|
||||
import type { StaticRendererProps } from '../../types/rendererProps';
|
||||
import { interpolateVariables } from '../../utils/interpolateVariables';
|
||||
import MarkdownContent from './components/MarkdownContent/MarkdownContent';
|
||||
import ScrollToBottomPill from './components/ScrollToBottomPill/ScrollToBottomPill';
|
||||
import { useOverflowBelow } from '../../hooks/useOverflowBelow';
|
||||
|
||||
import styles from './Renderer.module.scss';
|
||||
|
||||
const HORIZONTAL_ALIGN_CLASS: Record<DashboardtypesTextAlignDTO, string> = {
|
||||
[DashboardtypesTextAlignDTO.left]: styles.alignLeft,
|
||||
[DashboardtypesTextAlignDTO.center]: styles.alignCenter,
|
||||
[DashboardtypesTextAlignDTO.right]: styles.alignRight,
|
||||
};
|
||||
|
||||
// Static, so it is not rebuilt on every variable tick.
|
||||
const EMPTY_STATE = (
|
||||
<PanelMessage
|
||||
icon={<Pencil size={18} />}
|
||||
title="Nothing written yet"
|
||||
description="Add Markdown to this panel to show content."
|
||||
data-testid="text-panel-empty"
|
||||
/>
|
||||
);
|
||||
|
||||
const VERTICAL_ALIGN_CLASS: Record<DashboardtypesVerticalAlignDTO, string> = {
|
||||
[DashboardtypesVerticalAlignDTO.top]: styles.alignTop,
|
||||
[DashboardtypesVerticalAlignDTO.center]: styles.alignMiddle,
|
||||
[DashboardtypesVerticalAlignDTO.bottom]: styles.alignBottom,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the panel's own Markdown body. The first kind that issues no query, so it
|
||||
* reads nothing from `data` and has no loading or error state — malformed Markdown
|
||||
* renders as literal text rather than throwing.
|
||||
*/
|
||||
function Renderer({
|
||||
panel,
|
||||
dashboardId,
|
||||
onChangeText,
|
||||
}: StaticRendererProps<'signoz/TextPanel'>): JSX.Element {
|
||||
const { text, presentation } = panel.spec.plugin.spec;
|
||||
|
||||
const variables = useDashboardStore(
|
||||
selectResolvedVariables(dashboardId ?? ''),
|
||||
);
|
||||
|
||||
// Interpolate and parse together: a dashboard re-renders on every variable tick,
|
||||
// and re-parsing every text panel on each one is the cost worth avoiding.
|
||||
const body = useMemo(
|
||||
() => interpolateVariables(text ?? '', variables),
|
||||
[text, variables],
|
||||
);
|
||||
|
||||
// The authored body, not the interpolated one: an edit lands on what is saved.
|
||||
const interactive = useMemo(
|
||||
() =>
|
||||
onChangeText
|
||||
? { source: text ?? '', onChangeSource: onChangeText }
|
||||
: undefined,
|
||||
[onChangeText, text],
|
||||
);
|
||||
|
||||
const { scrollRef, hasMoreBelow, scrollToBottom } =
|
||||
useOverflowBelow<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div className={styles.host}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cx(
|
||||
styles.panel,
|
||||
HORIZONTAL_ALIGN_CLASS[
|
||||
presentation?.textAlign ?? DashboardtypesTextAlignDTO.left
|
||||
],
|
||||
VERTICAL_ALIGN_CLASS[
|
||||
presentation?.verticalAlign ?? DashboardtypesVerticalAlignDTO.top
|
||||
],
|
||||
)}
|
||||
data-testid="text-panel"
|
||||
>
|
||||
<MarkdownContent interactive={interactive} emptyState={EMPTY_STATE}>
|
||||
{body}
|
||||
</MarkdownContent>
|
||||
</div>
|
||||
{hasMoreBelow && <ScrollToBottomPill onClick={scrollToBottom} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Renderer;
|
||||
@@ -1,7 +1,8 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import MarkdownContent from '../MarkdownContent';
|
||||
import { loadLanguage } from '../syntaxLanguages';
|
||||
import MarkdownContent from '../components/MarkdownContent/MarkdownContent';
|
||||
import { loadLanguage } from '../../../utils/syntaxLanguages';
|
||||
|
||||
describe('MarkdownContent', () => {
|
||||
describe('security', () => {
|
||||
@@ -234,6 +235,23 @@ describe('MarkdownContent — interactive task lists', () => {
|
||||
expect(second).toBeChecked();
|
||||
});
|
||||
|
||||
it('warns on hover that a tick edits the panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MarkdownContent interactive={{ source, onChangeSource: jest.fn() }}>
|
||||
{source}
|
||||
</MarkdownContent>,
|
||||
);
|
||||
|
||||
await user.hover(screen.getAllByRole('checkbox')[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
'Toggling this updates the panel spec',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('checking one rewrites its marker in the source', () => {
|
||||
const onChangeSource = jest.fn();
|
||||
render(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
|
||||
import Renderer from '../Renderer';
|
||||
|
||||
function textPanel(text?: string): PanelOfKind<'signoz/TextPanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Runbook' },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text } },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as PanelOfKind<'signoz/TextPanel'>;
|
||||
}
|
||||
|
||||
function renderPanel(text?: string): void {
|
||||
render(
|
||||
<Renderer
|
||||
panelId="p1"
|
||||
panel={textPanel(text)}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Text panel empty state', () => {
|
||||
it.each([undefined, '', ' \n\t'])('stands in for a body of %j', (text) => {
|
||||
renderPanel(text);
|
||||
|
||||
expect(screen.getByTestId('text-panel-empty')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nothing written yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives way to the body once there is one', () => {
|
||||
renderPanel('# Runbook');
|
||||
|
||||
expect(screen.queryByTestId('text-panel-empty')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Runbook' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// An undefined variable renders literally, as queries treat one, so the body
|
||||
// is not empty and the panel shows it rather than the empty state.
|
||||
it('does not stand in for an unresolved variable', () => {
|
||||
renderPanel('$missing');
|
||||
|
||||
expect(screen.queryByTestId('text-panel-empty')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('$missing')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
|
||||
import Renderer from '../Renderer';
|
||||
|
||||
const panel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Runbook' },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text: '# hello' } },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as PanelOfKind<'signoz/TextPanel'>;
|
||||
|
||||
/** jsdom has no layout: stub the scroll geometry the hook reads. */
|
||||
function setScrollGeometry(
|
||||
el: HTMLElement,
|
||||
{ scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number },
|
||||
): void {
|
||||
Object.defineProperty(el, 'scrollHeight', {
|
||||
configurable: true,
|
||||
value: scrollHeight,
|
||||
});
|
||||
Object.defineProperty(el, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: clientHeight,
|
||||
});
|
||||
}
|
||||
|
||||
function renderPanel(): HTMLElement {
|
||||
render(
|
||||
<Renderer panelId="p1" panel={panel} panelMode={PanelMode.DASHBOARD_VIEW} />,
|
||||
);
|
||||
return screen.getByTestId('text-panel');
|
||||
}
|
||||
|
||||
describe('Text panel scroll-to-bottom pill', () => {
|
||||
it('is absent when the body fits', () => {
|
||||
const scroller = renderPanel();
|
||||
setScrollGeometry(scroller, { scrollHeight: 100, clientHeight: 100 });
|
||||
fireEvent.scroll(scroller);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('text-panel-scroll-more'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears when content extends below the fold and jumps to the end on click', () => {
|
||||
const scroller = renderPanel();
|
||||
setScrollGeometry(scroller, { scrollHeight: 400, clientHeight: 100 });
|
||||
act(() => {
|
||||
fireEvent.scroll(scroller);
|
||||
});
|
||||
|
||||
const pill = screen.getByTestId('text-panel-scroll-more');
|
||||
const scrollTo = jest.fn();
|
||||
scroller.scrollTo = scrollTo;
|
||||
fireEvent.click(pill);
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 400, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
it('hides once the user reaches the bottom', () => {
|
||||
const scroller = renderPanel();
|
||||
setScrollGeometry(scroller, { scrollHeight: 400, clientHeight: 100 });
|
||||
act(() => {
|
||||
fireEvent.scroll(scroller);
|
||||
});
|
||||
expect(screen.getByTestId('text-panel-scroll-more')).toBeInTheDocument();
|
||||
|
||||
scroller.scrollTop = 300;
|
||||
act(() => {
|
||||
fireEvent.scroll(scroller);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('text-panel-scroll-more'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
|
||||
import Renderer from '../Renderer';
|
||||
|
||||
const SOURCE = ['- [ ] first', '- [x] second'].join('\n');
|
||||
|
||||
function textPanel(text: string): PanelOfKind<'signoz/TextPanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Runbook' },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text } },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as PanelOfKind<'signoz/TextPanel'>;
|
||||
}
|
||||
|
||||
describe('Text panel task lists', () => {
|
||||
it('renders them read-only without a write channel', () => {
|
||||
render(
|
||||
<Renderer
|
||||
panelId="p1"
|
||||
panel={textPanel(SOURCE)}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getAllByRole('checkbox').forEach((box) => expect(box).toBeDisabled());
|
||||
});
|
||||
|
||||
it('reports the rewritten body when a host can save it', () => {
|
||||
const onChangeText = jest.fn();
|
||||
render(
|
||||
<Renderer
|
||||
panelId="p1"
|
||||
panel={textPanel(SOURCE)}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
onChangeText={onChangeText}
|
||||
/>,
|
||||
{ wrapper: TooltipProvider },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('checkbox')[0]);
|
||||
|
||||
expect(onChangeText).toHaveBeenCalledWith(
|
||||
['- [x] first', '- [x] second'].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('edits the authored body, not the interpolated one', () => {
|
||||
const onChangeText = jest.fn();
|
||||
render(
|
||||
<Renderer
|
||||
panelId="p1"
|
||||
panel={textPanel('- [ ] deploy $service')}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
onChangeText={onChangeText}
|
||||
/>,
|
||||
{ wrapper: TooltipProvider },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(onChangeText).toHaveBeenCalledWith('- [x] deploy $service');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
contrastRatio,
|
||||
inkForSurface,
|
||||
meetsContrast,
|
||||
MIN_CONTRAST_RATIO,
|
||||
normalizeHex,
|
||||
parseHex,
|
||||
rgbaFromHex,
|
||||
} from '../contrast';
|
||||
import {
|
||||
CUSTOM_INK,
|
||||
TEXT_BACKGROUND_PAIRS,
|
||||
TEXT_BACKGROUND_PRESETS,
|
||||
TRANSPARENT_BACKGROUND,
|
||||
} from '../presets';
|
||||
import {
|
||||
presetSurface,
|
||||
resolveTextBackground,
|
||||
selectionFromResolved,
|
||||
storedFromSelection,
|
||||
toStoredBackground,
|
||||
} from '../resolveTextBackground';
|
||||
import type { ResolvedTextBackground } from '../types';
|
||||
import { PanelTheme, TextBackgroundKind, TextBackgroundPreset } from '../types';
|
||||
|
||||
const THEMES: PanelTheme[] = Object.values(PanelTheme);
|
||||
|
||||
describe('preset tokens', () => {
|
||||
const pairs = TEXT_BACKGROUND_PRESETS.flatMap((preset) =>
|
||||
THEMES.map((theme) => ({
|
||||
preset,
|
||||
theme,
|
||||
...TEXT_BACKGROUND_PAIRS[preset][theme],
|
||||
})),
|
||||
);
|
||||
|
||||
it('covers all eight presets in both themes', () => {
|
||||
expect(pairs).toHaveLength(16);
|
||||
});
|
||||
|
||||
it.each(pairs)(
|
||||
'$preset/$theme clears the contrast floor',
|
||||
({ surface, ink }) => {
|
||||
expect(contrastRatio(ink, surface)).toBeGreaterThanOrEqual(
|
||||
MIN_CONTRAST_RATIO,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// A repeated surface would make the hex → preset lookup ambiguous.
|
||||
it('keeps all sixteen surfaces distinct', () => {
|
||||
const surfaces = pairs.map(({ surface }) => surface.toUpperCase());
|
||||
expect(new Set(surfaces).size).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseHex', () => {
|
||||
it('expands shorthand digits', () => {
|
||||
expect(parseHex('#abc')).toStrictEqual({ r: 170, g: 187, b: 204, a: 1 });
|
||||
});
|
||||
|
||||
it('reads the alpha channel from the four- and eight-digit forms', () => {
|
||||
expect(parseHex('#0000')?.a).toBe(0);
|
||||
expect(parseHex('#00000000')?.a).toBe(0);
|
||||
expect(parseHex('#aabbccff')?.a).toBe(1);
|
||||
});
|
||||
|
||||
it('treats a form without an alpha channel as opaque', () => {
|
||||
expect(parseHex('#aabbcc')?.a).toBe(1);
|
||||
});
|
||||
|
||||
it.each(['', 'aabbcc', 'red', '#abcde', '#gggggg'])('rejects %s', (color) => {
|
||||
expect(parseHex(color)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalises to the uppercase six-digit form', () => {
|
||||
expect(normalizeHex('#dce4ff')).toBe('#DCE4FF');
|
||||
expect(normalizeHex('#abc')).toBe('#AABBCC');
|
||||
expect(normalizeHex('#dce4ffcc')).toBe('#DCE4FF');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rgbaFromHex', () => {
|
||||
it('takes a share of the colour', () => {
|
||||
expect(rgbaFromHex('#DCE4FF', 0.82)).toBe('rgba(220, 228, 255, 0.82)');
|
||||
});
|
||||
|
||||
it('expands shorthand and ignores the source alpha', () => {
|
||||
expect(rgbaFromHex('#abc', 1)).toBe('rgba(170, 187, 204, 1)');
|
||||
expect(rgbaFromHex('#aabbcc00', 0.5)).toBe('rgba(170, 187, 204, 0.5)');
|
||||
});
|
||||
|
||||
it('returns nothing for a colour it cannot read', () => {
|
||||
expect(rgbaFromHex('red', 0.82)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inkForSurface', () => {
|
||||
it('puts light ink on a dark surface and dark ink on a light one', () => {
|
||||
expect(inkForSurface('#101010')).toBe(CUSTOM_INK.light);
|
||||
expect(inkForSurface('#F5F5F5')).toBe(CUSTOM_INK.dark);
|
||||
});
|
||||
|
||||
it('reports a mid surface as short of the floor without failing', () => {
|
||||
const surface = '#808080';
|
||||
expect(meetsContrast(surface, inkForSurface(surface))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTextBackground', () => {
|
||||
it.each([undefined, null, ''])('reads %s as the default surface', (stored) => {
|
||||
expect(resolveTextBackground(stored, PanelTheme.Dark)).toStrictEqual({
|
||||
kind: TextBackgroundKind.Default,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([TRANSPARENT_BACKGROUND, '#0000'])(
|
||||
'reads the zero-alpha colour %s as no card',
|
||||
(stored) => {
|
||||
expect(resolveTextBackground(stored, PanelTheme.Dark)).toStrictEqual({
|
||||
kind: TextBackgroundKind.None,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('resolves a surface stored in one theme to the pair of the other', () => {
|
||||
const storedInLight = presetSurface(
|
||||
TextBackgroundPreset.Amber,
|
||||
PanelTheme.Light,
|
||||
);
|
||||
|
||||
expect(resolveTextBackground(storedInLight, PanelTheme.Dark)).toStrictEqual({
|
||||
kind: TextBackgroundKind.Preset,
|
||||
preset: TextBackgroundPreset.Amber,
|
||||
...TEXT_BACKGROUND_PAIRS.amber.dark,
|
||||
});
|
||||
});
|
||||
|
||||
it('recognises a preset surface whatever its case', () => {
|
||||
const stored = presetSurface(
|
||||
TextBackgroundPreset.Forest,
|
||||
PanelTheme.Dark,
|
||||
).toLowerCase();
|
||||
|
||||
expect(resolveTextBackground(stored, PanelTheme.Light)).toMatchObject({
|
||||
kind: TextBackgroundKind.Preset,
|
||||
preset: TextBackgroundPreset.Forest,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a hex that is not a preset surface as a custom colour', () => {
|
||||
expect(resolveTextBackground('#3A2A64', PanelTheme.Dark)).toStrictEqual({
|
||||
kind: TextBackgroundKind.Custom,
|
||||
surface: '#3A2A64',
|
||||
ink: CUSTOM_INK.light,
|
||||
});
|
||||
});
|
||||
|
||||
it('holds a custom colour steady across a theme switch', () => {
|
||||
expect(resolveTextBackground('#3A2A64', PanelTheme.Light)).toStrictEqual(
|
||||
resolveTextBackground('#3A2A64', PanelTheme.Dark),
|
||||
);
|
||||
});
|
||||
|
||||
// The enum the API used to accept; neither value is a hex.
|
||||
it.each([
|
||||
['solid', TextBackgroundKind.Default],
|
||||
['transparent', TextBackgroundKind.None],
|
||||
])('migrates the legacy %s value to %s', (stored, kind) => {
|
||||
expect(resolveTextBackground(stored, PanelTheme.Dark)).toStrictEqual({
|
||||
kind,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default surface for an unreadable value', () => {
|
||||
expect(resolveTextBackground('rgb(1, 2, 3)', PanelTheme.Dark)).toStrictEqual({
|
||||
kind: TextBackgroundKind.Default,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor adapters', () => {
|
||||
it.each([
|
||||
[undefined, TextBackgroundKind.Default],
|
||||
[TRANSPARENT_BACKGROUND, TextBackgroundKind.None],
|
||||
['solid', TextBackgroundKind.Default],
|
||||
])('lights up the %s swatch', (stored, selection) => {
|
||||
expect(
|
||||
selectionFromResolved(resolveTextBackground(stored, PanelTheme.Dark)),
|
||||
).toBe(selection);
|
||||
});
|
||||
|
||||
it('lights up the preset a stored surface belongs to', () => {
|
||||
expect(
|
||||
selectionFromResolved(
|
||||
resolveTextBackground(
|
||||
presetSurface(TextBackgroundPreset.Slate, PanelTheme.Light),
|
||||
PanelTheme.Dark,
|
||||
),
|
||||
),
|
||||
).toBe(TextBackgroundPreset.Slate);
|
||||
});
|
||||
|
||||
it('lights up nothing for a custom colour', () => {
|
||||
expect(
|
||||
selectionFromResolved(resolveTextBackground('#3A2A64', PanelTheme.Dark)),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stores what each swatch means', () => {
|
||||
expect(storedFromSelection(TextBackgroundKind.None, PanelTheme.Dark)).toBe(
|
||||
TRANSPARENT_BACKGROUND,
|
||||
);
|
||||
expect(
|
||||
storedFromSelection(TextBackgroundKind.Default, PanelTheme.Dark),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
storedFromSelection(TextBackgroundPreset.Cherry, PanelTheme.Light),
|
||||
).toBe(presetSurface(TextBackgroundPreset.Cherry, PanelTheme.Light));
|
||||
});
|
||||
});
|
||||
|
||||
describe('round trip', () => {
|
||||
const cases: ResolvedTextBackground[] = [
|
||||
{ kind: TextBackgroundKind.None },
|
||||
{ kind: TextBackgroundKind.Default },
|
||||
{
|
||||
kind: TextBackgroundKind.Custom,
|
||||
surface: '#3A2A64',
|
||||
ink: CUSTOM_INK.light,
|
||||
},
|
||||
...TEXT_BACKGROUND_PRESETS.map((preset) => ({
|
||||
kind: TextBackgroundKind.Preset,
|
||||
preset,
|
||||
})),
|
||||
];
|
||||
|
||||
it.each(cases)(
|
||||
'preserves $kind $preset through a save and load',
|
||||
(resolved) => {
|
||||
THEMES.forEach((theme) => {
|
||||
const stored = toStoredBackground(resolved, theme);
|
||||
const reread = resolveTextBackground(stored, theme);
|
||||
|
||||
expect(reread.kind).toBe(resolved.kind);
|
||||
expect(reread.preset).toBe(resolved.preset);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
TRANSPARENT_BACKGROUND,
|
||||
'#3A2A64',
|
||||
'solid',
|
||||
'transparent',
|
||||
])('re-reading %s changes nothing', (stored) => {
|
||||
THEMES.forEach((theme) => {
|
||||
const once = resolveTextBackground(stored, theme);
|
||||
const twice = resolveTextBackground(toStoredBackground(once, theme), theme);
|
||||
|
||||
expect(twice).toStrictEqual(once);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { CUSTOM_INK } from './presets';
|
||||
|
||||
interface Channels {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
const HEX_PATTERN = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
|
||||
export function isHexColor(color: string): boolean {
|
||||
return HEX_PATTERN.test(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `#rgb`, `#rgba`, `#rrggbb` and `#rrggbbaa` — shorthand digits double,
|
||||
* and a form without an alpha channel is opaque. `undefined` for anything else.
|
||||
*/
|
||||
export function parseHex(color: string): Channels | undefined {
|
||||
if (!isHexColor(color)) {
|
||||
return undefined;
|
||||
}
|
||||
const hex = color.slice(1);
|
||||
const short = hex.length <= 4;
|
||||
const step = short ? 1 : 2;
|
||||
const channel = (index: number): number => {
|
||||
const digits = hex.slice(index * step, index * step + step);
|
||||
return parseInt(short ? digits + digits : digits, 16);
|
||||
};
|
||||
return {
|
||||
r: channel(0),
|
||||
g: channel(1),
|
||||
b: channel(2),
|
||||
a: hex.length === 4 || hex.length === 8 ? channel(3) / 255 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** The uppercase 6-digit form used as the preset lookup key. */
|
||||
export function normalizeHex(color: string): string | undefined {
|
||||
const channels = parseHex(color);
|
||||
if (!channels) {
|
||||
return undefined;
|
||||
}
|
||||
const pad = (value: number): string =>
|
||||
value.toString(16).padStart(2, '0').toUpperCase();
|
||||
return `#${pad(channels.r)}${pad(channels.g)}${pad(channels.b)}`;
|
||||
}
|
||||
|
||||
/** The colour at a given alpha; the source's own alpha is ignored. */
|
||||
export function rgbaFromHex(color: string, alpha: number): string | undefined {
|
||||
const channels = parseHex(color);
|
||||
if (!channels) {
|
||||
return undefined;
|
||||
}
|
||||
return `rgba(${channels.r}, ${channels.g}, ${channels.b}, ${alpha})`;
|
||||
}
|
||||
|
||||
/** WCAG 2.1 relative luminance; alpha is ignored. */
|
||||
export function relativeLuminance(color: string): number {
|
||||
const channels = parseHex(color);
|
||||
if (!channels) {
|
||||
return 0;
|
||||
}
|
||||
const linear = ([channels.r, channels.g, channels.b] as const).map((value) => {
|
||||
const srgb = value / 255;
|
||||
return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||
}
|
||||
|
||||
/** WCAG 2.1 contrast ratio, 1 to 21. */
|
||||
export function contrastRatio(foreground: string, background: string): number {
|
||||
const a = relativeLuminance(foreground);
|
||||
const b = relativeLuminance(background);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
export const MIN_CONTRAST_RATIO = 4.5;
|
||||
|
||||
/** Whichever fixed ink contrasts further, so a custom colour needs none of its own. */
|
||||
export function inkForSurface(surface: string): string {
|
||||
return contrastRatio(CUSTOM_INK.light, surface) >=
|
||||
contrastRatio(CUSTOM_INK.dark, surface)
|
||||
? CUSTOM_INK.light
|
||||
: CUSTOM_INK.dark;
|
||||
}
|
||||
|
||||
export function meetsContrast(surface: string, ink: string): boolean {
|
||||
return contrastRatio(ink, surface) >= MIN_CONTRAST_RATIO;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user