Compare commits

...

8 Commits

Author SHA1 Message Date
Nikhil Soni
47a9fce310 refactor(telemetrystore): drop app-side bulk-filtering override
Stop managing secondary_indices_enable_bulk_filtering from the app.
The ClickHouse#82283 workaround is fixed in 25.7+ (prod runs 25.12);
the setting is controlled server-side via ClickHouse profiles.

Assisted-by: Claude Opus 4.8
2026-09-15 19:24:11 +05:30
Nikhil Soni
130d168642 fix(telemetrystore): enable secondary_indices_enable_bulk_filtering
Wire the existing config field into the query hook instead of the
hardcoded override. The ClickHouse#82283 workaround is fixed in 25.7+
(prod runs 25.12), and keeping it a config knob lets it be flipped off
for older ClickHouse if ever needed.

Assisted-by: Claude Opus 4.8
2026-09-15 18:54:20 +05:30
Manika Malhotra
099e81202d chore(onboarding): update onboarding hint text (#12868)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Changes onboarding hint text

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/growth-pod/issues/1382 

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

Before:

<img width="671" height="745" alt="Screenshot 2026-09-15 at 17 01 43"
src="https://github.com/user-attachments/assets/29f1759f-42d0-4382-816b-a6c53f94808c"
/>


After:
<img width="691" height="751" alt="Screenshot 2026-09-15 at 17 00 33"
src="https://github.com/user-attachments/assets/d07ca372-b62d-497d-9c9e-24252c96ddcf"
/>


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->
2026-09-15 12:15:34 +00:00
Srikanth Chekuri
9c886be120 chore(querybuilder): compile every signal through one storage contract (#12802)
The semantic convention family as first call citizen revealed that the
current state of the query builder needs a bit refactoring for long term
maintenance.

The `FieldMapper` and `ConditionBuilder` are now one abstraction
`Storage`.

A storage now answers
- what the compiler cannot know i.e one read per field key (the bare
SQL, the membership present or absent, what an absent row reads, and
whether the read keeps its type or filters only).
- the fallback for a key metadata does not report
- its traits
- and one Condition compilation part.

And we introduce a new type to use in the system, `Resolved`

```
// Resolved is what resolution produces for one key: its meanings, and how
// they came to be. It is the only thing the compilers receive. Compile it
// with the operator and value it was resolved with.
type Resolved struct {
	Key    *telemetrytypes.TelemetryFieldKey
	Fields []*telemetrytypes.LogicalField
	// FromFallback: the fields came from the storage's fallback, not from
	// metadata matches.
	FromFallback bool
	// Ambiguous: the matches held several interpretations.
	Ambiguous bool
	// Skipped: the storage contributes nothing for this key.
	Skipped  bool
	Warnings []string
}
```

The prepared SQL has no changes, where it changed, it specifically made
the expression better by removing the redundant part.

- The prepared SQL remains identical with this refactoring
- No changes to integration tests


Assisted-by: Claude Fable 5.1
2026-09-15 11:36:06 +00:00
Abhi kumar
861e5f75cf fix(dashboards): restore all series when the isolated legend row is clicked (#12864)
#### Description

- Clicking the legend row of the only series still showing now restores
every series, so the isolating click undoes itself — hiding that last
series is refused, which left no way back to all from the row itself.
- With more than one series showing, the row click is unchanged: isolate
when everything is showing, hide/show otherwise.
- Applies to the pie legend and to Enter/Space too — same row component,
same handler.

#### Additional Information

The legend test file moved to `components/Legend/__tests__/`, next to
the component it covers.
2026-09-15 10:07:15 +00:00
Abhi kumar
755cca13cf feat(dashboards): revamp panel chart legend interaction (#12838)
#### Description

- The legend's two undiscoverable click targets are replaced by one
explicit set. A row click depends on what's on screen — isolate when
everything is showing, move the isolation while one series is showing,
hide/show that series otherwise — and the marker stays a separate target
so a single series can still be excluded in one click.
- Hover-revealed row actions: `+` to show a hidden series alongside the
current ones, a crosshair to isolate (or restore all when it's already
alone), and copy for the full untruncated series name. Their reveal is
pure CSS.
- Hovering a row lifts that series on the chart and dims the rest; the
dim is dropped as soon as the highlighted series is hidden, which
previously left every remaining series faded and read as an isolation.
- At least one series always stays visible, so a panel can no longer be
emptied.
- The bottom legend reserves exactly the one or two rows it shows — the
old estimate was built from a 40px row while rows are 28px with a 2px
gap, so two rows never quite fit. The right-side legend keeps its search
box and gains a `Showing N of M series` readout.
- Tooltip series rows now use the same marker and mono type as the
legend.

<img width="1961" height="637" alt="image"
src="https://github.com/user-attachments/assets/07c78273-dcd7-4c60-b3fc-7014a76aa693"
/>
<img width="425" height="154" alt="image"
src="https://github.com/user-attachments/assets/2ffba264-0067-4de2-b8b7-cf8d09c305be"
/>


https://github.com/user-attachments/assets/d5514736-2980-49b9-b62a-0dca8156c960



#### Additional Information

- All of this lives in the shared `lib/uPlotV2` legend, so it lands on
V1 dashboard panels, V2 panels, the alert chart preview, infra
monitoring and Pie at once. The logs/traces explorer still uses the
older `lib/uPlotLib` legend and is untouched.
- `Legend` is now presentational only; visibility lives with each chart
type — `PlotContext` for uPlot, `usePieInteractions` for the donut —
which is what lets both share one row component.
- `LegendRow` is memoised: the chart's cursor focus updates the focused
series on every pointer move over the plot, which re-runs the list's row
renderer.
- Legend hover no longer sets React state (it was re-rendering the whole
legend per row), and the legend dropped a `ResizeObserver` that only
existed to centre a single row.

Closes https://github.com/SigNoz/pulse-pod/issues/340
2026-09-15 07:12:09 +00:00
Vinicius Lourenço
e0c0ac4ea6 ci(storybook): add job to test storybooks (#12841)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Basically ensures our storybooks does not fail silently while we update
a page, this will give us errors/warnings for missing mocked APIs and
also for console.error logged by the page, I added few exceptions to
cover most of the basic use-cases we have today.

The implementation here is a little different from signoz/components
because there's so many pages and heavy components that the normal
vitest way fails and produce flakys enough to be unusable.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/6051
2026-09-15 02:40:24 +00:00
Naman Verma
c8e9e362f7 feat: add spec for text panel (#12711)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
#### Description

Add a new plugin schema for text panel. This also adds a check on the
query count. If the panel is text, then number of queries should be
zero, otherwise it should be 1.

Frontend changes to be built on top of this

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/303
Closes https://github.com/SigNoz/pulse-pod/issues/221

---------

Co-authored-by: Abhi kumar <ahrefabhi@gmail.com>
2026-09-11 13:29:01 +00:00
260 changed files with 10829 additions and 5188 deletions

View File

@@ -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' ||

View File

@@ -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:

View File

@@ -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:

View File

@@ -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.**

View File

@@ -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>

View 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;
}

View 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;

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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 "$@"

View File

@@ -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');

View File

@@ -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

View File

@@ -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,
};

View File

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

View File

@@ -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>
);

View File

@@ -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 => {

View File

@@ -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 ? (

View File

@@ -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}

View File

@@ -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));

View File

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

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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 &quot;{legendSearchQuery}&quot;
<div className={styles.emptyState}>
No series found matching &quot;{effectiveQuery}&quot;
</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)}
/>

View 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);
}

View 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);

View File

@@ -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);
}

View 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>
</>
);
}

View File

@@ -8,8 +8,8 @@ import Legend from './Legend';
/**
* uPlot legend controller. Derives the legend items + focus/visibility state
* from the chart config (useLegendsSync) and the 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}
/>
);
}

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});

View 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;

View 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),
);
}

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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);
});
});
});

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,
],
);

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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,
};
}

View File

@@ -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>

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -1,9 +1,9 @@
import { act, renderHook } from '@testing-library/react';
import { LegendAction } from 'lib/uPlotV2/components/types';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
import type { MouseEvent } from 'react';
import { PieSlice } from 'lib/visualization/charts/types';
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
@@ -24,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();
});

View File

@@ -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,
};
}

View File

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

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/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';

View File

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

View File

@@ -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}}',
},
]);
});
});

View File

@@ -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', () => {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 && (

View File

@@ -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`;
}

View File

@@ -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>
);
}

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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']));
});
});

View File

@@ -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');
});
});
});

View File

@@ -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.

View File

@@ -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;

View File

@@ -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();
});
});

View File

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

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -23,7 +23,7 @@ import { EQueryType } from 'types/common/dashboard';
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { 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';

View File

@@ -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;

View File

@@ -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>

View File

@@ -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}

View File

@@ -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={

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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({

View File

@@ -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

View File

@@ -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,
});

View File

@@ -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;
}
/**

View File

@@ -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,

View File

@@ -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

View File

@@ -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: {} });
});
});

View File

@@ -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);
});
});

View File

@@ -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 };
}

View File

@@ -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

View File

@@ -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]);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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(

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});
});

View File

@@ -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