mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-16 00:10:42 +01:00
Compare commits
6 Commits
feat/heatm
...
feat/sqlco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b19b89b004 | ||
|
|
9fb05731a7 | ||
|
|
861e5f75cf | ||
|
|
755cca13cf | ||
|
|
e0c0ac4ea6 | ||
|
|
c8e9e362f7 |
30
.github/workflows/jsci.yaml
vendored
30
.github/workflows/jsci.yaml
vendored
@@ -79,6 +79,36 @@ jobs:
|
||||
run: |
|
||||
cd frontend && pnpm generate:api
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run pnpm generate:api in frontend/ locally and commit."; exit 1)
|
||||
storybook:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-noble
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
- name: node-install
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: pnpm
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
- name: install-frontend
|
||||
run: cd frontend && pnpm install
|
||||
- name: test-storybook
|
||||
run: cd frontend && pnpm test:storybook --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
web-settings:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -21,4 +21,5 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
|
||||
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
|
||||
- [Service](service.md) - Managed service lifecycle with `factory.Service`
|
||||
- [SQL](sql.md) - Database and SQL patterns
|
||||
- [SQL Compiler](sqlcompiler.md) - Compiling the list filter DSL to relational-store WHERE clauses
|
||||
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`
|
||||
|
||||
84
docs/contributing/go/sqlcompiler.md
Normal file
84
docs/contributing/go/sqlcompiler.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# SQL Compiler
|
||||
|
||||
List pages (dashboards, alert rules) share one filter DSL in their search bars. [pkg/parser/filterquery/sqlcompiler](/pkg/parser/filterquery/sqlcompiler/compiler.go) compiles a DSL string into a WHERE clause for the relational store: `?`-placeholder SQL plus bind arguments, ready for bun on both SQLite and Postgres. Telemetry filters are a different pipeline. They stay on querybuilder's ClickHouse visitor.
|
||||
|
||||
The package owns everything generic about the language. A module adopting it writes exactly one thing: a `FieldResolver` that says which keys exist and what each maps to.
|
||||
|
||||
## What is the DSL?
|
||||
|
||||
The grammar lives at [grammar/FilterQuery.g4](/grammar/FilterQuery.g4), with the ANTLR-generated parser in [pkg/parser/filterquery/grammar](/pkg/parser/filterquery/grammar). It is the same grammar the telemetry search bars use, so the query language feels identical everywhere. The shapes that matter:
|
||||
|
||||
- Boolean structure: parentheses > `NOT` > `AND` > `OR`; adjacent terms with no connective are an implicit `AND`.
|
||||
- Comparisons: `key OP value`, e.g. `name CONTAINS cpu`, `created_at > '2025-01-01T00:00:00Z'`, `labels.team IN ('infra', 'platform')`, `labels.env EXISTS`. See the `comparison` rule in the grammar for the full operator list.
|
||||
- Free text: a bare or quoted token with no key. Quoting is the escape hatch for a phrase that looks like DSL.
|
||||
- Values: bare tokens or quoted strings; `IN` accepts `in(...)` and `[...]` forms.
|
||||
|
||||
## What does the framework already cover?
|
||||
|
||||
```go
|
||||
compiled, errs := sqlcompiler.Compile(query, formatter, resolver)
|
||||
```
|
||||
|
||||
`Compile` returns either a non-nil `*Compiled` or a list of human-readable errors. An empty query compiles to an empty `Compiled`; callers gate on `IsEmpty()`, not nil. On top of parsing, the package handles:
|
||||
|
||||
- Syntax errors, collected with line/column positions instead of failing on the first one.
|
||||
- The boolean tree: `AND`/`OR`/`NOT`, parentheses, implicit `AND`, and pruning of empty conditions.
|
||||
- Operator extraction, including inversion of `NOT LIKE`, `NOT IN`, `NOT EXISTS` and friends.
|
||||
- Predicate builders the resolver calls back into:
|
||||
|
||||
| Builder | Handles |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `BuildStringOperation` | `=`, `!=`, `LIKE`/`ILIKE`, `CONTAINS`, `IN` on a string column; escapes `%`/`_` for `CONTAINS`, rejects patterns ending in a dangling backslash, lowers both sides for `ILIKE` so SQLite and Postgres agree |
|
||||
| `BuildTimestampComparison` | equality, ranges and `BETWEEN` on RFC3339 timestamps |
|
||||
| `BuildBoolComparison` | `= true/false` |
|
||||
| `BuildFreeTextContains` | case-insensitive substring match, `COALESCE`d so `NOT (...)` does not drop rows where the column is NULL |
|
||||
|
||||
- Typed value extraction (`ExtractSingleStringValue`, `ExtractStringValueList`, ...) with accumulated errors: the user sees every problem in the query at once.
|
||||
- Argument binding through go-sqlbuilder; no value is ever interpolated into the SQL text.
|
||||
|
||||
## When do I write a FieldResolver?
|
||||
|
||||
Whenever a module adopts the DSL for its list page. The resolver is the per-module policy and the only code you write:
|
||||
|
||||
```go
|
||||
type FieldResolver interface {
|
||||
ResolveComparison(v *Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string
|
||||
ResolveFreeText(v *Visitor, value string) string
|
||||
}
|
||||
```
|
||||
|
||||
Rules for implementing one:
|
||||
|
||||
- Declare the key namespace in `pkg/types/<domain>`: `DSLKey` constants plus a `ReservedOps` map of key to allowed operators. The list API advertises these as `reservedKeywords`, so the frontend suggestions never go stale.
|
||||
- Reject a disallowed operator with `v.AddError(...)` and return `""`. Never panic, never fail fast; the compile fails at the end with all errors.
|
||||
- Map a key to a column expression through `v.Formatter` (`JSONExtractString`, `LowerExpression`), never by hand, so the expression is valid on both SQLite and Postgres.
|
||||
- Delegate the predicate to the `Build*` helpers above; do not hand-build SQL or manage arguments yourself.
|
||||
- For a key that lives in a relation table (dashboard tags, rule labels), build an `EXISTS` subquery on a fresh `sqlbuilder.SelectBuilder` and pass that builder into `BuildStringOperation`, so its arguments thread through the compile. For a negative operator, build the positive predicate and toggle `NotExists` on the outer builder.
|
||||
|
||||
The reference implementation is the dashboards resolver, [pkg/modules/dashboard/impldashboard/listfilter_resolver.go](/pkg/modules/dashboard/impldashboard/listfilter_resolver.go): reserved keys backed by columns and JSON paths, tag keys via `EXISTS` subqueries, free text across name, description and tags.
|
||||
|
||||
## How to wire it in?
|
||||
|
||||
Give the module a thin `Compile` wrapper that maps the error list onto the module's error code, as in [pkg/modules/dashboard/impldashboard/listfilter.go](/pkg/modules/dashboard/impldashboard/listfilter.go):
|
||||
|
||||
```go
|
||||
func Compile(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
|
||||
compiled, errs := sqlcompiler.Compile(query, formatter, dashboardFieldResolver{})
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
|
||||
"invalid filter query: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return compiled, nil
|
||||
}
|
||||
```
|
||||
|
||||
The store then appends `compiled.SQL` with `compiled.Args` to its list query when `!compiled.IsEmpty()`.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- One DSL, one compiler; a new list page adds a `FieldResolver`, not a new parser or SQL layer.
|
||||
- Keys and allowed operators live in `pkg/types/<domain>` and are advertised as `reservedKeywords`.
|
||||
- Column expressions go through `v.Formatter`; predicates go through the `Build*` helpers.
|
||||
- Report problems with `v.AddError` and return `""`; errors accumulate.
|
||||
- Relation-table keys use `EXISTS` subqueries on their own builder; negation toggles `NotExists`.
|
||||
- This package is for the relational store only; telemetry filtering stays in querybuilder.
|
||||
@@ -1,9 +1,11 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!--
|
||||
`index.html` links Inter from Google Fonts here. The link is parsed before the
|
||||
msw worker has started, so that one request escapes the iframe while every
|
||||
other font request the app makes is answered with an empty stylesheet; the
|
||||
local faces stand in for both.
|
||||
-->
|
||||
<link rel="stylesheet" href="storybook-fonts.css" />
|
||||
|
||||
<link rel="stylesheet" href="css/uPlot.min.css" />
|
||||
|
||||
<script>
|
||||
|
||||
50
frontend/.storybook/public/storybook-fonts.css
Normal file
50
frontend/.storybook/public/storybook-fonts.css
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* The five families the app pulls from Google Fonts, served from the files in
|
||||
* `public/fonts` instead. `msw/appShellHandlers.ts` answers the CDN with an
|
||||
* empty stylesheet so no request leaves the iframe, which without this left
|
||||
* every story on a fallback for the four families `src/styles.scss` imports at
|
||||
* runtime, and on whatever the Inter link in `index.html` happened to fetch
|
||||
* before the worker had started.
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
/* The comma is part of the filename and the dev server serves it raw, so it
|
||||
stays literal inside the quoted url rather than percent-encoded. */
|
||||
src: url('fonts/Inter-VariableFont_opsz,wght.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Work Sans';
|
||||
src: url('fonts/WorkSans-VariableFont_wght.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Space Mono';
|
||||
src: url('fonts/SpaceMono-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Fira Code';
|
||||
src: url('fonts/FiraCode-VariableFont_wght.ttf') format('truetype');
|
||||
font-weight: 300 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('fonts/GeistMonoVF.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
85
frontend/.storybook/test-runner.ts
Normal file
85
frontend/.storybook/test-runner.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { getStoryContext } from '@storybook/test-runner';
|
||||
import type { TestRunnerConfig } from '@storybook/test-runner';
|
||||
import type { Page } from 'playwright';
|
||||
|
||||
const IGNORED_MESSAGES = [
|
||||
// `preview-head.html` swaps a local stylesheet in for Google Fonts, but the
|
||||
// browser still warns on the real cross-origin one it briefly requests
|
||||
// before msw starts (no CORS headers), regardless of story content.
|
||||
/Can't access cssRules/,
|
||||
// Pre-existing dev-server noise, unrelated to any story.
|
||||
/Couldn't load preload assets/,
|
||||
// Fires because a Jest-driven browser sets a global testing flag React
|
||||
// checks for; unrelated to anything a story does.
|
||||
/current testing environment is not configured to support act/,
|
||||
// React and antd route dev-only warnings (missing keys, DOM nesting, API
|
||||
// deprecations) through `console.error` under this prefix; app-wide and
|
||||
// tracked separately from story regressions.
|
||||
/^Warning: /,
|
||||
// msw's own warning when its response listener count grows across many
|
||||
// story visits in one browser session; not a story defect.
|
||||
/MaxListenersExceededWarning/,
|
||||
// `preview-head.html`'s CSP intentionally blocks third-party iframes
|
||||
// (YouTube embeds, the docs pane) so they hit the real network instead of
|
||||
// an unanswered msw request; the block is the point, not a bug.
|
||||
/violates the following Content Security Policy directive/,
|
||||
];
|
||||
|
||||
const messagesByPage = new WeakMap<Page, string[]>();
|
||||
|
||||
/**
|
||||
* Only `console.error` fails a story. `console.warn` is dev-time advice from
|
||||
* app code (e.g. `aggregateData is null`) and from the runner itself; an
|
||||
* unmocked `/api/` call is a `console.error` in `src/storybook/msw/handlers.ts`.
|
||||
*/
|
||||
const config: TestRunnerConfig = {
|
||||
// msw logs every mocked request at `log`; keep it out of the failure dump
|
||||
// unless the job is re-run with debug logging (GitHub sets RUNNER_DEBUG=1).
|
||||
logLevel: process.env.RUNNER_DEBUG === '1' ? 'info' : 'warn',
|
||||
async preVisit(page): Promise<void> {
|
||||
const existing = messagesByPage.get(page);
|
||||
if (existing) {
|
||||
existing.length = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const messages: string[] = [];
|
||||
messagesByPage.set(page, messages);
|
||||
page.on('console', (message) => {
|
||||
if (
|
||||
message.type() === 'error' &&
|
||||
!IGNORED_MESSAGES.some((pattern) => pattern.test(message.text()))
|
||||
) {
|
||||
messages.push(`[error] ${message.text()}`);
|
||||
}
|
||||
});
|
||||
// The console message alone ("Failed to load resource") doesn't name the
|
||||
// URL; pairing it with the response is what makes a missing mock
|
||||
// actionable instead of just a status code.
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
messages.push(`[response] ${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
async postVisit(page, context): Promise<void> {
|
||||
const messages = messagesByPage.get(page) ?? [];
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A story that deliberately mocks a failure response (e.g. a 500 to test
|
||||
// an error state) logs the error it's testing for; opt it out per-story
|
||||
// with `parameters: { allowConsoleErrors: true }`.
|
||||
const storyContext = await getStoryContext(page, context);
|
||||
if (storyContext.parameters?.allowConsoleErrors) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Story "${context.name}" logged console error/warning:\n${messages.join('\n')}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev": "vite",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build -o storybook-static",
|
||||
"test:storybook": "bash scripts/test-storybook.sh",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prettify": "oxfmt",
|
||||
@@ -162,6 +163,7 @@
|
||||
"@jest/types": "30.2.0",
|
||||
"@storybook/addon-a11y": "10.5.9",
|
||||
"@storybook/react-vite": "10.5.9",
|
||||
"@storybook/test-runner": "0.24.5",
|
||||
"@testing-library/dom": "8.20.0",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "13.4.0",
|
||||
@@ -233,7 +235,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@babel/core@<=7.29.0": ">=7.29.6 <8",
|
||||
"@istanbuljs/load-nyc-config>js-yaml": ">=4.2.0 <5",
|
||||
"@istanbuljs/load-nyc-config>js-yaml": ">=4.3.1 <5",
|
||||
"cookie@<0.7.0": ">=0.7.1 <1",
|
||||
"dompurify@<=3.4.10": ">=3.4.11 <4",
|
||||
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1 <0.29.0",
|
||||
@@ -242,6 +244,14 @@
|
||||
"prismjs@<1.30.0": ">=1.30.0 <2",
|
||||
"react-router@>=6.7.0 <6.30.4": ">=6.30.4 <7",
|
||||
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2",
|
||||
"brace-expansion@<1.1.18": ">=1.1.18 <2",
|
||||
"brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4 <3",
|
||||
"brace-expansion@>=5.0.0 <5.0.9": ">=5.0.9 <6",
|
||||
"fast-uri@<3.1.5": ">=3.1.5 <4",
|
||||
"immutable@<5.1.8": ">=5.1.8 <6",
|
||||
"js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5",
|
||||
"less@<4.5.0": ">=4.5.0 <5",
|
||||
"nanoid@<3.3.18": ">=3.3.18 <4"
|
||||
}
|
||||
}
|
||||
|
||||
1076
frontend/pnpm-lock.yaml
generated
1076
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
31
frontend/scripts/test-storybook.sh
Executable file
31
frontend/scripts/test-storybook.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
story_count=$(find src -name '*.stories.tsx' | wc -l)
|
||||
if [ "$story_count" -eq 0 ]; then
|
||||
echo "No *.stories.tsx found under src/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# jest splits the sorted story files into contiguous shards and exits 1 when a
|
||||
# shard is empty, which happens on every shard above the file count. The runner
|
||||
# rejects jest's own `--passWithNoTests`, so skip those shards here.
|
||||
for arg in "$@"; do
|
||||
if [[ $arg == --shard=* ]]; then
|
||||
shard_index=${arg#--shard=}
|
||||
if [ "${shard_index%%/*}" -gt "$story_count" ]; then
|
||||
echo "Skipping ${arg#--shard=}: only ${story_count} story files"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
pnpm storybook --ci --quiet &
|
||||
SB_PID=$!
|
||||
trap 'kill "$SB_PID" 2>/dev/null || true' EXIT
|
||||
|
||||
until curl -sf http://127.0.0.1:6006/index.json >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
pnpm exec test-storybook --ci --maxWorkers=2 "$@"
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { AxiosError, AxiosResponse, isCancel } from 'axios';
|
||||
import { ErrorResponse } from 'types/api';
|
||||
import { ErrorStatusCode } from 'types/common';
|
||||
|
||||
@@ -42,6 +42,16 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
|
||||
};
|
||||
}
|
||||
if (request) {
|
||||
// Avoid logging error when the request was just cancelled for whatever reason
|
||||
if (isCancel(error)) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
// client never received a response, or request never left
|
||||
console.error('client never received a response, or request never left');
|
||||
|
||||
|
||||
@@ -5019,6 +5019,57 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesListPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind {
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
}
|
||||
export interface DashboardtypesHeaderOptionsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hide?: boolean;
|
||||
}
|
||||
|
||||
export enum DashboardtypesTextModeDTO {
|
||||
markdown = 'markdown',
|
||||
}
|
||||
export enum DashboardtypesTextAlignDTO {
|
||||
left = 'left',
|
||||
center = 'center',
|
||||
right = 'right',
|
||||
}
|
||||
export enum DashboardtypesVerticalAlignDTO {
|
||||
top = 'top',
|
||||
center = 'center',
|
||||
bottom = 'bottom',
|
||||
}
|
||||
export interface DashboardtypesTextPresentationDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
background?: string | null;
|
||||
textAlign?: DashboardtypesTextAlignDTO;
|
||||
verticalAlign?: DashboardtypesVerticalAlignDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesTextPanelSpecDTO {
|
||||
headerOptions?: DashboardtypesHeaderOptionsDTO;
|
||||
mode?: DashboardtypesTextModeDTO;
|
||||
presentation?: DashboardtypesTextPresentationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/TextPanel
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind;
|
||||
spec: DashboardtypesTextPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -5026,7 +5077,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -5950,6 +6002,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TablePanel' = 'signoz/TablePanel',
|
||||
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
|
||||
@@ -31,6 +31,8 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.BAR]: Uplot,
|
||||
[PANEL_TYPES.PIE]: null,
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -376,6 +376,7 @@ export enum PANEL_TYPES {
|
||||
BAR = 'bar',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
|
||||
@@ -376,7 +376,9 @@ export default function BillingContainer(): JSX.Element {
|
||||
</Typography.Link>
|
||||
</AuthZTooltip>
|
||||
{` if your payment information has changed. Email us at `}
|
||||
<Typography.Text color="muted">cloud-support@signoz.io</Typography.Text>
|
||||
<Typography.Text as="span" color="muted">
|
||||
cloud-support@signoz.io
|
||||
</Typography.Text>
|
||||
{` otherwise. Be sure to provide this information immediately to avoid interruption to your service.`}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
@@ -174,6 +174,7 @@ export default function ServiceTraces({
|
||||
columns={columns}
|
||||
dataSource={top5Services}
|
||||
pagination={false}
|
||||
rowKey="serviceName"
|
||||
className="services-table"
|
||||
onRow={(record: ServicesList): Record<string, unknown> => ({
|
||||
onClick: (event: React.MouseEvent): void => {
|
||||
|
||||
@@ -216,7 +216,7 @@ function K8sOptionsSidePanel<TData>({
|
||||
);
|
||||
return (
|
||||
<div className={styles.columnItem} key={column.id}>
|
||||
<Typography.Text size="sm" className={styles.columnLabel}>
|
||||
<Typography.Text as="span" size="sm" className={styles.columnLabel}>
|
||||
{column.label}
|
||||
</Typography.Text>
|
||||
{column.canBeHidden ? (
|
||||
|
||||
@@ -29,5 +29,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
BAR: true,
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
TEXT: false,
|
||||
EMPTY_WIDGET: false,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.LIST]: ListPanelWrapper,
|
||||
[PANEL_TYPES.VALUE]: ValuePanelWrapper,
|
||||
[PANEL_TYPES.TRACE]: null,
|
||||
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
[PANEL_TYPES.PIE]: PiePanelWrapper,
|
||||
[PANEL_TYPES.BAR]: BarPanel,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// Let the flex children shrink below their content height so the series list
|
||||
// scrolls within the capped legend height instead of overflowing the wrapper
|
||||
// (the default min-height:auto would block the shrink).
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
// flex:1 + min-height:0 pins the scroller to the space left after the
|
||||
// toolbar instead of growing to fit every row.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding-right: var(--spacing-2);
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.gridItem {
|
||||
// Or the item keeps its content width and the label never ellipsizes.
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.gridList {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
// min() keeps the column inside a narrow panel, where a wider one would push
|
||||
// the row's actions out of the clipped area.
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(min(var(--legend-item-width, 240px), 100%), 1fr)
|
||||
);
|
||||
gap: var(--spacing-1) var(--spacing-4);
|
||||
}
|
||||
|
||||
.container.isRight .gridList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: var(--spacing-16) 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.legend-search-container {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding-right: 8px;
|
||||
|
||||
.legend-search-input {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// Allow the flex children to shrink below their content height so the
|
||||
// virtualized grid scrolls within the capped legend height instead of
|
||||
// overflowing the wrapper (default min-height:auto would block the shrink).
|
||||
min-height: 0;
|
||||
|
||||
&:has(.legend-item-focused) .legend-item {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&:has(.legend-item-focused) .legend-item.legend-item-focused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend-empty-state {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.legend-virtuoso-container {
|
||||
// flex:1 + min-height:0 pins the scroller to the space left after the
|
||||
// search box (RIGHT legend) and lets it scroll instead of growing to fit
|
||||
// every row — without this the grid overflows a BOTTOM legend's fixed height.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.virtuoso-grid-list {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(var(--legend-average-width, 240px), 1fr)
|
||||
);
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
.virtuoso-grid-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&.legend-virtuoso-container-right {
|
||||
.virtuoso-grid-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&.legend-virtuoso-container-single-row {
|
||||
.virtuoso-grid-list {
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(var(--legend-average-width, 240px), max-content)
|
||||
);
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-row {
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
|
||||
&.legend-single-row {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.legend-row-right {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
&.legend-row-bottom {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
// Include padding within the width so a full-width row (legend-item-right) fits its
|
||||
// column instead of overflowing by the 16px horizontal padding — there is no global
|
||||
// border-box reset, so the default content-box would make it overflow.
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&.legend-item-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.legend-item-off {
|
||||
opacity: 0.3;
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
&.legend-item-focused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend-item-label-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.legend-marker {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-radius: 50%;
|
||||
min-width: 11px;
|
||||
min-height: 11px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.legend-copy-button {
|
||||
// Always laid out (space reserved) but transparent, so revealing it on
|
||||
// hover fades the icon in without reflowing the row / shifting the label.
|
||||
// Shrink the shared icon Button (defaults to a 2rem square) to the
|
||||
// compact legend row via its size tokens.
|
||||
--button-height: auto;
|
||||
--button-width: auto;
|
||||
--button-padding: 2px;
|
||||
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
.legend-copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,106 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { Input } from 'antd';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import { LegendPosition, LegendProps } from '../types';
|
||||
import { LegendAction, LegendPosition, LegendProps } from '../types';
|
||||
|
||||
import './Legend.styles.scss';
|
||||
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
|
||||
import LegendRow from './LegendRow';
|
||||
import LegendToolbar from './LegendToolbar';
|
||||
import { filterLegendItems, getShownSeriesState } from './utils';
|
||||
|
||||
export const MAX_LEGEND_WIDTH = 240;
|
||||
import styles from './Legend.module.scss';
|
||||
|
||||
/**
|
||||
* Presentational legend. Renders the supplied `items` (markers + labels, an
|
||||
* optional copy button, and a search box for the RIGHT position) and delegates
|
||||
* all interaction to the container handlers. Source-agnostic — the uPlot
|
||||
* charts feed it via UPlotLegend; Pie feeds it directly.
|
||||
* Presentational legend, source-agnostic: the uPlot charts feed it via
|
||||
* UPlotLegend, Pie feeds it directly. Every state change is delegated.
|
||||
*/
|
||||
export default function Legend({
|
||||
items,
|
||||
position,
|
||||
averageLegendWidth = MAX_LEGEND_WIDTH,
|
||||
focusedSeriesIndex,
|
||||
onClick,
|
||||
onMouseMove,
|
||||
onMouseLeave,
|
||||
onAction,
|
||||
showCopy = true,
|
||||
}: LegendProps): JSX.Element {
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [legendSearchQuery, setLegendSearchQuery] = useState('');
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
|
||||
// Search is intrinsic to the right-positioned legend.
|
||||
const searchEnabled = position === LegendPosition.RIGHT;
|
||||
const { width: containerWidth } = useResizeObserver(legendContainerRef);
|
||||
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
|
||||
const isRightPosition = position === LegendPosition.RIGHT;
|
||||
|
||||
const isSingleRow = useMemo(() => {
|
||||
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
|
||||
return false;
|
||||
}
|
||||
const totalLegendWidth = items.length * (averageLegendWidth + 16);
|
||||
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
|
||||
return totalRows <= 1;
|
||||
}, [averageLegendWidth, items.length, position, containerWidth]);
|
||||
|
||||
const visibleLegendItems = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const query = legendSearchQuery.trim().toLowerCase();
|
||||
return items.filter((item) => item.label?.toLowerCase().includes(query));
|
||||
}, [searchEnabled, legendSearchQuery, items]);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => {
|
||||
// `color` is uPlot's stroke union (string | fn | gradient); only a string
|
||||
// is a usable CSS colour for the marker.
|
||||
const markerColor = typeof item.color === 'string' ? item.color : undefined;
|
||||
return (
|
||||
<div
|
||||
key={item.seriesIndex}
|
||||
data-legend-item-id={item.seriesIndex}
|
||||
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
|
||||
'legend-item-off': !item.show,
|
||||
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
|
||||
})}
|
||||
>
|
||||
<TooltipSimple title={item.label} arrow side="top" disableHoverableContent>
|
||||
<div className="legend-item-label-trigger">
|
||||
<div
|
||||
className="legend-marker"
|
||||
style={{ borderColor: markerColor }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className="legend-label">{item.label}</span>
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
{showCopy && (
|
||||
<CopyButton
|
||||
value={item.label ?? ''}
|
||||
size={12}
|
||||
className="legend-copy-button"
|
||||
ariaLabel={`Copy ${item.label}`}
|
||||
testId="legend-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[focusedSeriesIndex, position, showCopy],
|
||||
const { visibleCount, soleShownSeriesIndex } = useMemo(
|
||||
() => getShownSeriesState(items),
|
||||
[items],
|
||||
);
|
||||
|
||||
const isEmptyState = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return false;
|
||||
}
|
||||
return visibleLegendItems.length === 0;
|
||||
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
|
||||
// A bottom legend gets two rows; spending one on chrome costs more chart than
|
||||
// the readout is worth.
|
||||
const showToolbar = isRightPosition && items.length > 0;
|
||||
const showFilter = showToolbar;
|
||||
|
||||
const effectiveQuery = showFilter ? filterQuery : '';
|
||||
|
||||
const visibleLegendItems = useMemo(
|
||||
() => filterLegendItems(items, effectiveQuery),
|
||||
[items, effectiveQuery],
|
||||
);
|
||||
|
||||
const isEmptyState =
|
||||
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
|
||||
|
||||
const isAllShown = visibleCount === items.length;
|
||||
|
||||
// A row that unmounts under the pointer never fires its own mouseleave.
|
||||
const handleMouseLeave = useCallback(
|
||||
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
|
||||
[onAction],
|
||||
);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => (
|
||||
<LegendRow
|
||||
key={item.seriesIndex}
|
||||
item={item}
|
||||
isSoleShown={soleShownSeriesIndex === item.seriesIndex}
|
||||
isAllShown={isAllShown}
|
||||
isFocused={focusedSeriesIndex === item.seriesIndex}
|
||||
showCopy={showCopy}
|
||||
onAction={onAction}
|
||||
/>
|
||||
),
|
||||
[soleShownSeriesIndex, isAllShown, focusedSeriesIndex, showCopy, onAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={legendContainerRef}
|
||||
className="legend-container"
|
||||
onClick={onClick}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={{
|
||||
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
|
||||
}}
|
||||
className={cx(styles.container, {
|
||||
[styles.isRight]: isRightPosition,
|
||||
})}
|
||||
style={{ ['--legend-item-width' as string]: `${itemWidth}px` }}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-testid="legend-container"
|
||||
>
|
||||
{searchEnabled && (
|
||||
<div className="legend-search-container">
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="Search..."
|
||||
value={legendSearchQuery}
|
||||
onChange={(e): void => setLegendSearchQuery(e.target.value)}
|
||||
data-testid="legend-search-input"
|
||||
className="legend-search-input"
|
||||
/>
|
||||
</div>
|
||||
{showToolbar && (
|
||||
<LegendToolbar
|
||||
visibleCount={visibleCount}
|
||||
totalCount={items.length}
|
||||
showFilter={showFilter}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
/>
|
||||
)}
|
||||
{isEmptyState ? (
|
||||
<div className="legend-empty-state">
|
||||
No series found matching "{legendSearchQuery}"
|
||||
<div className={styles.emptyState}>
|
||||
No series found matching "{effectiveQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
className={cx(
|
||||
'legend-virtuoso-container',
|
||||
`legend-virtuoso-container-${position.toLowerCase()}`,
|
||||
{ 'legend-virtuoso-container-single-row': isSingleRow },
|
||||
)}
|
||||
className={styles.scroller}
|
||||
listClassName={styles.gridList}
|
||||
itemClassName={styles.gridItem}
|
||||
data={visibleLegendItems}
|
||||
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
|
||||
/>
|
||||
|
||||
170
frontend/src/lib/uPlotV2/components/Legend/LegendRow.module.scss
Normal file
170
frontend/src/lib/uPlotV2/components/Legend/LegendRow.module.scss
Normal file
@@ -0,0 +1,170 @@
|
||||
.row {
|
||||
// Width of the revealed actions, given up by the label on hover only.
|
||||
--legend-actions-reserve: 78px;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
height: 28px;
|
||||
padding: 0 var(--spacing-3) 0 var(--spacing-4);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background 160ms linear;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.isFocused {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.marker {
|
||||
// Reads as a checkbox without being one: filled when shown, hollow when
|
||||
// hidden, deliberately not a check glyph.
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
// Above the actions, so a narrow row's chip never covers the series colour.
|
||||
z-index: 4;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
border-width: 1.5px;
|
||||
border-style: solid;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
box-shadow 200ms ease,
|
||||
background-color 160ms linear,
|
||||
opacity 160ms linear;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--l1-foreground) 30%, transparent);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Series names run long and have no spaces to break on, so they need both a
|
||||
// cap and a break rule or the tooltip becomes one panel-wide line.
|
||||
.rowTooltip {
|
||||
max-width: 420px;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1 1 auto;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--l2-foreground);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.isHidden .marker {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.isHidden .label {
|
||||
color: var(--l3-foreground);
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
/* Row actions */
|
||||
|
||||
.actions {
|
||||
position: absolute;
|
||||
top: var(--spacing-2);
|
||||
right: var(--spacing-3);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding-left: var(--spacing-5, 10px);
|
||||
// Sits on the row's hover background and masks the label's tail behind it.
|
||||
background: var(--l3-background);
|
||||
box-shadow: -8px 0 8px var(--l3-background);
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 180ms cubic-bezier(0.08, 0.52, 0.52, 1),
|
||||
transform 180ms cubic-bezier(0.08, 0.52, 0.52, 1);
|
||||
}
|
||||
|
||||
// :focus-visible, not :focus-within — the latter also matches the click that
|
||||
// just toggled the series, leaving the actions stuck open.
|
||||
.row:hover .actions,
|
||||
.row:focus-visible .actions,
|
||||
.row:has(:focus-visible) .actions {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// The cap spares rows sized to their actions, not their label.
|
||||
.row:hover .label,
|
||||
.row:focus-visible .label,
|
||||
.row:has(:focus-visible) .label {
|
||||
padding-right: min(var(--legend-actions-reserve), 50%);
|
||||
}
|
||||
|
||||
.actionTrigger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
--button-height: 20px;
|
||||
--button-width: 20px;
|
||||
--button-padding: 0;
|
||||
--button-variant-ghost-color: var(--l3-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actionButton.scopeButton {
|
||||
--button-width: auto;
|
||||
--button-padding: 0 var(--spacing-4);
|
||||
--button-font-size: var(--font-size-xs);
|
||||
--button-border-radius: calc(var(--radius) * 4);
|
||||
--button-base-border-width: 1px;
|
||||
// --l2-border is the actions bar's own background: one step up reads.
|
||||
--button-base-border-color: var(--l3-border);
|
||||
|
||||
border-style: solid;
|
||||
}
|
||||
.actionButton.scopeButton:hover {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
182
frontend/src/lib/uPlotV2/components/Legend/LegendRow.tsx
Normal file
182
frontend/src/lib/uPlotV2/components/Legend/LegendRow.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { KeyboardEvent, memo, MouseEvent, useCallback } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import { LegendAction, OnLegendAction } from '../types';
|
||||
|
||||
import { LEGEND_TOOLTIP_DELAY_MS } from './constants';
|
||||
import styles from './LegendRow.module.scss';
|
||||
|
||||
export interface LegendRowProps {
|
||||
item: LegendItem;
|
||||
/** The only series currently shown, so hiding it is refused. */
|
||||
isSoleShown: boolean;
|
||||
/** Nothing is hidden, so the row's action can only narrow the selection. */
|
||||
isAllShown: boolean;
|
||||
isFocused: boolean;
|
||||
showCopy: boolean;
|
||||
onAction: OnLegendAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* One legend row. The marker is its own target for excluding a single series —
|
||||
* the one thing the row click can't do while everything is showing. The actions
|
||||
* overlay the label's tail rather than taking layout width, and their reveal is
|
||||
* pure CSS.
|
||||
*/
|
||||
function LegendRow({
|
||||
item,
|
||||
isSoleShown,
|
||||
isAllShown,
|
||||
isFocused,
|
||||
showCopy,
|
||||
onAction,
|
||||
}: LegendRowProps): JSX.Element {
|
||||
const { seriesIndex, show } = item;
|
||||
const label = item.label ?? '';
|
||||
const isShowAllAction = show && !isAllShown;
|
||||
const scopeActionLabel = isShowAllAction
|
||||
? 'Show all series'
|
||||
: 'Show only current series';
|
||||
// `color` is uPlot's stroke union (string | fn | gradient); only a string is
|
||||
// a usable CSS colour for the marker.
|
||||
const seriesColor = typeof item.color === 'string' ? item.color : undefined;
|
||||
|
||||
/** Everything showing -> isolate; showing alone -> restore all. */
|
||||
const handleRowClick = useCallback((): void => {
|
||||
if (isSoleShown) {
|
||||
onAction({ type: LegendAction.SHOW_ALL });
|
||||
return;
|
||||
}
|
||||
onAction({
|
||||
type: isAllShown ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
|
||||
seriesIndex,
|
||||
});
|
||||
}, [isSoleShown, isAllShown, onAction, seriesIndex]);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||
event.stopPropagation();
|
||||
onAction({ type: LegendAction.TOGGLE, seriesIndex });
|
||||
},
|
||||
[onAction, seriesIndex],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
// Let the row actions handle their own keys.
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleRowClick();
|
||||
}
|
||||
},
|
||||
[handleRowClick],
|
||||
);
|
||||
|
||||
const handleScopeClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||
event.stopPropagation();
|
||||
if (isShowAllAction) {
|
||||
onAction({ type: LegendAction.SHOW_ALL });
|
||||
return;
|
||||
}
|
||||
onAction({ type: LegendAction.SHOW_ONLY, seriesIndex });
|
||||
},
|
||||
[isShowAllAction, onAction, seriesIndex],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(): void => onAction({ type: LegendAction.HOVER, seriesIndex }),
|
||||
[onAction, seriesIndex],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
|
||||
[onAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, {
|
||||
[styles.isHidden]: !show,
|
||||
[styles.isFocused]: isFocused,
|
||||
})}
|
||||
data-legend-item-id={seriesIndex}
|
||||
data-testid={`legend-item-${seriesIndex}`}
|
||||
role="switch"
|
||||
tabIndex={0}
|
||||
aria-checked={show}
|
||||
aria-label={label}
|
||||
onClick={handleRowClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.marker}
|
||||
style={{
|
||||
borderColor: seriesColor,
|
||||
backgroundColor: show ? seriesColor : 'transparent',
|
||||
}}
|
||||
onClick={handleMarkerClick}
|
||||
disabled={isSoleShown}
|
||||
aria-label={`${show ? 'Hide' : 'Show'} ${label}`}
|
||||
data-is-legend-marker={true}
|
||||
data-testid={`legend-marker-${seriesIndex}`}
|
||||
/>
|
||||
<TooltipSimple
|
||||
title={label}
|
||||
arrow
|
||||
side="top"
|
||||
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
|
||||
disableHoverableContent
|
||||
tooltipContentProps={{ className: styles.rowTooltip }}
|
||||
>
|
||||
<span className={styles.label}>{label}</span>
|
||||
</TooltipSimple>
|
||||
<div className={styles.actions}>
|
||||
<TooltipSimple
|
||||
title={scopeActionLabel}
|
||||
arrow
|
||||
side="top"
|
||||
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
|
||||
disableHoverableContent
|
||||
tooltipContentProps={{ className: styles.rowTooltip }}
|
||||
>
|
||||
{/* Radix's asChild merge strips the button's own data-testid. */}
|
||||
<span className={styles.actionTrigger}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={cx(styles.actionButton, styles.scopeButton)}
|
||||
onClick={handleScopeClick}
|
||||
aria-label={scopeActionLabel}
|
||||
testId={`legend-scope-${seriesIndex}`}
|
||||
>
|
||||
{isShowAllAction ? 'All' : 'Only'}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
{showCopy && (
|
||||
<CopyButton
|
||||
value={label}
|
||||
size={13}
|
||||
className={styles.actionButton}
|
||||
ariaLabel={`Copy ${label}`}
|
||||
testId={`legend-copy-${seriesIndex}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(LegendRow);
|
||||
@@ -0,0 +1,35 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-5, 10px);
|
||||
padding: 0 var(--spacing-4) var(--spacing-5, 10px);
|
||||
flex-shrink: 0;
|
||||
|
||||
> * {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
// Wraps rather than losing the count at its end.
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding-right: var(--spacing-4);
|
||||
padding-bottom: var(--spacing-5, 10px);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
56
frontend/src/lib/uPlotV2/components/Legend/LegendToolbar.tsx
Normal file
56
frontend/src/lib/uPlotV2/components/Legend/LegendToolbar.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ChangeEvent, useCallback } from 'react';
|
||||
import { Input } from 'antd';
|
||||
import { Search } from '@signozhq/icons';
|
||||
|
||||
import styles from './LegendToolbar.module.scss';
|
||||
|
||||
export interface LegendToolbarProps {
|
||||
visibleCount: number;
|
||||
totalCount: number;
|
||||
/** Search is intrinsic to the right-positioned legend. */
|
||||
showFilter: boolean;
|
||||
filterQuery: string;
|
||||
onFilterQueryChange: (query: string) => void;
|
||||
}
|
||||
|
||||
/** Legend chrome: the series search box and the "Showing N of M" readout. */
|
||||
export default function LegendToolbar({
|
||||
visibleCount,
|
||||
totalCount,
|
||||
showFilter,
|
||||
filterQuery,
|
||||
onFilterQueryChange,
|
||||
}: LegendToolbarProps): JSX.Element {
|
||||
const handleFilterChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void =>
|
||||
onFilterQueryChange(event.target.value),
|
||||
[onFilterQueryChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFilter && (
|
||||
<div className={styles.searchContainer}>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<Search size={12} className={styles.searchIcon} />}
|
||||
placeholder="Search..."
|
||||
value={filterQuery}
|
||||
onChange={handleFilterChange}
|
||||
className={styles.searchInput}
|
||||
data-testid="legend-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.toolbar}>
|
||||
<span
|
||||
className={styles.status}
|
||||
aria-live="polite"
|
||||
data-testid="legend-status"
|
||||
>
|
||||
{`Showing ${visibleCount} of ${totalCount} series`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import Legend from './Legend';
|
||||
|
||||
/**
|
||||
* uPlot legend controller. Derives the legend items + focus/visibility state
|
||||
* from the chart config (useLegendsSync) and the toggle/focus interactions from
|
||||
* the plot context (useLegendActions), then renders the presentational Legend.
|
||||
* from the chart config (useLegendsSync) and the series interactions from the
|
||||
* plot context (useLegendActions), then renders the presentational Legend.
|
||||
* Must be rendered inside a PlotContextProvider.
|
||||
*/
|
||||
export default function UPlotLegend({
|
||||
@@ -17,13 +17,8 @@ export default function UPlotLegend({
|
||||
config,
|
||||
averageLegendWidth,
|
||||
}: UPlotLegendProps): JSX.Element {
|
||||
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
|
||||
useLegendsSync({ config });
|
||||
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex,
|
||||
focusedSeriesIndex,
|
||||
});
|
||||
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
|
||||
const onAction = useLegendActions();
|
||||
|
||||
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
|
||||
|
||||
@@ -33,9 +28,7 @@ export default function UPlotLegend({
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import React from 'react';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
|
||||
import { useLegendActions } from '../../../hooks/useLegendActions';
|
||||
import UPlotLegend from '../UPlotLegend';
|
||||
import { LegendAction, LegendActionPayload, LegendPosition } from '../../types';
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
className,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
className?: string;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="virtuoso-grid" className={className}>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendActions');
|
||||
|
||||
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
|
||||
typeof useLegendsSync
|
||||
>;
|
||||
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
|
||||
typeof useLegendActions
|
||||
>;
|
||||
|
||||
/** The payloads of one action type, in dispatch order. */
|
||||
const dispatched = (
|
||||
onAction: jest.Mock,
|
||||
type: LegendAction,
|
||||
): LegendActionPayload[] =>
|
||||
onAction.mock.calls
|
||||
.map(([payload]) => payload as LegendActionPayload)
|
||||
.filter((payload) => payload.type === type);
|
||||
|
||||
describe('UPlotLegend', () => {
|
||||
const baseLegendItemsMap = {
|
||||
0: {
|
||||
seriesIndex: 0,
|
||||
label: 'A',
|
||||
show: true,
|
||||
color: '#ff0000',
|
||||
},
|
||||
1: {
|
||||
seriesIndex: 1,
|
||||
label: 'B',
|
||||
show: false,
|
||||
color: '#00ff00',
|
||||
},
|
||||
2: {
|
||||
seriesIndex: 2,
|
||||
label: 'C',
|
||||
show: true,
|
||||
color: '#0000ff',
|
||||
},
|
||||
};
|
||||
|
||||
let onAction: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
onAction = jest.fn();
|
||||
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: baseLegendItemsMap,
|
||||
focusedSeriesIndex: 1,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
|
||||
mockUseLegendActions.mockReturnValue(onAction);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderLegend = (position?: LegendPosition): RenderResult =>
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UPlotLegend
|
||||
position={position}
|
||||
// config is consumed by the mocked useLegendsSync hook, not directly
|
||||
config={{} as any}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
describe('layout and position', () => {
|
||||
it('renders the search input on a RIGHT legend', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
|
||||
renderLegend();
|
||||
|
||||
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('legend-status')).not.toBeInTheDocument();
|
||||
// The row interactions are the same in both placements.
|
||||
expect(screen.getByTestId('legend-item-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('legend-scope-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the marker with the series colour, filled only when shown', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
|
||||
),
|
||||
).toHaveStyle({
|
||||
'border-color': '#ff0000',
|
||||
'background-color': '#ff0000',
|
||||
});
|
||||
// Hidden series read as an empty checkbox.
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
|
||||
),
|
||||
).toHaveStyle({ 'background-color': 'transparent' });
|
||||
});
|
||||
|
||||
it('renders all legend items in the grid by default', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status readout', () => {
|
||||
it('reports how many series are showing', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-status')).toHaveTextContent(
|
||||
'Showing 2 of 3 series',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter behavior', () => {
|
||||
it('filters legend items based on the query (case-insensitive)', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'a');
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.queryByText('B')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('C')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when nothing matches', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'network');
|
||||
|
||||
expect(
|
||||
screen.getByText(/No series found matching "network"/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ignores a whitespace-only query', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), ' ');
|
||||
|
||||
expect(
|
||||
screen.queryByText(/No series found matching/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('row interactions', () => {
|
||||
const allShownItemsMap = {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1], show: true },
|
||||
2: { ...baseLegendItemsMap[2] },
|
||||
};
|
||||
|
||||
const mockAllShown = (): void => {
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: allShownItemsMap,
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
};
|
||||
|
||||
it('isolates the series when everything is showing', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockAllShown();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
// Nothing the user can see is there to exclude, so the click means Only.
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
|
||||
{ type: LegendAction.SHOW_ONLY, seriesIndex: 0 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('toggles the series once something is already hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes just that series when its marker is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockAllShown();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByTestId('legend-marker-0'));
|
||||
|
||||
// The marker is the one way to exclude a single series while
|
||||
// everything is showing — the row click isolates instead.
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stops the marker offering to hide the last series showing', () => {
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1] },
|
||||
2: { ...baseLegendItemsMap[2], show: false },
|
||||
},
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-marker-0')).toBeDisabled();
|
||||
expect(screen.getByTestId('legend-marker-1')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('labels the marker with what clicking it does', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-marker-0')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Hide A',
|
||||
);
|
||||
expect(screen.getByTestId('legend-marker-1')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Show B',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the clicked series to the selection while one is alone', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1] },
|
||||
2: { ...baseLegendItemsMap[2], show: false },
|
||||
},
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
// Series 0 is showing alone; clicking another row builds the selection
|
||||
// up rather than moving the isolation.
|
||||
await user.click(screen.getByText('B'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 1 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('toggles the series on Enter and Space', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const row = screen.getByTestId('legend-item-0');
|
||||
row.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
await user.keyboard(' ');
|
||||
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reflects visibility on the row for assistive tech', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-item-0')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByTestId('legend-item-1')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores every series from All without also toggling the row', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
// Series 0 is shown while B is hidden, so its action is All.
|
||||
await user.click(screen.getByTestId('legend-scope-0'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('isolates the series from Only on a hidden row', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByTestId('legend-scope-1'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
|
||||
{ type: LegendAction.SHOW_ONLY, seriesIndex: 1 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('highlights the hovered series and clears it on leave', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const row = screen.getByTestId('legend-item-0');
|
||||
await user.hover(row);
|
||||
expect(onAction).toHaveBeenCalledWith({
|
||||
type: LegendAction.HOVER,
|
||||
seriesIndex: 0,
|
||||
});
|
||||
|
||||
await user.unhover(row);
|
||||
expect(onAction).toHaveBeenCalledWith({
|
||||
type: LegendAction.HOVER,
|
||||
seriesIndex: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('one-series state', () => {
|
||||
const soleShownItemsMap = {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1] },
|
||||
2: { ...baseLegendItemsMap[2], show: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: soleShownItemsMap,
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('offers All on the shown row and Only on the hidden ones', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-scope-0')).toHaveTextContent('All');
|
||||
expect(screen.getByTestId('legend-scope-1')).toHaveTextContent('Only');
|
||||
expect(screen.getByTestId('legend-scope-2')).toHaveTextContent('Only');
|
||||
});
|
||||
|
||||
it('restores everything from All', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByTestId('legend-scope-0'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('restores everything when the row showing alone is clicked again', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { filterLegendItems, getShownSeriesState } from '../utils';
|
||||
|
||||
const items = (shown: boolean[]): LegendItem[] =>
|
||||
shown.map((show, index) => ({
|
||||
seriesIndex: index + 1,
|
||||
label: `series-${index}`,
|
||||
color: '#000',
|
||||
show,
|
||||
}));
|
||||
|
||||
describe('getShownSeriesState', () => {
|
||||
it('counts the shown series', () => {
|
||||
expect(getShownSeriesState(items([true, false, true]))).toStrictEqual({
|
||||
visibleCount: 2,
|
||||
soleShownSeriesIndex: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('names the series when exactly one is shown', () => {
|
||||
expect(getShownSeriesState(items([false, true, false]))).toStrictEqual({
|
||||
visibleCount: 1,
|
||||
soleShownSeriesIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports nothing shown', () => {
|
||||
expect(getShownSeriesState(items([false, false]))).toStrictEqual({
|
||||
visibleCount: 0,
|
||||
soleShownSeriesIndex: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterLegendItems', () => {
|
||||
it('matches case-insensitively on the label', () => {
|
||||
const filtered = filterLegendItems(items([true, true, true]), 'SERIES-1');
|
||||
expect(filtered.map((item) => item.label)).toStrictEqual(['series-1']);
|
||||
});
|
||||
|
||||
it('returns every item for a blank query', () => {
|
||||
expect(filterLegendItems(items([true, true]), ' ')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/uPlotV2/components/Legend/constants.ts
Normal file
20
frontend/src/lib/uPlotV2/components/Legend/constants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Widest a single legend item is allowed to get when sizing the legend grid. */
|
||||
export const MAX_LEGEND_WIDTH = 240;
|
||||
|
||||
/**
|
||||
* Enough for a row to contain its own hover actions, which a short label would
|
||||
* otherwise size a column too narrow for. Little room for the label is intended.
|
||||
*/
|
||||
export const MIN_LEGEND_ITEM_WIDTH = 110;
|
||||
|
||||
/** Marker + row padding, on top of the estimated label width. */
|
||||
export const LEGEND_ITEM_EXTRA_WIDTH = 16;
|
||||
|
||||
/** Must match `.row`'s height and the grid's row gap, or the reserved
|
||||
* rectangle clips a row. */
|
||||
export const LEGEND_ROW_HEIGHT = 28;
|
||||
export const LEGEND_ROW_GAP = 2;
|
||||
export const LEGEND_MAX_BOTTOM_ROWS = 2;
|
||||
|
||||
/** Hover delay before a row's full-name tooltip opens. */
|
||||
export const LEGEND_TOOLTIP_DELAY_MS = 500;
|
||||
34
frontend/src/lib/uPlotV2/components/Legend/utils.ts
Normal file
34
frontend/src/lib/uPlotV2/components/Legend/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
export interface ShownSeriesState {
|
||||
visibleCount: number;
|
||||
/** The series index when exactly one series is shown, else null. */
|
||||
soleShownSeriesIndex: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driven by what is actually shown, never a remembered isolation: hiding series
|
||||
* one at a time down to a single one is the same state as "Only".
|
||||
*/
|
||||
export function getShownSeriesState(items: LegendItem[]): ShownSeriesState {
|
||||
const shown = items.filter((item) => item.show);
|
||||
|
||||
return {
|
||||
visibleCount: shown.length,
|
||||
soleShownSeriesIndex: shown.length === 1 ? shown[0].seriesIndex : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterLegendItems(
|
||||
items: LegendItem[],
|
||||
query: string,
|
||||
): LegendItem[] {
|
||||
const normalisedQuery = query.trim().toLowerCase();
|
||||
if (!normalisedQuery) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((item) =>
|
||||
item.label?.toLowerCase().includes(normalisedQuery),
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Matches the legend row's marker.
|
||||
.uplotTooltipItemMarker {
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius);
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
border-width: 1.5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
@@ -30,11 +31,23 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// The legend's mono type; the container's Inter stays for the header.
|
||||
.uplotTooltipItemLabel,
|
||||
.uplotTooltipItemValue {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.uplotTooltipItemLabel {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.uplotTooltipItemValue {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.uplotTooltipItemContentSeparator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function TooltipItem({
|
||||
>
|
||||
<div
|
||||
className={Styles.uplotTooltipItemMarker}
|
||||
style={{ borderColor: item.color }}
|
||||
style={{ borderColor: item.color, backgroundColor: item.color }}
|
||||
data-is-legend-marker={true}
|
||||
data-testid={markerTestId}
|
||||
/>
|
||||
@@ -39,7 +39,7 @@ export default function TooltipItem({
|
||||
className={Styles.uplotTooltipItemContentSeparator}
|
||||
style={{ borderColor: item.color }}
|
||||
/>
|
||||
<span>{item.tooltipValue}</span>
|
||||
<span className={Styles.uplotTooltipItemValue}>{item.tooltipValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
|
||||
import { useLegendActions } from '../../hooks/useLegendActions';
|
||||
import UPlotLegend from '../Legend/UPlotLegend';
|
||||
import { LegendPosition } from '../types';
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
className,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
className?: string;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="virtuoso-grid" className={className}>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendActions');
|
||||
|
||||
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
|
||||
typeof useLegendsSync
|
||||
>;
|
||||
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
|
||||
typeof useLegendActions
|
||||
>;
|
||||
|
||||
describe('UPlotLegend', () => {
|
||||
const baseLegendItemsMap = {
|
||||
0: {
|
||||
seriesIndex: 0,
|
||||
label: 'A',
|
||||
show: true,
|
||||
color: '#ff0000',
|
||||
},
|
||||
1: {
|
||||
seriesIndex: 1,
|
||||
label: 'B',
|
||||
show: false,
|
||||
color: '#00ff00',
|
||||
},
|
||||
2: {
|
||||
seriesIndex: 2,
|
||||
label: 'C',
|
||||
show: true,
|
||||
color: '#0000ff',
|
||||
},
|
||||
};
|
||||
|
||||
let onLegendClick: jest.Mock;
|
||||
let onLegendMouseMove: jest.Mock;
|
||||
let onLegendMouseLeave: jest.Mock;
|
||||
let onFocusSeries: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
onLegendClick = jest.fn();
|
||||
onLegendMouseMove = jest.fn();
|
||||
onLegendMouseLeave = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: baseLegendItemsMap,
|
||||
focusedSeriesIndex: 1,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
|
||||
mockUseLegendActions.mockReturnValue({
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onFocusSeries,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderLegend = (position?: LegendPosition): RenderResult =>
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UPlotLegend
|
||||
position={position}
|
||||
// config is consumed by the mocked useLegendsSync hook, not directly
|
||||
config={{} as any}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
describe('layout and position', () => {
|
||||
it('renders search input when legend position is RIGHT', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render search input when legend position is BOTTOM (default)', () => {
|
||||
renderLegend();
|
||||
|
||||
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the marker with the correct border color', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const legendMarker = document.querySelector(
|
||||
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(legendMarker).toHaveStyle({
|
||||
'border-color': '#ff0000',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all legend items in the grid by default', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search behavior (RIGHT position)', () => {
|
||||
it('filters legend items based on search query (case-insensitive)', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, 'A');
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.queryByText('B')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('C')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no legend items match the search query', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, 'network');
|
||||
|
||||
expect(
|
||||
screen.getByText(/No series found matching "network"/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not filter or show empty state when search query is empty or only whitespace', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, ' ');
|
||||
|
||||
expect(
|
||||
screen.queryByText(/No series found matching/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('legend actions', () => {
|
||||
it('calls onLegendClick when a legend item is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
expect(onLegendClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls mouseMove when the mouse moves over a legend item', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const legendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
|
||||
await user.hover(legendItem);
|
||||
|
||||
expect(onLegendMouseMove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onLegendMouseLeave when the mouse leaves the legend container', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const container = document.querySelector('.legend-container') as HTMLElement;
|
||||
|
||||
await user.hover(container);
|
||||
await user.unhover(container);
|
||||
|
||||
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MouseEventHandler, ReactNode } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import uPlot from 'uplot';
|
||||
@@ -115,26 +115,39 @@ export enum LegendPosition {
|
||||
export interface LegendConfig {
|
||||
position: LegendPosition;
|
||||
}
|
||||
export enum LegendAction {
|
||||
TOGGLE = 'toggle',
|
||||
SHOW_ONLY = 'showOnly',
|
||||
SHOW_ALL = 'showAll',
|
||||
HOVER = 'hover',
|
||||
}
|
||||
|
||||
/** Everything the legend can ask of its container, as one dispatch. */
|
||||
export type LegendActionPayload =
|
||||
/** Row click / Space / Enter / marker click: hide or show that one series. */
|
||||
| { type: LegendAction.TOGGLE; seriesIndex: number }
|
||||
/** Show that series alone. */
|
||||
| { type: LegendAction.SHOW_ONLY; seriesIndex: number }
|
||||
/** Leave the narrowed selection and show every series. */
|
||||
| { type: LegendAction.SHOW_ALL }
|
||||
/** Row hover, for the chart-side highlight; null on leave. */
|
||||
| { type: LegendAction.HOVER; seriesIndex: number | null };
|
||||
|
||||
export type OnLegendAction = (payload: LegendActionPayload) => void;
|
||||
|
||||
/**
|
||||
* Presentational legend props. Source-agnostic: it renders whatever `items`
|
||||
* it's given and delegates interaction to the container handlers, so it serves
|
||||
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie). The search
|
||||
* box is intrinsic to the RIGHT position (derived from `position`, not a flag).
|
||||
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
|
||||
*/
|
||||
export interface LegendProps {
|
||||
items: LegendItem[];
|
||||
/** Legend placement; always supplied by the container. */
|
||||
position: LegendPosition;
|
||||
averageLegendWidth?: number;
|
||||
/** Series index to highlight (hovered/focused). */
|
||||
/** Series index highlighted by the chart cursor. */
|
||||
focusedSeriesIndex: number | null;
|
||||
/**
|
||||
* Container-delegated handlers. Items carry `data-legend-item-id`, so the
|
||||
* handler reads the target's id rather than binding per item.
|
||||
*/
|
||||
onClick: MouseEventHandler<HTMLDivElement>;
|
||||
onMouseMove: MouseEventHandler<HTMLDivElement>;
|
||||
onMouseLeave: () => void;
|
||||
onAction: OnLegendAction;
|
||||
/** Show the per-item copy button. Default true. */
|
||||
showCopy?: boolean;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ export const DEFAULT_HOVER_PROXIMITY_VALUE = 30; // only snap if within 30px hor
|
||||
export const DEFAULT_FOCUS_PROXIMITY_VALUE = 1e6;
|
||||
export const STEP_INTERVAL_MULTIPLIER = 3; // multiply the width computed by STEP_INTERVAL_MULTIPLIER to get the hover prox value
|
||||
|
||||
/** Opacity applied to the series that are NOT highlighted while a legend row is hovered. */
|
||||
export const LEGEND_HIGHLIGHT_DIM_ALPHA = 0.16;
|
||||
/** Stroke-width multiplier applied to the series highlighted from the legend. */
|
||||
export const LEGEND_HIGHLIGHT_WIDTH_RATIO = 1.6;
|
||||
|
||||
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
|
||||
focus: {
|
||||
alpha: 0.3,
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {
|
||||
LEGEND_HIGHLIGHT_DIM_ALPHA,
|
||||
LEGEND_HIGHLIGHT_WIDTH_RATIO,
|
||||
} from 'lib/uPlotV2/constants';
|
||||
import type { SeriesVisibilityItem } from 'lib/visualization/panels/types';
|
||||
import { updateSeriesVisibilityToLocalStorage } from 'lib/visualization/panels/utils/legendVisibilityUtils';
|
||||
import type uPlot from 'uplot';
|
||||
@@ -20,12 +24,26 @@ export interface IPlotContext {
|
||||
setPlotContextInitialState: (state: PlotContextInitialState) => void;
|
||||
onToggleSeriesVisibility: (seriesIndex: number) => void;
|
||||
onToggleSeriesOnOff: (seriesIndex: number) => void;
|
||||
/** Show this series alone. */
|
||||
onShowOnlySeries: (seriesIndex: number) => void;
|
||||
/** Show every series again. */
|
||||
onShowAllSeries: () => void;
|
||||
onFocusSeries: (seriesIndex: number | null) => void;
|
||||
/** Lift one series above the rest (dim + thicken) without changing visibility. */
|
||||
onHighlightSeries: (seriesIndex: number | null) => void;
|
||||
syncSeriesVisibilityToLocalStorage: () => void;
|
||||
}
|
||||
|
||||
export const PlotContext = createContext<IPlotContext | null>(null);
|
||||
|
||||
/** Data series (index 0 is the x-axis) currently drawn. */
|
||||
const countShownSeries = (plot: uPlot): number =>
|
||||
plot.series.reduce(
|
||||
(count, series, index) =>
|
||||
index > 0 && series.show !== false ? count + 1 : count,
|
||||
0,
|
||||
);
|
||||
|
||||
export const PlotContextProvider = ({
|
||||
children,
|
||||
}: PropsWithChildren): JSX.Element => {
|
||||
@@ -33,6 +51,9 @@ export const PlotContextProvider = ({
|
||||
const activeSeriesIndex = useRef<number | undefined>(undefined);
|
||||
const idRef = useRef<string | undefined>(undefined);
|
||||
const shouldSavePreferencesRef = useRef<boolean>(false);
|
||||
/** Pre-highlight stroke widths, captured on the first highlight so it can be undone. */
|
||||
const baseSeriesWidthsRef = useRef<Map<number, number | undefined>>(new Map());
|
||||
const highlightedSeriesIndexRef = useRef<number | null>(null);
|
||||
|
||||
const setPlotContextInitialState = useCallback(
|
||||
({
|
||||
@@ -43,6 +64,8 @@ export const PlotContextProvider = ({
|
||||
uPlotInstanceRef.current = uPlotInstance;
|
||||
idRef.current = id;
|
||||
activeSeriesIndex.current = undefined;
|
||||
baseSeriesWidthsRef.current = new Map();
|
||||
highlightedSeriesIndexRef.current = null;
|
||||
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
|
||||
},
|
||||
[],
|
||||
@@ -64,6 +87,54 @@ export const PlotContextProvider = ({
|
||||
updateSeriesVisibilityToLocalStorage(idRef.current, seriesVisibility);
|
||||
}, []);
|
||||
|
||||
const onHighlightSeries = useCallback((seriesIndex: number | null): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
return;
|
||||
}
|
||||
|
||||
highlightedSeriesIndexRef.current = seriesIndex;
|
||||
|
||||
plot.series.forEach((series, index) => {
|
||||
if (index === 0) {
|
||||
return;
|
||||
}
|
||||
if (!baseSeriesWidthsRef.current.has(index)) {
|
||||
baseSeriesWidthsRef.current.set(index, series.width);
|
||||
}
|
||||
const baseWidth = baseSeriesWidthsRef.current.get(index);
|
||||
const isHighlighted = index === seriesIndex;
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
series.alpha =
|
||||
seriesIndex === null || isHighlighted ? 1 : LEGEND_HIGHLIGHT_DIM_ALPHA;
|
||||
series.width =
|
||||
isHighlighted && baseWidth !== undefined
|
||||
? baseWidth * LEGEND_HIGHLIGHT_WIDTH_RATIO
|
||||
: baseWidth;
|
||||
/* eslint-enable no-param-reassign */
|
||||
});
|
||||
|
||||
// Only the stroke style changed, so the cached paths stay valid.
|
||||
plot.redraw(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Leaving the dim on a hidden series leaves every other one faded, which
|
||||
* reads as an isolation rather than as one series being excluded.
|
||||
*/
|
||||
const clearHighlightIfHidden = useCallback((): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
const highlightedIndex = highlightedSeriesIndexRef.current;
|
||||
if (!plot || highlightedIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (plot.series[highlightedIndex]?.show === false) {
|
||||
onHighlightSeries(null);
|
||||
}
|
||||
}, [onHighlightSeries]);
|
||||
|
||||
const onToggleSeriesVisibility = useCallback(
|
||||
(seriesIndex: number): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
@@ -103,14 +174,61 @@ export const PlotContextProvider = ({
|
||||
if (!series) {
|
||||
return;
|
||||
}
|
||||
|
||||
// An empty chart is never worth reaching.
|
||||
const isHiding = series.show !== false;
|
||||
if (isHiding && countShownSeries(plot) <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
plot.setSeries(seriesIndex, { show: !series.show });
|
||||
if (idRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
|
||||
clearHighlightIfHidden();
|
||||
},
|
||||
[syncSeriesVisibilityToLocalStorage],
|
||||
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
|
||||
);
|
||||
|
||||
/** Applies `resolveShow` to every data series in one batch, then persists. */
|
||||
const setSeriesVisibility = useCallback(
|
||||
(resolveShow: (seriesIndex: number) => boolean): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSeriesIndex.current = undefined;
|
||||
|
||||
plot.batch(() => {
|
||||
plot.series.forEach((_, index) => {
|
||||
if (index === 0) {
|
||||
return;
|
||||
}
|
||||
plot.setSeries(index, { show: resolveShow(index) });
|
||||
});
|
||||
if (idRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
});
|
||||
|
||||
clearHighlightIfHidden();
|
||||
},
|
||||
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
|
||||
);
|
||||
|
||||
const onShowOnlySeries = useCallback(
|
||||
(seriesIndex: number): void => {
|
||||
setSeriesVisibility((index) => index === seriesIndex);
|
||||
},
|
||||
[setSeriesVisibility],
|
||||
);
|
||||
|
||||
const onShowAllSeries = useCallback((): void => {
|
||||
setSeriesVisibility(() => true);
|
||||
}, [setSeriesVisibility]);
|
||||
|
||||
const onFocusSeries = useCallback((seriesIndex: number | null): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
@@ -131,14 +249,20 @@ export const PlotContextProvider = ({
|
||||
onToggleSeriesVisibility,
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
}),
|
||||
[
|
||||
onToggleSeriesVisibility,
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
|
||||
series,
|
||||
batch: jest.fn((fn: () => void) => fn()),
|
||||
setSeries: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
}) as unknown as uPlot;
|
||||
|
||||
interface TestComponentProps {
|
||||
@@ -44,7 +45,10 @@ const TestComponent = ({
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
} = usePlotContext();
|
||||
const handleInit = (): void => {
|
||||
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
|
||||
@@ -84,6 +88,13 @@ const TestComponent = ({
|
||||
>
|
||||
Toggle on/off 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-on-off-2"
|
||||
onClick={(): void => onToggleSeriesOnOff(2)}
|
||||
>
|
||||
Toggle on/off 2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-on-off-5"
|
||||
@@ -98,6 +109,34 @@ const TestComponent = ({
|
||||
>
|
||||
Focus series
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="show-only-1"
|
||||
onClick={(): void => onShowOnlySeries(1)}
|
||||
>
|
||||
Show only 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="show-all"
|
||||
onClick={(): void => onShowAllSeries()}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="highlight-1"
|
||||
onClick={(): void => onHighlightSeries(1)}
|
||||
>
|
||||
Highlight 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="clear-highlight"
|
||||
onClick={(): void => onHighlightSeries(null)}
|
||||
>
|
||||
Clear highlight
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -273,6 +312,7 @@ describe('PlotContext', () => {
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
@@ -324,6 +364,7 @@ describe('PlotContext', () => {
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
@@ -343,6 +384,48 @@ describe('PlotContext', () => {
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to hide the last series showing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
// An empty chart is never a state worth reaching.
|
||||
expect(plot.setSeries).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still shows a hidden series when only one is left showing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: true },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFocusSeries', () => {
|
||||
@@ -381,4 +464,193 @@ describe('PlotContext', () => {
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { focus: true }, false);
|
||||
});
|
||||
});
|
||||
describe('onShowOnlySeries', () => {
|
||||
const renderWithSeries = (
|
||||
series: MockSeries[],
|
||||
): { plot: uPlot; user: ReturnType<typeof userEvent.setup> } => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
return { plot, user };
|
||||
};
|
||||
|
||||
it('hides every other series, leaving the x-axis alone', async () => {
|
||||
const { plot, user } = renderWithSeries([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
]);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('show-only-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
|
||||
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps isolating the series that is already the only one shown', async () => {
|
||||
const { plot, user } = renderWithSeries([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('show-only-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onShowAllSeries', () => {
|
||||
it('shows every hidden series again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('show-all'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: true });
|
||||
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('onHighlightSeries', () => {
|
||||
const series = (): MockSeries[] => [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true, width: 2 },
|
||||
{ label: 'Memory', show: true, width: 2 },
|
||||
];
|
||||
|
||||
it('dims the other series and thickens the highlighted one', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
|
||||
expect(plot.series[1].alpha).toBe(1);
|
||||
expect(plot.series[1].width).toBe(3.2);
|
||||
expect(plot.series[2].alpha).toBe(0.16);
|
||||
expect(plot.series[2].width).toBe(2);
|
||||
// Only the stroke changed, so the cached paths are reused.
|
||||
expect(plot.redraw).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('restores every series when the highlight is cleared', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
await user.click(screen.getByTestId('clear-highlight'));
|
||||
|
||||
expect(plot.series[1].alpha).toBe(1);
|
||||
expect(plot.series[1].width).toBe(2);
|
||||
expect(plot.series[2].alpha).toBe(1);
|
||||
expect(plot.series[2].width).toBe(2);
|
||||
});
|
||||
|
||||
it('drops the dim when the highlighted series is hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
// The mock's setSeries doesn't mutate, so mirror what uPlot would do.
|
||||
(plot.setSeries as jest.Mock).mockImplementation(
|
||||
(index: number, opts: { show?: boolean }) => {
|
||||
if (typeof opts.show === 'boolean') {
|
||||
(plot.series[index] as MockSeries).show = opts.show;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
// Otherwise every remaining series stays faded and the panel reads as
|
||||
// an isolation instead of one series being excluded.
|
||||
expect(plot.series[2].alpha).toBe(1);
|
||||
expect(plot.series[2].width).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps the dim when a different series is hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
(plot.setSeries as jest.Mock).mockImplementation(
|
||||
(index: number, opts: { show?: boolean }) => {
|
||||
if (typeof opts.show === 'boolean') {
|
||||
(plot.series[index] as MockSeries).show = opts.show;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-2'));
|
||||
|
||||
expect(plot.series[1].alpha).toBe(1);
|
||||
expect(plot.series[2].alpha).toBe(0.16);
|
||||
});
|
||||
|
||||
it('leaves visibility untouched', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
|
||||
expect(plot.setSeries).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { LegendAction } from 'lib/uPlotV2/components/types';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
import { useLegendActions } from 'lib/uPlotV2/hooks/useLegendActions';
|
||||
|
||||
@@ -11,10 +12,12 @@ const mockUsePlotContext = usePlotContext as jest.MockedFunction<
|
||||
describe('useLegendActions', () => {
|
||||
let onToggleSeriesVisibility: jest.Mock;
|
||||
let onToggleSeriesOnOff: jest.Mock;
|
||||
let onFocusSeriesPlot: jest.Mock;
|
||||
let onShowOnlySeries: jest.Mock;
|
||||
let onShowAllSeries: jest.Mock;
|
||||
let onFocusSeries: jest.Mock;
|
||||
let onHighlightSeries: jest.Mock;
|
||||
let setPlotContextInitialState: jest.Mock;
|
||||
let syncSeriesVisibilityToLocalStorage: jest.Mock;
|
||||
let setFocusedSeriesIndexMock: jest.Mock;
|
||||
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -37,15 +40,20 @@ describe('useLegendActions', () => {
|
||||
beforeEach(() => {
|
||||
onToggleSeriesVisibility = jest.fn();
|
||||
onToggleSeriesOnOff = jest.fn();
|
||||
onFocusSeriesPlot = jest.fn();
|
||||
onShowOnlySeries = jest.fn();
|
||||
onShowAllSeries = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
onHighlightSeries = jest.fn();
|
||||
setPlotContextInitialState = jest.fn();
|
||||
syncSeriesVisibilityToLocalStorage = jest.fn();
|
||||
setFocusedSeriesIndexMock = jest.fn();
|
||||
|
||||
mockUsePlotContext.mockReturnValue({
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onFocusSeries: onFocusSeriesPlot,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
setPlotContextInitialState,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
});
|
||||
@@ -53,149 +61,65 @@ describe('useLegendActions', () => {
|
||||
cancelAnimationFrameSpy.mockClear();
|
||||
});
|
||||
|
||||
const createMouseEvent = (options: {
|
||||
legendItemId?: number;
|
||||
isMarker?: boolean;
|
||||
}): any => {
|
||||
const { legendItemId, isMarker = false } = options;
|
||||
describe('visibility actions', () => {
|
||||
it('toggles a single series on row click', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
return {
|
||||
target: {
|
||||
dataset: {
|
||||
...(isMarker ? { isLegendMarker: 'true' } : {}),
|
||||
},
|
||||
closest: jest.fn(() =>
|
||||
legendItemId !== undefined
|
||||
? { dataset: { legendItemId: String(legendItemId) } }
|
||||
: null,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
result.current({ type: LegendAction.TOGGLE, seriesIndex: 2 });
|
||||
|
||||
describe('onLegendClick', () => {
|
||||
it('toggles series visibility when clicking on legend label', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendClick(createMouseEvent({ legendItemId: 0 }));
|
||||
|
||||
expect(onToggleSeriesVisibility).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleSeriesVisibility).toHaveBeenCalledWith(0);
|
||||
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles series on/off when clicking on marker', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendClick(
|
||||
createMouseEvent({ legendItemId: 0, isMarker: true }),
|
||||
);
|
||||
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(0);
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(2);
|
||||
// The row must never isolate — that is what "Only" is for.
|
||||
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when click target is not inside a legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
it('forwards the Only and All actions to the plot', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendClick(createMouseEvent({}));
|
||||
result.current({ type: LegendAction.SHOW_ONLY, seriesIndex: 1 });
|
||||
result.current({ type: LegendAction.SHOW_ALL });
|
||||
|
||||
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
|
||||
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
expect(onShowOnlySeries).toHaveBeenCalledWith(1);
|
||||
expect(onShowAllSeries).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFocusSeries', () => {
|
||||
it('schedules focus update and calls plot focus handler via mouse move', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
describe('hover highlight', () => {
|
||||
it('highlights the hovered series', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 2 });
|
||||
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(0);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(0);
|
||||
expect(onHighlightSeries).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('cancels previous animation frame before scheduling new one on subsequent mouse moves', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
it('clears the highlight on leave', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: null });
|
||||
|
||||
expect(onHighlightSeries).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('coalesces rapid hovers into one frame', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 1 });
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 2 });
|
||||
|
||||
// Each new hover cancels the frame the previous one queued.
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLegendMouseMove', () => {
|
||||
it('focuses new series when hovering over different legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
it('cancels a pending highlight frame on unmount', () => {
|
||||
jest
|
||||
.spyOn(global, 'requestAnimationFrame')
|
||||
.mockImplementation((): number => 7);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
const { result, unmount } = renderHook(() => useLegendActions());
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 1 });
|
||||
unmount();
|
||||
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(1);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does nothing when hovering over already focused series', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
|
||||
expect(setFocusedSeriesIndexMock).not.toHaveBeenCalled();
|
||||
expect(onFocusSeriesPlot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLegendMouseLeave', () => {
|
||||
it('cancels pending animation frame and clears focus state', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current.onLegendMouseLeave();
|
||||
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(null);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(null);
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,117 +1,66 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
|
||||
export function useLegendActions({
|
||||
setFocusedSeriesIndex,
|
||||
focusedSeriesIndex,
|
||||
}: {
|
||||
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
|
||||
focusedSeriesIndex: number | null;
|
||||
}): {
|
||||
onLegendClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onFocusSeries: (seriesIndex: number | null) => void;
|
||||
onLegendMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
} {
|
||||
import {
|
||||
LegendAction,
|
||||
LegendActionPayload,
|
||||
OnLegendAction,
|
||||
} from '../components/types';
|
||||
|
||||
/**
|
||||
* Legend interactions, bound to the plot through PlotContext. Hover is coalesced
|
||||
* to one chart redraw per frame.
|
||||
*/
|
||||
export function useLegendActions(): OnLegendAction {
|
||||
const {
|
||||
onFocusSeries: onFocusSeriesPlot,
|
||||
onToggleSeriesOnOff,
|
||||
onToggleSeriesVisibility,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHighlightSeries,
|
||||
} = usePlotContext();
|
||||
|
||||
const rafId = useRef<number | null>(null); // requestAnimationFrame id
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
|
||||
const getLegendItemIdFromEvent = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>): string | undefined => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const legendItemElement = target.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
|
||||
return legendItemElement?.dataset.legendItemId;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onLegendClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const legendItemId = getLegendItemIdFromEvent(e);
|
||||
if (!legendItemId) {
|
||||
return;
|
||||
}
|
||||
const isLegendMarker = (e.target as HTMLElement).dataset.isLegendMarker;
|
||||
const seriesIndex = Number(legendItemId);
|
||||
|
||||
if (isLegendMarker) {
|
||||
onToggleSeriesOnOff(seriesIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
onToggleSeriesVisibility(seriesIndex);
|
||||
},
|
||||
[onToggleSeriesVisibility, onToggleSeriesOnOff, getLegendItemIdFromEvent],
|
||||
);
|
||||
|
||||
const onFocusSeries = useCallback(
|
||||
(seriesIndex: number | null): void => {
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
}
|
||||
rafId.current = requestAnimationFrame(() => {
|
||||
setFocusedSeriesIndex(seriesIndex);
|
||||
onFocusSeriesPlot(seriesIndex);
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[onFocusSeriesPlot],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const legendItemId = getLegendItemIdFromEvent(e);
|
||||
const seriesIndex = legendItemId ? Number(legendItemId) : null;
|
||||
if (seriesIndex === focusedSeriesIndex) {
|
||||
return;
|
||||
const cancelPendingHighlight = useCallback((): void => {
|
||||
if (rafIdRef.current != null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
onFocusSeries(seriesIndex);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onLegendMouseLeave = useCallback(
|
||||
(): void => {
|
||||
// Cancel any pending RAF from handleFocusSeries to prevent race condition
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
rafId.current = null;
|
||||
}
|
||||
setFocusedSeriesIndex(null);
|
||||
onFocusSeries(null);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[onFocusSeries],
|
||||
);
|
||||
useEffect(() => cancelPendingHighlight, [cancelPendingHighlight]);
|
||||
|
||||
// Cleanup pending animation frames on unmount
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
return useCallback(
|
||||
(payload: LegendActionPayload): void => {
|
||||
switch (payload.type) {
|
||||
case LegendAction.TOGGLE:
|
||||
onToggleSeriesOnOff(payload.seriesIndex);
|
||||
break;
|
||||
case LegendAction.SHOW_ONLY:
|
||||
onShowOnlySeries(payload.seriesIndex);
|
||||
break;
|
||||
case LegendAction.SHOW_ALL:
|
||||
onShowAllSeries();
|
||||
break;
|
||||
case LegendAction.HOVER: {
|
||||
const { seriesIndex } = payload;
|
||||
cancelPendingHighlight();
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
onHighlightSeries(seriesIndex);
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[
|
||||
cancelPendingHighlight,
|
||||
onHighlightSeries,
|
||||
onShowAllSeries,
|
||||
onShowOnlySeries,
|
||||
onToggleSeriesOnOff,
|
||||
],
|
||||
);
|
||||
return {
|
||||
onLegendClick,
|
||||
onFocusSeries,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,9 +45,7 @@ export default function Pie({
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onLegendAction,
|
||||
} = usePieInteractions(data, id);
|
||||
|
||||
const {
|
||||
@@ -227,9 +225,7 @@ export default function Pie({
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,17 +100,29 @@ describe('Pie', () => {
|
||||
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
|
||||
});
|
||||
|
||||
it('hides a slice when its legend marker is clicked', () => {
|
||||
it('isolates a slice when its legend row is clicked with everything showing', () => {
|
||||
renderPie();
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(3);
|
||||
|
||||
const marker = document.querySelector(
|
||||
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(marker);
|
||||
fireEvent.click(screen.getByTestId('legend-item-1'));
|
||||
|
||||
// Nothing visible to exclude, so the click isolates: one arc left.
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes a slice when its legend row is clicked with others already hidden', () => {
|
||||
renderPie();
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
|
||||
// Isolate, then add a second slice back, so nothing is isolated any more.
|
||||
fireEvent.click(screen.getByTestId('legend-item-1'));
|
||||
fireEvent.click(screen.getByTestId('legend-item-0'));
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(2);
|
||||
|
||||
fireEvent.click(screen.getByTestId('legend-item-0'));
|
||||
|
||||
// One slice hidden → one fewer arc drawn.
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(2);
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
|
||||
import {
|
||||
calculateAverageLegendWidth,
|
||||
calculateChartDimensions,
|
||||
} from 'lib/visualization/charts/utils';
|
||||
|
||||
const labels = (count: number, length = 20): string[] =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
@@ -49,63 +52,104 @@ describe('calculateChartDimensions', () => {
|
||||
expect(dims.width).toBe(784);
|
||||
});
|
||||
|
||||
it('RIGHT: never shrinks the column below the 150px floor', () => {
|
||||
it('RIGHT: never shrinks the column below the floor that fits its chrome', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(3, 3),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(150);
|
||||
expect(dims.width).toBe(850);
|
||||
expect(dims.legendWidth).toBe(190);
|
||||
expect(dims.width).toBe(810);
|
||||
});
|
||||
|
||||
it('RIGHT: on a narrow container the legend never takes more than 40% of the width', () => {
|
||||
it('RIGHT: on a narrow container the legend keeps its chrome, up to half the width', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 300,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(10, 40),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(120);
|
||||
expect(dims.width).toBe(180);
|
||||
// 40% is 120px, too narrow for the column's own toolbar.
|
||||
expect(dims.legendWidth).toBe(150);
|
||||
expect(dims.width).toBe(150);
|
||||
});
|
||||
|
||||
it('BOTTOM: a single row of items reserves one legend row', () => {
|
||||
it('RIGHT: stops widening the column once the panel is narrower than its chrome', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 200,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(10, 40),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(100);
|
||||
expect(dims.width).toBe(100);
|
||||
});
|
||||
|
||||
it('BOTTOM: items that fit one row reserve exactly one row', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(3),
|
||||
});
|
||||
// One row = line height (28) + padding (12).
|
||||
// One 28px row + the wrapper's 12px bottom padding.
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
expect(dims.height).toBe(460);
|
||||
expect(dims.legendWidth).toBe(1000);
|
||||
});
|
||||
|
||||
it('BOTTOM: many items cap at two rows on a tall container', () => {
|
||||
it('BOTTOM: more items than one row reserve exactly two rows', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
|
||||
expect(dims.legendHeight).toBe(68);
|
||||
expect(dims.height).toBe(432);
|
||||
// Two 28px rows + the 2px row gap + 12px bottom padding — no room for a
|
||||
// clipped third row, and none left over.
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
expect(dims.height).toBe(430);
|
||||
});
|
||||
|
||||
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
|
||||
it('BOTTOM: items one past a row still reserve two rows', () => {
|
||||
// 1000px wide fits 5 of these per row, so 6 items need a second row.
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 160,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(6),
|
||||
});
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
});
|
||||
|
||||
it('BOTTOM: drops to a single row rather than take half a short panel', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 120,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Without the height-relative cap the legend would take 68px of a 160px
|
||||
// panel and the chart (pie especially) collapses to a sliver.
|
||||
expect(dims.legendHeight).toBe(48); // 30% of 160
|
||||
expect(dims.height).toBe(112);
|
||||
// A whole row goes rather than a clipped one being reserved.
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
expect(dims.height).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateAverageLegendWidth', () => {
|
||||
it('scales with the label length', () => {
|
||||
// 16px of chrome + 30 chars at 8px.
|
||||
expect(calculateAverageLegendWidth(labels(4, 30))).toBe(256);
|
||||
});
|
||||
|
||||
it('never drops below what a row needs to contain its hover actions', () => {
|
||||
// Short or unnamed series would otherwise size a column the actions
|
||||
// escape, spilling over the item beside it.
|
||||
expect(calculateAverageLegendWidth(['cpu'])).toBe(110);
|
||||
expect(calculateAverageLegendWidth([''])).toBe(110);
|
||||
});
|
||||
|
||||
it('keeps the default estimate when there are no labels to measure', () => {
|
||||
expect(calculateAverageLegendWidth([])).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import {
|
||||
LEGEND_MAX_BOTTOM_ROWS,
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
LEGEND_ROW_GAP,
|
||||
LEGEND_ROW_HEIGHT,
|
||||
MAX_LEGEND_WIDTH,
|
||||
} from 'lib/uPlotV2/components/Legend/constants';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
export interface ChartDimensions {
|
||||
width: number;
|
||||
@@ -13,22 +19,31 @@ const LEGEND_WIDTH_PERCENTILE = 0.85;
|
||||
const DEFAULT_AVG_LABEL_LENGTH = 15;
|
||||
const BASE_LEGEND_WIDTH = 16;
|
||||
const LEGEND_PADDING = 12;
|
||||
const LEGEND_LINE_HEIGHT = 28;
|
||||
// Two rows are worth having, but not at the cost of half the panel.
|
||||
const MAX_SHORT_PANEL_LEGEND_RATIO = 0.5;
|
||||
|
||||
// RIGHT legend is a vertical column with its own width budget (cap protects the donut).
|
||||
const MAX_RIGHT_LEGEND_WIDTH = 320;
|
||||
const RIGHT_LEGEND_WIDTH_RATIO = 0.4;
|
||||
// Column padding + copy button, not covered by the text-length estimate.
|
||||
const RIGHT_LEGEND_RESERVED_WIDTH = 40;
|
||||
// Fits the toolbar's "Showing N of M series" readout plus the wrapper padding.
|
||||
const MIN_RIGHT_LEGEND_WIDTH = 190;
|
||||
// Past this the split inverts and the chart becomes the smaller half.
|
||||
const RIGHT_LEGEND_FLOOR_RATIO = 0.5;
|
||||
|
||||
/**
|
||||
* Calculates the average width of the legend items based on the labels of the series.
|
||||
* Never returns less than a legend row needs to hold its own hover actions.
|
||||
* @param legends - The labels of the series.
|
||||
* @returns The average width of the legend items.
|
||||
*/
|
||||
export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
if (legends.length === 0) {
|
||||
return DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
|
||||
return Math.max(
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
|
||||
@@ -36,7 +51,10 @@ export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
|
||||
const percentileLength = lengths[Math.max(0, index)];
|
||||
|
||||
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
|
||||
return Math.max(
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +70,9 @@ export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
* - Chart width is `containerWidth - legendWidth`.
|
||||
* - BOTTOM legend:
|
||||
* - Computes how many items fit per row, then uses at most 2 rows.
|
||||
* - `legendHeight` is derived from row count, capped by both a fixed pixel max and a % of container height.
|
||||
* - `legendHeight` is exactly those rows plus the wrapper's bottom padding, so
|
||||
* the rectangle never clips a row or reserves space for half of one. Two
|
||||
* rows that would take half a short panel fall back to one row.
|
||||
* - Chart height is `containerHeight - legendHeight`, never below 0.
|
||||
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
|
||||
*
|
||||
@@ -100,9 +120,14 @@ export function calculateChartDimensions({
|
||||
MAX_RIGHT_LEGEND_WIDTH,
|
||||
containerWidth * RIGHT_LEGEND_WIDTH_RATIO,
|
||||
);
|
||||
// The column's chrome outranks the 40% share on a narrow panel.
|
||||
const floorWidth = Math.min(
|
||||
MIN_RIGHT_LEGEND_WIDTH,
|
||||
containerWidth * RIGHT_LEGEND_FLOOR_RATIO,
|
||||
);
|
||||
const rightLegendWidth = Math.min(
|
||||
Math.max(150, desiredLegendWidth),
|
||||
maxRightLegendWidth,
|
||||
Math.max(MIN_RIGHT_LEGEND_WIDTH, desiredLegendWidth),
|
||||
Math.max(floorWidth, maxRightLegendWidth),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -115,8 +140,6 @@ export function calculateChartDimensions({
|
||||
};
|
||||
}
|
||||
|
||||
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
|
||||
|
||||
const legendItemWidth = Math.ceil(
|
||||
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
|
||||
);
|
||||
@@ -125,30 +148,30 @@ export function calculateChartDimensions({
|
||||
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
|
||||
);
|
||||
|
||||
const legendRowCount = Math.min(
|
||||
2,
|
||||
Math.ceil(legendItemCount / legendItemsPerRow),
|
||||
// The wrapper's bottom padding is inside this height (border-box).
|
||||
const heightForRows = (rowCount: number): number =>
|
||||
rowCount * LEGEND_ROW_HEIGHT +
|
||||
(rowCount - 1) * LEGEND_ROW_GAP +
|
||||
LEGEND_PADDING;
|
||||
|
||||
const neededRowCount = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
LEGEND_MAX_BOTTOM_ROWS,
|
||||
Math.ceil(legendItemCount / legendItemsPerRow),
|
||||
),
|
||||
);
|
||||
|
||||
const idealBottomLegendHeight =
|
||||
legendRowCount > 1
|
||||
? legendRowCount * legendRowHeight - LEGEND_PADDING
|
||||
: legendRowHeight;
|
||||
// Without this, short grid panels hand most of their area to the legend and
|
||||
// the chart — the pie donut especially — collapses to a sliver. Dropping a
|
||||
// whole row beats clipping one.
|
||||
const legendRowCount =
|
||||
neededRowCount > 1 &&
|
||||
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO
|
||||
? 1
|
||||
: neededRowCount;
|
||||
|
||||
// Cap at two rows / 80px, and never more than 30% of the container height
|
||||
// (the doc above always promised the %-cap; without it, short grid panels
|
||||
// hand most of their area to the legend and the chart — the pie donut
|
||||
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
|
||||
const maxAllowedLegendHeight = Math.min(
|
||||
2 * legendRowHeight,
|
||||
80,
|
||||
Math.floor(containerHeight * 0.3),
|
||||
);
|
||||
|
||||
const bottomLegendHeight = Math.min(
|
||||
idealBottomLegendHeight,
|
||||
maxAllowedLegendHeight,
|
||||
);
|
||||
const bottomLegendHeight = heightForRows(legendRowCount);
|
||||
|
||||
return {
|
||||
width: containerWidth,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { LegendAction } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
updateSeriesVisibilityToLocalStorage,
|
||||
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { PieSlice } from 'lib/visualization/charts/types';
|
||||
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
|
||||
@@ -24,22 +24,6 @@ const DATA: PieSlice[] = [
|
||||
{ label: 'checkout', value: 40, color: '#c' },
|
||||
];
|
||||
|
||||
// Builds a fake legend click/move event: `e.target.closest('[data-legend-item-id]')`
|
||||
// resolves to the item at `index`, and `e.target.dataset.isLegendMarker` flags marker clicks.
|
||||
function legendEvent(
|
||||
index: number | null,
|
||||
isMarker = false,
|
||||
): MouseEvent<HTMLDivElement> {
|
||||
const itemEl =
|
||||
index == null ? null : { dataset: { legendItemId: String(index) } };
|
||||
return {
|
||||
target: {
|
||||
closest: (): unknown => itemEl,
|
||||
dataset: { isLegendMarker: isMarker ? 'true' : undefined },
|
||||
},
|
||||
} as unknown as MouseEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
describe('usePieInteractions', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStored.mockReturnValue(null);
|
||||
@@ -59,11 +43,16 @@ describe('usePieInteractions', () => {
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
|
||||
describe('marker click (toggle one)', () => {
|
||||
describe('row toggle', () => {
|
||||
it('hides then unhides the clicked slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
|
||||
expect(result.current.legendItems[1].show).toBe(false);
|
||||
@@ -73,18 +62,50 @@ describe('usePieInteractions', () => {
|
||||
{ label: 'checkout', show: true },
|
||||
]);
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
expect(result.current.legendItems[1].show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('label click (isolate / reset)', () => {
|
||||
it('isolates the clicked slice, then resets on a second click', () => {
|
||||
describe('the last slice showing', () => {
|
||||
it('cannot be hidden', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// An empty donut is never a state worth reaching.
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Only', () => {
|
||||
it('isolates the slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
|
||||
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
|
||||
@@ -92,8 +113,39 @@ describe('usePieInteractions', () => {
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
it('switches the isolation to another slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('All', () => {
|
||||
it('brings every hidden slice back', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
});
|
||||
@@ -103,11 +155,37 @@ describe('usePieInteractions', () => {
|
||||
it('focuses the hovered slice and clears on leave', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(2)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 2 }),
|
||||
);
|
||||
expect(result.current.active).toStrictEqual(DATA[2]);
|
||||
expect(result.current.focusedSeriesIndex).toBe(2);
|
||||
|
||||
act(() => result.current.onLegendMouseLeave());
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.HOVER,
|
||||
seriesIndex: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current.active).toBeNull();
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the focus when the focused slice is hidden', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
// Otherwise every remaining arc stays dimmed and the donut reads as an
|
||||
// isolation instead of one slice being excluded.
|
||||
expect(result.current.active).toBeNull();
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
@@ -115,8 +193,15 @@ describe('usePieInteractions', () => {
|
||||
it('does not focus a hidden slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(1)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
|
||||
);
|
||||
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
@@ -125,7 +210,12 @@ describe('usePieInteractions', () => {
|
||||
describe('persistence', () => {
|
||||
it('does not write to storage when no id is provided', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
act(() => result.current.onLegendClick(legendEvent(0, true)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
expect(mockUpdateStored).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
LegendAction,
|
||||
LegendActionPayload,
|
||||
OnLegendAction,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
@@ -18,27 +23,15 @@ export interface UsePieInteractionsResult {
|
||||
legendItems: LegendItem[];
|
||||
/** Index of the active slice for the legend's focus highlight, or null. */
|
||||
focusedSeriesIndex: number | null;
|
||||
onLegendClick: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseMove: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
}
|
||||
|
||||
// Reads the slice index off the nearest `[data-legend-item-id]` ancestor of the
|
||||
// event target (the shared Legend tags each item with its seriesIndex).
|
||||
function getLegendIndex(e: MouseEvent<HTMLDivElement>): number | null {
|
||||
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
const id = el?.dataset.legendItemId;
|
||||
return id != null ? Number(id) : null;
|
||||
/** Every legend interaction, dispatched by type. */
|
||||
onLegendAction: OnLegendAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pie interaction + derived state: hover/focus, slice hide/unhide (mirroring the
|
||||
* uPlot legend — marker toggles one, label isolates), and persistence of the
|
||||
* hidden set to localStorage (keyed by `id`, matched by label) so it survives
|
||||
* reloads. Returns the visible slices, legend items, focus index, and the
|
||||
* legend container handlers.
|
||||
* Pie interaction + derived state: hover/focus, slice hide/show driven by the
|
||||
* shared legend's actions, and persistence of the hidden set to localStorage
|
||||
* (keyed by `id`, matched by label) so it survives reloads. Returns the visible
|
||||
* slices, legend items, focus index, and the legend action dispatch.
|
||||
*/
|
||||
export function usePieInteractions(
|
||||
data: PieSlice[],
|
||||
@@ -48,7 +41,6 @@ export function usePieInteractions(
|
||||
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const isolatedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
@@ -104,65 +96,88 @@ export function usePieInteractions(
|
||||
[id, data],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
const hoverSeries = useCallback(
|
||||
(sliceIndex: number | null): void => {
|
||||
// Don't focus/dim for hidden slices — they aren't on the donut.
|
||||
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
|
||||
setActive(
|
||||
sliceIndex != null && !hiddenIndices.has(sliceIndex)
|
||||
? data[sliceIndex]
|
||||
: null,
|
||||
);
|
||||
},
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// Marker click toggles just that slice on/off; label click isolates it
|
||||
// (clicking the isolated one again resets to all) — mirrors the uPlot legend.
|
||||
const onLegendClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
if (index == null) {
|
||||
return;
|
||||
}
|
||||
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
|
||||
|
||||
if (isMarker) {
|
||||
const next = new Set(hiddenIndices);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
const toggleSeries = useCallback(
|
||||
(sliceIndex: number): void => {
|
||||
const next = new Set(hiddenIndices);
|
||||
if (next.has(sliceIndex)) {
|
||||
next.delete(sliceIndex);
|
||||
} else {
|
||||
// An empty donut is never worth reaching.
|
||||
if (data.length - next.size <= 1) {
|
||||
return;
|
||||
}
|
||||
applyHidden(next);
|
||||
return;
|
||||
next.add(sliceIndex);
|
||||
}
|
||||
applyHidden(next);
|
||||
},
|
||||
[data.length, hiddenIndices, applyHidden],
|
||||
);
|
||||
|
||||
const isReset = isolatedIndexRef.current === index;
|
||||
isolatedIndexRef.current = isReset ? null : index;
|
||||
if (isReset) {
|
||||
applyHidden(new Set());
|
||||
return;
|
||||
}
|
||||
const showOnlySeries = useCallback(
|
||||
(sliceIndex: number): void => {
|
||||
const next = new Set<number>();
|
||||
data.forEach((_, i) => {
|
||||
if (i !== index) {
|
||||
next.add(i);
|
||||
data.forEach((_, index) => {
|
||||
if (index !== sliceIndex) {
|
||||
next.add(index);
|
||||
}
|
||||
});
|
||||
applyHidden(next);
|
||||
},
|
||||
[data, hiddenIndices, applyHidden],
|
||||
[data, applyHidden],
|
||||
);
|
||||
|
||||
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
|
||||
const showAllSeries = useCallback(
|
||||
(): void => applyHidden(new Set()),
|
||||
[applyHidden],
|
||||
);
|
||||
|
||||
const focusedIndex = active ? data.indexOf(active) : -1;
|
||||
const onLegendAction = useCallback(
|
||||
(payload: LegendActionPayload): void => {
|
||||
switch (payload.type) {
|
||||
case LegendAction.TOGGLE:
|
||||
toggleSeries(payload.seriesIndex);
|
||||
break;
|
||||
case LegendAction.SHOW_ONLY:
|
||||
showOnlySeries(payload.seriesIndex);
|
||||
break;
|
||||
case LegendAction.SHOW_ALL:
|
||||
showAllSeries();
|
||||
break;
|
||||
case LegendAction.HOVER:
|
||||
hoverSeries(payload.seriesIndex);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[toggleSeries, showOnlySeries, showAllSeries, hoverSeries],
|
||||
);
|
||||
|
||||
const activeIndex = active ? data.indexOf(active) : -1;
|
||||
// Left active, a hidden slice keeps every other arc dimmed, which reads as an
|
||||
// isolation rather than as one slice being excluded.
|
||||
const effectiveActive =
|
||||
activeIndex >= 0 && !hiddenIndices.has(activeIndex) ? active : null;
|
||||
const focusedIndex = effectiveActive ? activeIndex : -1;
|
||||
|
||||
return {
|
||||
active,
|
||||
active: effectiveActive,
|
||||
setActive,
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onLegendAction,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding: 0 12px 12px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/constants';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const KIND_LABEL: Record<VariableUsage['kind'], string> = {
|
||||
promql: 'PromQL',
|
||||
clickhouse: 'ClickHouse',
|
||||
variable: 'Variable',
|
||||
text: 'Markdown body',
|
||||
};
|
||||
|
||||
interface VariableImpactDialogProps {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { buildVariableImpactPatch } from '../utils/variableImpactPatch';
|
||||
import type { VariableUsage } from '../utils/variableUsages';
|
||||
|
||||
jest.mock('../variableAdapters', () => ({
|
||||
formModelToDto: (model: unknown): unknown => model,
|
||||
}));
|
||||
|
||||
const dashboard = {
|
||||
spec: {
|
||||
panels: {
|
||||
runbook: {
|
||||
spec: {
|
||||
display: { name: 'Runbook' },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text: 'env {{svc}}' } },
|
||||
queries: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
variables: [],
|
||||
},
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
const textUsage: VariableUsage = {
|
||||
id: 'panel:runbook:0',
|
||||
sourceType: 'panel',
|
||||
sourceId: 'runbook',
|
||||
sourceLabel: 'Runbook',
|
||||
kind: 'text',
|
||||
envelopeIndex: 0,
|
||||
currentText: 'env {{svc}}',
|
||||
resultingText: 'env {{zone}}',
|
||||
};
|
||||
|
||||
describe('buildVariableImpactPatch — text panel bodies', () => {
|
||||
it('replaces the plugin-spec text, never the (empty) queries', () => {
|
||||
const ops = buildVariableImpactPatch(dashboard, [], [textUsage]);
|
||||
|
||||
const panelOps = ops.filter((op) => op.path.includes('/panels/'));
|
||||
expect(panelOps).toStrictEqual([
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/spec/panels/runbook/spec/plugin/spec/text',
|
||||
value: 'env {{zone}}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,16 @@ function promqlPanel(name: string, query: string): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
function textPanel(name: string, text: string): unknown {
|
||||
return {
|
||||
spec: {
|
||||
display: { name },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text } },
|
||||
queries: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dashboard(
|
||||
panels: Record<string, unknown>,
|
||||
variables: VariableFormModel[],
|
||||
@@ -99,6 +109,38 @@ describe('findVariableUsages', () => {
|
||||
it('returns nothing for an unreferenced variable', () => {
|
||||
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
|
||||
});
|
||||
|
||||
describe('text panel bodies (TDD D5)', () => {
|
||||
const textDash = dashboard(
|
||||
{
|
||||
runbook: textPanel(
|
||||
'Runbook',
|
||||
'env {{svc}} / {{.svc}} / [[svc]] / $svc / {{svcx}}',
|
||||
),
|
||||
unrelated: textPanel('Plain', 'no tokens here'),
|
||||
},
|
||||
[variable({ name: 'svc', type: 'QUERY' })],
|
||||
);
|
||||
|
||||
it('finds the body usage and skips bodies without the token', () => {
|
||||
const usages = findVariableUsages(textDash, 'svc', 'rename', 'zone');
|
||||
expect(usages.map((u) => u.id)).toStrictEqual(['panel:runbook:0']);
|
||||
expect(usages[0].kind).toBe('text');
|
||||
expect(usages[0].sourceLabel).toBe('Runbook');
|
||||
});
|
||||
|
||||
it('rewrites all four syntaxes on rename, leaving other names alone', () => {
|
||||
const [usage] = findVariableUsages(textDash, 'svc', 'rename', 'zone');
|
||||
expect(usage.resultingText).toBe(
|
||||
'env {{zone}} / {{.zone}} / [[zone]] / $zone / {{svcx}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the body for review on delete', () => {
|
||||
const [usage] = findVariableUsages(textDash, 'svc', 'delete');
|
||||
expect(usage.resultingText).toBe(usage.currentText);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findApplyUsages', () => {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { isStaticPanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
|
||||
/**
|
||||
* The markdown body of a static (query-less) panel, or null for query kinds.
|
||||
* One localized cast: the plugin-spec union can't be narrowed by a dynamic kind.
|
||||
*/
|
||||
export function getTextPanelBody(
|
||||
spec: DashboardtypesPanelSpecDTO | undefined,
|
||||
): string | null {
|
||||
if (!spec?.plugin || !isStaticPanelKind(spec.plugin.kind)) {
|
||||
return null;
|
||||
}
|
||||
const { text } = spec.plugin.spec as { text?: string };
|
||||
return typeof text === 'string' ? text : null;
|
||||
}
|
||||
@@ -107,6 +107,18 @@ export function buildVariableImpactPatch(
|
||||
|
||||
byPanel.forEach((list, panelId) => {
|
||||
const panel = panels[panelId];
|
||||
|
||||
// A static kind's usage edits its markdown body, not a query.
|
||||
const textUsage = list.find((usage) => usage.kind === 'text');
|
||||
if (textUsage) {
|
||||
ops.push({
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: `/spec/panels/${panelId}/spec/plugin/spec/text`,
|
||||
value: textUsage.resultingText,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!panel?.spec?.queries?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
|
||||
import { getTextPanelBody } from './getTextPanelBody';
|
||||
import { dtoToFormModel } from '../variableAdapters';
|
||||
|
||||
/** The kind of query text a variable is referenced from. */
|
||||
@@ -19,7 +20,8 @@ export type VariableUsageKind =
|
||||
| 'builder'
|
||||
| 'promql'
|
||||
| 'clickhouse'
|
||||
| 'variable';
|
||||
| 'variable'
|
||||
| 'text';
|
||||
|
||||
export type VariableImpactMode = 'rename' | 'delete' | 'apply';
|
||||
|
||||
@@ -81,7 +83,7 @@ function computeResultingText(
|
||||
return rewriteVariableReferences(text, variableName, newName);
|
||||
}
|
||||
// delete: only builder filter clauses can be safely auto-stripped; raw PromQL/
|
||||
// ClickHouse and variable queries are left for the user to edit.
|
||||
// ClickHouse, markdown bodies and variable queries are left for the user to edit.
|
||||
return kind === 'builder'
|
||||
? removeVariableFromExpression(text, variableName)
|
||||
: text;
|
||||
@@ -106,6 +108,31 @@ export function findVariableUsages(
|
||||
const spec = dashboard.spec;
|
||||
|
||||
Object.entries(spec.panels ?? {}).forEach(([panelId, panel]) => {
|
||||
// A static kind references variables from its body, not a query (TDD D5 —
|
||||
// rename must rewrite text bodies too, or it silently orphans the tokens).
|
||||
const textBody = getTextPanelBody(panel?.spec);
|
||||
if (typeof textBody === 'string') {
|
||||
if (textContainsVariableReference(textBody, variableName)) {
|
||||
usages.push({
|
||||
id: `panel:${panelId}:0`,
|
||||
sourceType: 'panel',
|
||||
sourceId: panelId,
|
||||
sourceLabel: panel.spec?.display?.name || panelId,
|
||||
kind: 'text',
|
||||
envelopeIndex: 0,
|
||||
currentText: textBody,
|
||||
resultingText: computeResultingText(
|
||||
'text',
|
||||
textBody,
|
||||
variableName,
|
||||
mode,
|
||||
newName,
|
||||
),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const queries = panel?.spec?.queries;
|
||||
if (!queries?.length) {
|
||||
return;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
@@ -66,7 +67,14 @@ function ConfigPane({
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
const panelKind = spec.plugin.kind;
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
const sections = definition.sections;
|
||||
// The header toggle belongs with the title and description it hides, so the kind's
|
||||
// declaration still gates it but it renders above, out of the display options.
|
||||
const headerSection = definition.sections.find(
|
||||
(config) => config.kind === SectionKind.PanelHeader,
|
||||
);
|
||||
const sections = definition.sections.filter(
|
||||
(config) => config.kind !== SectionKind.PanelHeader,
|
||||
);
|
||||
|
||||
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
|
||||
|
||||
@@ -105,6 +113,23 @@ function ConfigPane({
|
||||
onChange={(e): void => setDisplayField('description', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{headerSection && (
|
||||
<SectionSlot
|
||||
bare
|
||||
config={headerSection}
|
||||
spec={spec}
|
||||
onChangeSpec={onChangeSpec}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sections.length > 0 && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
isStaticPanelKind,
|
||||
isQueryTypeSupportedByPanelKind,
|
||||
isSignalSupported,
|
||||
} from '../../../Panels/capabilities';
|
||||
@@ -37,6 +38,12 @@ export function getPanelTypeDisabledReason({
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
label: string;
|
||||
}): string | undefined {
|
||||
// A kind that renders without a query pairs with anything — it declares no
|
||||
// query types or signals, and the checks below would read that as "supports
|
||||
// nothing" and disable it everywhere.
|
||||
if (isStaticPanelKind(kind)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
|
||||
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ type SectionSlotProps = {
|
||||
config: SectionConfig;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Renders the editor alone, for a section promoted into the Panel Details fields. */
|
||||
bare?: boolean;
|
||||
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
|
||||
|
||||
// Per-section header content; `trigger` expands the section and runs the editor's handler.
|
||||
@@ -50,6 +52,7 @@ function SectionSlot({
|
||||
config,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
bare,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
signal,
|
||||
@@ -110,6 +113,28 @@ function SectionSlot({
|
||||
|
||||
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
|
||||
|
||||
const editorElement = (
|
||||
<Component
|
||||
value={get(spec)}
|
||||
controls={controls}
|
||||
onChange={(next): void => onChangeSpec(update(spec, next))}
|
||||
legendSeries={legendSeries}
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
registerHeaderAction={registerHeaderAction}
|
||||
/>
|
||||
);
|
||||
|
||||
if (bare) {
|
||||
return editorElement;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={title}
|
||||
@@ -118,21 +143,7 @@ function SectionSlot({
|
||||
onOpenChange={setOpen}
|
||||
headerSlot={headerSlot}
|
||||
>
|
||||
<Component
|
||||
value={get(spec)}
|
||||
controls={controls}
|
||||
onChange={(next): void => onChangeSpec(update(spec, next))}
|
||||
legendSeries={legendSeries}
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
registerHeaderAction={registerHeaderAction}
|
||||
/>
|
||||
{editorElement}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
function textSpec(): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Runbook', description: 'steps' },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text: '' } },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
function spec(unit?: string): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'CPU', description: 'usage' },
|
||||
@@ -93,6 +101,24 @@ describe('ConfigPane', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// It hides the title strip, so it sits with the title rather than under the
|
||||
// display options — and only a kind whose spec accepts `headerOptions` shows it.
|
||||
it('renders the hide-header toggle among the Panel Details fields', () => {
|
||||
renderConfigPane({ spec: textSpec() });
|
||||
|
||||
const toggle = screen.getByTestId('panel-header-hide');
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(screen.getByText('Hide header')).toBeInTheDocument();
|
||||
// No collapsible wrapper of its own.
|
||||
expect(screen.queryByText('Panel header')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the hide-header toggle for a kind that has no header options', () => {
|
||||
renderConfigPane();
|
||||
|
||||
expect(screen.queryByTestId('panel-header-hide')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Formatting section for a kind that declares it', () => {
|
||||
renderConfigPane();
|
||||
// The TimeSeries kind declares a Formatting section; its collapsible header shows.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
// The input carries focus, so the ring is drawn on the swatch around it.
|
||||
&:has(.input:focus-visible) {
|
||||
outline: 2px solid var(--bg-robin-400);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
// The real control, sized to the swatch and invisible over it: clicks and focus
|
||||
// land on the radio, the swatch is what the user sees.
|
||||
.input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selected {
|
||||
box-shadow: 0 0 0 2px var(--bg-robin-500);
|
||||
}
|
||||
|
||||
// Transparency has no colour to show, so it reads as the conventional checkerboard.
|
||||
.checkerboard {
|
||||
background-color: var(--l2-background);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
45deg,
|
||||
var(--l2-border) 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
var(--l2-border) 75%
|
||||
),
|
||||
linear-gradient(
|
||||
45deg,
|
||||
var(--l2-border) 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
var(--l2-border) 75%
|
||||
);
|
||||
background-size: 8px 8px;
|
||||
background-position:
|
||||
0 0,
|
||||
4px 4px;
|
||||
}
|
||||
|
||||
.defaultSurface {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
margin: 0 2px;
|
||||
background: var(--l2-border);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
TEXT_BACKGROUND_PAIRS,
|
||||
TEXT_BACKGROUND_PRESETS,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
|
||||
import type {
|
||||
PanelTheme,
|
||||
TextBackgroundPreset,
|
||||
TextBackgroundSelection,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
import { TextBackgroundKind } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
|
||||
import styles from './BackgroundSwatches.module.scss';
|
||||
|
||||
const PRESET_TITLES: Record<TextBackgroundPreset, string> = {
|
||||
robin: 'Robin',
|
||||
purple: 'Purple',
|
||||
sakura: 'Sakura',
|
||||
cherry: 'Cherry',
|
||||
amber: 'Amber',
|
||||
forest: 'Forest',
|
||||
sienna: 'Sienna',
|
||||
slate: 'Slate',
|
||||
};
|
||||
|
||||
type BaseSelection = TextBackgroundKind.None | TextBackgroundKind.Default;
|
||||
|
||||
const BASE_TITLES: Record<BaseSelection, string> = {
|
||||
none: 'Transparent',
|
||||
default: 'Default panel',
|
||||
};
|
||||
|
||||
/** Neither base swatch shows a colour, so its tooltip says what it does. */
|
||||
const BASE_TOOLTIPS: Record<BaseSelection, string> = {
|
||||
none: 'Transparent — no card, border or title bar',
|
||||
default: 'Default panel colour',
|
||||
};
|
||||
|
||||
const OPTIONS: TextBackgroundSelection[] = [
|
||||
TextBackgroundKind.None,
|
||||
TextBackgroundKind.Default,
|
||||
...TEXT_BACKGROUND_PRESETS,
|
||||
];
|
||||
|
||||
const DIVIDER_AFTER = 1;
|
||||
|
||||
interface BackgroundSwatchesProps {
|
||||
testId: string;
|
||||
/** Names the group for assistive tech — the row carries no visible label. */
|
||||
label: string;
|
||||
/** `undefined` while a custom colour is active: no swatch is selected. */
|
||||
value: TextBackgroundSelection | undefined;
|
||||
/** Swatches paint in this theme's pair, so what the user picks is what they see. */
|
||||
theme: PanelTheme;
|
||||
onChange: (value: TextBackgroundSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Text panel's background choices as one radio group. Native radios sharing a
|
||||
* `name`, so arrow-key movement, the single tab stop and selection-follows-focus
|
||||
* are the platform's; each input is transparent and fills its swatch.
|
||||
*/
|
||||
function BackgroundSwatches({
|
||||
testId,
|
||||
label,
|
||||
value,
|
||||
theme,
|
||||
onChange,
|
||||
}: BackgroundSwatchesProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.row}
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
>
|
||||
{OPTIONS.map((option, index) => {
|
||||
const isBase =
|
||||
option === TextBackgroundKind.None ||
|
||||
option === TextBackgroundKind.Default;
|
||||
const pair = isBase ? undefined : TEXT_BACKGROUND_PAIRS[option][theme];
|
||||
const title = isBase ? BASE_TITLES[option] : PRESET_TITLES[option];
|
||||
|
||||
return (
|
||||
<Fragment key={option}>
|
||||
<TooltipSimple title={isBase ? BASE_TOOLTIPS[option] : title} arrow>
|
||||
<label
|
||||
className={cx(styles.swatch, {
|
||||
[styles.checkerboard]: option === TextBackgroundKind.None,
|
||||
[styles.defaultSurface]: option === TextBackgroundKind.Default,
|
||||
[styles.selected]: option === value,
|
||||
})}
|
||||
style={pair ? { background: pair.surface, color: pair.ink } : undefined}
|
||||
data-testid={`${testId}-${option}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className={styles.input}
|
||||
name={testId}
|
||||
value={option}
|
||||
checked={option === value}
|
||||
aria-label={title}
|
||||
onChange={(): void => onChange(option)}
|
||||
/>
|
||||
{option === value && <Check size={14} />}
|
||||
</label>
|
||||
</TooltipSimple>
|
||||
{index === DIVIDER_AFTER && <span className={styles.divider} />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BackgroundSwatches;
|
||||
@@ -0,0 +1,67 @@
|
||||
.row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.active {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
.chip {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// No colour to show yet, so the chip advertises that it opens a picker.
|
||||
.chipEmpty {
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
var(--bg-cherry-400),
|
||||
var(--bg-amber-400),
|
||||
var(--bg-forest-400),
|
||||
var(--bg-robin-400),
|
||||
var(--bg-sakura-400),
|
||||
var(--bg-cherry-400)
|
||||
);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.hex {
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
// Appended under the picker's own panel.
|
||||
.contrast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 4px 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--bg-amber-400);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Check, ChevronDown, TriangleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ColorPicker } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
contrastRatio,
|
||||
inkForSurface,
|
||||
MIN_CONTRAST_RATIO,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/contrast';
|
||||
|
||||
import styles from './CustomBackgroundRow.module.scss';
|
||||
|
||||
const HEX_PLACEHOLDER = '#______';
|
||||
|
||||
/** What the picker opens on before a colour is chosen. */
|
||||
const INITIAL_COLOR = '#3A2A63';
|
||||
|
||||
interface CustomBackgroundRowProps {
|
||||
testId: string;
|
||||
/** The stored hex while a custom colour is active; `undefined` otherwise. */
|
||||
value: string | undefined;
|
||||
onChange: (hex: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The custom colour, as a row rather than a swatch: it opens a picker instead of
|
||||
* committing a value in one click. The picker warns below the contrast floor but
|
||||
* never blocks the choice.
|
||||
*/
|
||||
function CustomBackgroundRow({
|
||||
testId,
|
||||
value,
|
||||
onChange,
|
||||
}: CustomBackgroundRowProps): JSX.Element {
|
||||
const color = value ?? INITIAL_COLOR;
|
||||
const ratio = contrastRatio(inkForSurface(color), color);
|
||||
const isLegible = ratio >= MIN_CONTRAST_RATIO;
|
||||
|
||||
const contrastMessage = isLegible
|
||||
? `Contrast ${ratio.toFixed(1)}:1`
|
||||
: `Contrast ${ratio.toFixed(1)}:1 — below ${MIN_CONTRAST_RATIO}:1`;
|
||||
|
||||
function renderPanel(panel: ReactNode): ReactNode {
|
||||
return (
|
||||
<>
|
||||
{panel}
|
||||
<div
|
||||
className={cx(styles.contrast, { [styles.warning]: !isLegible })}
|
||||
data-testid={`${testId}-contrast`}
|
||||
>
|
||||
{!isLegible && <TriangleAlert size={12} />}
|
||||
<span className="translate-safe">{contrastMessage}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ColorPicker
|
||||
value={color}
|
||||
size="small"
|
||||
showText={false}
|
||||
trigger="click"
|
||||
panelRender={renderPanel}
|
||||
onChangeComplete={(next): void => onChange(next.toHexString())}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.row, { [styles.active]: value !== undefined })}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span
|
||||
className={cx(styles.chip, { [styles.chipEmpty]: value === undefined })}
|
||||
style={
|
||||
value ? { background: value, color: inkForSurface(value) } : undefined
|
||||
}
|
||||
>
|
||||
{value !== undefined && <Check size={14} />}
|
||||
</span>
|
||||
<Typography.Text className={styles.label}>Custom</Typography.Text>
|
||||
<span className={cx(styles.hex, 'translate-safe')}>
|
||||
{value ?? HEX_PLACEHOLDER}
|
||||
</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</ColorPicker>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomBackgroundRow;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { TEXT_BACKGROUND_PAIRS } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
|
||||
import {
|
||||
PanelTheme,
|
||||
TextBackgroundKind,
|
||||
TextBackgroundPreset,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
|
||||
import BackgroundSwatches from '../BackgroundSwatches';
|
||||
|
||||
function renderRow(
|
||||
props: Partial<React.ComponentProps<typeof BackgroundSwatches>> = {},
|
||||
): jest.Mock {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<BackgroundSwatches
|
||||
testId="background"
|
||||
label="Panel background"
|
||||
theme={PanelTheme.Dark}
|
||||
value={TextBackgroundKind.Default}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe('BackgroundSwatches', () => {
|
||||
it('offers transparent, the default surface and the eight presets in order', () => {
|
||||
renderRow();
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('radio')
|
||||
.map((swatch) => swatch.getAttribute('aria-label')),
|
||||
).toStrictEqual([
|
||||
'Transparent',
|
||||
'Default panel',
|
||||
'Robin',
|
||||
'Purple',
|
||||
'Sakura',
|
||||
'Cherry',
|
||||
'Amber',
|
||||
'Forest',
|
||||
'Sienna',
|
||||
'Slate',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is one labelled group', () => {
|
||||
renderRow();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radiogroup', { name: 'Panel background' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['background-none', 'Transparent — no card, border or title bar'],
|
||||
['background-default', 'Default panel colour'],
|
||||
['background-sakura', 'Sakura'],
|
||||
])('explains %s on hover', async (swatchId, copy) => {
|
||||
renderRow();
|
||||
|
||||
fireEvent.focus(screen.getByTestId(swatchId));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(copy);
|
||||
});
|
||||
});
|
||||
|
||||
it('paints each preset in the given theme', () => {
|
||||
renderRow({ theme: PanelTheme.Light });
|
||||
|
||||
expect(screen.getByTestId('background-amber')).toHaveStyle({
|
||||
background: TEXT_BACKGROUND_PAIRS.amber.light.surface,
|
||||
color: TEXT_BACKGROUND_PAIRS.amber.light.ink,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only the selected swatch, and checks it', () => {
|
||||
renderRow({ value: TextBackgroundPreset.Forest });
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Forest' })).toBeChecked();
|
||||
expect(
|
||||
screen.getByRole('radio', { name: 'Default panel' }),
|
||||
).not.toBeChecked();
|
||||
expect(
|
||||
screen.getByTestId('background-forest').querySelector('svg'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('background-default').querySelector('svg'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports the swatch that was clicked', () => {
|
||||
const onChange = renderRow();
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Sienna' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('sienna');
|
||||
});
|
||||
|
||||
// jsdom does not implement radio arrow navigation, so the shared name — what
|
||||
// makes them one group — is what there is to assert.
|
||||
it('groups every swatch under one radio name', () => {
|
||||
renderRow();
|
||||
|
||||
const names = new Set(
|
||||
screen.getAllByRole('radio').map((swatch) => swatch.getAttribute('name')),
|
||||
);
|
||||
|
||||
expect(names).toStrictEqual(new Set(['background']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import CustomBackgroundRow from '../CustomBackgroundRow';
|
||||
|
||||
function renderRow(value?: string): jest.Mock {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<CustomBackgroundRow testId="custom" value={value} onChange={onChange} />,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe('CustomBackgroundRow', () => {
|
||||
it('stands in for the hex while no custom colour is set', () => {
|
||||
renderRow();
|
||||
|
||||
expect(screen.getByTestId('custom')).toHaveTextContent('#______');
|
||||
});
|
||||
|
||||
it('shows the stored hex once one is set', () => {
|
||||
renderRow('#3A2A63');
|
||||
|
||||
expect(screen.getByTestId('custom')).toHaveTextContent('#3A2A63');
|
||||
});
|
||||
|
||||
it('checks the chip only while the custom colour is the selection', () => {
|
||||
renderRow('#3A2A63');
|
||||
|
||||
expect(screen.getByTestId('custom').querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the chip unchecked while no custom colour is set', () => {
|
||||
renderRow();
|
||||
|
||||
expect(screen.getByTestId('custom').querySelectorAll('svg')).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('the picker', () => {
|
||||
it('opens on the row', () => {
|
||||
renderRow('#3A2A63');
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom'));
|
||||
|
||||
expect(screen.getByTestId('custom-contrast')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports the contrast the colour achieves', () => {
|
||||
renderRow('#3A2A63');
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom'));
|
||||
|
||||
// The derived ink is pure white, not purple's paired ink.
|
||||
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
|
||||
'Contrast 12.4:1',
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when no ink clears the floor, without disabling anything', () => {
|
||||
renderRow('#808080');
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom'));
|
||||
|
||||
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
|
||||
'below 4.5:1',
|
||||
);
|
||||
expect(screen.getByTestId('custom')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('says nothing about the floor when the colour clears it', () => {
|
||||
renderRow('#111111');
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom'));
|
||||
|
||||
expect(screen.getByTestId('custom-contrast')).not.toHaveTextContent('below');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,8 @@ import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppea
|
||||
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
|
||||
import FormattingSection from './sections/FormattingSection/FormattingSection';
|
||||
import LegendSection from './sections/LegendSection/LegendSection';
|
||||
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
|
||||
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
|
||||
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
|
||||
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
|
||||
|
||||
@@ -117,6 +119,23 @@ export const SECTION_REGISTRY: {
|
||||
update: (spec, buckets): PanelSpec =>
|
||||
updatePluginSlice(spec, 'histogramBuckets', buckets),
|
||||
},
|
||||
[SectionKind.TextLayout]: {
|
||||
Component: TextLayoutSection,
|
||||
get: (spec): SectionSpecMap[SectionKind.TextLayout] | undefined =>
|
||||
getPluginSlice<SectionSpecMap[SectionKind.TextLayout]>(spec, 'presentation'),
|
||||
update: (spec, presentation): PanelSpec =>
|
||||
updatePluginSlice(spec, 'presentation', presentation),
|
||||
},
|
||||
[SectionKind.PanelHeader]: {
|
||||
Component: PanelHeaderSection,
|
||||
get: (spec): SectionSpecMap[SectionKind.PanelHeader] | undefined =>
|
||||
getPluginSlice<SectionSpecMap[SectionKind.PanelHeader]>(
|
||||
spec,
|
||||
'headerOptions',
|
||||
),
|
||||
update: (spec, headerOptions): PanelSpec =>
|
||||
updatePluginSlice(spec, 'headerOptions', headerOptions),
|
||||
},
|
||||
[SectionKind.ContextLinks]: {
|
||||
Component: ContextLinksSection,
|
||||
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
|
||||
/** Edits the Text panel's `headerOptions` slice: the panel card's title strip. */
|
||||
function PanelHeaderSection({
|
||||
value,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.PanelHeader>): JSX.Element {
|
||||
return (
|
||||
<ConfigSwitch
|
||||
testId="panel-header-hide"
|
||||
title="Hide header"
|
||||
description="Drop the title strip on the dashboard; hovering the panel shows controls for drag and actions."
|
||||
value={value?.hide === true}
|
||||
onChange={(hide): void => onChange({ ...value, hide })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelHeaderSection;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import PanelHeaderSection from '../PanelHeaderSection';
|
||||
|
||||
describe('PanelHeaderSection', () => {
|
||||
it('toggles hide on', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<PanelHeaderSection value={undefined} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-header-hide'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ hide: true });
|
||||
});
|
||||
|
||||
it('toggles hide back off', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<PanelHeaderSection value={{ hide: true }} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-header-hide'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ hide: false });
|
||||
});
|
||||
|
||||
it('shows the header by default when the slice is empty', () => {
|
||||
render(<PanelHeaderSection value={undefined} onChange={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('panel-header-hide')).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
DashboardtypesTextAlignDTO,
|
||||
DashboardtypesVerticalAlignDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import {
|
||||
resolveTextBackground,
|
||||
selectionFromResolved,
|
||||
storedFromSelection,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/resolveTextBackground';
|
||||
import type { TextBackgroundSelection } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
import {
|
||||
PanelTheme,
|
||||
TextBackgroundKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import BackgroundSwatches from '../../controls/BackgroundSwatches/BackgroundSwatches';
|
||||
import CustomBackgroundRow from '../../controls/BackgroundSwatches/CustomBackgroundRow';
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
|
||||
import styles from './TextLayoutSection.module.scss';
|
||||
|
||||
const HORIZONTAL_OPTIONS = [
|
||||
{ value: DashboardtypesTextAlignDTO.left, label: 'Left' },
|
||||
{ value: DashboardtypesTextAlignDTO.center, label: 'Center' },
|
||||
{ value: DashboardtypesTextAlignDTO.right, label: 'Right' },
|
||||
];
|
||||
|
||||
const VERTICAL_OPTIONS = [
|
||||
{ value: DashboardtypesVerticalAlignDTO.top, label: 'Top' },
|
||||
{ value: DashboardtypesVerticalAlignDTO.center, label: 'Middle' },
|
||||
{ value: DashboardtypesVerticalAlignDTO.bottom, label: 'Bottom' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the Text panel's `presentation` slice: body alignment and the card
|
||||
* background (TDD D7 — scoped to the text spec, not the panel envelope).
|
||||
*/
|
||||
function TextLayoutSection({
|
||||
value,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.TextLayout>): JSX.Element {
|
||||
const theme = useIsDarkMode() ? PanelTheme.Dark : PanelTheme.Light;
|
||||
const background = resolveTextBackground(value?.background, theme);
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Horizontal alignment</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="text-layout-horizontal-align"
|
||||
items={HORIZONTAL_OPTIONS}
|
||||
value={value?.textAlign ?? DashboardtypesTextAlignDTO.left}
|
||||
onChange={(textAlign): void => onChange({ ...value, textAlign })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Vertical alignment</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="text-layout-vertical-align"
|
||||
items={VERTICAL_OPTIONS}
|
||||
value={value?.verticalAlign ?? DashboardtypesVerticalAlignDTO.top}
|
||||
onChange={(verticalAlign): void => onChange({ ...value, verticalAlign })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Background</Typography.Text>
|
||||
<BackgroundSwatches
|
||||
testId="text-layout-background"
|
||||
label="Panel background"
|
||||
theme={theme}
|
||||
value={selectionFromResolved(background)}
|
||||
onChange={(selection: TextBackgroundSelection): void =>
|
||||
onChange({
|
||||
...value,
|
||||
background: storedFromSelection(selection, theme),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<CustomBackgroundRow
|
||||
testId="text-layout-background-custom"
|
||||
value={
|
||||
background.kind === TextBackgroundKind.Custom
|
||||
? background.surface
|
||||
: undefined
|
||||
}
|
||||
onChange={(hex): void => onChange({ ...value, background: hex })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextLayoutSection;
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
fireEvent,
|
||||
render as rtlRender,
|
||||
type RenderResult,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import {
|
||||
DashboardtypesTextAlignDTO,
|
||||
DashboardtypesVerticalAlignDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
TEXT_BACKGROUND_PAIRS,
|
||||
TRANSPARENT_BACKGROUND,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
|
||||
|
||||
import TextLayoutSection from '../TextLayoutSection';
|
||||
|
||||
const value = {
|
||||
textAlign: DashboardtypesTextAlignDTO.left,
|
||||
verticalAlign: DashboardtypesVerticalAlignDTO.top,
|
||||
};
|
||||
|
||||
// The swatch tooltips need a provider; AppLayout supplies one at runtime.
|
||||
function render(ui: ReactElement): RenderResult {
|
||||
return rtlRender(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
}
|
||||
|
||||
// The theme context defaults to dark, so the swatches paint the dark pairs.
|
||||
describe('TextLayoutSection', () => {
|
||||
it('changes horizontal alignment', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<TextLayoutSection value={value} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Center'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...value,
|
||||
textAlign: DashboardtypesTextAlignDTO.center,
|
||||
});
|
||||
});
|
||||
|
||||
it('changes vertical alignment', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<TextLayoutSection value={value} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Bottom'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...value,
|
||||
verticalAlign: DashboardtypesVerticalAlignDTO.bottom,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores the surface of the theme a preset was picked in', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<TextLayoutSection value={value} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Amber' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...value,
|
||||
background: TEXT_BACKGROUND_PAIRS.amber.dark.surface,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores a zero-alpha colour for transparent', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<TextLayoutSection value={value} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Transparent' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...value,
|
||||
background: TRANSPARENT_BACKGROUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('unsets the background for the default panel surface', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<TextLayoutSection
|
||||
value={{ ...value, background: TRANSPARENT_BACKGROUND }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Default panel' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ ...value, background: undefined });
|
||||
});
|
||||
|
||||
it('lights up the swatch the stored surface belongs to', () => {
|
||||
render(
|
||||
<TextLayoutSection
|
||||
value={{ ...value, background: TEXT_BACKGROUND_PAIRS.sakura.light.surface }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Sakura' })).toBeChecked();
|
||||
});
|
||||
|
||||
it('stores a custom colour straight from the picker', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<TextLayoutSection value={value} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('text-layout-background-custom'));
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: '3A2A64' },
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...value,
|
||||
background: '#3a2a64',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a stored custom colour on the custom row alone', () => {
|
||||
render(
|
||||
<TextLayoutSection
|
||||
value={{ ...value, background: '#3A2A64' }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('text-layout-background-custom')).toHaveTextContent(
|
||||
'#3A2A64',
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole<HTMLInputElement>('radio')
|
||||
.filter((swatch) => swatch.checked),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('selects the default surface when nothing is stored', () => {
|
||||
render(<TextLayoutSection value={undefined} onChange={jest.fn()} />);
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Default panel' })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: 'Transparent' })).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -26,16 +26,3 @@
|
||||
background: var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
// The static editor's preview: the panel card the grid shows, minus actions.
|
||||
.staticPreviewSurface {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
margin: 12px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../../Panels/types/panelKind';
|
||||
import { toPanelType } from '../../Panels/types/panelKind';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
@@ -60,7 +60,7 @@ function PanelEditorQueryBuilder({
|
||||
}: PanelEditorQueryBuilderProps): JSX.Element {
|
||||
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
|
||||
// builder offers for this kind comes from the kind's own declaration.
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelDefinition.kind];
|
||||
const panelType = toPanelType(panelDefinition.kind);
|
||||
// Raw rows: the builder drops its aggregation controls, and with them the trace
|
||||
// operator that combines aggregated trace queries (V1 parity).
|
||||
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
// A static pane never scrolls — the panel card clips, and the renderer scrolls its
|
||||
// own body when the content outgrows it, as on the grid.
|
||||
.previewStatic {
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -56,6 +63,14 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
// A static kind's card takes its colours from the background the panel declares,
|
||||
// falling back to the same tokens the query surface uses.
|
||||
.surfaceStatic {
|
||||
border-color: var(--text-panel-border, var(--l2-border));
|
||||
background: var(--text-panel-surface, var(--l2-background));
|
||||
color: var(--text-panel-ink, inherit);
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -5,10 +5,16 @@ import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
|
||||
import { useTextBackground } from 'pages/DashboardPage/DashboardContainer/Panels/hooks/useTextBackground';
|
||||
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type {
|
||||
RenderableQueryPanelDefinition,
|
||||
RenderableStaticPanelDefinition,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import { getPanelQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import { isPanelHeaderHidden } from 'pages/DashboardPage/DashboardContainer/Panels/utils/isPanelHeaderHidden';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
@@ -17,9 +23,15 @@ import type {
|
||||
import PlotTag from './PlotTag';
|
||||
import styles from './PreviewPane.module.scss';
|
||||
|
||||
interface PreviewPaneProps {
|
||||
interface PreviewPaneBaseProps {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
|
||||
panelMode?: PanelMode;
|
||||
}
|
||||
|
||||
interface QueryPreviewPaneProps extends PreviewPaneBaseProps {
|
||||
mode: 'query';
|
||||
/** The kind's definition, narrowed to the query arm — this preview is the query render path. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
data: PanelQueryData;
|
||||
@@ -34,8 +46,6 @@ interface PreviewPaneProps {
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
|
||||
pagination?: PanelPagination;
|
||||
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
|
||||
panelMode?: PanelMode;
|
||||
/** Hide the preview's top row entirely (query-type badge + time picker) — the View modal has its own header. */
|
||||
hideHeader?: boolean;
|
||||
/** Dashboard-wide preferences (cursor sync, …) forwarded to the body; the modal isolates cursor-sync. */
|
||||
@@ -48,41 +58,43 @@ interface PreviewPaneProps {
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
interface StaticPreviewPaneProps extends PreviewPaneBaseProps {
|
||||
mode: 'static';
|
||||
/** The kind's definition, narrowed to the static arm — no query, no Run step. */
|
||||
panelDefinition: RenderableStaticPanelDefinition;
|
||||
/** Saves an edit made from the rendered body into the draft; absent = read-only. */
|
||||
onChangeText?: (text: string) => void;
|
||||
}
|
||||
|
||||
type PreviewPaneProps = QueryPreviewPaneProps | StaticPreviewPaneProps;
|
||||
|
||||
/**
|
||||
* Live preview for the panel editor: renders the draft through the same `PanelBody`
|
||||
* the dashboard grid uses (only `panelMode` differs), so the preview is the
|
||||
* production render path. The query result is owned by the editor root.
|
||||
* Live preview for the panel editor and the View modal: the draft rendered through
|
||||
* the same body the dashboard grid uses (only `panelMode` differs), so the preview
|
||||
* is the production render path. A query draft's result is owned by the editor
|
||||
* root; a static draft re-renders straight from the spec on every edit.
|
||||
*/
|
||||
function PreviewPane({
|
||||
panelId,
|
||||
panel,
|
||||
panelDefinition,
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
pagination,
|
||||
panelMode = PanelMode.DASHBOARD_EDIT,
|
||||
hideHeader = false,
|
||||
dashboardPreference,
|
||||
onCloseStandaloneView,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const queryType = getPanelQueryType(panel);
|
||||
function PreviewPane(props: PreviewPaneProps): JSX.Element {
|
||||
const { panelId, panel, panelMode = PanelMode.DASHBOARD_EDIT } = props;
|
||||
const query = props.mode === 'query' ? props : null;
|
||||
const staticDraft = props.mode === 'static' ? props : null;
|
||||
const background = useTextBackground(panel.spec);
|
||||
|
||||
// Search term is ephemeral preview state, threaded to header + renderer but
|
||||
// not persisted to the draft spec. Only kinds that declare it render the box.
|
||||
const searchable = !!panelDefinition.actions.search;
|
||||
const searchable = !!query?.panelDefinition.actions.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
{!hideHeader && (
|
||||
<div
|
||||
className={cx(styles.preview, { [styles.previewStatic]: !!staticDraft })}
|
||||
>
|
||||
{query && !query.hideHeader && (
|
||||
<div className={styles.header}>
|
||||
<PlotTag queryType={queryType} className={styles.queryType} />
|
||||
<PlotTag
|
||||
queryType={getPanelQueryType(panel)}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
@@ -91,39 +103,67 @@ function PreviewPane({
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={cx(styles.surface, {
|
||||
[styles.surfaceStacked]: panelMode === PanelMode.STANDALONE_VIEW,
|
||||
[styles.surfaceStacked]:
|
||||
!!query && panelMode === PanelMode.STANDALONE_VIEW,
|
||||
[styles.surfaceStatic]: !!staticDraft,
|
||||
})}
|
||||
style={background.style}
|
||||
>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
searchable={searchable}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
Renderer={panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={panelMode}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
onClick={onClick}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
{query ? (
|
||||
<>
|
||||
<PanelHeader
|
||||
mode="query"
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={query.data}
|
||||
isFetching={query.isFetching}
|
||||
error={query.error}
|
||||
warning={query.data.response?.data?.warning}
|
||||
searchable={searchable}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
Renderer={query.panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={query.data}
|
||||
isFetching={query.isFetching}
|
||||
isPreviousData={query.isPreviousData}
|
||||
error={query.error}
|
||||
refetch={query.refetch}
|
||||
onDragSelect={query.onDragSelect}
|
||||
panelMode={panelMode}
|
||||
dashboardPreference={query.dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={query.pagination}
|
||||
onCloseStandaloneView={query.onCloseStandaloneView}
|
||||
onClick={query.onClick}
|
||||
enableDrillDown={query.enableDrillDown}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
staticDraft && (
|
||||
<>
|
||||
{!isPanelHeaderHidden(panel.spec) && (
|
||||
<PanelHeader
|
||||
mode="static"
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
hideActions
|
||||
/>
|
||||
)}
|
||||
<StaticPanelBody
|
||||
Renderer={staticDraft.panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
panelMode={panelMode}
|
||||
onChangeText={staticDraft.onChangeText}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
@@ -217,7 +217,7 @@ function QueryEditorBody({
|
||||
const onSwitchToView = useSwitchToViewMode({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
|
||||
panelType: toPanelType(panelKind),
|
||||
query: currentQuery,
|
||||
spec: draft.spec,
|
||||
});
|
||||
@@ -286,6 +286,7 @@ function QueryEditorBody({
|
||||
}
|
||||
preview={
|
||||
<PreviewPane
|
||||
mode="query"
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
|
||||
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
|
||||
@@ -16,12 +13,12 @@ import Header from './Header/Header';
|
||||
import PanelEditorLayout, {
|
||||
PANE_SPLIT,
|
||||
} from './PanelEditorLayout/PanelEditorLayout';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import type { PanelEditorContainerProps } from './index';
|
||||
import type { PanelEditorDraftApi } from './types';
|
||||
import { withPanelText } from '../Panels/utils/withPanelText';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
|
||||
import styles from './PanelEditor.module.scss';
|
||||
|
||||
interface StaticEditorBodyProps extends PanelEditorContainerProps {
|
||||
draftApi: PanelEditorDraftApi;
|
||||
panelDefinition: RenderableStaticPanelDefinition;
|
||||
@@ -51,7 +48,7 @@ function StaticEditorBody({
|
||||
useDashboardEditContext();
|
||||
|
||||
const { draft, spec, setSpec, isSpecDirty } = draftApi;
|
||||
const { EditorPane, Renderer } = panelDefinition;
|
||||
const { EditorPane } = panelDefinition;
|
||||
|
||||
const { save, isSaving } = usePanelEditorSave({
|
||||
dashboardId,
|
||||
@@ -80,6 +77,11 @@ function StaticEditorBody({
|
||||
}
|
||||
}, [isEditable, save, draft.spec, setScrollTargetId, onSaved, showErrorModal]);
|
||||
|
||||
const onChangeText = useCallback(
|
||||
(text: string): void => setSpec(withPanelText(spec, text)),
|
||||
[spec, setSpec],
|
||||
);
|
||||
|
||||
const onCloseEditor = useCallback((): void => {
|
||||
if (!isNew) {
|
||||
setScrollTargetId(panelId);
|
||||
@@ -103,22 +105,14 @@ function StaticEditorBody({
|
||||
/>
|
||||
}
|
||||
preview={
|
||||
<div className={styles.staticPreviewSurface}>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
data={EMPTY_PANEL_QUERY_DATA}
|
||||
isFetching={false}
|
||||
error={null}
|
||||
hideActions
|
||||
/>
|
||||
<StaticPanelBody
|
||||
Renderer={Renderer}
|
||||
panel={draft}
|
||||
panelId={panelId}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
/>
|
||||
</div>
|
||||
<PreviewPane
|
||||
mode="static"
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
onChangeText={isEditable ? onChangeText : undefined}
|
||||
/>
|
||||
}
|
||||
editor={<EditorPane spec={spec} onChangeSpec={setSpec} />}
|
||||
config={
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
|
||||
import StaticEditorBody from '../StaticEditorBody';
|
||||
import type { PanelEditorDraftApi } from '../types';
|
||||
import { usePanelEditorSave } from '../hooks/usePanelEditorSave';
|
||||
|
||||
jest.mock('../hooks/usePanelEditorSave', () => ({
|
||||
usePanelEditorSave: jest.fn(),
|
||||
}));
|
||||
// Chrome + collaborators stubbed: this suite asserts the static body's wiring —
|
||||
// the save shape above all — not their internals.
|
||||
jest.mock('../Header/Header', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onSave }: { onSave: () => void }): JSX.Element => (
|
||||
<button type="button" data-testid="header-save" onClick={onSave}>
|
||||
Save
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
jest.mock('../ConfigPane/ConfigPane', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="config-pane" />,
|
||||
}));
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader',
|
||||
() => ({ __esModule: true, default: (): null => null }),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="static-preview-body" />,
|
||||
}),
|
||||
);
|
||||
jest.mock('@signozhq/ui/sonner', () => ({ toast: { success: jest.fn() } }));
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
useErrorModal: (): unknown => ({ showErrorModal: jest.fn() }),
|
||||
}));
|
||||
// The derivation has its own suite (useDashboardEditContext.authz); these cases are
|
||||
// about what the static body does with a given edit context, so control it directly.
|
||||
let editContext = { isEditable: true, editChecks: [], editDisabledTooltip: '' };
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): typeof editContext => editContext,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockUseSave = usePanelEditorSave as jest.Mock;
|
||||
|
||||
// The draft deliberately carries a stray query: Save must strip it — the API
|
||||
// rejects anything but [] for a static kind.
|
||||
const draft = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Runbook' },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text: '# hi' } },
|
||||
queries: [{ spec: {} }],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const draftApi: PanelEditorDraftApi = {
|
||||
draft,
|
||||
spec: draft.spec,
|
||||
setSpec: jest.fn(),
|
||||
isSpecDirty: false,
|
||||
reset: jest.fn(),
|
||||
};
|
||||
|
||||
const definition = {
|
||||
kind: 'signoz/TextPanel',
|
||||
displayName: 'Text',
|
||||
sections: [],
|
||||
actions: {},
|
||||
mode: 'static',
|
||||
Renderer: (): null => null,
|
||||
EditorPane: (): JSX.Element => <div data-testid="editor-pane" />,
|
||||
} as unknown as RenderableStaticPanelDefinition;
|
||||
|
||||
function renderBody(): void {
|
||||
render(
|
||||
<StaticEditorBody
|
||||
dashboardId="d1"
|
||||
panelId="p1"
|
||||
panel={draft}
|
||||
onClose={jest.fn()}
|
||||
onSaved={jest.fn()}
|
||||
draftApi={draftApi}
|
||||
panelDefinition={definition}
|
||||
onChangePanelKind={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('StaticEditorBody', () => {
|
||||
beforeEach(() => {
|
||||
mockUseSave.mockReset();
|
||||
editContext = { isEditable: true, editChecks: [], editDisabledTooltip: '' };
|
||||
mockUseSave.mockReturnValue({
|
||||
save: jest.fn().mockResolvedValue('p1'),
|
||||
isSaving: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the editor pane and the live preview, no query builder', () => {
|
||||
renderBody();
|
||||
|
||||
expect(screen.getByTestId('editor-pane')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('static-preview-body')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-query-builder'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves the spec with queries forced to [] — the only shape the API accepts', async () => {
|
||||
const save = jest.fn().mockResolvedValue('p1');
|
||||
mockUseSave.mockReturnValue({ save, isSaving: false });
|
||||
renderBody();
|
||||
|
||||
fireEvent.click(screen.getByTestId('header-save'));
|
||||
|
||||
await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
||||
expect(save).toHaveBeenCalledWith({ ...draft.spec, queries: [] });
|
||||
});
|
||||
|
||||
it('does not save when the dashboard is not editable', () => {
|
||||
const save = jest.fn();
|
||||
mockUseSave.mockReturnValue({ save, isSaving: false });
|
||||
editContext = {
|
||||
isEditable: false,
|
||||
editChecks: [],
|
||||
editDisabledTooltip: 'Dashboard is locked',
|
||||
};
|
||||
renderBody();
|
||||
|
||||
fireEvent.click(screen.getByTestId('header-save'));
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -115,3 +115,15 @@ describe('newPanelRoute', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseNewPanelKind — kinds without a legacy panel type', () => {
|
||||
it('accepts a registered static kind', () => {
|
||||
expect(parseNewPanelKind('new', '?panelKind=signoz%2FTextPanel')).toBe(
|
||||
'signoz/TextPanel',
|
||||
);
|
||||
});
|
||||
|
||||
it('still rejects a kind that is not registered', () => {
|
||||
expect(parseNewPanelKind('new', '?panelKind=signoz%2FNopePanel')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
usePanelQuery,
|
||||
type PanelQueryTimeOverride,
|
||||
@@ -90,7 +90,7 @@ export function usePanelEditSession({
|
||||
// Hosts fork on `definition.mode` before mounting this session (the editor and
|
||||
// View modal shells) — asserted rather than assumed.
|
||||
const panelDefinition = requireQueryPanelDefinition(panelKind);
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
const panelType = toPanelType(panelKind);
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
|
||||
const query = usePanelQuery({
|
||||
|
||||
@@ -19,10 +19,7 @@ import type {
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../../Panels/types/panelKind';
|
||||
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
|
||||
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
import {
|
||||
@@ -110,7 +107,7 @@ export function usePanelTypeSwitch({
|
||||
builderQuery: query,
|
||||
});
|
||||
|
||||
const newPanelType = PANEL_KIND_TO_PANEL_TYPE[newKind];
|
||||
const newPanelType = toPanelType(newKind);
|
||||
|
||||
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
|
||||
// dynamically-chosen kind can't be correlated with its spec statically (as in
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
import QueryEditorBody from './QueryEditorBody';
|
||||
import StaticEditorBody from './StaticEditorBody';
|
||||
@@ -41,7 +41,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
|
||||
panelType: toPanelType(panelKind),
|
||||
setSpec: draftApi.setSpec,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { PANELS } from '../Panels/registry';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
PANEL_TYPE_TO_PANEL_KIND,
|
||||
type PanelKind,
|
||||
} from '../Panels/types/panelKind';
|
||||
@@ -40,7 +40,9 @@ export function parseNewPanelKind(
|
||||
return null;
|
||||
}
|
||||
const kind = new URLSearchParams(search).get(PANEL_KIND_PARAM);
|
||||
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
|
||||
// Gated on the registry, not the legacy map — a static kind has no legacy
|
||||
// panel type, and the map would reject its route as a stale link.
|
||||
return kind && kind in PANELS ? (kind as PanelKind) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,8 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
|
||||
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/ListPanel': [QUERY_BUILDER],
|
||||
// Static kind: no query surface at all.
|
||||
'signoz/TextPanel': [],
|
||||
};
|
||||
|
||||
const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
|
||||
@@ -45,11 +47,16 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
|
||||
'signoz/TablePanel': [metrics, logs, traces],
|
||||
// List renders raw rows; metrics produce no row data.
|
||||
'signoz/ListPanel': [logs, traces],
|
||||
'signoz/TextPanel': [],
|
||||
};
|
||||
|
||||
// Exhaustive over PanelKind, so a new kind can't ship without stating how its request is
|
||||
// shaped — the check that used to be implicit in a legacy PANEL_TYPES switch.
|
||||
const EXPECTED_QUERY_CAPABILITIES: Record<PanelKind, PanelQueryCapabilities> = {
|
||||
// Partial: a static kind declares no query capabilities — the lookup below
|
||||
// resolves undefined on both sides for it.
|
||||
const EXPECTED_QUERY_CAPABILITIES: Partial<
|
||||
Record<PanelKind, PanelQueryCapabilities>
|
||||
> = {
|
||||
'signoz/TimeSeriesPanel': {
|
||||
requestType: time_series,
|
||||
formatTableResultForUI: false,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
selectViewPanelExtendWindow,
|
||||
useViewPanelStore,
|
||||
} from '../../../store/useViewPanelStore';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../../types/panelKind';
|
||||
import { toPanelType } from '../../types/panelKind';
|
||||
import PanelLoader from '../PanelLoader/PanelLoader';
|
||||
import PanelMessage, { PanelMessageAction } from '../PanelMessage/PanelMessage';
|
||||
import { useExtendTimeWindow } from './useExtendTimeWindow';
|
||||
@@ -57,7 +57,7 @@ function NoData({
|
||||
// `panelType` stays on the event so existing reports keep resolving; `panelKind` is the
|
||||
// V2 identity, and the only one that can tell two kinds sharing a panel type apart.
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
const panelType = toPanelType(panelKind);
|
||||
|
||||
const extendAction: PanelMessageAction | undefined =
|
||||
activeExtend?.canExtend && activeExtend.actionLabel
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { rgbaFromHex } from '../../kinds/TextPanel/background/contrast';
|
||||
import {
|
||||
INK_ALPHAS,
|
||||
SECONDARY_INK_OPACITY,
|
||||
TEXT_BACKGROUND_PAIRS,
|
||||
TRANSPARENT_BACKGROUND,
|
||||
} from '../../kinds/TextPanel/background/presets';
|
||||
import { useTextBackground } from '../useTextBackground';
|
||||
|
||||
const isDarkMode = jest.fn<boolean, []>(() => true);
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => isDarkMode(),
|
||||
}));
|
||||
|
||||
function textPanel(background?: string): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Panel' },
|
||||
plugin: {
|
||||
kind: 'signoz/TextPanel',
|
||||
spec: { text: '', presentation: { background } },
|
||||
},
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
describe('useTextBackground', () => {
|
||||
beforeEach(() => {
|
||||
isDarkMode.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('sets no custom properties for the default surface', () => {
|
||||
const { result } = renderHook(() => useTextBackground(textPanel()));
|
||||
|
||||
expect(result.current).toStrictEqual({ kind: 'default', style: {} });
|
||||
});
|
||||
|
||||
// The card, its border and the header's divider all read these.
|
||||
it('paints a zero-alpha background transparent rather than dropping the card', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBackground(textPanel(TRANSPARENT_BACKGROUND)),
|
||||
);
|
||||
|
||||
expect(result.current).toStrictEqual({
|
||||
kind: 'none',
|
||||
style: {
|
||||
'--text-panel-surface': 'transparent',
|
||||
'--text-panel-border': 'transparent',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes the preset pair for the current theme', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBackground(textPanel(TEXT_BACKGROUND_PAIRS.amber.dark.surface)),
|
||||
);
|
||||
|
||||
const { ink } = TEXT_BACKGROUND_PAIRS.amber.dark;
|
||||
|
||||
expect(result.current.style).toStrictEqual({
|
||||
'--text-panel-surface': TEXT_BACKGROUND_PAIRS.amber.dark.surface,
|
||||
'--text-panel-ink': ink,
|
||||
'--text-panel-border': 'rgba(255, 255, 255, 0.09)',
|
||||
'--text-panel-link-decoration': 'underline',
|
||||
'--text-panel-ink-secondary': rgbaFromHex(ink, SECONDARY_INK_OPACITY),
|
||||
'--text-panel-grip': rgbaFromHex(ink, INK_ALPHAS['--text-panel-grip']),
|
||||
'--scrollbar-thumb': rgbaFromHex(ink, INK_ALPHAS['--scrollbar-thumb']),
|
||||
'--scrollbar-thumb-hover': rgbaFromHex(
|
||||
ink,
|
||||
INK_ALPHAS['--scrollbar-thumb-hover'],
|
||||
),
|
||||
'--text-panel-pill-surface': rgbaFromHex(
|
||||
ink,
|
||||
INK_ALPHAS['--text-panel-pill-surface'],
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('draws the surface chrome from the ink', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBackground(textPanel(TEXT_BACKGROUND_PAIRS.amber.light.surface)),
|
||||
);
|
||||
|
||||
Object.keys(INK_ALPHAS).forEach((name) => {
|
||||
expect(result.current.style).toHaveProperty(name);
|
||||
});
|
||||
});
|
||||
|
||||
// Stored light, read in dark: the ink is the dark pair's.
|
||||
it('carries the secondary ink and the link underline', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBackground(textPanel(TEXT_BACKGROUND_PAIRS.sakura.light.surface)),
|
||||
);
|
||||
|
||||
expect(result.current.style).toMatchObject({
|
||||
'--text-panel-ink-secondary': `rgba(253, 232, 242, ${SECONDARY_INK_OPACITY})`,
|
||||
'--text-panel-link-decoration': 'underline',
|
||||
});
|
||||
});
|
||||
|
||||
it('re-resolves a stored surface when the theme changes', () => {
|
||||
const spec = textPanel(TEXT_BACKGROUND_PAIRS.forest.dark.surface);
|
||||
const { result, rerender } = renderHook(() => useTextBackground(spec));
|
||||
|
||||
expect(result.current.style).toMatchObject({
|
||||
'--text-panel-surface': TEXT_BACKGROUND_PAIRS.forest.dark.surface,
|
||||
});
|
||||
|
||||
isDarkMode.mockReturnValue(false);
|
||||
rerender();
|
||||
|
||||
expect(result.current.style).toMatchObject({
|
||||
'--text-panel-surface': TEXT_BACKGROUND_PAIRS.forest.light.surface,
|
||||
'--text-panel-ink': TEXT_BACKGROUND_PAIRS.forest.light.ink,
|
||||
'--text-panel-border': 'rgba(0, 0, 0, 0.07)',
|
||||
});
|
||||
});
|
||||
|
||||
it('paints a custom colour the same in both themes', () => {
|
||||
const spec = textPanel('#3A2A64');
|
||||
const { result, rerender } = renderHook(() => useTextBackground(spec));
|
||||
const inDark = result.current.style;
|
||||
|
||||
isDarkMode.mockReturnValue(false);
|
||||
rerender();
|
||||
|
||||
expect(result.current.style).toMatchObject({
|
||||
'--text-panel-surface': '#3A2A64',
|
||||
'--text-panel-ink': inDark['--text-panel-ink' as keyof typeof inDark],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a kind without a presentation slice alone', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBackground({
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO),
|
||||
);
|
||||
|
||||
expect(result.current).toStrictEqual({ kind: 'default', style: {} });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useUpdatePanelText } from '../useUpdatePanelText';
|
||||
|
||||
const patchAsync = jest.fn<Promise<unknown>, [unknown]>(() =>
|
||||
Promise.resolve(undefined),
|
||||
);
|
||||
const showErrorModal = jest.fn();
|
||||
let store = { dashboardId: 'dash-1' };
|
||||
let editContext = { isEditable: true };
|
||||
|
||||
jest.mock('../../../hooks/useOptimisticPatch', () => ({
|
||||
useOptimisticPatch: (): unknown => ({ patchAsync }),
|
||||
}));
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
useErrorModal: (): unknown => ({ showErrorModal }),
|
||||
}));
|
||||
jest.mock('../../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (select: (s: typeof store) => unknown): unknown =>
|
||||
select(store),
|
||||
}));
|
||||
jest.mock('../../../hooks/useDashboardEditContext', () => ({
|
||||
useDashboardEditContext: (): typeof editContext => editContext,
|
||||
}));
|
||||
|
||||
describe('useUpdatePanelText', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
store = { dashboardId: 'dash-1' };
|
||||
editContext = { isEditable: true };
|
||||
});
|
||||
|
||||
it('patches the panel body', () => {
|
||||
const { result } = renderHook(() => useUpdatePanelText('p1'));
|
||||
|
||||
result.current?.('- [x] done');
|
||||
|
||||
expect(patchAsync).toHaveBeenCalledWith([
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/p1/spec/plugin/spec/text',
|
||||
value: '- [x] done',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives no callback when the viewer cannot edit', () => {
|
||||
editContext = { isEditable: false };
|
||||
|
||||
const { result } = renderHook(() => useUpdatePanelText('p1'));
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives no callback outside a dashboard', () => {
|
||||
store = { dashboardId: '' };
|
||||
|
||||
const { result } = renderHook(() => useUpdatePanelText('p1'));
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces a failed save', async () => {
|
||||
const failure = new Error('locked');
|
||||
patchAsync.mockRejectedValueOnce(failure);
|
||||
const { result } = renderHook(() => useUpdatePanelText('p1'));
|
||||
|
||||
result.current?.('- [x] done');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(showErrorModal).toHaveBeenCalledWith(failure);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
// Within this distance of the end counts as "at the bottom", so the pill isn't
|
||||
// kept alive by sub-pixel rounding or a trailing margin.
|
||||
const BOTTOM_EPSILON_PX = 16;
|
||||
|
||||
interface UseOverflowBelowResult<T extends HTMLElement> {
|
||||
scrollRef: RefObject<T>;
|
||||
/** Content extends below the fold and the user isn't at the bottom yet. */
|
||||
hasMoreBelow: boolean;
|
||||
scrollToBottom: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether a scroll container has unseen content below the fold. Re-measures
|
||||
* on scroll, on container resize, and on every commit — the cheap way to follow
|
||||
* content growth (a live preview re-rendering as the body is typed) without
|
||||
* observing the subtree.
|
||||
*/
|
||||
export function useOverflowBelow<
|
||||
T extends HTMLElement,
|
||||
>(): UseOverflowBelowResult<T> {
|
||||
const scrollRef = useRef<T>(null);
|
||||
const [hasMoreBelow, setHasMoreBelow] = useState(false);
|
||||
|
||||
const measure = useCallback((): void => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const remaining = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
setHasMoreBelow(remaining > BOTTOM_EPSILON_PX);
|
||||
}, []);
|
||||
|
||||
// No deps on purpose: runs after every commit. setState bails on unchanged
|
||||
// values, so this settles instead of looping.
|
||||
useEffect(() => {
|
||||
measure();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) {
|
||||
return undefined;
|
||||
}
|
||||
el.addEventListener('scroll', measure, { passive: true });
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return (): void => {
|
||||
el.removeEventListener('scroll', measure);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [measure]);
|
||||
|
||||
const scrollToBottom = useCallback((): void => {
|
||||
const el = scrollRef.current;
|
||||
el?.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
return { scrollRef, hasMoreBelow, scrollToBottom };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isLanguageRegistered, loadLanguage } from './syntaxLanguages';
|
||||
import { isLanguageRegistered, loadLanguage } from '../utils/syntaxLanguages';
|
||||
|
||||
/**
|
||||
* Registers `language` with Prism on demand, reporting when it is ready to
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { rgbaFromHex } from '../kinds/TextPanel/background/contrast';
|
||||
import {
|
||||
INK_ALPHAS,
|
||||
PRESET_BORDER,
|
||||
} from '../kinds/TextPanel/background/presets';
|
||||
import { resolveTextBackground } from '../kinds/TextPanel/background/resolveTextBackground';
|
||||
import {
|
||||
PanelTheme,
|
||||
TextBackgroundKind,
|
||||
} from '../kinds/TextPanel/background/types';
|
||||
|
||||
export interface TextBackground {
|
||||
kind: TextBackgroundKind;
|
||||
/**
|
||||
* Custom properties for the card root. Empty for `default`, so the stylesheet's
|
||||
* own fallbacks decide — nothing here hardcodes a surface.
|
||||
*/
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const NO_STYLE: CSSProperties = {};
|
||||
|
||||
// D7: `None` drops the card so the body sits on the dashboard canvas. It rides the
|
||||
// same properties as a colour, which takes the header's divider with it.
|
||||
const CARDLESS_STYLE = {
|
||||
'--text-panel-surface': 'transparent',
|
||||
'--text-panel-border': 'transparent',
|
||||
} as CSSProperties;
|
||||
|
||||
function inkShares(ink: string): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(INK_ALPHAS).map(([name, alpha]) => [
|
||||
name,
|
||||
rgbaFromHex(ink, alpha) ?? ink,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The card is an ancestor of the renderer, so the host owns these properties and
|
||||
* everything below inherits them.
|
||||
*
|
||||
* Reading one plugin-spec field off the kind union is the accepted smell (TDD
|
||||
* D7): a dynamic kind can't narrow it, hence one localized cast per host.
|
||||
*/
|
||||
export function useTextBackground(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
): TextBackground {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const background = (
|
||||
spec.plugin.spec as {
|
||||
presentation?: { background?: string | null };
|
||||
}
|
||||
).presentation?.background;
|
||||
|
||||
return useMemo(() => {
|
||||
const theme = isDarkMode ? PanelTheme.Dark : PanelTheme.Light;
|
||||
const resolved = resolveTextBackground(background, theme);
|
||||
|
||||
if (resolved.kind === TextBackgroundKind.None) {
|
||||
return { kind: resolved.kind, style: CARDLESS_STYLE };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: resolved.kind,
|
||||
style:
|
||||
resolved.surface && resolved.ink
|
||||
? ({
|
||||
'--text-panel-surface': resolved.surface,
|
||||
'--text-panel-ink': resolved.ink,
|
||||
'--text-panel-border': PRESET_BORDER[theme],
|
||||
'--text-panel-link-decoration': 'underline',
|
||||
...inkShares(resolved.ink),
|
||||
} as CSSProperties)
|
||||
: NO_STYLE,
|
||||
};
|
||||
}, [background, isDarkMode]);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import type APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import { setPanelTextOp } from '../../patchOps';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* Saves a panel's authored body, or `undefined` when the viewer cannot edit it —
|
||||
* the absent callback is the read-only gate, so nothing downstream re-checks.
|
||||
* The patch is optimistic: the edit shows at once and rolls back if it fails.
|
||||
*/
|
||||
export function useUpdatePanelText(
|
||||
panelId: string,
|
||||
): ((text: string) => void) | undefined {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const { isEditable } = useDashboardEditContext();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const save = useCallback(
|
||||
(text: string): void => {
|
||||
patchAsync([setPanelTextOp(panelId, text)]).catch((error) => {
|
||||
showErrorModal(error as APIError);
|
||||
});
|
||||
},
|
||||
[panelId, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return dashboardId && isEditable ? save : undefined;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 8px 12px;
|
||||
overflow: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
// Horizontal alignment inherits into the rendered body, which deliberately leaves
|
||||
// `text-align` alone so the panel can own it.
|
||||
.alignLeft {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.alignCenter {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alignRight {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// `text-align` moves only inline content: the table is a block box inside its
|
||||
// scroll wrapper and stays put, and list markers hang at the list's left edge.
|
||||
// (0,2,1) beats the body reset at (0,2,0); `list-style-position` goes on the
|
||||
// `li` directly because the body's `list-style` shorthand on `ul`/`ol` resets
|
||||
// the inherited position.
|
||||
.panel.alignRight table {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.panel.alignCenter table {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.panel.alignRight li,
|
||||
.panel.alignCenter li {
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.alignTop {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
// Auto margins, not `justify-content`: when the body overflows, an auto margin
|
||||
// resolves to zero so the content's top stays scrollable — `center`/`flex-end`
|
||||
// push the overflow above the scrollport, where no scroll position reaches it.
|
||||
// Specificity (0,3,0): the body root's `all: revert` reset sits at (0,2,0) and
|
||||
// would strip a tied margin rule.
|
||||
.panel.alignMiddle > *:first-child {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.panel.alignBottom > *:first-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
// Positioning context for the scroll-to-bottom pill floating over the body.
|
||||
.host {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Pencil } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
DashboardtypesTextAlignDTO,
|
||||
DashboardtypesVerticalAlignDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
|
||||
import PanelMessage from '../../components/PanelMessage/PanelMessage';
|
||||
import type { StaticRendererProps } from '../../types/rendererProps';
|
||||
import { interpolateVariables } from '../../utils/interpolateVariables';
|
||||
import MarkdownContent from './components/MarkdownContent/MarkdownContent';
|
||||
import ScrollToBottomPill from './components/ScrollToBottomPill/ScrollToBottomPill';
|
||||
import { useOverflowBelow } from '../../hooks/useOverflowBelow';
|
||||
|
||||
import styles from './Renderer.module.scss';
|
||||
|
||||
const HORIZONTAL_ALIGN_CLASS: Record<DashboardtypesTextAlignDTO, string> = {
|
||||
[DashboardtypesTextAlignDTO.left]: styles.alignLeft,
|
||||
[DashboardtypesTextAlignDTO.center]: styles.alignCenter,
|
||||
[DashboardtypesTextAlignDTO.right]: styles.alignRight,
|
||||
};
|
||||
|
||||
// Static, so it is not rebuilt on every variable tick.
|
||||
const EMPTY_STATE = (
|
||||
<PanelMessage
|
||||
icon={<Pencil size={18} />}
|
||||
title="Nothing written yet"
|
||||
description="Add Markdown to this panel to show content."
|
||||
data-testid="text-panel-empty"
|
||||
/>
|
||||
);
|
||||
|
||||
const VERTICAL_ALIGN_CLASS: Record<DashboardtypesVerticalAlignDTO, string> = {
|
||||
[DashboardtypesVerticalAlignDTO.top]: styles.alignTop,
|
||||
[DashboardtypesVerticalAlignDTO.center]: styles.alignMiddle,
|
||||
[DashboardtypesVerticalAlignDTO.bottom]: styles.alignBottom,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the panel's own Markdown body. The first kind that issues no query, so it
|
||||
* reads nothing from `data` and has no loading or error state — malformed Markdown
|
||||
* renders as literal text rather than throwing.
|
||||
*/
|
||||
function Renderer({
|
||||
panel,
|
||||
dashboardId,
|
||||
onChangeText,
|
||||
}: StaticRendererProps<'signoz/TextPanel'>): JSX.Element {
|
||||
const { text, presentation } = panel.spec.plugin.spec;
|
||||
|
||||
const variables = useDashboardStore(
|
||||
selectResolvedVariables(dashboardId ?? ''),
|
||||
);
|
||||
|
||||
// Interpolate and parse together: a dashboard re-renders on every variable tick,
|
||||
// and re-parsing every text panel on each one is the cost worth avoiding.
|
||||
const body = useMemo(
|
||||
() => interpolateVariables(text ?? '', variables),
|
||||
[text, variables],
|
||||
);
|
||||
|
||||
// The authored body, not the interpolated one: an edit lands on what is saved.
|
||||
const interactive = useMemo(
|
||||
() =>
|
||||
onChangeText
|
||||
? { source: text ?? '', onChangeSource: onChangeText }
|
||||
: undefined,
|
||||
[onChangeText, text],
|
||||
);
|
||||
|
||||
const { scrollRef, hasMoreBelow, scrollToBottom } =
|
||||
useOverflowBelow<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div className={styles.host}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cx(
|
||||
styles.panel,
|
||||
HORIZONTAL_ALIGN_CLASS[
|
||||
presentation?.textAlign ?? DashboardtypesTextAlignDTO.left
|
||||
],
|
||||
VERTICAL_ALIGN_CLASS[
|
||||
presentation?.verticalAlign ?? DashboardtypesVerticalAlignDTO.top
|
||||
],
|
||||
)}
|
||||
data-testid="text-panel"
|
||||
>
|
||||
<MarkdownContent interactive={interactive} emptyState={EMPTY_STATE}>
|
||||
{body}
|
||||
</MarkdownContent>
|
||||
</div>
|
||||
{hasMoreBelow && <ScrollToBottomPill onClick={scrollToBottom} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Renderer;
|
||||
@@ -1,7 +1,8 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import MarkdownContent from '../MarkdownContent';
|
||||
import { loadLanguage } from '../syntaxLanguages';
|
||||
import MarkdownContent from '../components/MarkdownContent/MarkdownContent';
|
||||
import { loadLanguage } from '../../../utils/syntaxLanguages';
|
||||
|
||||
describe('MarkdownContent', () => {
|
||||
describe('security', () => {
|
||||
@@ -234,6 +235,23 @@ describe('MarkdownContent — interactive task lists', () => {
|
||||
expect(second).toBeChecked();
|
||||
});
|
||||
|
||||
it('warns on hover that a tick edits the panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MarkdownContent interactive={{ source, onChangeSource: jest.fn() }}>
|
||||
{source}
|
||||
</MarkdownContent>,
|
||||
);
|
||||
|
||||
await user.hover(screen.getAllByRole('checkbox')[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
'Toggling this updates the panel spec',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('checking one rewrites its marker in the source', () => {
|
||||
const onChangeSource = jest.fn();
|
||||
render(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
|
||||
import Renderer from '../Renderer';
|
||||
|
||||
function textPanel(text?: string): PanelOfKind<'signoz/TextPanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Runbook' },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text } },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as PanelOfKind<'signoz/TextPanel'>;
|
||||
}
|
||||
|
||||
function renderPanel(text?: string): void {
|
||||
render(
|
||||
<Renderer
|
||||
panelId="p1"
|
||||
panel={textPanel(text)}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Text panel empty state', () => {
|
||||
it.each([undefined, '', ' \n\t'])('stands in for a body of %j', (text) => {
|
||||
renderPanel(text);
|
||||
|
||||
expect(screen.getByTestId('text-panel-empty')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nothing written yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives way to the body once there is one', () => {
|
||||
renderPanel('# Runbook');
|
||||
|
||||
expect(screen.queryByTestId('text-panel-empty')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Runbook' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// An undefined variable renders literally, as queries treat one, so the body
|
||||
// is not empty and the panel shows it rather than the empty state.
|
||||
it('does not stand in for an unresolved variable', () => {
|
||||
renderPanel('$missing');
|
||||
|
||||
expect(screen.queryByTestId('text-panel-empty')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('$missing')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
|
||||
import Renderer from '../Renderer';
|
||||
|
||||
const panel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Runbook' },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text: '# hello' } },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as PanelOfKind<'signoz/TextPanel'>;
|
||||
|
||||
/** jsdom has no layout: stub the scroll geometry the hook reads. */
|
||||
function setScrollGeometry(
|
||||
el: HTMLElement,
|
||||
{ scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number },
|
||||
): void {
|
||||
Object.defineProperty(el, 'scrollHeight', {
|
||||
configurable: true,
|
||||
value: scrollHeight,
|
||||
});
|
||||
Object.defineProperty(el, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: clientHeight,
|
||||
});
|
||||
}
|
||||
|
||||
function renderPanel(): HTMLElement {
|
||||
render(
|
||||
<Renderer panelId="p1" panel={panel} panelMode={PanelMode.DASHBOARD_VIEW} />,
|
||||
);
|
||||
return screen.getByTestId('text-panel');
|
||||
}
|
||||
|
||||
describe('Text panel scroll-to-bottom pill', () => {
|
||||
it('is absent when the body fits', () => {
|
||||
const scroller = renderPanel();
|
||||
setScrollGeometry(scroller, { scrollHeight: 100, clientHeight: 100 });
|
||||
fireEvent.scroll(scroller);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('text-panel-scroll-more'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appears when content extends below the fold and jumps to the end on click', () => {
|
||||
const scroller = renderPanel();
|
||||
setScrollGeometry(scroller, { scrollHeight: 400, clientHeight: 100 });
|
||||
act(() => {
|
||||
fireEvent.scroll(scroller);
|
||||
});
|
||||
|
||||
const pill = screen.getByTestId('text-panel-scroll-more');
|
||||
const scrollTo = jest.fn();
|
||||
scroller.scrollTo = scrollTo;
|
||||
fireEvent.click(pill);
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 400, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
it('hides once the user reaches the bottom', () => {
|
||||
const scroller = renderPanel();
|
||||
setScrollGeometry(scroller, { scrollHeight: 400, clientHeight: 100 });
|
||||
act(() => {
|
||||
fireEvent.scroll(scroller);
|
||||
});
|
||||
expect(screen.getByTestId('text-panel-scroll-more')).toBeInTheDocument();
|
||||
|
||||
scroller.scrollTop = 300;
|
||||
act(() => {
|
||||
fireEvent.scroll(scroller);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('text-panel-scroll-more'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
|
||||
import Renderer from '../Renderer';
|
||||
|
||||
const SOURCE = ['- [ ] first', '- [x] second'].join('\n');
|
||||
|
||||
function textPanel(text: string): PanelOfKind<'signoz/TextPanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Runbook' },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text } },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as PanelOfKind<'signoz/TextPanel'>;
|
||||
}
|
||||
|
||||
describe('Text panel task lists', () => {
|
||||
it('renders them read-only without a write channel', () => {
|
||||
render(
|
||||
<Renderer
|
||||
panelId="p1"
|
||||
panel={textPanel(SOURCE)}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getAllByRole('checkbox').forEach((box) => expect(box).toBeDisabled());
|
||||
});
|
||||
|
||||
it('reports the rewritten body when a host can save it', () => {
|
||||
const onChangeText = jest.fn();
|
||||
render(
|
||||
<Renderer
|
||||
panelId="p1"
|
||||
panel={textPanel(SOURCE)}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
onChangeText={onChangeText}
|
||||
/>,
|
||||
{ wrapper: TooltipProvider },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('checkbox')[0]);
|
||||
|
||||
expect(onChangeText).toHaveBeenCalledWith(
|
||||
['- [x] first', '- [x] second'].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('edits the authored body, not the interpolated one', () => {
|
||||
const onChangeText = jest.fn();
|
||||
render(
|
||||
<Renderer
|
||||
panelId="p1"
|
||||
panel={textPanel('- [ ] deploy $service')}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
onChangeText={onChangeText}
|
||||
/>,
|
||||
{ wrapper: TooltipProvider },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(onChangeText).toHaveBeenCalledWith('- [x] deploy $service');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
contrastRatio,
|
||||
inkForSurface,
|
||||
meetsContrast,
|
||||
MIN_CONTRAST_RATIO,
|
||||
normalizeHex,
|
||||
parseHex,
|
||||
rgbaFromHex,
|
||||
} from '../contrast';
|
||||
import {
|
||||
CUSTOM_INK,
|
||||
TEXT_BACKGROUND_PAIRS,
|
||||
TEXT_BACKGROUND_PRESETS,
|
||||
TRANSPARENT_BACKGROUND,
|
||||
} from '../presets';
|
||||
import {
|
||||
presetSurface,
|
||||
resolveTextBackground,
|
||||
selectionFromResolved,
|
||||
storedFromSelection,
|
||||
toStoredBackground,
|
||||
} from '../resolveTextBackground';
|
||||
import type { ResolvedTextBackground } from '../types';
|
||||
import { PanelTheme, TextBackgroundKind, TextBackgroundPreset } from '../types';
|
||||
|
||||
const THEMES: PanelTheme[] = Object.values(PanelTheme);
|
||||
|
||||
describe('preset tokens', () => {
|
||||
const pairs = TEXT_BACKGROUND_PRESETS.flatMap((preset) =>
|
||||
THEMES.map((theme) => ({
|
||||
preset,
|
||||
theme,
|
||||
...TEXT_BACKGROUND_PAIRS[preset][theme],
|
||||
})),
|
||||
);
|
||||
|
||||
it('covers all eight presets in both themes', () => {
|
||||
expect(pairs).toHaveLength(16);
|
||||
});
|
||||
|
||||
it.each(pairs)(
|
||||
'$preset/$theme clears the contrast floor',
|
||||
({ surface, ink }) => {
|
||||
expect(contrastRatio(ink, surface)).toBeGreaterThanOrEqual(
|
||||
MIN_CONTRAST_RATIO,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// A repeated surface would make the hex → preset lookup ambiguous.
|
||||
it('keeps all sixteen surfaces distinct', () => {
|
||||
const surfaces = pairs.map(({ surface }) => surface.toUpperCase());
|
||||
expect(new Set(surfaces).size).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseHex', () => {
|
||||
it('expands shorthand digits', () => {
|
||||
expect(parseHex('#abc')).toStrictEqual({ r: 170, g: 187, b: 204, a: 1 });
|
||||
});
|
||||
|
||||
it('reads the alpha channel from the four- and eight-digit forms', () => {
|
||||
expect(parseHex('#0000')?.a).toBe(0);
|
||||
expect(parseHex('#00000000')?.a).toBe(0);
|
||||
expect(parseHex('#aabbccff')?.a).toBe(1);
|
||||
});
|
||||
|
||||
it('treats a form without an alpha channel as opaque', () => {
|
||||
expect(parseHex('#aabbcc')?.a).toBe(1);
|
||||
});
|
||||
|
||||
it.each(['', 'aabbcc', 'red', '#abcde', '#gggggg'])('rejects %s', (color) => {
|
||||
expect(parseHex(color)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalises to the uppercase six-digit form', () => {
|
||||
expect(normalizeHex('#dce4ff')).toBe('#DCE4FF');
|
||||
expect(normalizeHex('#abc')).toBe('#AABBCC');
|
||||
expect(normalizeHex('#dce4ffcc')).toBe('#DCE4FF');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rgbaFromHex', () => {
|
||||
it('takes a share of the colour', () => {
|
||||
expect(rgbaFromHex('#DCE4FF', 0.82)).toBe('rgba(220, 228, 255, 0.82)');
|
||||
});
|
||||
|
||||
it('expands shorthand and ignores the source alpha', () => {
|
||||
expect(rgbaFromHex('#abc', 1)).toBe('rgba(170, 187, 204, 1)');
|
||||
expect(rgbaFromHex('#aabbcc00', 0.5)).toBe('rgba(170, 187, 204, 0.5)');
|
||||
});
|
||||
|
||||
it('returns nothing for a colour it cannot read', () => {
|
||||
expect(rgbaFromHex('red', 0.82)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inkForSurface', () => {
|
||||
it('puts light ink on a dark surface and dark ink on a light one', () => {
|
||||
expect(inkForSurface('#101010')).toBe(CUSTOM_INK.light);
|
||||
expect(inkForSurface('#F5F5F5')).toBe(CUSTOM_INK.dark);
|
||||
});
|
||||
|
||||
it('reports a mid surface as short of the floor without failing', () => {
|
||||
const surface = '#808080';
|
||||
expect(meetsContrast(surface, inkForSurface(surface))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTextBackground', () => {
|
||||
it.each([undefined, null, ''])('reads %s as the default surface', (stored) => {
|
||||
expect(resolveTextBackground(stored, PanelTheme.Dark)).toStrictEqual({
|
||||
kind: TextBackgroundKind.Default,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([TRANSPARENT_BACKGROUND, '#0000'])(
|
||||
'reads the zero-alpha colour %s as no card',
|
||||
(stored) => {
|
||||
expect(resolveTextBackground(stored, PanelTheme.Dark)).toStrictEqual({
|
||||
kind: TextBackgroundKind.None,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('resolves a surface stored in one theme to the pair of the other', () => {
|
||||
const storedInLight = presetSurface(
|
||||
TextBackgroundPreset.Amber,
|
||||
PanelTheme.Light,
|
||||
);
|
||||
|
||||
expect(resolveTextBackground(storedInLight, PanelTheme.Dark)).toStrictEqual({
|
||||
kind: TextBackgroundKind.Preset,
|
||||
preset: TextBackgroundPreset.Amber,
|
||||
...TEXT_BACKGROUND_PAIRS.amber.dark,
|
||||
});
|
||||
});
|
||||
|
||||
it('recognises a preset surface whatever its case', () => {
|
||||
const stored = presetSurface(
|
||||
TextBackgroundPreset.Forest,
|
||||
PanelTheme.Dark,
|
||||
).toLowerCase();
|
||||
|
||||
expect(resolveTextBackground(stored, PanelTheme.Light)).toMatchObject({
|
||||
kind: TextBackgroundKind.Preset,
|
||||
preset: TextBackgroundPreset.Forest,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a hex that is not a preset surface as a custom colour', () => {
|
||||
expect(resolveTextBackground('#3A2A64', PanelTheme.Dark)).toStrictEqual({
|
||||
kind: TextBackgroundKind.Custom,
|
||||
surface: '#3A2A64',
|
||||
ink: CUSTOM_INK.light,
|
||||
});
|
||||
});
|
||||
|
||||
it('holds a custom colour steady across a theme switch', () => {
|
||||
expect(resolveTextBackground('#3A2A64', PanelTheme.Light)).toStrictEqual(
|
||||
resolveTextBackground('#3A2A64', PanelTheme.Dark),
|
||||
);
|
||||
});
|
||||
|
||||
// The enum the API used to accept; neither value is a hex.
|
||||
it.each([
|
||||
['solid', TextBackgroundKind.Default],
|
||||
['transparent', TextBackgroundKind.None],
|
||||
])('migrates the legacy %s value to %s', (stored, kind) => {
|
||||
expect(resolveTextBackground(stored, PanelTheme.Dark)).toStrictEqual({
|
||||
kind,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default surface for an unreadable value', () => {
|
||||
expect(resolveTextBackground('rgb(1, 2, 3)', PanelTheme.Dark)).toStrictEqual({
|
||||
kind: TextBackgroundKind.Default,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor adapters', () => {
|
||||
it.each([
|
||||
[undefined, TextBackgroundKind.Default],
|
||||
[TRANSPARENT_BACKGROUND, TextBackgroundKind.None],
|
||||
['solid', TextBackgroundKind.Default],
|
||||
])('lights up the %s swatch', (stored, selection) => {
|
||||
expect(
|
||||
selectionFromResolved(resolveTextBackground(stored, PanelTheme.Dark)),
|
||||
).toBe(selection);
|
||||
});
|
||||
|
||||
it('lights up the preset a stored surface belongs to', () => {
|
||||
expect(
|
||||
selectionFromResolved(
|
||||
resolveTextBackground(
|
||||
presetSurface(TextBackgroundPreset.Slate, PanelTheme.Light),
|
||||
PanelTheme.Dark,
|
||||
),
|
||||
),
|
||||
).toBe(TextBackgroundPreset.Slate);
|
||||
});
|
||||
|
||||
it('lights up nothing for a custom colour', () => {
|
||||
expect(
|
||||
selectionFromResolved(resolveTextBackground('#3A2A64', PanelTheme.Dark)),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stores what each swatch means', () => {
|
||||
expect(storedFromSelection(TextBackgroundKind.None, PanelTheme.Dark)).toBe(
|
||||
TRANSPARENT_BACKGROUND,
|
||||
);
|
||||
expect(
|
||||
storedFromSelection(TextBackgroundKind.Default, PanelTheme.Dark),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
storedFromSelection(TextBackgroundPreset.Cherry, PanelTheme.Light),
|
||||
).toBe(presetSurface(TextBackgroundPreset.Cherry, PanelTheme.Light));
|
||||
});
|
||||
});
|
||||
|
||||
describe('round trip', () => {
|
||||
const cases: ResolvedTextBackground[] = [
|
||||
{ kind: TextBackgroundKind.None },
|
||||
{ kind: TextBackgroundKind.Default },
|
||||
{
|
||||
kind: TextBackgroundKind.Custom,
|
||||
surface: '#3A2A64',
|
||||
ink: CUSTOM_INK.light,
|
||||
},
|
||||
...TEXT_BACKGROUND_PRESETS.map((preset) => ({
|
||||
kind: TextBackgroundKind.Preset,
|
||||
preset,
|
||||
})),
|
||||
];
|
||||
|
||||
it.each(cases)(
|
||||
'preserves $kind $preset through a save and load',
|
||||
(resolved) => {
|
||||
THEMES.forEach((theme) => {
|
||||
const stored = toStoredBackground(resolved, theme);
|
||||
const reread = resolveTextBackground(stored, theme);
|
||||
|
||||
expect(reread.kind).toBe(resolved.kind);
|
||||
expect(reread.preset).toBe(resolved.preset);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
TRANSPARENT_BACKGROUND,
|
||||
'#3A2A64',
|
||||
'solid',
|
||||
'transparent',
|
||||
])('re-reading %s changes nothing', (stored) => {
|
||||
THEMES.forEach((theme) => {
|
||||
const once = resolveTextBackground(stored, theme);
|
||||
const twice = resolveTextBackground(toStoredBackground(once, theme), theme);
|
||||
|
||||
expect(twice).toStrictEqual(once);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { CUSTOM_INK } from './presets';
|
||||
|
||||
interface Channels {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
const HEX_PATTERN = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
|
||||
export function isHexColor(color: string): boolean {
|
||||
return HEX_PATTERN.test(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `#rgb`, `#rgba`, `#rrggbb` and `#rrggbbaa` — shorthand digits double,
|
||||
* and a form without an alpha channel is opaque. `undefined` for anything else.
|
||||
*/
|
||||
export function parseHex(color: string): Channels | undefined {
|
||||
if (!isHexColor(color)) {
|
||||
return undefined;
|
||||
}
|
||||
const hex = color.slice(1);
|
||||
const short = hex.length <= 4;
|
||||
const step = short ? 1 : 2;
|
||||
const channel = (index: number): number => {
|
||||
const digits = hex.slice(index * step, index * step + step);
|
||||
return parseInt(short ? digits + digits : digits, 16);
|
||||
};
|
||||
return {
|
||||
r: channel(0),
|
||||
g: channel(1),
|
||||
b: channel(2),
|
||||
a: hex.length === 4 || hex.length === 8 ? channel(3) / 255 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** The uppercase 6-digit form used as the preset lookup key. */
|
||||
export function normalizeHex(color: string): string | undefined {
|
||||
const channels = parseHex(color);
|
||||
if (!channels) {
|
||||
return undefined;
|
||||
}
|
||||
const pad = (value: number): string =>
|
||||
value.toString(16).padStart(2, '0').toUpperCase();
|
||||
return `#${pad(channels.r)}${pad(channels.g)}${pad(channels.b)}`;
|
||||
}
|
||||
|
||||
/** The colour at a given alpha; the source's own alpha is ignored. */
|
||||
export function rgbaFromHex(color: string, alpha: number): string | undefined {
|
||||
const channels = parseHex(color);
|
||||
if (!channels) {
|
||||
return undefined;
|
||||
}
|
||||
return `rgba(${channels.r}, ${channels.g}, ${channels.b}, ${alpha})`;
|
||||
}
|
||||
|
||||
/** WCAG 2.1 relative luminance; alpha is ignored. */
|
||||
export function relativeLuminance(color: string): number {
|
||||
const channels = parseHex(color);
|
||||
if (!channels) {
|
||||
return 0;
|
||||
}
|
||||
const linear = ([channels.r, channels.g, channels.b] as const).map((value) => {
|
||||
const srgb = value / 255;
|
||||
return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||
}
|
||||
|
||||
/** WCAG 2.1 contrast ratio, 1 to 21. */
|
||||
export function contrastRatio(foreground: string, background: string): number {
|
||||
const a = relativeLuminance(foreground);
|
||||
const b = relativeLuminance(background);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
export const MIN_CONTRAST_RATIO = 4.5;
|
||||
|
||||
/** Whichever fixed ink contrasts further, so a custom colour needs none of its own. */
|
||||
export function inkForSurface(surface: string): string {
|
||||
return contrastRatio(CUSTOM_INK.light, surface) >=
|
||||
contrastRatio(CUSTOM_INK.dark, surface)
|
||||
? CUSTOM_INK.light
|
||||
: CUSTOM_INK.dark;
|
||||
}
|
||||
|
||||
export function meetsContrast(surface: string, ink: string): boolean {
|
||||
return contrastRatio(ink, surface) >= MIN_CONTRAST_RATIO;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { PanelTheme, TextBackgroundPair } from './types';
|
||||
import { TextBackgroundPreset } from './types';
|
||||
|
||||
/** Declaration order is the swatch row order, after Transparent and Default panel. */
|
||||
export const TEXT_BACKGROUND_PRESETS: readonly TextBackgroundPreset[] =
|
||||
Object.values(TextBackgroundPreset);
|
||||
|
||||
/**
|
||||
* The sixteen surfaces must stay distinct — `resolveTextBackground` recovers a
|
||||
* preset name from a stored hex — and every pair must clear 4.5:1. Both are
|
||||
* asserted in `__tests__/textBackground.test.ts`.
|
||||
*/
|
||||
export const TEXT_BACKGROUND_PAIRS: Record<
|
||||
TextBackgroundPreset,
|
||||
Record<PanelTheme, TextBackgroundPair>
|
||||
> = {
|
||||
robin: {
|
||||
light: { surface: '#DCE4FF', ink: '#16224D' },
|
||||
dark: { surface: '#24356E', ink: '#EDF1FF' },
|
||||
},
|
||||
purple: {
|
||||
light: { surface: '#E8DEFB', ink: '#2B1B4D' },
|
||||
dark: { surface: '#3A2A63', ink: '#F1EAFE' },
|
||||
},
|
||||
sakura: {
|
||||
light: { surface: '#FBDCEB', ink: '#4A1730' },
|
||||
dark: { surface: '#5F2342', ink: '#FDE8F2' },
|
||||
},
|
||||
cherry: {
|
||||
light: { surface: '#FBDCDC', ink: '#4C1717' },
|
||||
dark: { surface: '#63262A', ink: '#FDE9E9' },
|
||||
},
|
||||
amber: {
|
||||
light: { surface: '#FBEECC', ink: '#45320A' },
|
||||
dark: { surface: '#5B4415', ink: '#FDF3DC' },
|
||||
},
|
||||
forest: {
|
||||
light: { surface: '#D6F2E2', ink: '#0F3A25' },
|
||||
dark: { surface: '#1D4A33', ink: '#E3F7EC' },
|
||||
},
|
||||
sienna: {
|
||||
light: { surface: '#F0E4D8', ink: '#40301F' },
|
||||
dark: { surface: '#56412C', ink: '#F5EADF' },
|
||||
},
|
||||
slate: {
|
||||
light: { surface: '#E4E6EA', ink: '#1D212D' },
|
||||
dark: { surface: '#2C3140', ink: '#EDEEF0' },
|
||||
},
|
||||
};
|
||||
|
||||
/** How `TextBackgroundKind.None` survives a string-only schema. */
|
||||
export const TRANSPARENT_BACKGROUND = '#00000000';
|
||||
|
||||
/** The theme's own overlay ink, so a note keeps the edge weight of its neighbours. */
|
||||
export const PRESET_BORDER: Record<PanelTheme, string> = {
|
||||
light: 'rgba(0, 0, 0, 0.07)',
|
||||
dark: 'rgba(255, 255, 255, 0.09)',
|
||||
};
|
||||
|
||||
export const SECONDARY_INK_OPACITY = 0.82;
|
||||
|
||||
/**
|
||||
* Surface chrome, as a share of the pair's ink. Each name falls back to its
|
||||
* original token in the stylesheet, so a panel with no background is untouched.
|
||||
* `--scrollbar-thumb*` are unscoped on purpose: they override the shared
|
||||
* scrollbar mixin, which any surface may want to retint.
|
||||
*/
|
||||
export const INK_ALPHAS: Record<string, number> = {
|
||||
'--text-panel-ink-secondary': SECONDARY_INK_OPACITY,
|
||||
'--text-panel-grip': 0.28,
|
||||
'--scrollbar-thumb': 0.24,
|
||||
'--scrollbar-thumb-hover': 0.4,
|
||||
'--text-panel-pill-surface': 0.16,
|
||||
};
|
||||
|
||||
/** The two inks a custom surface picks between. */
|
||||
export const CUSTOM_INK: Record<PanelTheme, string> = {
|
||||
light: '#FFFFFF',
|
||||
dark: '#1D212D',
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalised surface hex to the preset that owns it, both themes: a panel saved
|
||||
* in dark mode resolves to its preset in light mode, with no re-save.
|
||||
*/
|
||||
export const PRESET_BY_SURFACE: Record<string, TextBackgroundPreset> =
|
||||
Object.fromEntries(
|
||||
TEXT_BACKGROUND_PRESETS.flatMap((preset) => [
|
||||
[TEXT_BACKGROUND_PAIRS[preset].light.surface.toUpperCase(), preset],
|
||||
[TEXT_BACKGROUND_PAIRS[preset].dark.surface.toUpperCase(), preset],
|
||||
]),
|
||||
);
|
||||
@@ -0,0 +1,122 @@
|
||||
import { inkForSurface, normalizeHex, parseHex } from './contrast';
|
||||
import {
|
||||
PRESET_BY_SURFACE,
|
||||
TEXT_BACKGROUND_PAIRS,
|
||||
TRANSPARENT_BACKGROUND,
|
||||
} from './presets';
|
||||
import type {
|
||||
PanelTheme,
|
||||
ResolvedTextBackground,
|
||||
TextBackgroundPreset,
|
||||
TextBackgroundSelection,
|
||||
} from './types';
|
||||
import { TextBackgroundKind } from './types';
|
||||
|
||||
/** `presentation.background` before it was a hex string; the API rejects both now. */
|
||||
const LEGACY_VALUES: Record<string, TextBackgroundKind> = {
|
||||
solid: TextBackgroundKind.Default,
|
||||
transparent: TextBackgroundKind.None,
|
||||
};
|
||||
|
||||
const DEFAULT_BACKGROUND: ResolvedTextBackground = {
|
||||
kind: TextBackgroundKind.Default,
|
||||
};
|
||||
|
||||
/**
|
||||
* A stored preset surface resolves to its pair in the *current* theme, so a panel
|
||||
* follows a theme switch with no re-save; any other hex is a custom colour, which
|
||||
* does not adapt.
|
||||
*/
|
||||
export function resolveTextBackground(
|
||||
background: string | null | undefined,
|
||||
theme: PanelTheme,
|
||||
): ResolvedTextBackground {
|
||||
if (!background) {
|
||||
return DEFAULT_BACKGROUND;
|
||||
}
|
||||
|
||||
const legacy = LEGACY_VALUES[background];
|
||||
if (legacy) {
|
||||
return legacy === TextBackgroundKind.None
|
||||
? { kind: TextBackgroundKind.None }
|
||||
: DEFAULT_BACKGROUND;
|
||||
}
|
||||
|
||||
const channels = parseHex(background);
|
||||
if (!channels) {
|
||||
return DEFAULT_BACKGROUND;
|
||||
}
|
||||
if (channels.a === 0) {
|
||||
return { kind: TextBackgroundKind.None };
|
||||
}
|
||||
|
||||
const normalized = normalizeHex(background);
|
||||
const preset = normalized ? PRESET_BY_SURFACE[normalized] : undefined;
|
||||
if (preset) {
|
||||
return {
|
||||
kind: TextBackgroundKind.Preset,
|
||||
preset,
|
||||
...TEXT_BACKGROUND_PAIRS[preset][theme],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: TextBackgroundKind.Custom,
|
||||
surface: background,
|
||||
ink: inkForSurface(background),
|
||||
};
|
||||
}
|
||||
|
||||
/** A preset stores the current theme's surface; `default` stores nothing. */
|
||||
export function toStoredBackground(
|
||||
resolved: ResolvedTextBackground,
|
||||
theme: PanelTheme,
|
||||
): string | undefined {
|
||||
switch (resolved.kind) {
|
||||
case TextBackgroundKind.None:
|
||||
return TRANSPARENT_BACKGROUND;
|
||||
case TextBackgroundKind.Preset:
|
||||
return resolved.preset
|
||||
? TEXT_BACKGROUND_PAIRS[resolved.preset][theme].surface
|
||||
: undefined;
|
||||
case TextBackgroundKind.Custom:
|
||||
return resolved.surface;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Which swatch lights up; `undefined` for a custom colour, which has no swatch. */
|
||||
export function selectionFromResolved(
|
||||
resolved: ResolvedTextBackground,
|
||||
): TextBackgroundSelection | undefined {
|
||||
if (resolved.kind === TextBackgroundKind.Preset) {
|
||||
return resolved.preset;
|
||||
}
|
||||
return resolved.kind === TextBackgroundKind.Custom ? undefined : resolved.kind;
|
||||
}
|
||||
|
||||
/** What a swatch click stores. */
|
||||
export function storedFromSelection(
|
||||
selection: TextBackgroundSelection,
|
||||
theme: PanelTheme,
|
||||
): string | undefined {
|
||||
if (
|
||||
selection === TextBackgroundKind.None ||
|
||||
selection === TextBackgroundKind.Default
|
||||
) {
|
||||
return toStoredBackground({ kind: selection }, theme);
|
||||
}
|
||||
return toStoredBackground(
|
||||
{ kind: TextBackgroundKind.Preset, preset: selection },
|
||||
theme,
|
||||
);
|
||||
}
|
||||
|
||||
/** The hex a swatch paints in the given theme. */
|
||||
export function presetSurface(
|
||||
preset: TextBackgroundPreset,
|
||||
theme: PanelTheme,
|
||||
): string {
|
||||
return TEXT_BACKGROUND_PAIRS[preset][theme].surface;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user