mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-27 19:40:41 +00:00
Compare commits
63 Commits
issue_nerv
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47bfbd2189 | ||
|
|
91bbeca0a1 | ||
|
|
08af17a4b4 | ||
|
|
576900b90e | ||
|
|
4783fb819f | ||
|
|
7b06313f13 | ||
|
|
62254e29fa | ||
|
|
d546e770d6 | ||
|
|
5df8f465df | ||
|
|
6b3cc9a13f | ||
|
|
afa4c83179 | ||
|
|
387d4f5344 | ||
|
|
273356d1f3 | ||
|
|
240f853552 | ||
|
|
4d621ed107 | ||
|
|
9d21c3a12c | ||
|
|
748593738a | ||
|
|
8cb649196c | ||
|
|
c2cf250415 | ||
|
|
322ddfba08 | ||
|
|
d38402c1e2 | ||
|
|
8d2099cd2d | ||
|
|
2a67aa2e60 | ||
|
|
d9b83fda28 | ||
|
|
a750b7614d | ||
|
|
3af0d78f0e | ||
|
|
eeebb22cbc | ||
|
|
b2cca84795 | ||
|
|
8ff0c47f6c | ||
|
|
37cc361030 | ||
|
|
0f2f7f0264 | ||
|
|
45ed44f7a0 | ||
|
|
83dfde7af3 | ||
|
|
bd7d21ec8b | ||
|
|
b3ac3fa132 | ||
|
|
9a8f0fcb84 | ||
|
|
43ce3c6cc7 | ||
|
|
edc599ec34 | ||
|
|
62691ef1d8 | ||
|
|
9fd60567c3 | ||
|
|
bb732c41fc | ||
|
|
9244797fb9 | ||
|
|
2d2e63df4e | ||
|
|
ae85d95b0c | ||
|
|
1ce461d47f | ||
|
|
c0778e426a | ||
|
|
5266f089b9 | ||
|
|
d736f62033 | ||
|
|
bf7a6b44f7 | ||
|
|
b41df346db | ||
|
|
77d31cb122 | ||
|
|
82ee9953de | ||
|
|
05d7dd4e92 | ||
|
|
16a8a850d2 | ||
|
|
070343a6c0 | ||
|
|
19af7782d9 | ||
|
|
119ab97240 | ||
|
|
e163ec3b1b | ||
|
|
1e89b49ecf | ||
|
|
8a3702a7f9 | ||
|
|
0376eec24f | ||
|
|
368efd2afe | ||
|
|
fe9165447f |
142
.claude/skills/authz-configurator/SKILL.md
Normal file
142
.claude/skills/authz-configurator/SKILL.md
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: authz-configurator
|
||||
description: Add authorization (authz) to a SigNoz backend feature end-to-end — register kinds/resources, grant managed-role permissions, wire routes with ResourceDefs, backfill tuples for existing orgs, and write the authz integration tests. Use this whenever the user mentions authz, authorization, permissions, access control, managed roles (admin/editor/viewer), OpenFGA, ResourceDef, CheckResources, 403/forbidden behavior on an endpoint, wiring or protecting a new route, registering a new resource/kind, or writing integration tests that assert role-based allow/deny. Also use it when a new API endpoint is being added and its access control hasn't been decided yet — every route needs an authz decision even if the user didn't say the word.
|
||||
---
|
||||
|
||||
# SigNoz Authz Workflow
|
||||
|
||||
This skill automates the procedure documented in `docs/contributing/go/authz.md`. Read that doc first — it is the canonical explanation of the concepts (types, verbs, kinds, resources, selectors, tuples, the community/enterprise split). This skill tells you what to do; the doc tells you why it works.
|
||||
|
||||
The core model in one paragraph: permissions are never attached to users. They are attached to **roles** as OpenFGA tuples, and users/service accounts are made **assignees** of roles. You declare everything in code registries under `pkg/types/coretypes/`; tuples for new organizations are derived from those registries at org bootstrap. Routes declare what resource+verb they touch via `ResourceDef`s, and middleware turns that into a check that works in both community (role gate) and enterprise (per-resource FGA) editions.
|
||||
|
||||
## Before you start
|
||||
|
||||
Figure out what shape the work is. Ask the user if unclear:
|
||||
|
||||
- **New resource** (new kind + routes) → all steps below.
|
||||
- **New route on an existing resource** → step 4 only (maybe step 3 if roles need new verbs).
|
||||
- **Changing what a role can do** → steps 3, 5, 6, 7.
|
||||
- **Just tests** → steps 6, 7.
|
||||
|
||||
Almost every feature resource is a `metaresource` (dashboards, rules, pipelines) or a `telemetryresource` (logs, traces, metrics). If the feature genuinely needs a brand-new FGA **type**, stop and flag it to the user — that requires editing both `pkg/authz/openfgaschema/base.fga` and `ee/authz/openfgaschema/base.fga` together and is almost never the right call. A new **kind** never touches the schema.
|
||||
|
||||
## Step 1: Register the kind
|
||||
|
||||
Edit `pkg/types/coretypes/registry_kind.go`. Add a var and append it to the `Kinds` slice (both are required — the slice is what gets validated and iterated):
|
||||
|
||||
```go
|
||||
KindThing = MustNewKind("thing")
|
||||
```
|
||||
|
||||
Kind names are lowercase, hyphen-separated (`quick-filter`, `trace-funnel`, `notification-channel`).
|
||||
|
||||
Also add the kind to `Kind.Enum()` in `pkg/types/coretypes/kind.go` — a second hand-maintained list mirroring `Kinds` that feeds the OpenAPI enum. It is easy to miss because nothing fails at build time without it; the drift only shows up in the generated spec.
|
||||
|
||||
## Step 2: Register the resource
|
||||
|
||||
Edit `pkg/types/coretypes/registry_resource.go`. Add a var and append it to the `Resources` slice:
|
||||
|
||||
```go
|
||||
ResourceMetaResourceThing = NewResourceMetaResource(KindThing)
|
||||
```
|
||||
|
||||
Use `NewResourceTelemetryResource(KindThing)` for telemetry data resources. Pass an explicit verb list only when the resource supports fewer verbs than the type default — see `ResourceMetaResourceFactorAPIKey` in that file for the shape.
|
||||
|
||||
## Step 3: Grant permissions to managed roles
|
||||
|
||||
Edit `pkg/types/coretypes/registry_managed_role.go`. Add `Transaction` entries to `ManagedRoleToTransactions` for each role that may act on the resource:
|
||||
|
||||
```go
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindThing}, WildCardSelectorString)},
|
||||
```
|
||||
|
||||
The convention: **admin** gets everything; **editor** gets full CRUD only on day-to-day observability resources (dashboards, rules, saved views — not access control or org settings); **viewer** gets `read`/`list`; **anonymous** gets nothing (public dashboards are the only exception). Match the file's existing style, including its one-line grouping comment per resource block (e.g. `// thing — editors manage, viewers read`) — that file uses grouping comments as structure; do not add comments anywhere else.
|
||||
|
||||
This map is the single source of truth: changing it affects only organizations created afterwards (see step 5 for existing orgs), and the integration suite asserts its contents via a golden file (see step 6).
|
||||
|
||||
## Step 4: Wire the route
|
||||
|
||||
Register routes in the existing domain file under `pkg/apiserver/signozapiserver/` — do not create new per-sub-resource route files. Each route needs three coordinated pieces:
|
||||
|
||||
1. **`provider.authzMiddleware.CheckResources(handlerFn, roles...)`** — the role list is the community-edition fallback gate (which managed roles may call this route when per-resource checks are unavailable).
|
||||
2. **`handler.WithResourceDefs(...)`** — declares resource, verb, audit category, ID extractor, and selector.
|
||||
3. **`SecuritySchemes: newScopedSecuritySchemes([]string{resource.Scope(verb)})`** — advertises the scope in the OpenAPI spec.
|
||||
|
||||
Selector rules (getting these wrong silently over- or under-authorizes):
|
||||
|
||||
| Operation | Selector | ID extractor |
|
||||
|---|---|---|
|
||||
| create | `WildcardSelector` | `ResponseJSONPath("data.id")` (ID exists only after the handler runs; recorded for audit) |
|
||||
| list | `WildcardSelector` | none |
|
||||
| read/update/delete of one instance | `IDSelector` | `PathParam("id")` or `BodyJSONPath(...)` |
|
||||
| request ID ≠ FGA selector (e.g. route has a role UUID, FGA objects use role names) | custom `SelectorFunc` | as appropriate |
|
||||
|
||||
Routes that link two resources check **both** sides. Read `references/route-wiring.md` before wiring any create route, linking route (attach/detach, parent-child), or custom selector — it has complete real examples from `serviceaccount.go`.
|
||||
|
||||
Prefer `CheckResources` + `ResourceDef` for anything resource-shaped. The legacy gates `ViewAccess`/`EditAccess`/`AdminAccess` are coarse role-only checks — do not use them for new routes. `OpenAccess` means no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes.
|
||||
|
||||
## Step 5: Backfill existing organizations (only if needed)
|
||||
|
||||
New organizations derive their tuples from the registries at creation — no migration needed for them. If **existing** organizations must get the new permissions, add a migration in `pkg/sqlmigration/` that does two things per org:
|
||||
|
||||
1. **Insert the FGA tuples** — follow `083_add_role_crud_tuples.go`: one `tuple` row (+ `changelog` row) per role/verb/object, with `ON CONFLICT ... DO NOTHING` so it stays idempotent, handling both the Postgres and SQLite tuple column layouts as that file does.
|
||||
2. **Refresh the managed roles' JSON record** — since `099_add_role_transaction_groups.go`, each role row carries a `transaction_groups` JSON column that mirrors its permissions. Tuples and JSON must move together or the stored record drifts from the registry. Follow 099's managed-role loop: marshal `authtypes.NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[roleName])` and `UPDATE role SET transaction_groups = ? WHERE org_id = ? AND type = 'managed' AND name = ?`. Serializing from the registry (which already contains your new grants) makes this step self-updating. Custom roles need no changes — only managed roles mirror the registry.
|
||||
|
||||
Register the new migration factory in `NewSQLMigrationProviderFactories` in `pkg/signoz/provider.go`, appended after the existing entries. The factory name passed to `factory.MustNewName` must match `^[a-z][a-z0-9_-]{0,30}$` — at most 31 characters, or it panics at startup; prefer a short name like `notification_template_tuples` over `add_notification_template_tuples`.
|
||||
|
||||
Ask the user whether backfill is needed rather than assuming — shipping a feature to existing orgs almost always means yes.
|
||||
|
||||
## Step 6: Update the golden testdata and regenerate CI-checked artifacts
|
||||
|
||||
If you changed `ManagedRoleToTransactions`, update `tests/integration/testdata/role/managed_role_transactions.json` to match — the `role/01_register.py` integration test compares each managed role's verb/type/kind matrix against this file and will fail otherwise.
|
||||
|
||||
If you touched the registries or `Kind.Enum()`, regenerate the derived artifacts. The `goci` GitHub workflow reruns each of these and fails the PR on any uncommitted diff:
|
||||
|
||||
```bash
|
||||
go run cmd/enterprise/*.go generate openapi # docs/api/openapi.yml
|
||||
go run cmd/enterprise/*.go generate authz # frontend/src/lib/authz/hooks/useAuthZ/permissions.type.ts
|
||||
go run cmd/enterprise/*.go generate config transaction-groups # frontend/src/schemas/generated/transactionGroups.schema.json
|
||||
```
|
||||
|
||||
Commit whatever they change; never hand-edit these generated files.
|
||||
|
||||
`generate authz` is allowlist-based: it only emits resources listed in the `allowedResources` map inside `runGenerateAuthz` in `cmd/authz.go`. For the new resource to surface in the UI's permission gating (AuthZButton and friends), add it there —
|
||||
|
||||
```go
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceThing).String(): true,
|
||||
```
|
||||
|
||||
— then rerun `generate authz` and commit the regenerated `permissions.type.ts`. If the resource has no UI surface yet, skip the allowlist entry and the command produces no diff; that is expected.
|
||||
|
||||
## Step 7: Write integration tests
|
||||
|
||||
Read `references/integration-tests.md` for the suite layout, fixtures, and canonical allow/deny test shapes. The minimum bar for a new authz'd resource: each managed role's expected 200-vs-403 matrix on the new routes, and (if enterprise/FGA behavior matters) per-object scoping with a custom role — granted instance succeeds, non-granted instance gets 403, revoke flips it back to 403.
|
||||
|
||||
Python test conventions that are hard rules in this repo: inline `requests.*` calls directly (never wrap single API calls in helpers), always pass `timeout=`, and run `make py-fmt && make py-lint` before finishing.
|
||||
|
||||
## Verification
|
||||
|
||||
Run these before declaring the work done:
|
||||
|
||||
```bash
|
||||
make go-build-community
|
||||
```
|
||||
|
||||
Consistency greps — every one of these must hold:
|
||||
|
||||
- the new `Kind` var appears in BOTH the `Kinds` slice (`registry_kind.go`) and `Kind.Enum()` (`kind.go`)
|
||||
- the three `generate` commands from step 6 have been run and leave `git diff` clean when re-run
|
||||
- if the resource is UI-gated, it appears in `allowedResources` in `cmd/authz.go` and `permissions.type.ts` was regenerated
|
||||
- the new `Resource` var appears in the `Resources` slice (`registry_resource.go`)
|
||||
- every `ResourceDef` in the route file references the registered resource and one of its allowed verbs
|
||||
- every route's `SecuritySchemes` scope matches its `ResourceDef` verb
|
||||
- if `ManagedRoleToTransactions` changed, `managed_role_transactions.json` changed with it
|
||||
|
||||
Then run the relevant integration suites (`make py-test-setup` from the repo root boots the environment; pytest runs from `tests/`):
|
||||
|
||||
```bash
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/role/
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/<your-suite>/
|
||||
```
|
||||
|
||||
General repo conventions apply throughout: no code comments (the grouping comments in `registry_managed_role.go` are the sole exception), complete descriptive names, and never commit without an explicit ask.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Authz Integration Tests Reference
|
||||
|
||||
Existing authz suites to imitate: `integration/tests/role/` (managed roles, custom-role CRUD, FGA scoping), `integration/tests/serviceaccount/` (API-key auth by role, role assignment), `integration/tests/passwordauthn/04_role.py` (user role changes, editor denied on admin endpoints).
|
||||
|
||||
## Where a new test goes
|
||||
|
||||
- Suite package: `tests/integration/tests/<suite>/` — pick the existing suite matching the feature's domain, or create a new package when the feature is its own domain.
|
||||
- File name: two-digit ordered prefix, `NN_<topic>.py` (`03_fga.py`, `04_roles.py`). Files in a suite run in order; setup tests early in the file establish state that later tests use.
|
||||
- Golden/testdata JSON: `tests/integration/testdata/<suite>/`, loaded via `fixtures.fs.get_testdata_file_path`.
|
||||
- If the change touched `ManagedRoleToTransactions`, update `tests/integration/testdata/role/managed_role_transactions.json` — `role/01_register.py` asserts every managed role's verb/type/kind matrix against it.
|
||||
|
||||
## Environment and running
|
||||
|
||||
`make` targets run from the repo root; direct `uv run pytest` commands run from `tests/`:
|
||||
|
||||
```bash
|
||||
make py-test-setup # repo root — boot the stack once (~4 min), keep it with --reuse
|
||||
cd tests
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/<suite>/
|
||||
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/role/03_fga.py::test_read_scoped_to_granted_role
|
||||
make py-test-teardown # repo root — destroy when done; never mix --reuse and --teardown
|
||||
make py-fmt && make py-lint # repo root — mandatory before finishing
|
||||
```
|
||||
|
||||
## Fixtures you'll need
|
||||
|
||||
From `tests/fixtures/auth.py` (registered via `pytest_plugins` in `tests/conftest.py`):
|
||||
|
||||
- `signoz: types.SigNoz` — the running stack; build URLs with `signoz.self.host_configs["8080"].get("/api/v1/...")`.
|
||||
- `create_user_admin` — package-scoped; registers the first admin (`USER_ADMIN_EMAIL` / `USER_ADMIN_PASSWORD`). Take it as an unused argument in every test that needs the org to exist.
|
||||
- `get_token(email, password) -> str` — bearer token for a user.
|
||||
- `add_license(signoz, make_http_mocks, get_token)` — custom roles are enterprise-only; call it in a `test_apply_license` at the top of any suite that creates custom roles.
|
||||
- `create_active_user(signoz, admin_token, email=..., role="signoz-viewer", password=..., name=...) -> user_id` — invite + activate. `role` is a managed role name looked up verbatim (e.g. `signoz-viewer`).
|
||||
- `change_user_role(signoz, admin_token, user_id, old_role, new_role)` — e.g. `"signoz-viewer"` → your custom role name.
|
||||
|
||||
From `tests/fixtures/role.py`:
|
||||
|
||||
- `create_role(token, name, transaction_groups=None, description="") -> role_id` (fixture).
|
||||
- `find_role_by_name(signoz, token, name) -> role_id` — plain function, import `from fixtures.role import find_role_by_name`.
|
||||
- `transaction_group(relation, type_name, kind_name, selectors)` — builds one permission entry, e.g. `transaction_group("read", "metaresource", "thing", ["*"])` or with a specific instance name/ID as selector.
|
||||
|
||||
From `tests/fixtures/serviceaccount.py`:
|
||||
|
||||
- `create_service_account_with_key(signoz, token, name, role="signoz-admin") -> (sa_id, api_key)`.
|
||||
- Service accounts authenticate with the header `SIGNOZ-API-KEY: <key>`; users with `Authorization: Bearer <token>`.
|
||||
|
||||
Hard rule: inline `requests.get/post/put/delete` calls directly in tests — do not wrap single API calls in helper functions or fixtures. Always pass `timeout=5`. Include the response text in assertion messages so failures are diagnosable.
|
||||
|
||||
## Shape 1: managed-role matrix (community-safe)
|
||||
|
||||
For each role, assert the expected status on each new route. Pattern from `serviceaccount/03_auth.py`:
|
||||
|
||||
```python
|
||||
def test_editor_forbidden_on_admin_endpoint(signoz, create_user_admin, get_token):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
_, api_key = create_service_account_with_key(signoz, token, "sa-role-editor", role="signoz-editor")
|
||||
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/service_accounts"),
|
||||
headers={"SIGNOZ-API-KEY": api_key},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"expected 403, got {resp.status_code}: {resp.text}"
|
||||
```
|
||||
|
||||
The user-token variant creates an editor/viewer via `create_active_user` and asserts 403 on the route, 200 for admin.
|
||||
|
||||
## Shape 2: per-object FGA scoping (enterprise, custom role)
|
||||
|
||||
Grant instance verbs on one object only; assert the other stays forbidden. Structure from `role/03_fga.py`:
|
||||
|
||||
```python
|
||||
def test_apply_license(signoz, create_user_admin, make_http_mocks, get_token):
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
def test_setup_actor(signoz, create_user_admin, get_token, create_role):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
create_role(
|
||||
admin_token,
|
||||
"thing-fga-actor",
|
||||
[
|
||||
transaction_group("read", "metaresource", "thing", [_TARGET_A_ID]),
|
||||
transaction_group("list", "metaresource", "thing", ["*"]),
|
||||
],
|
||||
)
|
||||
user_id = create_active_user(signoz, admin_token, email=_ACTOR_EMAIL, role="signoz-viewer", password=_ACTOR_PASSWORD, name="thing fga actor")
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", "thing-fga-actor")
|
||||
|
||||
|
||||
def test_read_scoped_to_grant(signoz, create_user_admin, get_token):
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/things/{_TARGET_A_ID}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
|
||||
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/things/{_TARGET_B_ID}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"expected 403, got {resp.status_code}: {resp.text}"
|
||||
```
|
||||
|
||||
Key semantics to test (they mirror the selector rules on the route side):
|
||||
|
||||
- `read`/`update`/`delete` granted on a specific selector authorize only that instance; `*` authorizes all.
|
||||
- `create` and `list` are collection-scoped — only a `*` grant makes them pass, and `list` on `*` returns every instance including ones the caller can't `read` individually.
|
||||
- Revoking the grant (update the role's transaction groups, or `change_user_role` back) flips the previously-allowed call to 403.
|
||||
|
||||
Module-level constants for names/emails (see `role/03_fga.py` — `_ACTOR_ROLE_NAME`, `_TARGET_A`) keep the setup test and the assertion tests in sync.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
- Every new route appears in at least one allow case and one deny case.
|
||||
- Suites that create custom roles start with `test_apply_license`.
|
||||
- `make py-fmt && make py-lint` pass.
|
||||
- The suite passes against a `--reuse` stack: `uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/<suite>/`.
|
||||
155
.claude/skills/authz-configurator/references/route-wiring.md
Normal file
155
.claude/skills/authz-configurator/references/route-wiring.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Route Wiring Reference
|
||||
|
||||
All examples below are real code from `pkg/apiserver/signozapiserver/serviceaccount.go`. Match this style exactly.
|
||||
|
||||
## Basic route: create (wildcard selector, response-phase ID)
|
||||
|
||||
```go
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Create, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account",
|
||||
Description: "This endpoint creates a service account",
|
||||
Request: new(serviceaccounttypes.PostableServiceAccount),
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceServiceAccount,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Create checks the wildcard (can the caller create *any* instance?) but still records the new instance's ID for audit via `ResponseJSONPath` — the ID only exists after the handler runs.
|
||||
|
||||
Instance routes (read/update/delete) differ only in verb, `ID: coretypes.PathParam("id")`, and `Selector: coretypes.IDSelector`. List routes use `WildcardSelector` and omit `ID` entirely.
|
||||
|
||||
Pick the `Category` matching the resource's domain — grep `ActionCategory` in `pkg/types/coretypes/` for the registered values (`ActionCategoryAccessControl` is for access-control resources; observability resources use their own categories).
|
||||
|
||||
## ID extractors (`pkg/types/coretypes/extractor.go`)
|
||||
|
||||
| Extractor | Phase | Use for |
|
||||
|---|---|---|
|
||||
| `PathParam("id")` | request | ID in the URL path |
|
||||
| `BodyJSONPath("data.id")` | request | ID in the request body (gjson path) |
|
||||
| `BodyJSONArray("ids")` | request | multiple IDs in a body array |
|
||||
| `ResponseJSONPath("data.id")` | response | ID known only after the handler (create) |
|
||||
| `NewResourceIDExtractor(phase, fn)` | either | ID needs a lookup (see below) |
|
||||
| `OneID(extractor)` | — | lifts a single-ID extractor where a multi-ID one is required (`SourceIDs`/`TargetIDs`/`ChildIDs`) |
|
||||
|
||||
Custom extractor example — the route path carries a service-account-role ID but the check needs the underlying role ID, so the extractor resolves it through the module:
|
||||
|
||||
```go
|
||||
func (provider *provider) roleIDExtractor() coretypes.ResourceIDExtractor {
|
||||
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
|
||||
if ec.Request == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
serviceAccountRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
serviceAccountRole, err := provider.serviceAccountGetter.GetServiceAccountRole(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), serviceAccountRoleID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return serviceAccountRole.RoleID.String(), nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Custom SelectorFunc
|
||||
|
||||
Use one when the extracted ID is not what FGA objects use. Roles are the canonical case: routes receive a role UUID, but FGA role objects are keyed by role *name*. Always include the wildcard alongside the specific selector — authorization on `*` implies authorization on every instance:
|
||||
|
||||
```go
|
||||
func (provider *provider) roleSelector(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
|
||||
roleID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
role, err := provider.authzService.Get(ctx, orgID, roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []coretypes.Selector{
|
||||
resource.Type().MustSelector(role.Name),
|
||||
resource.Type().MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Linking routes: siblings (peer-to-peer)
|
||||
|
||||
Attaching a role to a service account requires `attach` on **both** resources — the same verb checked on each side. Advertise both scopes:
|
||||
|
||||
```go
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
|
||||
// ...
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceServiceAccount,
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("serviceAccountId")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
```
|
||||
|
||||
The detach route (`DELETE .../service_account_roles/{id}`) uses `VerbDetach`, and its path `{id}` identifies the **assignment** (the service account role), not the role. Both IDs are resolved from it via lookup-helper extractors — `provider.serviceAccountIDExtractor()` and `provider.roleIDExtractor()` fetch the assignment and return its service account ID and role ID.
|
||||
|
||||
## Linking routes: parent-child
|
||||
|
||||
Creating an API key *under* a service account checks two different things: `create` on the child resource, and `attach` on the parent. Declare both defs in the same `WithResourceDefs`:
|
||||
|
||||
```go
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachParentChildResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ParentResource: coretypes.ResourceServiceAccount,
|
||||
ParentID: coretypes.PathParam("id"),
|
||||
ParentSelector: coretypes.IDSelector,
|
||||
ChildResource: coretypes.ResourceMetaResourceFactorAPIKey,
|
||||
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
},
|
||||
),
|
||||
```
|
||||
|
||||
Within the parent-child def, the child is only recorded for audit — the authz check on the child comes from its own `BasicResourceDef`. Deletion mirrors this with `VerbDelete` on the child and `VerbDetach` on the parent (see the `/api/v1/service_accounts/{id}/keys/{fid}` DELETE route).
|
||||
|
||||
## Legacy gates — do not use for new routes
|
||||
|
||||
`ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles". They give up per-resource granularity, produce no audit resources, and don't advertise scopes. They exist for old routes; new routes use `CheckResources` + `ResourceDef`. `OpenAccess` performs no authorization at all (authentication still applies — used for `/me`-style routes); `CheckWithoutClaims` is for anonymous routes such as public dashboards.
|
||||
|
||||
Full def types live in `pkg/http/handler/resourcedef.go`; middleware behavior in `pkg/http/middleware/authz.go` and `pkg/http/middleware/resource.go`.
|
||||
3812
docs/api/openapi.yml
3812
docs/api/openapi.yml
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,7 @@ const mockPaths = {
|
||||
const mockTzDate = jest.fn(
|
||||
(date: Date, _timezone: string) => new Date(date.getTime()),
|
||||
);
|
||||
const mockOrient = jest.fn();
|
||||
|
||||
// Mock uPlot constructor - this needs to be a proper constructor function
|
||||
function MockUPlot(
|
||||
@@ -70,6 +71,7 @@ function MockUPlot(
|
||||
// Add static methods to the constructor
|
||||
MockUPlot.tzDate = mockTzDate;
|
||||
MockUPlot.paths = mockPaths;
|
||||
MockUPlot.orient = mockOrient;
|
||||
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
|
||||
MockUPlot.pxRatio = 1;
|
||||
|
||||
|
||||
@@ -163,9 +163,9 @@
|
||||
"@commitlint/config-conventional": "20.4.4",
|
||||
"@jest/globals": "30.4.1",
|
||||
"@jest/types": "30.2.0",
|
||||
"@storybook/addon-a11y": "10.5.9",
|
||||
"@storybook/addon-docs": "10.5.9",
|
||||
"@storybook/react-vite": "10.5.9",
|
||||
"@storybook/addon-a11y": "10.6.3",
|
||||
"@storybook/addon-docs": "10.6.3",
|
||||
"@storybook/react-vite": "10.6.3",
|
||||
"@storybook/test-runner": "0.24.5",
|
||||
"@testing-library/dom": "8.20.0",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
@@ -213,10 +213,10 @@
|
||||
"redux-mock-store": "1.5.4",
|
||||
"sass": "1.97.3",
|
||||
"sharp": "0.35.4",
|
||||
"storybook": "10.5.9",
|
||||
"storybook": "10.6.3",
|
||||
"stylelint": "17.15.0",
|
||||
"svgo": "4.1.0",
|
||||
"ts-jest": "29.4.9",
|
||||
"ts-jest": "29.4.15",
|
||||
"typescript-plugin-css-modules": "5.2.0",
|
||||
"use-sync-external-store": "1.6.0",
|
||||
"vite-plugin-checker": "0.12.0",
|
||||
|
||||
385
frontend/pnpm-lock.yaml
generated
385
frontend/pnpm-lock.yaml
generated
@@ -8,9 +8,9 @@ overrides:
|
||||
'@babel/core@<=7.29.0': '>=7.29.6 <8'
|
||||
'@istanbuljs/load-nyc-config>js-yaml': '>=4.3.2 <5'
|
||||
'@xmldom/xmldom@<0.8.15': '>=0.8.15 <0.9'
|
||||
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'
|
||||
brace-expansion@<1.1.21: '>=1.1.21 <2'
|
||||
brace-expansion@>=2.0.0 <2.1.7: '>=2.1.7 <3'
|
||||
brace-expansion@>=5.0.0 <5.0.12: '>=5.0.12 <6'
|
||||
browserslist@<4.28.7: '>=4.28.7 <5'
|
||||
cookie@<0.7.0: '>=0.7.1 <1'
|
||||
dompurify@<=3.4.12: '>=3.4.13 <4'
|
||||
@@ -364,17 +364,17 @@ importers:
|
||||
specifier: 30.2.0
|
||||
version: 30.2.0
|
||||
'@storybook/addon-a11y':
|
||||
specifier: 10.5.9
|
||||
version: 10.5.9(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
specifier: 10.6.3
|
||||
version: 10.6.3(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
'@storybook/addon-docs':
|
||||
specifier: 10.5.9
|
||||
version: 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
specifier: 10.6.3
|
||||
version: 10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
'@storybook/react-vite':
|
||||
specifier: 10.5.9
|
||||
version: 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)
|
||||
specifier: 10.6.3
|
||||
version: 10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)
|
||||
'@storybook/test-runner':
|
||||
specifier: 0.24.5
|
||||
version: 0.24.5(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3))
|
||||
version: 0.24.5(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3))
|
||||
'@testing-library/dom':
|
||||
specifier: 8.20.0
|
||||
version: 8.20.0
|
||||
@@ -514,8 +514,8 @@ importers:
|
||||
specifier: 0.35.4
|
||||
version: 0.35.4(@types/node@16.18.25)
|
||||
storybook:
|
||||
specifier: 10.5.9
|
||||
version: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
specifier: 10.6.3
|
||||
version: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
stylelint:
|
||||
specifier: 17.15.0
|
||||
version: 17.15.0(typescript@5.9.3)
|
||||
@@ -523,8 +523,8 @@ importers:
|
||||
specifier: 4.1.0
|
||||
version: 4.1.0
|
||||
ts-jest:
|
||||
specifier: 29.4.9
|
||||
version: 29.4.9(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.2.0)(babel-jest@29.6.4(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.2.0(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3)))(typescript@5.9.3)
|
||||
specifier: 29.4.15
|
||||
version: 29.4.15(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.2.0)(babel-jest@29.6.4(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.2.0(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3)))(typescript@5.9.3)
|
||||
typescript-plugin-css-modules:
|
||||
specifier: 5.2.0
|
||||
version: 5.2.0(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3))(typescript@5.9.3)
|
||||
@@ -1525,8 +1525,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@emnapi/core@1.11.2':
|
||||
resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==}
|
||||
'@emnapi/core@1.11.0':
|
||||
resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==}
|
||||
|
||||
'@emnapi/core@1.8.1':
|
||||
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
||||
@@ -1534,8 +1534,8 @@ packages:
|
||||
'@emnapi/core@1.9.2':
|
||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||
|
||||
'@emnapi/runtime@1.11.2':
|
||||
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
|
||||
'@emnapi/runtime@1.11.0':
|
||||
resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==}
|
||||
|
||||
'@emnapi/runtime@1.11.3':
|
||||
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
|
||||
@@ -2170,6 +2170,9 @@ packages:
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0':
|
||||
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
@@ -2452,106 +2455,106 @@ packages:
|
||||
'@oxc-project/types@0.127.0':
|
||||
resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.24.2':
|
||||
resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==}
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.21.2':
|
||||
resolution: {integrity: sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxc-resolver/binding-android-arm64@11.24.2':
|
||||
resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==}
|
||||
'@oxc-resolver/binding-android-arm64@11.21.2':
|
||||
resolution: {integrity: sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxc-resolver/binding-darwin-arm64@11.24.2':
|
||||
resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==}
|
||||
'@oxc-resolver/binding-darwin-arm64@11.21.2':
|
||||
resolution: {integrity: sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-resolver/binding-darwin-x64@11.24.2':
|
||||
resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==}
|
||||
'@oxc-resolver/binding-darwin-x64@11.21.2':
|
||||
resolution: {integrity: sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-resolver/binding-freebsd-x64@11.24.2':
|
||||
resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==}
|
||||
'@oxc-resolver/binding-freebsd-x64@11.21.2':
|
||||
resolution: {integrity: sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2':
|
||||
resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==}
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2':
|
||||
resolution: {integrity: sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.24.2':
|
||||
resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==}
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.21.2':
|
||||
resolution: {integrity: sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==}
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.21.2':
|
||||
resolution: {integrity: sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.24.2':
|
||||
resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==}
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.21.2':
|
||||
resolution: {integrity: sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==}
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.21.2':
|
||||
resolution: {integrity: sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==}
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.21.2':
|
||||
resolution: {integrity: sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.24.2':
|
||||
resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==}
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.21.2':
|
||||
resolution: {integrity: sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==}
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.21.2':
|
||||
resolution: {integrity: sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==}
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.21.2':
|
||||
resolution: {integrity: sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.24.2':
|
||||
resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==}
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.21.2':
|
||||
resolution: {integrity: sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.24.2':
|
||||
resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==}
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.21.2':
|
||||
resolution: {integrity: sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.24.2':
|
||||
resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==}
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.21.2':
|
||||
resolution: {integrity: sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.24.2':
|
||||
resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==}
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.21.2':
|
||||
resolution: {integrity: sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.24.2':
|
||||
resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==}
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.21.2':
|
||||
resolution: {integrity: sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -3660,44 +3663,26 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@storybook/addon-a11y@10.5.9':
|
||||
resolution: {integrity: sha512-MvXkoJIVRcZrqhWi+wynYAmFkvTWm5aHdJfYohhgPeEJVDRF91zejtOsN7zPRE/lQvBDY+IiomKxeRN3UhWNVw==}
|
||||
'@storybook/addon-a11y@10.6.3':
|
||||
resolution: {integrity: sha512-nqvjpmu5g+PPtEKwgE0qAi4wfuQ9yngOb/1Gm24pBBZOuRsd3UMEBjKORSuAGrIAz9X3LCFfQi7sCcBFXvFbwg==}
|
||||
peerDependencies:
|
||||
storybook: ^10.5.9
|
||||
storybook: ^10.6.3
|
||||
|
||||
'@storybook/addon-docs@10.5.9':
|
||||
resolution: {integrity: sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==}
|
||||
'@storybook/addon-docs@10.6.3':
|
||||
resolution: {integrity: sha512-365f712au4w/xvFqEKY6TIvTeGGAY7FKRB8zP4CFa3VSv9oyxHaq/ublFg4YDbCeRagppwl1PZ6k4+PoUor3+A==}
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
storybook: ^10.5.9
|
||||
storybook: ^10.6.3
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@storybook/builder-vite@10.5.9':
|
||||
resolution: {integrity: sha512-Zg4JbGQiHFPGlFJ9HM+XPgzKmU/RFPCymhohVRJhBBYfmgaQgz0flWWzscseCDpl638MNd8/r/H+nwuoBgSYDg==}
|
||||
'@storybook/builder-vite@10.6.3':
|
||||
resolution: {integrity: sha512-1+WCZZg/fPxxyHDB5sFKniFt9/4RFUIzoNqMujv+BLRROM+gQeyUHvdvoyZOZ51+P8ee+/PgnNKHjsTupV+nOA==}
|
||||
peerDependencies:
|
||||
storybook: ^10.5.9
|
||||
storybook: ^10.6.3
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
'@storybook/csf-plugin@10.5.9':
|
||||
resolution: {integrity: sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==}
|
||||
peerDependencies:
|
||||
esbuild: '>=0.28.1 <0.29.0'
|
||||
rollup: '*'
|
||||
storybook: ^10.5.9
|
||||
vite: '*'
|
||||
webpack: '*'
|
||||
peerDependenciesMeta:
|
||||
esbuild:
|
||||
optional: true
|
||||
rollup:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
webpack:
|
||||
optional: true
|
||||
|
||||
'@storybook/global@5.0.0':
|
||||
resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==}
|
||||
|
||||
@@ -3706,40 +3691,40 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@storybook/react-dom-shim@10.5.9':
|
||||
resolution: {integrity: sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==}
|
||||
'@storybook/react-dom-shim@10.6.3':
|
||||
resolution: {integrity: sha512-cJUuPrieiTl06kzlgaU89iG+CtLAgUTt0Xdvm8qFT9yR2qwafSaswhwYWlZCNX4nWuOu7eK7uzWTwzKqyDBOWQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
storybook: ^10.5.9
|
||||
storybook: ^10.6.3
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@storybook/react-vite@10.5.9':
|
||||
resolution: {integrity: sha512-mDI+UssrOEP8IwgBsefLG+gqW1gZbZEPbpH5PoHK/P1WPi8BfrLEqrFi9H9wmMkal/Vh8SmL1awgOi0ir3XwcQ==}
|
||||
'@storybook/react-vite@10.6.3':
|
||||
resolution: {integrity: sha512-geWwLi9JnaRG2t9bYBfz+94skARzzXNT1Toaf0lk7Oog13rtNK7ay2pWNm9SP6/L2S7mEjoWu9+zHLpwxXvfIw==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
storybook: ^10.5.9
|
||||
storybook: ^10.6.3
|
||||
typescript: '>= 4.9.x'
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@storybook/react@10.5.9':
|
||||
resolution: {integrity: sha512-kApGOuNT26NkpioTsr1iT/Q2c44tA7OIsNUSyFqtT7W8k3fRn/jQWfrDegYxty0WG0wxdNMZq2ndRfOOF8HaHw==}
|
||||
'@storybook/react@10.6.3':
|
||||
resolution: {integrity: sha512-3tfAkZNdUyx3I4IjM8dgbNWg4k0gj89Hi1MPiawfTybRRWhvnX2YqV9Ut4mrud7YzRv0v+U7+ZYEvj+maUPaow==}
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
storybook: ^10.5.9
|
||||
storybook: ^10.6.3
|
||||
typescript: '>= 4.9.x'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
@@ -4761,14 +4746,14 @@ packages:
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||
brace-expansion@1.1.21:
|
||||
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
|
||||
|
||||
brace-expansion@2.1.4:
|
||||
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
||||
brace-expansion@2.1.7:
|
||||
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
brace-expansion@5.0.12:
|
||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
@@ -6149,8 +6134,8 @@ packages:
|
||||
resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==}
|
||||
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
||||
|
||||
handlebars@4.7.9:
|
||||
resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==}
|
||||
handlebars@4.7.10:
|
||||
resolution: {integrity: sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==}
|
||||
engines: {node: '>=0.4.7'}
|
||||
hasBin: true
|
||||
|
||||
@@ -7207,6 +7192,9 @@ packages:
|
||||
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
magic-string@1.4.3:
|
||||
resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
|
||||
|
||||
make-dir@3.1.0:
|
||||
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7782,8 +7770,8 @@ packages:
|
||||
resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
||||
oxc-resolver@11.24.2:
|
||||
resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==}
|
||||
oxc-resolver@11.21.2:
|
||||
resolution: {integrity: sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==}
|
||||
|
||||
oxfmt@0.73.0:
|
||||
resolution: {integrity: sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==}
|
||||
@@ -9175,8 +9163,8 @@ packages:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
storybook@10.5.9:
|
||||
resolution: {integrity: sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==}
|
||||
storybook@10.6.3:
|
||||
resolution: {integrity: sha512-lNBvcZFG44C98KvQHYzfq1O46m+sl94zY1+na5/9E3ihtNMbvpOgYyMFMIcWYUvOMo3OM4q9jzvB73KU20bU8w==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -9465,8 +9453,8 @@ packages:
|
||||
ts-easing@0.2.0:
|
||||
resolution: {integrity: sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==}
|
||||
|
||||
ts-jest@29.4.9:
|
||||
resolution: {integrity: sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==}
|
||||
ts-jest@29.4.15:
|
||||
resolution: {integrity: sha512-s2qmEUYFRqbjz1wv0EmccNPVOb45HOFpFuhzQJLKShYVisuTbCFPIIAIV2EuK5rIxb4BejLDbH7dnBZfYjybUQ==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -11351,7 +11339,7 @@ snapshots:
|
||||
react: 18.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@emnapi/core@1.11.2':
|
||||
'@emnapi/core@1.11.0':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.2
|
||||
tslib: 2.8.1
|
||||
@@ -11369,7 +11357,7 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.11.2':
|
||||
'@emnapi/runtime@1.11.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
@@ -12090,6 +12078,8 @@ snapshots:
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.1
|
||||
@@ -12195,10 +12185,10 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
|
||||
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.2
|
||||
'@emnapi/runtime': 1.11.2
|
||||
'@emnapi/core': 1.11.0
|
||||
'@emnapi/runtime': 1.11.0
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
@@ -12411,65 +12401,65 @@ snapshots:
|
||||
|
||||
'@oxc-project/types@0.127.0': {}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.24.2':
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-android-arm64@11.24.2':
|
||||
'@oxc-resolver/binding-android-arm64@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-darwin-arm64@11.24.2':
|
||||
'@oxc-resolver/binding-darwin-arm64@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-darwin-x64@11.24.2':
|
||||
'@oxc-resolver/binding-darwin-x64@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-freebsd-x64@11.24.2':
|
||||
'@oxc-resolver/binding-freebsd-x64@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2':
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.24.2':
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.24.2':
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.24.2':
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.24.2':
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.24.2':
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.24.2':
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.24.2':
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.24.2':
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.24.2':
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.24.2':
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.24.2':
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.21.2':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.2
|
||||
'@emnapi/runtime': 1.11.2
|
||||
'@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
|
||||
'@emnapi/core': 1.11.0
|
||||
'@emnapi/runtime': 1.11.0
|
||||
'@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.24.2':
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.24.2':
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.73.0':
|
||||
@@ -13412,49 +13402,32 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@storybook/addon-a11y@10.5.9(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
'@storybook/addon-a11y@10.6.3(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
dependencies:
|
||||
'@storybook/global': 5.0.0
|
||||
axe-core: 4.13.0
|
||||
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
|
||||
'@storybook/addon-docs@10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
'@storybook/addon-docs@10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
dependencies:
|
||||
'@mdx-js/react': 3.1.1(@types/react@18.0.26)(react@18.2.0)
|
||||
'@storybook/csf-plugin': 10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
'@storybook/icons': 2.1.0(react@18.2.0)
|
||||
'@storybook/react-dom-shim': 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
'@storybook/react-dom-shim': 10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
ts-dedent: 2.3.0
|
||||
unplugin: 2.3.11
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
transitivePeerDependencies:
|
||||
- '@types/react-dom'
|
||||
- esbuild
|
||||
- rollup
|
||||
- vite
|
||||
- webpack
|
||||
|
||||
'@storybook/builder-vite@10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
'@storybook/builder-vite@10.6.3(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
dependencies:
|
||||
'@storybook/csf-plugin': 10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
ts-dedent: 2.3.0
|
||||
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1)
|
||||
transitivePeerDependencies:
|
||||
- esbuild
|
||||
- rollup
|
||||
- webpack
|
||||
|
||||
'@storybook/csf-plugin@10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
dependencies:
|
||||
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
unplugin: 2.3.11
|
||||
optionalDependencies:
|
||||
esbuild: 0.28.1
|
||||
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1)
|
||||
|
||||
'@storybook/global@5.0.0': {}
|
||||
|
||||
@@ -13462,28 +13435,28 @@ snapshots:
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
|
||||
'@storybook/react-dom-shim@10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
'@storybook/react-dom-shim@10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@storybook/react-vite@10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)':
|
||||
'@storybook/react-vite@10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(typescript@5.9.3)
|
||||
'@rollup/pluginutils': 5.4.0
|
||||
'@storybook/builder-vite': 10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
'@storybook/react': 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)
|
||||
'@storybook/builder-vite': 10.6.3(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
'@storybook/react': 10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)
|
||||
empathic: 2.0.1
|
||||
magic-string: 0.30.8
|
||||
magic-string: 1.4.3
|
||||
react: 18.2.0
|
||||
react-docgen: 8.0.3
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
resolve: 1.22.11
|
||||
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
tsconfig-paths: 4.2.0
|
||||
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1)
|
||||
optionalDependencies:
|
||||
@@ -13491,20 +13464,18 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
- esbuild
|
||||
- rollup
|
||||
- supports-color
|
||||
- webpack
|
||||
|
||||
'@storybook/react@10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)':
|
||||
'@storybook/react@10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@storybook/global': 5.0.0
|
||||
'@storybook/react-dom-shim': 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
'@storybook/react-dom-shim': 10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
react: 18.2.0
|
||||
react-docgen: 8.0.3
|
||||
react-docgen-typescript: 2.4.0(typescript@5.9.3)
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
@@ -13512,7 +13483,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@storybook/test-runner@0.24.5(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3))':
|
||||
'@storybook/test-runner@0.24.5(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/generator': 7.29.7
|
||||
@@ -13534,7 +13505,7 @@ snapshots:
|
||||
playwright: 1.57.0
|
||||
playwright-core: 1.57.0
|
||||
rimraf: 3.0.2
|
||||
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
uuid: 8.3.2
|
||||
transitivePeerDependencies:
|
||||
- '@swc/helpers'
|
||||
@@ -13674,7 +13645,7 @@ snapshots:
|
||||
'@testing-library/jest-dom@6.9.1':
|
||||
dependencies:
|
||||
'@adobe/css-tools': 4.5.0
|
||||
aria-query: 5.1.3
|
||||
aria-query: 5.3.0
|
||||
css.escape: 1.5.1
|
||||
dom-accessibility-api: 0.6.3
|
||||
picocolors: 1.1.1
|
||||
@@ -14662,16 +14633,16 @@ snapshots:
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
brace-expansion@1.1.21:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.1.4:
|
||||
brace-expansion@2.1.7:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
brace-expansion@5.0.12:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -16125,7 +16096,7 @@ snapshots:
|
||||
|
||||
graphql@16.8.1: {}
|
||||
|
||||
handlebars@4.7.9:
|
||||
handlebars@4.7.10:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
neo-async: 2.6.2
|
||||
@@ -17498,6 +17469,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magic-string@1.4.3:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
make-dir@3.1.0:
|
||||
dependencies:
|
||||
semver: 6.3.1
|
||||
@@ -17908,19 +17883,19 @@ snapshots:
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
brace-expansion: 5.0.12
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.18
|
||||
brace-expansion: 1.1.21
|
||||
|
||||
minimatch@5.1.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.4
|
||||
brace-expansion: 2.1.7
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.4
|
||||
brace-expansion: 2.1.7
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -18304,27 +18279,27 @@ snapshots:
|
||||
'@oxc-parser/binding-win32-ia32-msvc': 0.127.0
|
||||
'@oxc-parser/binding-win32-x64-msvc': 0.127.0
|
||||
|
||||
oxc-resolver@11.24.2:
|
||||
oxc-resolver@11.21.2:
|
||||
optionalDependencies:
|
||||
'@oxc-resolver/binding-android-arm-eabi': 11.24.2
|
||||
'@oxc-resolver/binding-android-arm64': 11.24.2
|
||||
'@oxc-resolver/binding-darwin-arm64': 11.24.2
|
||||
'@oxc-resolver/binding-darwin-x64': 11.24.2
|
||||
'@oxc-resolver/binding-freebsd-x64': 11.24.2
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2
|
||||
'@oxc-resolver/binding-linux-arm64-gnu': 11.24.2
|
||||
'@oxc-resolver/binding-linux-arm64-musl': 11.24.2
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2
|
||||
'@oxc-resolver/binding-linux-riscv64-musl': 11.24.2
|
||||
'@oxc-resolver/binding-linux-s390x-gnu': 11.24.2
|
||||
'@oxc-resolver/binding-linux-x64-gnu': 11.24.2
|
||||
'@oxc-resolver/binding-linux-x64-musl': 11.24.2
|
||||
'@oxc-resolver/binding-openharmony-arm64': 11.24.2
|
||||
'@oxc-resolver/binding-wasm32-wasi': 11.24.2
|
||||
'@oxc-resolver/binding-win32-arm64-msvc': 11.24.2
|
||||
'@oxc-resolver/binding-win32-x64-msvc': 11.24.2
|
||||
'@oxc-resolver/binding-android-arm-eabi': 11.21.2
|
||||
'@oxc-resolver/binding-android-arm64': 11.21.2
|
||||
'@oxc-resolver/binding-darwin-arm64': 11.21.2
|
||||
'@oxc-resolver/binding-darwin-x64': 11.21.2
|
||||
'@oxc-resolver/binding-freebsd-x64': 11.21.2
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf': 11.21.2
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf': 11.21.2
|
||||
'@oxc-resolver/binding-linux-arm64-gnu': 11.21.2
|
||||
'@oxc-resolver/binding-linux-arm64-musl': 11.21.2
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu': 11.21.2
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu': 11.21.2
|
||||
'@oxc-resolver/binding-linux-riscv64-musl': 11.21.2
|
||||
'@oxc-resolver/binding-linux-s390x-gnu': 11.21.2
|
||||
'@oxc-resolver/binding-linux-x64-gnu': 11.21.2
|
||||
'@oxc-resolver/binding-linux-x64-musl': 11.21.2
|
||||
'@oxc-resolver/binding-openharmony-arm64': 11.21.2
|
||||
'@oxc-resolver/binding-wasm32-wasi': 11.21.2
|
||||
'@oxc-resolver/binding-win32-arm64-msvc': 11.21.2
|
||||
'@oxc-resolver/binding-win32-x64-msvc': 11.21.2
|
||||
|
||||
oxfmt@0.73.0:
|
||||
dependencies:
|
||||
@@ -19922,7 +19897,7 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
internal-slot: 1.1.0
|
||||
|
||||
storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0):
|
||||
storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0):
|
||||
dependencies:
|
||||
'@storybook/global': 5.0.0
|
||||
'@storybook/icons': 2.1.0(react@18.2.0)
|
||||
@@ -19936,7 +19911,7 @@ snapshots:
|
||||
jsonc-parser: 3.3.1
|
||||
open: 10.2.0
|
||||
oxc-parser: 0.127.0
|
||||
oxc-resolver: 11.24.2
|
||||
oxc-resolver: 11.21.2
|
||||
recast: 0.23.21
|
||||
semver: 7.8.5
|
||||
use-sync-external-store: 1.6.0(react@18.2.0)
|
||||
@@ -20254,11 +20229,11 @@ snapshots:
|
||||
|
||||
ts-easing@0.2.0: {}
|
||||
|
||||
ts-jest@29.4.9(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.2.0)(babel-jest@29.6.4(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.2.0(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3)))(typescript@5.9.3):
|
||||
ts-jest@29.4.15(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.2.0)(babel-jest@29.6.4(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.2.0(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3)))(typescript@5.9.3):
|
||||
dependencies:
|
||||
bs-logger: 0.2.6
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
handlebars: 4.7.9
|
||||
handlebars: 4.7.10
|
||||
jest: 30.2.0(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3))
|
||||
json5: 2.2.3
|
||||
lodash.memoize: 4.1.2
|
||||
|
||||
@@ -20,13 +20,13 @@ overrides:
|
||||
'@xmldom/xmldom@<0.8.15': '>=0.8.15 <0.9'
|
||||
# via: babel-plugin-istanbul > test-exclude@6 > minimatch@3.1.5 (^1.1.7); also glob@7
|
||||
# remove: blocked — babel-plugin-istanbul pins test-exclude@6, which pins minimatch@3
|
||||
brace-expansion@<1.1.18: '>=1.1.18 <2'
|
||||
brace-expansion@<1.1.21: '>=1.1.21 <2'
|
||||
# via: jest > glob@10 > minimatch@9.0.9 (^2.0.1); also minimatch@5.1.9 (^2.0.1)
|
||||
# remove: blocked — jest 30 resolves glob@10 > minimatch@9 internally
|
||||
'brace-expansion@>=2.0.0 <2.1.4': '>=2.1.4 <3'
|
||||
'brace-expansion@>=2.0.0 <2.1.7': '>=2.1.7 <3'
|
||||
# via: eslint-plugin-sonarjs@4.0.2 (minimatch ^10.2.4) > minimatch@10.2.5 (^5.0.5)
|
||||
# remove: blocked — minimatch@10.2.6 (latest) only widens to ^5.0.8, still vulnerable
|
||||
'brace-expansion@>=5.0.0 <5.0.9': '>=5.0.9 <6'
|
||||
'brace-expansion@>=5.0.0 <5.0.12': '>=5.0.12 <6'
|
||||
# via: direct devDep @babel/core > @babel/helper-compilation-targets (browserslist ^4.24.0)
|
||||
# remove: blocked, the range is open so the floor is what pulls the fix.
|
||||
# Also lifts baseline-browser-mapping, which browserslist@4.28.7 floors at ^2.11.20
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import {
|
||||
QueryKeyValueRequestProps,
|
||||
QueryKeyValueSuggestionsResponseProps,
|
||||
} from 'types/api/querySuggestions/types';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useGetFieldsValues` hook (or `getFieldsValues` fetcher) from
|
||||
* `api/generated/services/fields` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
export const getValueSuggestions = (
|
||||
props: QueryKeyValueRequestProps,
|
||||
): Promise<AxiosResponse<QueryKeyValueSuggestionsResponseProps>> => {
|
||||
const { signal, key, searchText, signalSource, metricName } = props;
|
||||
|
||||
const encodedSignal = encodeURIComponent(signal);
|
||||
const encodedKey = encodeURIComponent(key);
|
||||
const encodedMetricName = encodeURIComponent(metricName || '');
|
||||
const encodedSearchText = encodeURIComponent(searchText);
|
||||
const encodedSource = encodeURIComponent(signalSource || '');
|
||||
|
||||
return axios.get(
|
||||
`/fields/values?signal=${encodedSignal}&name=${encodedKey}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&source=${encodedSource}`,
|
||||
);
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
convertBuilderQueriesToV5,
|
||||
mapPanelTypeToRequestType,
|
||||
prepareQueryRangePayloadV5,
|
||||
} from './prepareQueryRangePayloadV5';
|
||||
|
||||
@@ -904,6 +905,12 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapPanelTypeToRequestType', () => {
|
||||
it('sends a scatter plot as scalar, like a table', () => {
|
||||
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertBuilderQueriesToV5 having normalization', () => {
|
||||
const buildSpec = (having: unknown): MetricBuilderQuery => {
|
||||
const [envelope] = convertBuilderQueriesToV5(
|
||||
|
||||
@@ -51,6 +51,7 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
|
||||
case PANEL_TYPES.BAR:
|
||||
return 'time_series';
|
||||
case PANEL_TYPES.TABLE:
|
||||
case PANEL_TYPES.SCATTER:
|
||||
case PANEL_TYPES.PIE:
|
||||
case PANEL_TYPES.VALUE:
|
||||
return 'scalar';
|
||||
@@ -274,6 +275,7 @@ export function createAggregation(
|
||||
queryData.dataSource === DataSource.METRICS &&
|
||||
panelType &&
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.SCATTER ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE);
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
interface EmptyQuickFilterIconProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function EmptyQuickFilterIcon({
|
||||
width = 32,
|
||||
height = 32,
|
||||
className,
|
||||
}: EmptyQuickFilterIconProps): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M17.948 6.906h-6.62V6.24h6.62v.667ZM17.948 10.293h-6.62v-.667h6.62v.667Z"
|
||||
fill="#616161"
|
||||
/>
|
||||
<path
|
||||
d="M16.175 29.403H13.62V3.442s.238-.554 1.278-.554 1.278.554 1.278.554v25.96h-.002Z"
|
||||
fill="#9E9E9E"
|
||||
/>
|
||||
<path
|
||||
d="M15.52 2.971v20.974H13.62v1.91c.323.001.614.04.876.097a1.3 1.3 0 0 1 1.024 1.269v2.182h.656V3.442c-.002 0-.14-.318-.658-.471Z"
|
||||
fill="#757575"
|
||||
/>
|
||||
<path
|
||||
d="M22.363 2.737a5.382 5.382 0 0 0-5.382 5.402 5.365 5.365 0 0 0 5.382 5.382 5.378 5.378 0 0 0 5.382-5.382c0-2.975-2.407-5.402-5.382-5.402Z"
|
||||
fill="#2196F3"
|
||||
/>
|
||||
<path
|
||||
d="M24.714 4.749c-.338-.085-1.01-.174-2.349-.174-1.337 0-2.01.087-2.348.174-.2.05-.87.328-.87 1.077v4.618c0 .253.205.46.46.46h.17v.51c0 .15.12.27.268.27h.545c.149 0 .269-.12.269-.27v-.51h3.008v.51c0 .15.12.27.27.27h.544c.149 0 .269-.12.269-.27v-.51h.173a.46.46 0 0 0 .46-.46V5.826c0-.717-.669-1.029-.869-1.077Zm-3.802.366c0-.113.091-.204.205-.204h2.493c.113 0 .204.09.204.204v.362a.204.204 0 0 1-.204.205h-2.493a.204.204 0 0 1-.205-.205v-.362Zm.042 4.864a.138.138 0 0 1-.137.138h-.55a.469.469 0 0 1-.468-.469v-.246c0-.076.062-.138.138-.138h.548c.258 0 .47.209.47.469v.246Zm3.973-.33a.469.469 0 0 1-.468.468h-.55a.138.138 0 0 1-.137-.138v-.246c0-.258.209-.47.469-.47h.549c.075 0 .137.063.137.139v.246Zm.125-2.007c0 .297-.645.817-2.689.817-2.046 0-2.689-.482-2.689-.817V6.277c0-.089.09-.31.311-.31h4.791c.223 0 .276.224.276.31v1.365Z"
|
||||
fill="#fff"
|
||||
/>
|
||||
<path
|
||||
d="M18.861 24.652h-7.926a.506.506 0 0 1-.507-.507V14.99c0-.28.227-.507.507-.507h7.924c.28 0 .507.227.507.507v9.155c0 .28-.227.507-.505.507Z"
|
||||
fill="#F5F5F5"
|
||||
/>
|
||||
<path
|
||||
opacity=".8"
|
||||
d="M18.006 23.139H11.79l-.017-7.236h6.217l.016 7.236Z"
|
||||
fill="#82AEC0"
|
||||
/>
|
||||
<path d="M14.66 23.234v-7.33h.444v7.33h-.445Z" fill="#F5F5F5" />
|
||||
<path d="M14.66 15.903h-2.887v.605h2.886v-.605Z" fill="#616161" />
|
||||
<path
|
||||
d="M18.03 21.878h-6.264v-.222h6.264v.223ZM18.03 20.412h-6.264v-.222h6.264v.222ZM17.99 18.945h-6.217v-.222h6.217v.222ZM17.99 17.481h-6.224v-.222h6.224v.222Z"
|
||||
fill="#F5F5F5"
|
||||
/>
|
||||
<path
|
||||
d="M17.988 18.534H15.1v.605h2.887v-.605ZM14.672 19.999h-2.877v.604h2.877V20Z"
|
||||
fill="#616161"
|
||||
/>
|
||||
<path
|
||||
d="M10.935 14.817a.173.173 0 0 0-.173.173v9.155c0 .096.077.174.173.174h7.926a.173.173 0 0 0 .171-.174V14.99a.173.173 0 0 0-.173-.173h-7.924Zm-.84.173a.84.84 0 0 1 .84-.84h7.924a.84.84 0 0 1 .84.84v9.155a.84.84 0 0 1-.838.84h-7.926a.84.84 0 0 1-.84-.84V14.99Z"
|
||||
fill="#9E9E9E"
|
||||
/>
|
||||
<path
|
||||
d="M10.968 24.323c-.344.031-.344-.195-.344-.258V15.1c0-.251.204-.455.455-.455h7.693c.21 0 .305.126.262.384 0 0-.026-.165-.226-.165h-7.726a.237.237 0 0 0-.236.236v8.968c0 .21.122.256.122.256Z"
|
||||
fill="#757575"
|
||||
/>
|
||||
<path d="M12.268 4.933H4.695v6.577h7.573V4.933Z" fill="#FFCA28" />
|
||||
<path
|
||||
d="M11.846 4.933c.233 0 .422.189.422.422v5.733a.422.422 0 0 1-.422.422H5.12a.422.422 0 0 1-.423-.422V5.355c0-.233.19-.422.423-.422h6.726Zm0-.445H5.12a.868.868 0 0 0-.867.867v5.733c0 .478.389.867.867.867h6.726a.868.868 0 0 0 .867-.867V5.355a.866.866 0 0 0-.867-.867Z"
|
||||
fill="#9E9E9E"
|
||||
/>
|
||||
<path
|
||||
d="M12.27 7.308H4.696v-.444h7.575v.444ZM12.27 9.579H4.696v-.444h7.575v.444Z"
|
||||
fill="#FFFDE7"
|
||||
/>
|
||||
<path
|
||||
d="M7.066 5.664H5.162v.502h1.904v-.502ZM6.597 10.27H5.162v.503h1.435v-.502ZM11.648 10.27h-1.435v.503h1.435v-.502ZM11.648 5.664h-1.435v.502h1.435v-.502ZM10.302 7.968h-.827v.502h.827v-.502ZM11.648 7.968h-.826v.502h.826v-.502ZM7.802 7.968h-2.64v.502h2.64v-.502Z"
|
||||
fill="#757575"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
EmptyQuickFilterIcon.defaultProps = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default EmptyQuickFilterIcon;
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
STEP_INTERVAL_PLACEHOLDER,
|
||||
} from '../../queryBuilderFields.utils';
|
||||
|
||||
import { isScalarPanelType } from '../panelTypes';
|
||||
import QueryAggregationSelect from './QueryAggregationSelect';
|
||||
|
||||
import './QueryAggregation.styles.scss';
|
||||
@@ -45,10 +46,7 @@ function QueryAggregationOptions({
|
||||
const isNonMetricSource =
|
||||
dataSource === DataSource.TRACES || dataSource === DataSource.LOGS;
|
||||
|
||||
if (
|
||||
isNonMetricSource &&
|
||||
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
|
||||
) {
|
||||
if (isNonMetricSource && isScalarPanelType(panelType)) {
|
||||
return { hidden: true, disabled: false, pinned: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import cx from 'classnames';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QBEntityOptions from 'container/QueryBuilder/components/QBEntityOptions/QBEntityOptions';
|
||||
import { QueryProps } from 'container/QueryBuilder/type';
|
||||
import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearchV2/SpanScopeSelector';
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
resolveQueryBuilderField,
|
||||
} from '../queryBuilderFields.utils';
|
||||
|
||||
import { isScalarPanelType } from './panelTypes';
|
||||
import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection';
|
||||
import { MetricsSelect } from './MetricsSelect/MetricsSelect';
|
||||
import QueryAddOns from './QueryAddOns/QueryAddOns';
|
||||
@@ -112,11 +112,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
}
|
||||
|
||||
const showReduceTo = useMemo(
|
||||
() =>
|
||||
dataSource === DataSource.METRICS &&
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE),
|
||||
() => dataSource === DataSource.METRICS && isScalarPanelType(panelType),
|
||||
[dataSource, panelType],
|
||||
);
|
||||
|
||||
|
||||
13
frontend/src/components/QueryBuilderV2/QueryV2/panelTypes.ts
Normal file
13
frontend/src/components/QueryBuilderV2/QueryV2/panelTypes.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
/** Panels that reduce each query to one value per group over the whole window. */
|
||||
const SCALAR_PANEL_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
PANEL_TYPES.TABLE,
|
||||
PANEL_TYPES.SCATTER,
|
||||
PANEL_TYPES.PIE,
|
||||
PANEL_TYPES.VALUE,
|
||||
]);
|
||||
|
||||
export function isScalarPanelType(panelType?: string | null): boolean {
|
||||
return panelType != null && SCALAR_PANEL_TYPES.has(panelType);
|
||||
}
|
||||
@@ -14,6 +14,17 @@ jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
|
||||
const queryData = initialQueriesMap.traces.builder
|
||||
.queryData[0] as IBuilderQuery;
|
||||
|
||||
function renderFor(panelType: PANEL_TYPES, dataSource: DataSource): void {
|
||||
render(
|
||||
<QueryAggregationOptions
|
||||
dataSource={dataSource}
|
||||
panelType={panelType}
|
||||
onAggregationIntervalChange={jest.fn()}
|
||||
queryData={queryData}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('QueryAggregationOptions step interval placeholder', () => {
|
||||
it('shows the configured placeholder', () => {
|
||||
render(
|
||||
@@ -34,14 +45,27 @@ describe('QueryAggregationOptions step interval placeholder', () => {
|
||||
});
|
||||
|
||||
it('falls back to Auto', () => {
|
||||
render(
|
||||
<QueryAggregationOptions
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
onAggregationIntervalChange={jest.fn()}
|
||||
queryData={queryData}
|
||||
/>,
|
||||
);
|
||||
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
|
||||
expect(screen.getByPlaceholderText('Auto')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryAggregationOptions step interval', () => {
|
||||
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER, PANEL_TYPES.PIE])(
|
||||
'is hidden for a %s panel over traces, which reduces the whole window',
|
||||
(panelType) => {
|
||||
renderFor(panelType, DataSource.TRACES);
|
||||
expect(screen.queryByText('every')).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('stays for a scatter plot over metrics, where it sets the series step', () => {
|
||||
renderFor(PANEL_TYPES.SCATTER, DataSource.METRICS);
|
||||
expect(screen.getByText('every')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays for a time series over traces', () => {
|
||||
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
|
||||
expect(screen.getByText('every')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
.checkbox-filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.search {
|
||||
input {
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
--input-font-size: 14px;
|
||||
--input-border-color: var(--l1-border);
|
||||
--input-focus-border-color: var(--primary-background);
|
||||
--input-focus-outline-width: 0;
|
||||
--input-focus-outline-offset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-header-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
|
||||
.left-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
.right-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 48px;
|
||||
|
||||
.clear-all {
|
||||
font-size: 12px;
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.filter-separator {
|
||||
height: 1px;
|
||||
background-color: var(--l1-border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
|
||||
.checkbox-value-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: calc(100% - 24px);
|
||||
cursor: pointer;
|
||||
|
||||
.value-string {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.filter-disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
.value-string {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
cursor: not-allowed;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Stack "Only"/"All" and "Toggle" in a single cell so revealing
|
||||
// one swaps in place instead of shifting the row layout.
|
||||
.value-actions {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: end;
|
||||
|
||||
> * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
// `display` is animated as a discrete property so the button
|
||||
// stays mounted through its fade-out on exit.
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hovering the label/value area reveals the "Only"/"All" action.
|
||||
.checkbox-value-section:hover .only-btn {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
// Entry animation start state (fires on the display switch).
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
// Hovering the checkbox reveals the "Toggle" action.
|
||||
[data-slot='checkbox']:hover ~ .checkbox-value-section .toggle-btn {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-data {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.show-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.show-more-text {
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.go-to-docs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 44px;
|
||||
&__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
|
||||
&-message {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
&__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
margin: 0 0 4px;
|
||||
padding: 0;
|
||||
&-text {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label-false {
|
||||
width: 2px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--danger-background);
|
||||
}
|
||||
|
||||
.label-true {
|
||||
width: 2px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-forest-500);
|
||||
}
|
||||
@@ -1,476 +0,0 @@
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { FiltersType, QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import { quickFiltersAttributeValuesResponse } from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import CheckboxFilter from './Checkbox';
|
||||
|
||||
// Mock the query builder hook
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
const mockUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
|
||||
// Mock the aggregate values hook
|
||||
jest.mock('hooks/queryBuilder/useGetAggregateValues');
|
||||
|
||||
const mockUseGetAggregateValues = jest.mocked(useGetAggregateValues);
|
||||
|
||||
// Mock the key value suggestions hook
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeyValueSuggestions');
|
||||
|
||||
const mockUseGetQueryKeyValueSuggestions = jest.mocked(
|
||||
useGetQueryKeyValueSuggestions,
|
||||
);
|
||||
|
||||
interface MockFilterConfig {
|
||||
title: string;
|
||||
attributeKey: {
|
||||
key: string;
|
||||
dataType: DataTypes;
|
||||
type: string;
|
||||
};
|
||||
dataSource: DataSource;
|
||||
defaultOpen: boolean;
|
||||
type: FiltersType;
|
||||
}
|
||||
|
||||
const SERVICE_NAME_KEY = 'service.name';
|
||||
const OTEL_DEMO = 'otel-demo';
|
||||
const SAMPLE_FLASK = 'sample-flask';
|
||||
const OTLP_PYTHON = 'otlp-python';
|
||||
const MQ_KAFKA = 'mq-kafka';
|
||||
|
||||
const MOCK_SERVICE_NAMES = [MQ_KAFKA, OTEL_DEMO, OTLP_PYTHON, SAMPLE_FLASK];
|
||||
|
||||
const createMockFilter = (
|
||||
overrides: Partial<MockFilterConfig> = {},
|
||||
): MockFilterConfig => ({
|
||||
title: 'Service Name',
|
||||
attributeKey: {
|
||||
key: SERVICE_NAME_KEY,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
dataSource: DataSource.LOGS,
|
||||
defaultOpen: false,
|
||||
type: FiltersType.CHECKBOX,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockQueryBuilderData = (hasActiveFilters = false): any => ({
|
||||
lastUsedQuery: 0,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: hasActiveFilters
|
||||
? [
|
||||
{
|
||||
key: {
|
||||
key: SERVICE_NAME_KEY,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: 'in',
|
||||
value: [OTEL_DEMO, SAMPLE_FLASK],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
});
|
||||
|
||||
describe('CheckboxFilter - User Flows', () => {
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default mock implementations for useGetAggregateValues
|
||||
mockUseGetAggregateValues.mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
stringAttributeValues: MOCK_SERVICE_NAMES,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
} as unknown as UseQueryResult<SuccessResponse<IAttributeValuesResponse>>);
|
||||
|
||||
// Default mock implementations for useGetQueryKeyValueSuggestions
|
||||
// Returns data in the format expected by the hook
|
||||
mockUseGetQueryKeyValueSuggestions.mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
values: {
|
||||
stringValues: MOCK_SERVICE_NAMES,
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
} as any);
|
||||
|
||||
// Setup MSW server for API calls
|
||||
server.use(
|
||||
rest.get('*/api/v3/autocomplete/attribute_values', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should auto-open filter and prioritize checked items with visual separator when user opens page with active filters', async () => {
|
||||
// Mock query builder with active filters
|
||||
mockUseQueryBuilder.mockReturnValue(createMockQueryBuilderData(true) as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: false });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
// User should see the filter is automatically opened (not collapsed)
|
||||
expect(screen.getByText('Service Name')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// User should see visual separator between checked and unchecked items
|
||||
expect(screen.getByTestId('filter-separator')).toBeInTheDocument();
|
||||
|
||||
// User should see checked items at the top
|
||||
await waitFor(() => {
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes).toHaveLength(4); // Ensure we have exactly 4 checkboxes
|
||||
expect(checkboxes[0]).toBeChecked(); // otel-demo should be first and checked
|
||||
expect(checkboxes[1]).toBeChecked(); // sample-flask should be second and checked
|
||||
expect(checkboxes[2]).not.toBeChecked(); // mq-kafka should be unchecked
|
||||
expect(checkboxes[3]).not.toBeChecked(); // otlp-python should be unchecked
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect user preference when user manually toggles filter over auto-open behavior', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Mock query builder with active filters
|
||||
mockUseQueryBuilder.mockReturnValue(createMockQueryBuilderData(true) as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: false });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Initially auto-opened due to active filters
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// User manually closes the filter
|
||||
await user.click(screen.getByText('Service Name'));
|
||||
|
||||
// User should see filter is now closed (respecting user preference)
|
||||
expect(
|
||||
screen.queryByPlaceholderText('Filter values'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// User manually opens the filter again
|
||||
await user.click(screen.getByText('Service Name'));
|
||||
|
||||
// User should see filter is now open (respecting user preference)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update query filters when a checkbox is clicked', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
|
||||
// Start with no active filters so clicking a checkbox creates one
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
...createMockQueryBuilderData(false),
|
||||
redirectWithQueryBuilderData,
|
||||
} as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: true });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for checkboxes to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
|
||||
});
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
|
||||
// User unchecks the first value (`mq-kafka`)
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Composite query params (query builder data) should be updated via redirectWithQueryBuilderData
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
const updatedFilters = updatedQuery.builder.queryData[0].filters;
|
||||
|
||||
expect(updatedFilters.items).toHaveLength(1);
|
||||
expect(updatedFilters.items[0].key.key).toBe(SERVICE_NAME_KEY);
|
||||
// When unchecking from an "all selected" state, we use a NOT_IN filter for that value
|
||||
expect(updatedFilters.items[0].op).toBe('not in');
|
||||
expect(updatedFilters.items[0].value).toBe('mq-kafka');
|
||||
});
|
||||
|
||||
it('should set an IN filter with only the clicked value when using Only', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
|
||||
// Existing filter: service.name IN ['mq-kafka', 'otel-demo']
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
lastUsedQuery: 0,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: {
|
||||
key: SERVICE_NAME_KEY,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['mq-kafka', 'otel-demo'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData,
|
||||
} as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: true });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for values to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('mq-kafka')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the value label to trigger the "Only" behavior
|
||||
await userEvent.click(screen.getByText('mq-kafka'));
|
||||
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
|
||||
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
const updatedFilters = updatedQuery.builder.queryData[0].filters;
|
||||
|
||||
expect(updatedFilters.items).toHaveLength(1);
|
||||
expect(updatedFilters.items[0].key.key).toBe(SERVICE_NAME_KEY);
|
||||
expect(updatedFilters.items[0].op).toBe('in');
|
||||
expect(updatedFilters.items[0].value).toBe('mq-kafka');
|
||||
});
|
||||
|
||||
it('should clear filters for the attribute when using All', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
|
||||
// Existing filter: service.name IN ['mq-kafka']
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
lastUsedQuery: 0,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: {
|
||||
key: SERVICE_NAME_KEY,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['mq-kafka'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData,
|
||||
} as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: true });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('mq-kafka')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Only one value is selected, so clicking it should switch to "All" (no filter for this key)
|
||||
await userEvent.click(screen.getByText('mq-kafka'));
|
||||
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
|
||||
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
const updatedFilters = updatedQuery.builder.queryData[0].filters;
|
||||
|
||||
const filtersForServiceName = updatedFilters.items.filter(
|
||||
(item: any) => item.key?.key === SERVICE_NAME_KEY,
|
||||
);
|
||||
|
||||
expect(filtersForServiceName).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should match filter when query uses resource. prefix (resource.service.name matches service.name)', async () => {
|
||||
// Filter config uses unprefixed key (service.name)
|
||||
// Query has filter with resource. prefix (resource.service.name)
|
||||
// Checkbox should recognize the match and show checked state
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
lastUsedQuery: 0,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: {
|
||||
key: 'resource.service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: 'in',
|
||||
value: [OTEL_DEMO],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
} as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: false });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Filter should auto-open because it has active filters (key match via prefix stripping)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// otel-demo should be checked (filter uses resource.service.name IN [otel-demo])
|
||||
// Checked items are sorted to the top, so otel-demo is first
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes[0]).toBeChecked();
|
||||
expect(screen.getByText(OTEL_DEMO)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should extend an existing IN filter when checking an additional value', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
|
||||
// Existing filter: service.name IN 'mq-kafka'
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
lastUsedQuery: 0,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: {
|
||||
key: SERVICE_NAME_KEY,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: 'in',
|
||||
value: 'mq-kafka',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData,
|
||||
} as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: true });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for checkboxes to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
|
||||
});
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
|
||||
// First checkbox corresponds to 'mq-kafka' (already selected),
|
||||
// second will be 'otel-demo' which we now select additionally.
|
||||
await userEvent.click(checkboxes[1]);
|
||||
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
|
||||
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
const updatedFilters = updatedQuery.builder.queryData[0].filters;
|
||||
const [filterForServiceName] = updatedFilters.items;
|
||||
|
||||
expect(filterForServiceName.key.key).toBe(SERVICE_NAME_KEY);
|
||||
expect(filterForServiceName.op).toBe('in');
|
||||
expect(filterForServiceName.value).toStrictEqual(['mq-kafka', 'otel-demo']);
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFilterChangeEventData,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import CheckboxFilterHeader from './CheckboxFilterHeader';
|
||||
import CheckboxValueRow from './CheckboxValueRow';
|
||||
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
|
||||
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
|
||||
import useCheckboxDisclosure from './useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from './useCheckboxFilterActions';
|
||||
import useCheckboxFilterState, {
|
||||
FILTER_DISABLED_REASON,
|
||||
} from './useCheckboxFilterState';
|
||||
import useCheckboxFilterValues from './useCheckboxFilterValues';
|
||||
|
||||
import './Checkbox.styles.scss';
|
||||
|
||||
const SOURCES_WITH_EMPTY_STATE_ENABLED = [QuickFiltersSource.LOGS_EXPLORER];
|
||||
|
||||
interface ICheckboxProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
const { source, filter, onFilterChange, onQuickFilterChange } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
visibleItemsCount,
|
||||
onToggleOpen,
|
||||
onShowMore,
|
||||
} = useCheckboxDisclosure({ filter, activeQueryIndex });
|
||||
|
||||
const { attributeValues, isLoading } = useCheckboxFilterValues({
|
||||
filter,
|
||||
source,
|
||||
searchText,
|
||||
isOpen,
|
||||
});
|
||||
|
||||
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
|
||||
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
|
||||
|
||||
const { onChange, onClear } = useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
onQuickFilterChange,
|
||||
});
|
||||
|
||||
const setSearchTextDebounced = useDebouncedFn((...args) => {
|
||||
setSearchText(args[0] as string);
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
// Sort checked items to the top, then unchecked items
|
||||
const currentAttributeKeys = useMemo(() => {
|
||||
const checkedValues = attributeValues.filter(
|
||||
(val) => currentFilterState[val],
|
||||
);
|
||||
const uncheckedValues = attributeValues.filter(
|
||||
(val) => !currentFilterState[val],
|
||||
);
|
||||
return [...checkedValues, ...uncheckedValues].slice(0, visibleItemsCount);
|
||||
}, [attributeValues, currentFilterState, visibleItemsCount]);
|
||||
|
||||
// Count of checked values in the currently visible items
|
||||
const checkedValuesCount = useMemo(
|
||||
() => currentAttributeKeys.filter((val) => currentFilterState[val]).length,
|
||||
[currentAttributeKeys, currentFilterState],
|
||||
);
|
||||
|
||||
const isEmptyStateWithDocsEnabled =
|
||||
SOURCES_WITH_EMPTY_STATE_ENABLED.includes(source) &&
|
||||
!searchText &&
|
||||
!attributeValues.length;
|
||||
|
||||
return (
|
||||
<div className="checkbox-filter">
|
||||
<CheckboxFilterHeader
|
||||
title={filter.title}
|
||||
isOpen={isOpen}
|
||||
showClearAll={!!attributeValues.length}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
/>
|
||||
{isOpen && isLoading && !attributeValues.length && (
|
||||
<section className="loading">
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
</section>
|
||||
)}
|
||||
{isOpen && !isLoading && (
|
||||
<>
|
||||
{!isEmptyStateWithDocsEnabled && (
|
||||
<section className="search">
|
||||
<Input
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{attributeValues.length > 0 ? (
|
||||
<section className="values">
|
||||
{currentAttributeKeys.map((value: string, index: number) => (
|
||||
<Fragment key={value}>
|
||||
{index === checkedValuesCount && checkedValuesCount > 0 && (
|
||||
<div
|
||||
key="separator"
|
||||
className="filter-separator"
|
||||
data-testid="filter-separator"
|
||||
/>
|
||||
)}
|
||||
<CheckboxValueRow
|
||||
value={value}
|
||||
checked={currentFilterState[value]}
|
||||
disabled={isFilterDisabled}
|
||||
disabledTooltip={FILTER_DISABLED_REASON}
|
||||
title={filter.title}
|
||||
onlyButtonLabel={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'
|
||||
}
|
||||
customRendererForValue={filter.customRendererForValue}
|
||||
onCheckboxChange={(checked): void => onChange(value, checked, false)}
|
||||
onOnlyOrAllClick={(): void =>
|
||||
onChange(value, currentFilterState[value], true)
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</section>
|
||||
) : isEmptyStateWithDocsEnabled ? (
|
||||
<LogsQuickFilterEmptyState attributeKey={filter.attributeKey.key} />
|
||||
) : (
|
||||
<section className="no-data">
|
||||
<Typography.Text>No values found</Typography.Text>{' '}
|
||||
</section>
|
||||
)}
|
||||
{visibleItemsCount < attributeValues?.length && (
|
||||
<section className="show-more">
|
||||
<Typography.Text className="show-more-text" onClick={onShowMore}>
|
||||
Show More...
|
||||
</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CheckboxFilter.defaultProps = {
|
||||
onFilterChange: null,
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
interface CheckboxFilterHeaderProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
showClearAll: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
function CheckboxFilterHeader({
|
||||
title,
|
||||
isOpen,
|
||||
showClearAll,
|
||||
onToggleOpen,
|
||||
onClear,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
return (
|
||||
<section className="filter-header-checkbox" onClick={onToggleOpen}>
|
||||
<section className="left-action">
|
||||
{isOpen ? (
|
||||
<ChevronDown size={13} cursor="pointer" />
|
||||
) : (
|
||||
<ChevronRight size={13} cursor="pointer" />
|
||||
)}
|
||||
<Typography.Text className="title">{title}</Typography.Text>
|
||||
</section>
|
||||
<section className="right-action">
|
||||
{isOpen && showClearAll && (
|
||||
<Typography.Text
|
||||
className="clear-all"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</Typography.Text>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxFilterHeader;
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Button } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface CheckboxValueRowProps {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
disabledTooltip?: string;
|
||||
title: string;
|
||||
onlyButtonLabel: string;
|
||||
customRendererForValue?: (value: string) => JSX.Element;
|
||||
onCheckboxChange: (checked: boolean) => void;
|
||||
onOnlyOrAllClick: () => void;
|
||||
}
|
||||
|
||||
function CheckboxValueRow({
|
||||
value,
|
||||
checked,
|
||||
disabled,
|
||||
disabledTooltip,
|
||||
title,
|
||||
onlyButtonLabel,
|
||||
customRendererForValue,
|
||||
onCheckboxChange,
|
||||
onOnlyOrAllClick,
|
||||
}: CheckboxValueRowProps): JSX.Element {
|
||||
return (
|
||||
<div className="value">
|
||||
<Checkbox
|
||||
color="primary"
|
||||
disabledTooltip={disabledTooltip}
|
||||
onChange={(isChecked): void => onCheckboxChange(isChecked === true)}
|
||||
value={checked}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cx('checkbox-value-section', disabled ? 'filter-disabled' : '')}
|
||||
onClick={(): void => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onOnlyOrAllClick();
|
||||
}}
|
||||
>
|
||||
<div className={`${title} label-${value}`} />
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<Tooltip title={String(value)} side="top" align="start">
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="value-actions">
|
||||
<Button type="text" className="only-btn">
|
||||
{onlyButtonLabel}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CheckboxValueRow.defaultProps = {
|
||||
disabledTooltip: undefined,
|
||||
customRendererForValue: undefined,
|
||||
};
|
||||
|
||||
export default CheckboxValueRow;
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from 'antd';
|
||||
import EmptyQuickFilterIcon from 'assets/CustomIcons/EmptyQuickFilterIcon';
|
||||
import { ArrowUpRight } from '@signozhq/icons';
|
||||
|
||||
const QUICK_FILTER_DOC_PATHS: Record<string, string> = {
|
||||
severity_text: 'severity-text',
|
||||
'deployment.environment': 'environment',
|
||||
'service.name': 'service-name',
|
||||
'host.name': 'hostname',
|
||||
'k8s.cluster.name': 'k8s-cluster-name',
|
||||
'k8s.deployment.name': 'k8s-deployment-name',
|
||||
'k8s.namespace.name': 'k8s-namespace-name',
|
||||
'k8s.pod.name': 'k8s-pod-name',
|
||||
};
|
||||
|
||||
function LogsQuickFilterEmptyState({
|
||||
attributeKey,
|
||||
}: {
|
||||
attributeKey: string;
|
||||
}): JSX.Element {
|
||||
const handleLearnMoreClick = (): void => {
|
||||
const section = QUICK_FILTER_DOC_PATHS[attributeKey];
|
||||
|
||||
window.open(
|
||||
`https://signoz.io/docs/logs-management/features/logs-quick-filters#${section}`,
|
||||
'_blank',
|
||||
);
|
||||
};
|
||||
return (
|
||||
<section className="go-to-docs">
|
||||
<div className="go-to-docs__container">
|
||||
<div className="go-to-docs__container-icon">
|
||||
<EmptyQuickFilterIcon />
|
||||
</div>
|
||||
<div className="go-to-docs__container-message">
|
||||
{`You'd need to parse out this attribute to start getting them as a fast
|
||||
filter.`}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="link"
|
||||
className="go-to-docs__button"
|
||||
onClick={handleLearnMoreClick}
|
||||
>
|
||||
<div className="go-to-docs__button-text">Learn more</div>
|
||||
<ArrowUpRight size={14} color={Color.BG_ROBIN_400} />
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogsQuickFilterEmptyState;
|
||||
@@ -0,0 +1 @@
|
||||
export const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
|
||||
@@ -2,10 +2,9 @@ import { useMemo, useState } from 'react';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import { DEFAULT_VISIBLE_ITEMS_COUNT } from './constants';
|
||||
import { isKeyMatch } from './utils';
|
||||
|
||||
const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
|
||||
|
||||
interface UseCheckboxDisclosureProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
activeQueryIndex: number;
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
interface UseCheckboxFilterValuesProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
searchText: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
interface UseCheckboxFilterValuesReturn {
|
||||
attributeValues: string[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function useCheckboxFilterValues({
|
||||
filter,
|
||||
source,
|
||||
searchText,
|
||||
isOpen,
|
||||
}: UseCheckboxFilterValuesProps): UseCheckboxFilterValuesReturn {
|
||||
const { data, isLoading } = useGetAggregateValues(
|
||||
{
|
||||
aggregateOperator: filter.aggregateOperator || 'noop',
|
||||
dataSource: filter.dataSource || DataSource.LOGS,
|
||||
aggregateAttribute: filter.aggregateAttribute || '',
|
||||
attributeKey: filter.attributeKey.key,
|
||||
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
|
||||
tagType: filter.attributeKey.type || '',
|
||||
searchText: searchText ?? '',
|
||||
},
|
||||
{
|
||||
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
|
||||
useGetQueryKeyValueSuggestions({
|
||||
key: filter.attributeKey.key,
|
||||
signal: filter.dataSource || DataSource.LOGS,
|
||||
signalSource: 'meter',
|
||||
options: {
|
||||
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const attributeValues: string[] = useMemo(() => {
|
||||
const dataType = filter.attributeKey.dataType || DataTypes.String;
|
||||
|
||||
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
|
||||
// Process the response data
|
||||
const responseData = keyValueSuggestions?.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
|
||||
// Generate options from string values - explicitly handle empty strings
|
||||
const stringOptions = stringValues
|
||||
// Strict filtering for empty string - we'll handle it as a special case if needed
|
||||
.filter(
|
||||
(value: string | null | undefined): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
);
|
||||
|
||||
// Generate options from number values
|
||||
const numberOptions = numberValues
|
||||
.filter(
|
||||
(value: number | null | undefined): value is number =>
|
||||
value !== null && value !== undefined,
|
||||
)
|
||||
.map((value: number) => value.toString());
|
||||
|
||||
// Combine all options and make sure we don't have duplicate labels
|
||||
return [...stringOptions, ...numberOptions];
|
||||
}
|
||||
|
||||
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
|
||||
return (data?.payload?.[key] || []).filter(
|
||||
(val) => val !== undefined && val !== null,
|
||||
);
|
||||
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
|
||||
|
||||
return {
|
||||
attributeValues,
|
||||
isLoading: isLoading || isLoadingKeyValueSuggestions,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCheckboxFilterValues;
|
||||
@@ -6,44 +6,6 @@
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.sectionActions {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
width 0s linear 0.16s;
|
||||
}
|
||||
|
||||
.checkboxFilter:hover .sectionActions,
|
||||
.sectionActions.sectionActionsPinned {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.sectionActionsPinned .sectionActionHoverOnly {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.checkboxFilter:hover .sectionActionsPinned .sectionActionHoverOnly {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.search {
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
QuickFiltersSource,
|
||||
} from '../../../types';
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
import { DEFAULT_VISIBLE_ITEMS_COUNT } from '../constants';
|
||||
|
||||
export const DEFAULT_FILTER: IQuickFiltersConfig = {
|
||||
type: FiltersType.CHECKBOX,
|
||||
@@ -30,6 +31,13 @@ export const DEFAULT_USE_FIELD_APIS: QuickFilterCheckboxUseFieldApis = {
|
||||
existingQuery: null,
|
||||
};
|
||||
|
||||
// Enough extra values to push a section past the count that shows its search box.
|
||||
// Named to sort last, so a test's own values stay within the first page.
|
||||
export const EXTRA_VALUES = Array.from(
|
||||
{ length: DEFAULT_VISIBLE_ITEMS_COUNT },
|
||||
(_, index) => `zz-extra-value-${index + 1}`,
|
||||
);
|
||||
|
||||
export function mockFieldsValuesAPI(response: {
|
||||
relatedValues?: (string | null)[];
|
||||
stringValues?: (string | null)[];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Skeleton } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
import {
|
||||
@@ -17,6 +16,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
|
||||
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
|
||||
import { DEFAULT_VISIBLE_ITEMS_COUNT } from '../constants';
|
||||
import useCheckboxDisclosure from '../useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from '../useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from '../useCheckboxFilterState';
|
||||
@@ -45,14 +45,8 @@ export default function CheckboxFilterV2(
|
||||
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
|
||||
props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [isSearchOpen, setIsSearchOpen] = useState<boolean>(false);
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
|
||||
const handleToggleSearch = (): void => {
|
||||
setIsSearchOpen((prev) => !prev);
|
||||
setSearchText('');
|
||||
};
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
@@ -121,6 +115,14 @@ export default function CheckboxFilterV2(
|
||||
setSearchText(args[0] as string);
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
const handleToggleOpen = (): void => {
|
||||
if (isOpen) {
|
||||
setSearchTextDebounced.cancel();
|
||||
setSearchText('');
|
||||
}
|
||||
onToggleOpen();
|
||||
};
|
||||
|
||||
const currentFilterOp = useMemo(() => {
|
||||
const filterSync = currentQuery?.builder.queryData?.[
|
||||
activeQueryIndex
|
||||
@@ -166,19 +168,22 @@ export default function CheckboxFilterV2(
|
||||
relatedExclusions,
|
||||
});
|
||||
|
||||
// Kept while a search is typed, so narrowing the list does not remove the box,
|
||||
// and while a refetch still shows the previous, narrowed results.
|
||||
const wasSearchShown = useRef(false);
|
||||
const isSearchShown =
|
||||
searchText !== '' ||
|
||||
totalCount > DEFAULT_VISIBLE_ITEMS_COUNT ||
|
||||
(isFetching && wasSearchShown.current);
|
||||
wasSearchShown.current = isSearchShown;
|
||||
|
||||
return (
|
||||
<div className={styles.checkboxFilter} data-testid="checkbox-filter-v2">
|
||||
<CheckboxFilterV2Header
|
||||
title={filter.title}
|
||||
isOpen={isOpen}
|
||||
actionsClassName={classNames(styles.sectionActions, {
|
||||
[styles.sectionActionsPinned]: isSearchOpen,
|
||||
})}
|
||||
resetActionClassName={
|
||||
isSearchOpen ? styles.sectionActionHoverOnly : undefined
|
||||
}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
canReset={isSomeFilterPresentForCurrentAttribute}
|
||||
onToggleOpen={handleToggleOpen}
|
||||
onClear={onClear}
|
||||
/>
|
||||
{isOpen && isLoading && !hasLoadedOnce.current && (
|
||||
@@ -188,10 +193,9 @@ export default function CheckboxFilterV2(
|
||||
)}
|
||||
{isOpen && (!isLoading || hasLoadedOnce.current) && (
|
||||
<>
|
||||
{isSearchOpen && (
|
||||
{isSearchShown && (
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight, Search, Undo2 } from '@signozhq/icons';
|
||||
import { ChevronDown, ChevronRight, Undo2 } from '@signozhq/icons';
|
||||
|
||||
import { SectionActionButton } from '../../shared/SectionActionButton/SectionActionButton';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import styles from './CheckboxFilterV2Header.module.scss';
|
||||
|
||||
interface CheckboxFilterHeaderProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
actionsClassName?: string;
|
||||
resetActionClassName?: string;
|
||||
canReset: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onToggleSearch: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Header({
|
||||
title,
|
||||
isOpen,
|
||||
actionsClassName,
|
||||
resetActionClassName,
|
||||
canReset,
|
||||
onToggleOpen,
|
||||
onToggleSearch,
|
||||
onClear,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
const [isTitleTruncated, setIsTitleTruncated] = useState(false);
|
||||
@@ -64,21 +58,14 @@ export function CheckboxFilterV2Header({
|
||||
)}
|
||||
<Tooltip title={isTitleTruncated ? title : undefined}>{titleText}</Tooltip>
|
||||
</section>
|
||||
{isOpen && (
|
||||
<section className={classNames(styles.rightAction, actionsClassName)}>
|
||||
{isOpen && canReset && (
|
||||
<section className={styles.rightAction}>
|
||||
<SectionActionButton
|
||||
icon={<Undo2 size={14} />}
|
||||
className={resetActionClassName}
|
||||
tooltip="Reset"
|
||||
onClick={onClear}
|
||||
testId="checkbox-filter-clear-all"
|
||||
/>
|
||||
<SectionActionButton
|
||||
icon={<Search size={14} />}
|
||||
tooltip="Search"
|
||||
onClick={onToggleSearch}
|
||||
testId="checkbox-filter-search-toggle"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildQueryBuilderOverrides,
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
EXTRA_VALUES,
|
||||
getFilterFromCall,
|
||||
mockFieldsValuesAPI,
|
||||
renderWithFilter,
|
||||
@@ -18,6 +19,25 @@ import {
|
||||
|
||||
setupServer();
|
||||
|
||||
const activeEnvironmentFilter = buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe('CheckboxFilterV2 - interactions', () => {
|
||||
describe('search functionality', () => {
|
||||
it('filters values based on search text', async () => {
|
||||
@@ -30,7 +50,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
const values =
|
||||
searchTextReceived === ''
|
||||
? ['production', 'staging', 'development']
|
||||
? ['production', 'staging', 'development', ...EXTRA_VALUES]
|
||||
: ['production'];
|
||||
|
||||
return res(
|
||||
@@ -60,7 +80,6 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -73,6 +92,41 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
screen.queryByTestId('checkbox-value-row-staging'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('checkbox-filter-search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no search box when every value fits', async () => {
|
||||
mockFieldsValuesAPI({ stringValues: EXTRA_VALUES });
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId(`checkbox-value-row-${EXTRA_VALUES[0]}`);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-search'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the search box once there are more values than fit', async () => {
|
||||
mockFieldsValuesAPI({ stringValues: ['production', ...EXTRA_VALUES] });
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('checkbox-filter-search'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters values via search while preserving existingQuery context', async () => {
|
||||
@@ -92,7 +146,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging', 'development'],
|
||||
stringValues: ['staging', 'development', ...EXTRA_VALUES],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -144,7 +198,6 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
// Related values now appear in "Related" section (no badge, uses divider instead)
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -172,7 +225,10 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : ['prod-match'],
|
||||
stringValues:
|
||||
searchText === ''
|
||||
? ['prod', 'staging', ...EXTRA_VALUES]
|
||||
: ['prod-match'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -194,7 +250,6 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -220,7 +275,8 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : [],
|
||||
stringValues:
|
||||
searchText === '' ? ['prod', 'staging', ...EXTRA_VALUES] : [],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -239,7 +295,6 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'xyz-no-match');
|
||||
|
||||
@@ -316,7 +371,9 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
const relatedValues =
|
||||
searchText === '' ? ['pod-a-v1', 'pod-b-v1', 'pod-c-v1'] : ['pod-a-v1']; // filtered to match 'pod-a'
|
||||
const stringValues =
|
||||
searchText === '' ? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2'] : ['pod-a-v2']; // filtered to match 'pod-a'
|
||||
searchText === ''
|
||||
? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2', ...EXTRA_VALUES]
|
||||
: ['pod-a-v2']; // filtered to match 'pod-a'
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
@@ -347,7 +404,6 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-pod-a-v1');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'pod-a');
|
||||
|
||||
@@ -425,6 +481,113 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
});
|
||||
|
||||
it('opens a closed filter when the query filters on its key', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{ queryBuilderOverrides: activeEnvironmentFilter },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-header')).toHaveAttribute(
|
||||
'data-state',
|
||||
'open',
|
||||
);
|
||||
await expect(
|
||||
screen.findByTestId('checkbox-value-row-production'),
|
||||
).resolves.toHaveAttribute('data-state', 'checked');
|
||||
});
|
||||
|
||||
it('keeps the user toggle over the auto-open', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{ queryBuilderOverrides: activeEnvironmentFilter },
|
||||
);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
await user.click(header);
|
||||
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-production'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(header);
|
||||
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
});
|
||||
|
||||
it('clears the search when the filter is collapsed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const values = Array.from(
|
||||
{ length: 12 },
|
||||
(_, i) => `value-${String(i).padStart(2, '0')}`,
|
||||
);
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: values.filter((value) => value.includes(searchText)),
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-00');
|
||||
await user.type(screen.getByTestId('checkbox-filter-search'), '11');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-value-00'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
await user.click(header);
|
||||
await user.click(header);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-00');
|
||||
expect(screen.getByTestId('checkbox-filter-search')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('show more functionality', () => {
|
||||
@@ -520,7 +683,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the reset action when expanded even with no active filter', async () => {
|
||||
it('hides reset when the section has no filter applied', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
@@ -535,45 +698,9 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
// Reset is always available on an expanded section now (hover-gated via
|
||||
// CSS), not conditional on an active filter.
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not dispatch on clear when the key has no filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onFilterChange).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when clear clicked', async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
EXTRA_VALUES,
|
||||
mockFieldsValuesAPI,
|
||||
mockFieldsValuesAPILoading,
|
||||
setupServer,
|
||||
@@ -89,7 +90,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: ['production', 'staging'],
|
||||
stringValues: ['production', 'staging', ...EXTRA_VALUES],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -110,7 +111,6 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ describe('CheckboxFilterV2Header', () => {
|
||||
const defaultProps = {
|
||||
title: 'Environment',
|
||||
isOpen: false,
|
||||
canReset: true,
|
||||
onToggleOpen: jest.fn(),
|
||||
onToggleSearch: jest.fn(),
|
||||
onClear: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -30,12 +30,9 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
});
|
||||
|
||||
it('does not render the section actions when collapsed', () => {
|
||||
it('does not render reset when collapsed', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-search-toggle'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -50,14 +47,19 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
});
|
||||
|
||||
it('renders both search and reset actions when expanded', () => {
|
||||
it('renders reset when the section has a filter applied', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-search-toggle'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides reset when there is nothing to reset', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen canReset={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
@@ -99,25 +101,6 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onToggleSearch on search click without toggling open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleSearch = jest.fn();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
onToggleSearch={onToggleSearch}
|
||||
onToggleOpen={onToggleOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
|
||||
expect(onToggleSearch).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClear on reset click without toggling open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
import { isFunction } from 'lodash-es';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
|
||||
import useActiveQueryIndex from './hooks/useActiveQueryIndex';
|
||||
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
|
||||
import Duration from './FilterRenderers/Duration/Duration';
|
||||
@@ -310,25 +309,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
<section className="filters">
|
||||
{filterConfig.map((filter) => {
|
||||
switch (filter.type) {
|
||||
case FiltersType.CHECKBOX:
|
||||
return useFieldApis ? (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
useFieldApis={useFieldApis}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
/>
|
||||
);
|
||||
case FiltersType.DURATION:
|
||||
return (
|
||||
<Duration
|
||||
@@ -340,9 +320,8 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
);
|
||||
case FiltersType.SLIDER:
|
||||
return <Slider key={filter.attributeKey.key} />;
|
||||
// eslint-disable-next-line sonarjs/no-duplicated-branches
|
||||
default:
|
||||
return useFieldApis ? (
|
||||
return (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
@@ -351,14 +330,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
useFieldApis={useFieldApis}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
@@ -440,5 +411,4 @@ QuickFilters.defaultProps = {
|
||||
config: [],
|
||||
showFilterCollapse: true,
|
||||
showQueryName: true,
|
||||
useFieldApis: undefined,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import { SIDEBAR_TOOLTIP_DELAY_MS } from './constants';
|
||||
import { useCloseOnOutsidePress } from './useCloseOnOutsidePress';
|
||||
|
||||
import styles from './QuickFiltersLayout.module.scss';
|
||||
|
||||
@@ -61,12 +62,20 @@ function QuickFiltersLayout({
|
||||
|
||||
const isSliding = hasQuickFilters && isViewsListOpen;
|
||||
|
||||
const openViews = (): void => setIsViewsListOpen(true);
|
||||
const toggleViews = (): void => setIsViewsListOpen((isOpen) => !isOpen);
|
||||
const closeViews = (): void => setIsViewsListOpen(false);
|
||||
const markSidebarPress = useCloseOnOutsidePress(
|
||||
showSidebar && isSliding,
|
||||
closeViews,
|
||||
);
|
||||
|
||||
const savedViewsHeader = hasSavedViews ? (
|
||||
<SavedViewsHeader
|
||||
{...savedViewProps}
|
||||
onOpenViews={
|
||||
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
|
||||
}
|
||||
onOpenViews={isViewsListVisible ? undefined : openViews}
|
||||
isViewsOpen={isViewsListOpen}
|
||||
onToggleViews={isViewsListPinned ? undefined : toggleViews}
|
||||
onCollapse={hasQuickFilters ? undefined : onToggleFilters}
|
||||
/>
|
||||
) : undefined;
|
||||
@@ -77,6 +86,7 @@ function QuickFiltersLayout({
|
||||
{showSidebar && (
|
||||
<aside
|
||||
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
|
||||
onPointerDownCapture={markSidebarPress}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
|
||||
@@ -98,9 +108,7 @@ function QuickFiltersLayout({
|
||||
{hasSavedViews && isViewsListVisible && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={
|
||||
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
|
||||
}
|
||||
onClose={isViewsListPinned ? undefined : closeViews}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,7 @@ jest.mock('../../QuickFilters', () => ({
|
||||
|
||||
const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
useFieldApis: { startUnixMilli: 0, endUnixMilli: 0 },
|
||||
};
|
||||
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
|
||||
|
||||
@@ -217,6 +218,62 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens and closes the panel from the view name', async () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const toggle = await screen.findByTestId('saved-views-name-toggle');
|
||||
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
await user.click(toggle);
|
||||
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(drawer()).toHaveClass('isOpen');
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
});
|
||||
|
||||
it('closes the panel on a click outside the sidebar, not inside it', async () => {
|
||||
render(
|
||||
<>
|
||||
<button type="button" data-testid="page-tab">
|
||||
Pipelines
|
||||
</button>
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div data-testid="page-content">content</div>
|
||||
</QuickFiltersLayout>
|
||||
</>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByTestId('saved-views-name-toggle'));
|
||||
await user.click(screen.getByTestId('saved-views-panel-header'));
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('page-tab'));
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
|
||||
await user.click(await screen.findByTestId('saved-views-name-toggle'));
|
||||
await user.click(screen.getByTestId('page-content'));
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hands the source to the header and the panel', async () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
@@ -257,6 +314,9 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('saved-views-name-toggle'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('saved-views-panel-header'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { useCloseOnOutsidePress } from '../useCloseOnOutsidePress';
|
||||
|
||||
function Harness({
|
||||
enabled,
|
||||
onClose,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
onClose: () => void;
|
||||
}): JSX.Element {
|
||||
const markInside = useCloseOnOutsidePress(enabled, onClose);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onPointerDownCapture={markInside}>
|
||||
<button type="button" data-testid="inside">
|
||||
inside
|
||||
</button>
|
||||
{createPortal(
|
||||
<button type="button" data-testid="portal">
|
||||
menu item
|
||||
</button>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
<button type="button" data-testid="outside">
|
||||
outside
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useCloseOnOutsidePress', () => {
|
||||
it('closes on a press outside', async () => {
|
||||
const onClose: jest.MockedFunction<() => void> = jest.fn();
|
||||
render(<Harness enabled onClose={onClose} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('outside'));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stays open on a press inside, including a portal rendered from inside', async () => {
|
||||
const onClose: jest.MockedFunction<() => void> = jest.fn();
|
||||
render(<Harness enabled onClose={onClose} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('inside'));
|
||||
await userEvent.click(screen.getByTestId('portal'));
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing while disabled', async () => {
|
||||
const onClose: jest.MockedFunction<() => void> = jest.fn();
|
||||
render(<Harness enabled={false} onClose={onClose} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('outside'));
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Calls `onClose` on any press outside the element given the returned handler as
|
||||
* `onPointerDownCapture`. Menus and dialogs rendered in portals still count as
|
||||
* inside: React events follow the component tree, so their presses reach the
|
||||
* handler before the document listener runs.
|
||||
*/
|
||||
export function useCloseOnOutsidePress(
|
||||
enabled: boolean,
|
||||
onClose: () => void,
|
||||
): () => void {
|
||||
const isPressInside = useRef(false);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
isPressInside.current = false;
|
||||
|
||||
const handlePointerDown = (): void => {
|
||||
if (isPressInside.current) {
|
||||
isPressInside.current = false;
|
||||
return;
|
||||
}
|
||||
onCloseRef.current();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return (): void =>
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, [enabled]);
|
||||
|
||||
return useCallback((): void => {
|
||||
isPressInside.current = true;
|
||||
}, []);
|
||||
}
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { attributeValuesResponse } from '@/storybook/msw/__story_mockdata__/attributes';
|
||||
import { fieldKeysResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
fieldValuesResponse,
|
||||
} from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { quickFiltersResponse } from '@/storybook/msw/__story_mockdata__/quickFilters';
|
||||
|
||||
import { FiltersType } from '../types';
|
||||
import { FiltersType, QuickFilterCheckboxUseFieldApis } from '../types';
|
||||
|
||||
const customFilters = [
|
||||
{
|
||||
@@ -61,13 +63,15 @@ export const checkboxConfig = [
|
||||
},
|
||||
];
|
||||
|
||||
export const attributeValuesHandler = (
|
||||
values: readonly string[],
|
||||
): RequestHandler =>
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_values',
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(attributeValuesResponse(values))),
|
||||
export const fieldApis: QuickFilterCheckboxUseFieldApis = {
|
||||
startUnixMilli: 1700000000000,
|
||||
endUnixMilli: 1700003600000,
|
||||
existingQuery: null,
|
||||
};
|
||||
|
||||
export const fieldValuesHandler = (values: readonly string[]): RequestHandler =>
|
||||
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(fieldValuesResponse(values))),
|
||||
);
|
||||
|
||||
export const handlers = [
|
||||
@@ -82,7 +86,7 @@ export const handlers = [
|
||||
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(fieldKeysResponse(['k8s.namespace.name']))),
|
||||
),
|
||||
attributeValuesHandler(['checkout', 'frontend', 'payments']),
|
||||
fieldValuesHandler(['checkout', 'frontend', 'payments']),
|
||||
];
|
||||
|
||||
export const loadingFiltersHandlers = [
|
||||
|
||||
@@ -7,8 +7,9 @@ import type { GlobalMockArgs } from '@/storybook/globals';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import {
|
||||
attributeValuesHandler,
|
||||
checkboxConfig,
|
||||
fieldApis,
|
||||
fieldValuesHandler,
|
||||
handlers,
|
||||
LONG_FILTER_VALUES,
|
||||
loadingFiltersHandlers,
|
||||
@@ -34,6 +35,7 @@ const meta = {
|
||||
handleFilterVisibilityChange: (): void => undefined,
|
||||
signal: SignalType.LOGS,
|
||||
source: QuickFiltersSource.LOGS_EXPLORER,
|
||||
useFieldApis: fieldApis,
|
||||
},
|
||||
parameters: {
|
||||
msw: { handlers },
|
||||
@@ -114,7 +116,7 @@ export const SelectedExpandedCheckbox: Story = {
|
||||
export const Tooltips: TooltipsStory = {
|
||||
args: { signal: undefined, tooltipsOpen: true },
|
||||
parameters: {
|
||||
msw: { handlers: [attributeValuesHandler(LONG_FILTER_VALUES), ...handlers] },
|
||||
msw: { handlers: [fieldValuesHandler(LONG_FILTER_VALUES), ...handlers] },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import {
|
||||
otherFiltersResponse,
|
||||
quickFiltersAttributeValuesResponse,
|
||||
quickFiltersFieldValuesResponse,
|
||||
quickFiltersListResponse,
|
||||
} from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import { server } from 'mocks-server/server';
|
||||
@@ -25,7 +25,7 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from '../types';
|
||||
import { QuickFiltersConfig } from './constants';
|
||||
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
@@ -38,7 +38,6 @@ const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const SIGNAL = SignalType.LOGS;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const fieldsKeysURL = `${BASE_URL}/api/v1/fields/keys`;
|
||||
const attributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
const NOT_AUTHORIZED_TEXT = /is not authorized to perform/i;
|
||||
@@ -71,11 +70,8 @@ beforeEach(() => {
|
||||
rest.get(fieldsKeysURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(otherFiltersResponse)),
|
||||
),
|
||||
rest.get(attributeValuesURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
rest.get(fieldsValuesURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
res(ctx.status(200), ctx.json(quickFiltersFieldValuesResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -91,6 +87,7 @@ function renderWithSignal(): void {
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
signal={SIGNAL}
|
||||
handleFilterVisibilityChange={jest.fn()}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -101,6 +98,7 @@ function renderStaticConfig(): void {
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
config={QuickFiltersConfig}
|
||||
handleFilterVisibilityChange={jest.fn()}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
otherFiltersResponse,
|
||||
quickFiltersAttributeValuesResponse,
|
||||
quickFiltersFieldValuesResponse,
|
||||
quickFiltersListResponse,
|
||||
} from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import { server } from 'mocks-server/server';
|
||||
@@ -17,7 +17,7 @@ import '@testing-library/jest-dom';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
|
||||
import { QuickFiltersConfig } from './constants';
|
||||
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
@@ -37,7 +37,6 @@ const SIGNAL = SignalType.LOGS;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
|
||||
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
const FILTER_OS_DESCRIPTION = 'os.description';
|
||||
@@ -62,11 +61,8 @@ const setupServer = (): void => {
|
||||
putHandler(await req.json());
|
||||
return res(ctx.status(200), ctx.json({}));
|
||||
}),
|
||||
rest.get(quickFiltersAttributeValuesURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
rest.get(fieldsValuesURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
res(ctx.status(200), ctx.json(quickFiltersFieldValuesResponse)),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -83,6 +79,7 @@ function TestQuickFilters({
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
signal={signal}
|
||||
/>
|
||||
);
|
||||
@@ -105,6 +102,7 @@ function TestQuickFiltersApiMonitoring({
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
signal={signal}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { FiltersType } from '../types';
|
||||
import { FiltersType, QuickFilterCheckboxUseFieldApis } from '../types';
|
||||
|
||||
export const QuickFiltersConfig = [
|
||||
{
|
||||
@@ -24,3 +24,9 @@ export const QuickFiltersConfig = [
|
||||
defaultOpen: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const QuickFiltersFieldApis: QuickFilterCheckboxUseFieldApis = {
|
||||
startUnixMilli: 1700000000000,
|
||||
endUnixMilli: 1700003600000,
|
||||
existingQuery: null,
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface IQuickFiltersProps {
|
||||
className?: string;
|
||||
showFilterCollapse?: boolean;
|
||||
showQueryName?: boolean;
|
||||
useFieldApis?: QuickFilterCheckboxUseFieldApis;
|
||||
useFieldApis: QuickFilterCheckboxUseFieldApis;
|
||||
savedViewsHeader?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -75,9 +75,6 @@ export enum QuickFiltersSource {
|
||||
AI_OBSERVABILITY = 'ai-observability',
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in: fetch values from the /v1/fields/values API instead of /v3/autocomplete/attribute_values
|
||||
*/
|
||||
export type QuickFilterCheckboxUseFieldApis = {
|
||||
startUnixMilli: number;
|
||||
endUnixMilli: number;
|
||||
|
||||
@@ -36,6 +36,7 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.SCATTER]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -337,6 +337,7 @@ export enum PANEL_TYPES {
|
||||
TRACE = 'trace',
|
||||
BAR = 'bar',
|
||||
AREA = 'area',
|
||||
SCATTER = 'scatter',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
HEATMAP = 'heatmap',
|
||||
|
||||
@@ -71,6 +71,7 @@ export const AIAssistantOpenSource = {
|
||||
Shortcut: 'shortcut',
|
||||
Cmdk: 'cmdk',
|
||||
TraceDetails: 'trace_details',
|
||||
BottomStrip: 'bottom_strip',
|
||||
} as const;
|
||||
export type AIAssistantOpenSource =
|
||||
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
import {
|
||||
AIAssistantEvents,
|
||||
AIAssistantOpenSource,
|
||||
} from 'container/AIAssistant/events';
|
||||
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
|
||||
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
|
||||
import {
|
||||
openAIAssistant,
|
||||
@@ -37,6 +44,14 @@ function AskNoz(): JSX.Element | null {
|
||||
// The drawer does not render on the Noz full page, so the button would be inert.
|
||||
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
|
||||
|
||||
const handleOpen = useCallback((): void => {
|
||||
void logEvent(AIAssistantEvents.Opened, {
|
||||
source: AIAssistantOpenSource.BottomStrip,
|
||||
currentPage: normalizePage(pathname),
|
||||
});
|
||||
openAIAssistant();
|
||||
}, [pathname]);
|
||||
|
||||
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
|
||||
return null;
|
||||
}
|
||||
@@ -57,7 +72,7 @@ function AskNoz(): JSX.Element | null {
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Noz size={16} />}
|
||||
onClick={(): void => openAIAssistant()}
|
||||
onClick={handleOpen}
|
||||
aria-label={
|
||||
showPendingBadge
|
||||
? `Ask Noz, ${pendingUserInputCount} ${
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
AIAssistantEvents,
|
||||
AIAssistantOpenSource,
|
||||
} from 'container/AIAssistant/events';
|
||||
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { render } from 'tests/test-utils';
|
||||
@@ -6,6 +11,10 @@ import { render } from 'tests/test-utils';
|
||||
import AskNoz from '../AskNoz';
|
||||
|
||||
jest.mock('hooks/useIsAIAssistantEnabled');
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
|
||||
openAIAssistant: jest.fn(),
|
||||
useAIAssistantStore: jest.fn(),
|
||||
@@ -91,6 +100,17 @@ describe('AskNoz', () => {
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the open as coming from the strip', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
fireEvent.click(getByRole('button'));
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
AIAssistantEvents.Opened,
|
||||
expect.objectContaining({ source: AIAssistantOpenSource.BottomStrip }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending badge', () => {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { MessageSquareText } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
BottomStripEventKeys,
|
||||
BottomStripEvents,
|
||||
BottomStripSupportType,
|
||||
} from '../events';
|
||||
|
||||
function SupportButton(): JSX.Element | null {
|
||||
const chatSupport = useChatSupport();
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
@@ -14,7 +21,13 @@ function SupportButton(): JSX.Element | null {
|
||||
}
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (chatSupport === ChatSupportState.NeedsCard) {
|
||||
const needsCard = chatSupport === ChatSupportState.NeedsCard;
|
||||
void logEvent(BottomStripEvents.SupportClicked, {
|
||||
[BottomStripEventKeys.Type]: needsCard
|
||||
? BottomStripSupportType.AddCard
|
||||
: BottomStripSupportType.Pylon,
|
||||
});
|
||||
if (needsCard) {
|
||||
setIsAddCreditCardModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { BottomStripEvents, BottomStripSupportType } from '../../events';
|
||||
import SupportButton from '../SupportButton';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useChatSupport', () => ({
|
||||
...jest.requireActual('hooks/useChatSupport'),
|
||||
useChatSupport: jest.fn(),
|
||||
@@ -37,6 +44,9 @@ describe('SupportButton', () => {
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(window.Pylon).toHaveBeenCalledWith('show');
|
||||
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
|
||||
type: BottomStripSupportType.Pylon,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not open the credit card modal', () => {
|
||||
@@ -64,6 +74,9 @@ describe('SupportButton', () => {
|
||||
|
||||
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
|
||||
expect(window.Pylon).not.toHaveBeenCalled();
|
||||
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
|
||||
type: BottomStripSupportType.AddCard,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
12
frontend/src/container/BottomStrip/events.ts
Normal file
12
frontend/src/container/BottomStrip/events.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export enum BottomStripEvents {
|
||||
SupportClicked = 'Bottom Strip: Support clicked',
|
||||
}
|
||||
|
||||
export enum BottomStripEventKeys {
|
||||
Type = 'type',
|
||||
}
|
||||
|
||||
export enum BottomStripSupportType {
|
||||
Pylon = 'pylon',
|
||||
AddCard = 'add_card',
|
||||
}
|
||||
@@ -57,10 +57,6 @@
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0 !important;
|
||||
padding-block: 0 !important;
|
||||
|
||||
:global(.quick-filters .checkbox-filter) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SavedViewCreatePermission,
|
||||
SavedViewListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import {
|
||||
MY_VIEW_NAME,
|
||||
@@ -19,11 +20,13 @@ import {
|
||||
SAVED_VIEW_LOAD_FAILED_NAME,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from './constants';
|
||||
import { SavedViewCreatedFrom } from './events';
|
||||
import { useActiveSavedView } from './hooks/useActiveSavedView';
|
||||
import { useSaveNewView } from './hooks/useSaveNewView';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import SaveChangesMenu from './SaveChangesMenu';
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SavedViewsNameToggle from './SavedViewsNameToggle';
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import { SaveViewModalMode } from './types';
|
||||
|
||||
@@ -32,11 +35,16 @@ import styles from './SavedViewsHeader.module.scss';
|
||||
function SavedViewsHeader({
|
||||
source,
|
||||
onOpenViews,
|
||||
isViewsOpen = false,
|
||||
onToggleViews,
|
||||
onCollapse,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
// Absent while the list is on screen.
|
||||
onOpenViews?: () => void;
|
||||
isViewsOpen?: boolean;
|
||||
// Absent while the list is pinned open.
|
||||
onToggleViews?: () => void;
|
||||
onCollapse?: () => void;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
@@ -49,12 +57,27 @@ function SavedViewsHeader({
|
||||
} = useActiveSavedView(source);
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const readPermission = buildSavedViewReadPermission(viewKey);
|
||||
const { isLoading: isCheckingList, deniedPermissions: deniedList } = useAuthZ(
|
||||
[SavedViewListPermission],
|
||||
{ enabled: !!onToggleViews },
|
||||
);
|
||||
const canListViews = !isCheckingList && deniedList.length === 0;
|
||||
const { revertView, clearView, createView, updateView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
|
||||
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
|
||||
|
||||
const handleSave = useSaveNewView(createView);
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const handleSave = useCallback(
|
||||
(displayName: string): Promise<boolean> =>
|
||||
saveNewView(
|
||||
displayName,
|
||||
modalMode === 'saveAsNew'
|
||||
? SavedViewCreatedFrom.SaveAsNew
|
||||
: SavedViewCreatedFrom.Header,
|
||||
),
|
||||
[saveNewView, modalMode],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(async (): Promise<void> => {
|
||||
if (view && (await updateView(view))) {
|
||||
@@ -72,6 +95,9 @@ function SavedViewsHeader({
|
||||
name = SAVED_VIEW_LOAD_FAILED_NAME;
|
||||
}
|
||||
const isDirty = !isError && !!view && hasUnsavedChanges;
|
||||
// Same gates as the All views button; an open panel can always be closed.
|
||||
const canToggleViews =
|
||||
isViewsOpen || (canListViews && !isDirty && !isReadDenied);
|
||||
|
||||
const nameChip = (
|
||||
<span
|
||||
@@ -112,10 +138,17 @@ function SavedViewsHeader({
|
||||
className={styles.skeleton}
|
||||
testId="saved-views-loading"
|
||||
/>
|
||||
) : isReadDenied ? (
|
||||
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
|
||||
) : (
|
||||
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
|
||||
<SavedViewsNameToggle
|
||||
isOpen={isViewsOpen}
|
||||
onToggle={canToggleViews ? onToggleViews : undefined}
|
||||
>
|
||||
{isReadDenied ? (
|
||||
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
|
||||
) : (
|
||||
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
|
||||
)}
|
||||
</SavedViewsNameToggle>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
|
||||
import { SAVED_VIEWS_LIST_LOADING_ROWS } from './constants';
|
||||
import DeleteSavedViewDialog from './DeleteSavedViewDialog';
|
||||
import { SavedViewCreatedFrom } from './events';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import { useSavedViewHoverCard } from './hooks/useSavedViewHoverCard';
|
||||
import { useSavedViewRowActions } from './hooks/useSavedViewRowActions';
|
||||
@@ -35,7 +36,7 @@ function SavedViewsList({
|
||||
search: string;
|
||||
}): JSX.Element {
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { selectView, clearView, createView, isSaving } =
|
||||
const { selectView, clearView, resetView, createView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
|
||||
@@ -102,10 +103,10 @@ function SavedViewsList({
|
||||
return;
|
||||
}
|
||||
if (pendingAction.view.id === viewKey) {
|
||||
clearView();
|
||||
resetView();
|
||||
}
|
||||
setPendingAction(null);
|
||||
}, [pendingAction, deleteView, viewKey, clearView]);
|
||||
}, [pendingAction, deleteView, viewKey, resetView]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -141,7 +142,9 @@ function SavedViewsList({
|
||||
<SavedViewsEmptyState
|
||||
isSearching={search.trim().length > 0}
|
||||
isSaving={isSaving}
|
||||
onSave={saveNewView}
|
||||
onSave={(displayName): Promise<boolean> =>
|
||||
saveNewView(displayName, SavedViewCreatedFrom.EmptyState)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.toggle {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
--button-height: auto;
|
||||
// A flex label lets the chip shrink and use its own ellipsis.
|
||||
--button-label-display: flex;
|
||||
}
|
||||
|
||||
// The chip inherits its type from the button; doubled to outrank the button's
|
||||
// size rule so the chip reads the same as when it is not a toggle.
|
||||
.toggle.toggle > button {
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
}
|
||||
36
frontend/src/container/SavedViews/SavedViewsNameToggle.tsx
Normal file
36
frontend/src/container/SavedViews/SavedViewsNameToggle.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './SavedViewsNameToggle.module.scss';
|
||||
|
||||
function SavedViewsNameToggle({
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onToggle?: () => void;
|
||||
children: ReactElement;
|
||||
}): JSX.Element {
|
||||
if (!onToggle) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.toggle}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
textOverflow="hidden"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
testId="saved-views-name-toggle"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsNameToggle;
|
||||
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewCreatedFrom, SavedViewEvents } from '../events';
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const logEventMock = jest.fn();
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): void => logEventMock(...args),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
// copy-to-clipboard falls back to window.prompt in jsdom.
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, jest.fn()],
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
const source = SavedviewtypesSourceDTO.traces;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
|
||||
function renderAll(
|
||||
url = explorerUrl(PATH, {}),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsHeader source={source} onOpenViews={jest.fn()} />
|
||||
<SavedViewsPanel source={source} onClose={jest.fn()} />
|
||||
</>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const row = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
// Buttons and menu rows ignore clicks until their permission check settles.
|
||||
async function clickWhenUsable(testId: string): Promise<void> {
|
||||
await waitFor(() => expectUsable(screen.getByTestId(testId)));
|
||||
await userEvent.click(screen.getByTestId(testId));
|
||||
}
|
||||
|
||||
async function chooseMenuItem(testId: string): Promise<void> {
|
||||
const item = await screen.findByTestId(testId);
|
||||
await waitFor(() => expectRowUsable(item), MENU_CHECK_TIMEOUT);
|
||||
fireEvent.click(item);
|
||||
}
|
||||
|
||||
async function chooseFromRowMenu(viewName: string, key: string): Promise<void> {
|
||||
await screen.findByTestId('saved-views-list');
|
||||
fireEvent.click(within(row(viewName)).getByTestId('saved-views-row-menu'));
|
||||
await chooseMenuItem(`saved-views-row-menu-item-${key}`);
|
||||
}
|
||||
|
||||
async function chooseFromSaveMenu(key: string): Promise<void> {
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await chooseMenuItem(`saved-views-save-changes-item-${key}`);
|
||||
}
|
||||
|
||||
async function submitName(name: string): Promise<void> {
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), name);
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
}
|
||||
|
||||
async function waitForView(name: string): Promise<void> {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(name),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
|
||||
);
|
||||
}
|
||||
|
||||
const callsOf = (event: SavedViewEvents): unknown[][] =>
|
||||
logEventMock.mock.calls.filter(([name]) => name === event);
|
||||
|
||||
describe('SavedViews analytics', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
logEventMock.mockClear();
|
||||
jest.mocked(toast.error).mockClear();
|
||||
});
|
||||
|
||||
it('logs a view created from the header', async () => {
|
||||
mockSavedViewsApi([errors], { createdId: 'view-new' });
|
||||
renderAll();
|
||||
|
||||
await clickWhenUsable('saved-views-create');
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
|
||||
source,
|
||||
viewId: 'view-new',
|
||||
from: SavedViewCreatedFrom.Header,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a view created from the empty state', async () => {
|
||||
mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
renderAll();
|
||||
|
||||
await screen.findByTestId('saved-views-list-create');
|
||||
await clickWhenUsable('saved-views-list-create');
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
|
||||
source,
|
||||
viewId: 'view-new',
|
||||
from: SavedViewCreatedFrom.EmptyState,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log a create that fails', async () => {
|
||||
mockSavedViewsApi([errors], { createStatus: 500 });
|
||||
renderAll();
|
||||
|
||||
await clickWhenUsable('saved-views-create');
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(callsOf(SavedViewEvents.ViewCreated)).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('on a changed view', () => {
|
||||
async function openDirtyView(): Promise<void> {
|
||||
const { history } = renderAll(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await screen.findByTestId('saved-views-discard');
|
||||
}
|
||||
|
||||
it('does not log an update that fails', async () => {
|
||||
mockSavedViewsApi([errors], { updateStatus: 500 });
|
||||
await openDirtyView();
|
||||
|
||||
await chooseFromSaveMenu('update');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(callsOf(SavedViewEvents.ViewUpdated)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('logs an update', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
await openDirtyView();
|
||||
|
||||
await chooseFromSaveMenu('update');
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewUpdated, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a view created with save as new', async () => {
|
||||
mockSavedViewsApi([errors], { createdId: 'view-copy' });
|
||||
await openDirtyView();
|
||||
|
||||
await chooseFromSaveMenu('save-as-new');
|
||||
await submitName('Errors copy');
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
|
||||
source,
|
||||
viewId: 'view-copy',
|
||||
from: SavedViewCreatedFrom.SaveAsNew,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs discarded changes', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
await openDirtyView();
|
||||
logEventMock.mockClear();
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-discard'));
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ChangesDiscarded, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a cleared view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderAll(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-clear'));
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a view cleared from its row', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderAll(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
await userEvent.click(await screen.findByTestId('saved-views-row-clear'));
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs only the delete when the open view is deleted', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderAll(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
await chooseFromRowMenu('Errors', 'delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewDeleted, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('My view'),
|
||||
);
|
||||
expect(callsOf(SavedViewEvents.ViewCleared)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('logs a copied link', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderAll();
|
||||
|
||||
await chooseFromRowMenu('Errors', 'copyLink');
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.LinkCopied, {
|
||||
source,
|
||||
viewId: errors.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs edited view details', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderAll();
|
||||
|
||||
await chooseFromRowMenu('Errors', 'editDetails');
|
||||
await submitName(' renamed');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
SavedViewEvents.ViewDetailsEdited,
|
||||
{ source, viewId: errors.id },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log edited view details that fail to save', async () => {
|
||||
mockSavedViewsApi([errors], { updateStatus: 500 });
|
||||
renderAll();
|
||||
|
||||
await chooseFromRowMenu('Errors', 'editDetails');
|
||||
await submitName(' renamed');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(callsOf(SavedViewEvents.ViewDetailsEdited)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not log a delete that fails', async () => {
|
||||
mockSavedViewsApi([errors], { deleteStatus: 500 });
|
||||
renderAll();
|
||||
|
||||
await chooseFromRowMenu('Errors', 'delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(callsOf(SavedViewEvents.ViewDeleted)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ function renderHeader(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
onToggleViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
@@ -262,4 +263,46 @@ describe('SavedViews - AuthZ', () => {
|
||||
expect(screen.queryAllByText(message).length).toBeGreaterThan(0),
|
||||
);
|
||||
});
|
||||
|
||||
describe('view name toggle', () => {
|
||||
it('toggles the views from the name when list is allowed', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('saved-views-name-toggle'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the name plain without list, the All views button says why', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
server.use(setupAuthzDeny(SavedViewListPermission));
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-open')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('saved-views-name-toggle'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the name plain on a view it cannot read', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'No view access',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('saved-views-name-toggle'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,11 +30,14 @@ const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
function renderHeader(
|
||||
url: string,
|
||||
isViewsOpen = false,
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
isViewsOpen={isViewsOpen}
|
||||
onToggleViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
@@ -45,8 +48,9 @@ const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
|
||||
|
||||
async function openDirtyView(
|
||||
view: ReturnType<typeof makeView>,
|
||||
isViewsOpen = false,
|
||||
): Promise<ReturnType<typeof renderWithExplorerProviders>> {
|
||||
const rendered = renderHeader(viewUrl(PATH, view));
|
||||
const rendered = renderHeader(viewUrl(PATH, view), isViewsOpen);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
view.spec.displayName,
|
||||
@@ -226,4 +230,36 @@ describe('SavedViewsHeader saving', () => {
|
||||
);
|
||||
expect(requests.created).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('view name toggle with unsaved changes', () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
|
||||
it('stops opening the views from the name once the view changes', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await screen.findByTestId('saved-views-name-toggle');
|
||||
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await screen.findByTestId('saved-views-discard');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('saved-views-name-toggle'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still closes open views from the name', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
await openDirtyView(errors, true);
|
||||
|
||||
expect(screen.getByTestId('saved-views-name-toggle')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,11 +19,15 @@ export function mockSavedViewsApi(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
{
|
||||
createStatus = 201,
|
||||
updateStatus = 204,
|
||||
deleteStatus = 204,
|
||||
createdId = 'view-new',
|
||||
getDelay = 0,
|
||||
listFailures = 0,
|
||||
}: {
|
||||
createStatus?: number;
|
||||
updateStatus?: number;
|
||||
deleteStatus?: number;
|
||||
createdId?: string;
|
||||
getDelay?: number;
|
||||
listFailures?: number;
|
||||
@@ -85,20 +89,38 @@ export function mockSavedViewsApi(
|
||||
const body = await req.json();
|
||||
const id = String(req.params.id);
|
||||
requests.updated.push({ id, body });
|
||||
if (updateStatus >= 400) {
|
||||
return res(
|
||||
ctx.status(updateStatus),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'boom' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
const index = views.findIndex((v) => v.id === id);
|
||||
if (index >= 0) {
|
||||
views[index] = { ...views[index], spec: body.spec };
|
||||
}
|
||||
return res(ctx.status(204));
|
||||
return res(ctx.status(updateStatus));
|
||||
}),
|
||||
rest.delete(`${API}/:id`, (req, res, ctx) => {
|
||||
const id = String(req.params.id);
|
||||
requests.deleted.push(id);
|
||||
if (deleteStatus >= 400) {
|
||||
return res(
|
||||
ctx.status(deleteStatus),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'boom' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
const index = views.findIndex((v) => v.id === id);
|
||||
if (index >= 0) {
|
||||
views.splice(index, 1);
|
||||
}
|
||||
return res(ctx.status(204));
|
||||
return res(ctx.status(deleteStatus));
|
||||
}),
|
||||
);
|
||||
return requests;
|
||||
|
||||
23
frontend/src/container/SavedViews/events.ts
Normal file
23
frontend/src/container/SavedViews/events.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export enum SavedViewEvents {
|
||||
ViewCreated = 'Saved Views: View created',
|
||||
ViewUpdated = 'Saved Views: View updated',
|
||||
ViewDetailsEdited = 'Saved Views: View details edited',
|
||||
ViewDeleted = 'Saved Views: View deleted',
|
||||
LinkCopied = 'Saved Views: Link copied',
|
||||
ViewCleared = 'Saved Views: View cleared',
|
||||
ChangesDiscarded = 'Saved Views: Changes discarded',
|
||||
}
|
||||
|
||||
export enum SavedViewEventKeys {
|
||||
// Injected on every event by useSavedViewLogEvent
|
||||
Source = 'source',
|
||||
|
||||
ViewId = 'viewId',
|
||||
From = 'from',
|
||||
}
|
||||
|
||||
export enum SavedViewCreatedFrom {
|
||||
Header = 'header',
|
||||
SaveAsNew = 'save_as_new',
|
||||
EmptyState = 'empty_state',
|
||||
}
|
||||
@@ -2,14 +2,15 @@ import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { SAVED_VIEW_TOAST_POSITION } from '../constants';
|
||||
import { SavedViewCreatedFrom } from '../events';
|
||||
import { UseSavedViewActionsResult } from '../types';
|
||||
|
||||
export function useSaveNewView(
|
||||
createView: UseSavedViewActionsResult['createView'],
|
||||
): (displayName: string) => Promise<boolean> {
|
||||
): (displayName: string, from: SavedViewCreatedFrom) => Promise<boolean> {
|
||||
return useCallback(
|
||||
async (displayName: string): Promise<boolean> => {
|
||||
if (!(await createView(displayName))) {
|
||||
async (displayName: string, from: SavedViewCreatedFrom): Promise<boolean> => {
|
||||
if (!(await createView(displayName, from))) {
|
||||
return false;
|
||||
}
|
||||
toast.success('View created', {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
@@ -30,12 +31,18 @@ import {
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
SAVED_VIEW_URL_PARAMS,
|
||||
} from '../constants';
|
||||
import {
|
||||
SavedViewCreatedFrom,
|
||||
SavedViewEventKeys,
|
||||
SavedViewEvents,
|
||||
} from '../events';
|
||||
import { UseSavedViewActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { getViewColumnsAndFormatting } from '../utils/getViewColumnsAndFormatting';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
|
||||
import { useLastUsedView } from './useLastUsedView';
|
||||
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
|
||||
|
||||
// Columns and formatting need no handling here: with a viewKey in the url the
|
||||
// preference provider reads them from the view, and without one it falls back
|
||||
@@ -50,6 +57,8 @@ export function useSavedViewActions(
|
||||
useQueryBuilder();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
|
||||
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
|
||||
const { options } = useOptionsMenu({
|
||||
@@ -107,13 +116,16 @@ export function useSavedViewActions(
|
||||
}
|
||||
}
|
||||
selectView(view);
|
||||
logSavedViewEvent(SavedViewEvents.ChangesDiscarded, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[optionsDataSource, preferencesSlice, selectView],
|
||||
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
|
||||
);
|
||||
|
||||
// The query builder redirects only ever add params, so the url is rebuilt
|
||||
// here; the time range and everything else a view does not set stays.
|
||||
const clearView = useCallback((): void => {
|
||||
const resetView = useCallback((): void => {
|
||||
clearLastUsedView();
|
||||
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
@@ -121,8 +133,18 @@ export function useSavedViewActions(
|
||||
safeNavigate(`${pathname}?${params.toString()}`);
|
||||
}, [clearLastUsedView, pathname, safeNavigate]);
|
||||
|
||||
const clearView = useCallback((): void => {
|
||||
resetView();
|
||||
logSavedViewEvent(SavedViewEvents.ViewCleared, {
|
||||
[SavedViewEventKeys.ViewId]: viewKey,
|
||||
});
|
||||
}, [resetView, logSavedViewEvent, viewKey]);
|
||||
|
||||
const createView = useCallback(
|
||||
async (displayName: string): Promise<string | undefined> => {
|
||||
async (
|
||||
displayName: string,
|
||||
from: SavedViewCreatedFrom,
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const { data } = await createSavedView({
|
||||
data: {
|
||||
@@ -137,6 +159,10 @@ export function useSavedViewActions(
|
||||
[QueryParams.viewKey]: data.id,
|
||||
});
|
||||
setLastUsedView(data.id, displayName);
|
||||
logSavedViewEvent(SavedViewEvents.ViewCreated, {
|
||||
[SavedViewEventKeys.ViewId]: data.id,
|
||||
[SavedViewEventKeys.From]: from,
|
||||
});
|
||||
return data.id;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -158,6 +184,7 @@ export function useSavedViewActions(
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
setLastUsedView,
|
||||
logSavedViewEvent,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -173,6 +200,9 @@ export function useSavedViewActions(
|
||||
},
|
||||
});
|
||||
await invalidateViews(view.id);
|
||||
logSavedViewEvent(SavedViewEvents.ViewUpdated, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -185,13 +215,14 @@ export function useSavedViewActions(
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, toCurrentSpec, invalidateViews],
|
||||
[updateSavedView, source, toCurrentSpec, invalidateViews, logSavedViewEvent],
|
||||
);
|
||||
|
||||
return {
|
||||
selectView,
|
||||
revertView,
|
||||
clearView,
|
||||
resetView,
|
||||
createView,
|
||||
updateView,
|
||||
isSaving: isCreating || isUpdating,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useCallback } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { SavedViewEventKeys, SavedViewEvents } from '../events';
|
||||
|
||||
export type SavedViewLogEvent = (
|
||||
event: SavedViewEvents,
|
||||
attributes?: Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
export function useSavedViewLogEvent(
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): SavedViewLogEvent {
|
||||
return useCallback(
|
||||
(event: SavedViewEvents, attributes: Record<string, unknown> = {}): void => {
|
||||
void logEvent(event, {
|
||||
[SavedViewEventKeys.Source]: source,
|
||||
...attributes,
|
||||
});
|
||||
},
|
||||
[source],
|
||||
);
|
||||
}
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
SAVED_VIEW_SCHEMA_VERSION,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from '../constants';
|
||||
import { SavedViewEventKeys, SavedViewEvents } from '../events';
|
||||
import { UseSavedViewRowActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
|
||||
|
||||
function showErrorToast(error: unknown, fallback: string): void {
|
||||
toast.error(
|
||||
@@ -42,6 +44,7 @@ export function useSavedViewRowActions(
|
||||
const queryClient = useQueryClient();
|
||||
const { pathname } = useLocation();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
|
||||
useUpdateSavedView();
|
||||
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
|
||||
@@ -55,8 +58,11 @@ export function useSavedViewRowActions(
|
||||
});
|
||||
copyToClipboard(getAbsoluteUrl(path));
|
||||
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.LinkCopied, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[pathname, copyToClipboard],
|
||||
[pathname, copyToClipboard, logSavedViewEvent],
|
||||
);
|
||||
|
||||
const copyViewId = useCallback(
|
||||
@@ -83,13 +89,16 @@ export function useSavedViewRowActions(
|
||||
});
|
||||
await invalidateSavedViews(queryClient, source, view.id);
|
||||
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorToast(error, 'Could not update the view');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, queryClient],
|
||||
[updateSavedView, source, queryClient, logSavedViewEvent],
|
||||
);
|
||||
|
||||
// The deleted view's own query is left alone: refetching it would fail
|
||||
@@ -100,13 +109,16 @@ export function useSavedViewRowActions(
|
||||
await deleteSavedView({ pathParams: { id: view.id } });
|
||||
await invalidateSavedViews(queryClient, source);
|
||||
toast.success('View deleted', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.ViewDeleted, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorToast(error, 'Could not delete the view');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[deleteSavedView, source, queryClient],
|
||||
[deleteSavedView, source, queryClient, logSavedViewEvent],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewCreatedFrom } from './events';
|
||||
|
||||
export type SavedViewSourcePage = DataSource | 'meter';
|
||||
|
||||
export type SavedViewOptions = Pick<
|
||||
@@ -51,7 +53,12 @@ export interface UseSavedViewActionsResult {
|
||||
selectView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
revertView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
clearView: () => void;
|
||||
createView: (displayName: string) => Promise<string | undefined>;
|
||||
/** Clears the open view without logging it, for flows that are not a clear. */
|
||||
resetView: () => void;
|
||||
createView: (
|
||||
displayName: string,
|
||||
from: SavedViewCreatedFrom,
|
||||
) => Promise<string | undefined>;
|
||||
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
TRACE: false,
|
||||
BAR: true,
|
||||
AREA: true,
|
||||
SCATTER: false,
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
HEATMAP: false,
|
||||
|
||||
@@ -16,6 +16,7 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.TRACE]: null,
|
||||
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.SCATTER]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
[PANEL_TYPES.PIE]: PiePanelWrapper,
|
||||
[PANEL_TYPES.BAR]: BarPanel,
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { QueryKeyValueSuggestionsResponseProps } from 'types/api/querySuggestions/types';
|
||||
|
||||
export const useGetQueryKeyValueSuggestions = ({
|
||||
key,
|
||||
signal,
|
||||
searchText,
|
||||
signalSource,
|
||||
metricName,
|
||||
options,
|
||||
}: {
|
||||
key: string;
|
||||
signal: 'traces' | 'logs' | 'metrics';
|
||||
searchText?: string;
|
||||
signalSource?: 'meter' | '';
|
||||
options?: UseQueryOptions<
|
||||
AxiosResponse<QueryKeyValueSuggestionsResponseProps>,
|
||||
AxiosError
|
||||
>;
|
||||
metricName?: string;
|
||||
}): UseQueryResult<
|
||||
AxiosResponse<QueryKeyValueSuggestionsResponseProps>,
|
||||
AxiosError
|
||||
> =>
|
||||
useQuery<AxiosResponse<QueryKeyValueSuggestionsResponseProps>, AxiosError>({
|
||||
queryKey: [
|
||||
'queryKeyValueSuggestions',
|
||||
key,
|
||||
signal,
|
||||
searchText,
|
||||
signalSource,
|
||||
metricName,
|
||||
],
|
||||
queryFn: () =>
|
||||
getValueSuggestions({
|
||||
signal,
|
||||
key,
|
||||
searchText: searchText || '',
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: metricName || '',
|
||||
}),
|
||||
...options,
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export type PartialPanelTypes = {
|
||||
[PANEL_TYPES.AREA]: 'area';
|
||||
[PANEL_TYPES.VALUE]: 'value';
|
||||
[PANEL_TYPES.PIE]: 'pie';
|
||||
[PANEL_TYPES.SCATTER]: 'scatter';
|
||||
[PANEL_TYPES.HISTOGRAM]: 'histogram';
|
||||
[PANEL_TYPES.HEATMAP]: 'heatmap';
|
||||
};
|
||||
@@ -150,6 +151,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[PANEL_TYPES.HEATMAP]: bySource(HEATMAP, HEATMAP_METRICS),
|
||||
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.SCATTER]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
|
||||
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Sections own the padding so the footer spans edge to edge.
|
||||
.container {
|
||||
// Same inset as the heatmap tooltip.
|
||||
--gutter: 14px;
|
||||
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-6) var(--gutter);
|
||||
}
|
||||
|
||||
// Matches the legend row's marker.
|
||||
.marker {
|
||||
box-sizing: border-box;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid;
|
||||
border-radius: var(--radius);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-100);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-5) var(--gutter);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.rowMuted {
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
flex: 0 0 auto;
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
139
frontend/src/lib/uPlotV2/components/Tooltip/ScatterTooltip.tsx
Normal file
139
frontend/src/lib/uPlotV2/components/Tooltip/ScatterTooltip.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { ScatterTooltipProps } from '../types';
|
||||
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
|
||||
|
||||
import Styles from './ScatterTooltip.module.scss';
|
||||
|
||||
/** Own component: the shared Tooltip lists one row per series at a shared x. */
|
||||
export default function ScatterTooltip({
|
||||
uPlotInstance,
|
||||
dataIndexes,
|
||||
seriesIndex,
|
||||
channels,
|
||||
resolvePointGroupValues,
|
||||
decimalPrecision,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: ScatterTooltipProps): JSX.Element | null {
|
||||
const hoveredPoint = useMemo(
|
||||
() => resolveHoveredPoint(uPlotInstance, seriesIndex, dataIndexes),
|
||||
[uPlotInstance, seriesIndex, dataIndexes],
|
||||
);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
hoveredPoint
|
||||
? buildChannelRows(hoveredPoint, channels, decimalPrecision)
|
||||
: [],
|
||||
[hoveredPoint, channels, decimalPrecision],
|
||||
);
|
||||
|
||||
const labels = useMemo(
|
||||
() =>
|
||||
hoveredPoint
|
||||
? (resolvePointGroupValues?.(
|
||||
hoveredPoint.seriesIndex,
|
||||
hoveredPoint.dataIndex,
|
||||
) ?? [])
|
||||
: [],
|
||||
[hoveredPoint, resolvePointGroupValues],
|
||||
);
|
||||
|
||||
if (!hoveredPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="scatter-tooltip"
|
||||
>
|
||||
<div className={Styles.header}>
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{
|
||||
backgroundColor: hoveredPoint.color,
|
||||
borderColor: hoveredPoint.color,
|
||||
}}
|
||||
/>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={Styles.title}
|
||||
title={hoveredPoint.label}
|
||||
testId="scatter-tooltip-title"
|
||||
>
|
||||
{hoveredPoint.label}
|
||||
</Typography.Text>
|
||||
{isPinned && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Pin size={12} />}
|
||||
testId="scatter-tooltip-status"
|
||||
>
|
||||
Pinned
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} />
|
||||
|
||||
<div className={Styles.rows}>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={Styles.row}
|
||||
data-testid="scatter-tooltip-row"
|
||||
>
|
||||
<Typography.Text as="span" className={Styles.rowLabel}>
|
||||
{row.label}
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" className={Styles.rowValue}>
|
||||
{row.value}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{labels.length > 0 && (
|
||||
<>
|
||||
<span className={Styles.divider} />
|
||||
<div className={Styles.rows}>
|
||||
{labels.map((label) => (
|
||||
<div
|
||||
key={label.key}
|
||||
className={cx(Styles.row, Styles.rowMuted)}
|
||||
data-testid="scatter-tooltip-label"
|
||||
>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={Styles.rowLabel}
|
||||
title={label.key}
|
||||
>
|
||||
{label.key}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={Styles.rowValue}
|
||||
title={label.value}
|
||||
>
|
||||
{label.value}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
buildChannelRows,
|
||||
resolveHoveredPoint,
|
||||
ScatterHoveredPoint,
|
||||
} from '../scatterTooltipContent';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
getToolTipValue: jest.fn((value: number | string, unit?: string) =>
|
||||
`${value} ${unit ?? ''}`.trim(),
|
||||
),
|
||||
}));
|
||||
|
||||
const plot = {
|
||||
data: [
|
||||
null,
|
||||
[
|
||||
[10, 20],
|
||||
[100, 200],
|
||||
[5, null],
|
||||
],
|
||||
[[30], [300]],
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{ label: 'cart', stroke: '#ff0000' },
|
||||
{ label: 'checkout', stroke: (): string => '#00ff00' },
|
||||
],
|
||||
} as unknown as uPlot;
|
||||
|
||||
describe('resolveHoveredPoint', () => {
|
||||
it('reads the focused series at its own data index', () => {
|
||||
expect(resolveHoveredPoint(plot, 1, [null, 1, null])).toStrictEqual({
|
||||
seriesIndex: 1,
|
||||
dataIndex: 1,
|
||||
label: 'cart',
|
||||
color: '#ff0000',
|
||||
x: 20,
|
||||
y: 200,
|
||||
size: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the size column when present and resolves function strokes', () => {
|
||||
expect(resolveHoveredPoint(plot, 1, [null, 0, null])?.size).toBe(5);
|
||||
expect(resolveHoveredPoint(plot, 2, [null, null, 0])).toMatchObject({
|
||||
label: 'checkout',
|
||||
color: '#00ff00',
|
||||
size: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('is null without a focused series or an index for it', () => {
|
||||
expect(resolveHoveredPoint(plot, null, [null, 0, null])).toBeNull();
|
||||
expect(resolveHoveredPoint(plot, 0, [0, 0, null])).toBeNull();
|
||||
expect(resolveHoveredPoint(plot, 1, [null, null, null])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildChannelRows', () => {
|
||||
const point: ScatterHoveredPoint = {
|
||||
seriesIndex: 1,
|
||||
dataIndex: 0,
|
||||
label: 'cart',
|
||||
color: '#f00',
|
||||
x: 12,
|
||||
y: 340,
|
||||
size: 7,
|
||||
};
|
||||
|
||||
it('formats x and y with their own units', () => {
|
||||
const rows = buildChannelRows(point, {
|
||||
x: { label: 'Throughput', unit: 'reqps' },
|
||||
y: { label: 'p99', unit: 'ms' },
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([
|
||||
{ label: 'Throughput', value: '12 reqps' },
|
||||
{ label: 'p99', value: '340 ms' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds the size row only when the channel is mapped and the point has one', () => {
|
||||
const channels = {
|
||||
x: { label: 'x' },
|
||||
y: { label: 'y' },
|
||||
size: { label: 'Errors' },
|
||||
};
|
||||
|
||||
expect(buildChannelRows(point, channels)).toHaveLength(3);
|
||||
expect(buildChannelRows({ ...point, size: null }, channels)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('leaves out a size row that repeats the x row', () => {
|
||||
const rows = buildChannelRows(
|
||||
{ ...point, size: point.x },
|
||||
{
|
||||
x: { label: 'A.count()' },
|
||||
y: { label: 'p99' },
|
||||
size: { label: 'A.count()' },
|
||||
},
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.label)).toStrictEqual(['A.count()', 'p99']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type {
|
||||
ScatterChannel,
|
||||
ScatterChannels,
|
||||
ScatterSeriesData,
|
||||
} from '../../plugins/ScatterPlugin/types';
|
||||
import { resolveSeriesColor } from './utils';
|
||||
|
||||
export interface ScatterHoveredPoint {
|
||||
seriesIndex: number;
|
||||
dataIndex: number;
|
||||
label: string;
|
||||
color: string;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
export interface ScatterTooltipRow {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function resolveHoveredPoint(
|
||||
u: uPlot,
|
||||
seriesIndex: number | null,
|
||||
dataIndexes: Array<number | null>,
|
||||
): ScatterHoveredPoint | null {
|
||||
if (seriesIndex == null || seriesIndex < 1) {
|
||||
return null;
|
||||
}
|
||||
const dataIndex = dataIndexes[seriesIndex];
|
||||
const series = u.series[seriesIndex];
|
||||
const columns = u.data[seriesIndex] as unknown as
|
||||
| ScatterSeriesData
|
||||
| undefined;
|
||||
if (dataIndex == null || !series || !columns) {
|
||||
return null;
|
||||
}
|
||||
const x = columns[0][dataIndex];
|
||||
const y = columns[1][dataIndex];
|
||||
if (x == null || y == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
seriesIndex,
|
||||
dataIndex,
|
||||
label: String(series.label ?? ''),
|
||||
color: resolveSeriesColor(series.stroke, u, seriesIndex),
|
||||
x,
|
||||
y,
|
||||
size: columns[2]?.[dataIndex] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatChannel(
|
||||
value: number,
|
||||
channel: ScatterChannel,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): string {
|
||||
return getToolTipValue(value, channel.unit, decimalPrecision);
|
||||
}
|
||||
|
||||
export function buildChannelRows(
|
||||
point: ScatterHoveredPoint,
|
||||
channels: ScatterChannels,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): ScatterTooltipRow[] {
|
||||
const rows: ScatterTooltipRow[] = [
|
||||
{
|
||||
label: channels.x.label,
|
||||
value: formatChannel(point.x, channels.x, decimalPrecision),
|
||||
},
|
||||
{
|
||||
label: channels.y.label,
|
||||
value: formatChannel(point.y, channels.y, decimalPrecision),
|
||||
},
|
||||
];
|
||||
if (channels.size && point.size != null) {
|
||||
const sizeRow = {
|
||||
label: channels.size.label,
|
||||
value: formatChannel(point.size, channels.size, decimalPrecision),
|
||||
};
|
||||
// Size bound to the X or Y column would only repeat that row.
|
||||
if (
|
||||
!rows.some(
|
||||
(row) => row.label === sizeRow.label && row.value === sizeRow.value,
|
||||
)
|
||||
) {
|
||||
rows.push(sizeRow);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import uPlot from 'uplot';
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
import { LegendItem } from '../config/types';
|
||||
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
|
||||
import type {
|
||||
ScatterChannels,
|
||||
ScatterPointLabel,
|
||||
} from '../plugins/ScatterPlugin/types';
|
||||
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
|
||||
|
||||
/**
|
||||
@@ -119,6 +123,16 @@ export interface HeatmapTooltipProps
|
||||
groupColor: string;
|
||||
}
|
||||
|
||||
export interface ScatterTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {
|
||||
channels: ScatterChannels;
|
||||
/** The group values behind a point, e.g. `service.name` → `cart`. */
|
||||
resolvePointGroupValues?: (
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
) => ScatterPointLabel[];
|
||||
}
|
||||
|
||||
export type TooltipProps =
|
||||
| TimeSeriesTooltipProps
|
||||
| BarTooltipProps
|
||||
|
||||
@@ -39,6 +39,16 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
};
|
||||
}
|
||||
|
||||
/** Styled like the grid. */
|
||||
private buildBorderConfig(
|
||||
grid: uPlot.Axis.Grid | undefined,
|
||||
): uPlot.Axis.Border | undefined {
|
||||
if (this.props.showBorder === false || !grid || grid.show === false) {
|
||||
return undefined;
|
||||
}
|
||||
return { show: true, stroke: grid.stroke, width: grid.width };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ticks configuration
|
||||
*/
|
||||
@@ -59,31 +69,47 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
|
||||
/**
|
||||
* Build values formatter for X-axis (time)
|
||||
* A non-time x axis with a unit or precision formats like a value axis.
|
||||
*/
|
||||
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
|
||||
const { isTimeAxis } = this.props;
|
||||
const { isTimeAxis, yAxisUnit, decimalPrecision } = this.props;
|
||||
|
||||
if (isTimeAxis) {
|
||||
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
|
||||
}
|
||||
|
||||
if (yAxisUnit !== undefined || decimalPrecision !== undefined) {
|
||||
return this.buildValueAxisFormatter();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build values formatter for Y-axis (values with units)
|
||||
* Blanks splits outside the scale; uPlot's asinh splits always include ±threshold.
|
||||
*/
|
||||
private buildYAxisValuesFormatter(): uPlot.Axis.Values {
|
||||
const { yAxisUnit, decimalPrecision } = this.props;
|
||||
private buildValueAxisFormatter(): uPlot.Axis.Values {
|
||||
const { yAxisUnit, decimalPrecision, scaleKey } = this.props;
|
||||
|
||||
return (_, t): string[] =>
|
||||
t.map((v) => {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) {
|
||||
return (u, t): string[] => {
|
||||
const scale = u?.scales?.[scaleKey];
|
||||
const min = scale?.min ?? -Infinity;
|
||||
const max = scale?.max ?? Infinity;
|
||||
return t.map((v) => {
|
||||
if (
|
||||
v === null ||
|
||||
v === undefined ||
|
||||
Number.isNaN(v) ||
|
||||
v < min ||
|
||||
v > max
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
|
||||
return `${value}`;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +127,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
return scaleKey === 'x'
|
||||
? this.buildXAxisValuesFormatter()
|
||||
: scaleKey === 'y'
|
||||
? this.buildYAxisValuesFormatter()
|
||||
? this.buildValueAxisFormatter()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -149,10 +175,12 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
side = 2, // bottom by default
|
||||
space,
|
||||
splits,
|
||||
filter,
|
||||
gap = 5, // default gap is 5
|
||||
} = this.props;
|
||||
|
||||
const grid = this.buildGridConfig();
|
||||
const border = this.buildBorderConfig(grid);
|
||||
const ticks = this.buildTicksConfig();
|
||||
const values = this.buildValuesFormatter();
|
||||
const size = this.buildSizeCalculator();
|
||||
@@ -177,6 +205,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
if (ticks) {
|
||||
axisConfig.ticks = ticks;
|
||||
}
|
||||
if (border) {
|
||||
axisConfig.border = border;
|
||||
}
|
||||
if (values) {
|
||||
axisConfig.values = values;
|
||||
}
|
||||
@@ -189,6 +220,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
if (space !== undefined) {
|
||||
axisConfig.space = space;
|
||||
}
|
||||
if (filter) {
|
||||
axisConfig.filter = filter;
|
||||
}
|
||||
if (size) {
|
||||
axisConfig.size = size;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ConfigBuilder,
|
||||
ConfigBuilderProps,
|
||||
LegendItem,
|
||||
PlotMode,
|
||||
SelectionPreferencesSource,
|
||||
StackMode,
|
||||
} from './types';
|
||||
@@ -66,6 +67,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private stackMode: StackMode = StackMode.None;
|
||||
|
||||
private mode: PlotMode = PlotMode.Aligned;
|
||||
|
||||
private cursor: Cursor | undefined;
|
||||
|
||||
private hooks: Hooks.Arrays = {};
|
||||
@@ -163,6 +166,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
return this.stackMode;
|
||||
}
|
||||
|
||||
setMode(mode: PlotMode): void {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or merge a scale configuration
|
||||
*/
|
||||
@@ -519,6 +526,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
{} as Record<string, uPlot.Scale>,
|
||||
);
|
||||
|
||||
if (this.mode === PlotMode.Faceted) {
|
||||
config.mode = this.mode as number as uPlot.Mode;
|
||||
}
|
||||
|
||||
config.hooks = this.hooks;
|
||||
config.select = this.select;
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
|
||||
range,
|
||||
thresholds,
|
||||
logBase = 10,
|
||||
asinhThreshold,
|
||||
padMinBy = 0,
|
||||
padMaxBy = 0.05,
|
||||
} = this.props;
|
||||
@@ -99,6 +100,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
|
||||
time,
|
||||
distr,
|
||||
logBase,
|
||||
asinhThreshold,
|
||||
});
|
||||
|
||||
const { rangeConfig, hardMinOnly, hardMaxOnly, hasFixedRange } =
|
||||
|
||||
@@ -88,6 +88,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
lineConfig.fill = finalFillColor;
|
||||
} else if (this.props.drawStyle === DrawStyle.Histogram) {
|
||||
lineConfig.fill = `${finalFillColor}40`;
|
||||
} else if (this.props.drawStyle === DrawStyle.Scatter) {
|
||||
lineConfig.fill = `${finalFillColor}${toAlphaHex(resolveFillOpacity(fillOpacity))}`;
|
||||
} else if (fillMode && fillMode !== FillMode.None) {
|
||||
const resolvedOpacity = resolveFillOpacity(fillOpacity);
|
||||
if (fillMode === FillMode.Solid) {
|
||||
@@ -123,7 +125,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
return { paths: pathBuilder };
|
||||
}
|
||||
|
||||
if (drawStyle === DrawStyle.Points) {
|
||||
if (isMarkerOnlyDrawStyle(drawStyle)) {
|
||||
return { paths: (): null => null };
|
||||
}
|
||||
|
||||
@@ -195,6 +197,10 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
if (drawStyle === DrawStyle.Points) {
|
||||
return true;
|
||||
}
|
||||
// The discs are the series path; uPlot's own points would double-draw them.
|
||||
if (drawStyle === DrawStyle.Scatter) {
|
||||
return false;
|
||||
}
|
||||
return !!showPoints;
|
||||
}
|
||||
|
||||
@@ -226,6 +232,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
show = true,
|
||||
metric,
|
||||
partialTimestamps,
|
||||
facets,
|
||||
} = this.props;
|
||||
|
||||
const resolvedLineColor = this.getLineColor();
|
||||
@@ -255,6 +262,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
|
||||
metric,
|
||||
...(partialTimestamps?.size ? { partialTimestamps } : {}),
|
||||
...(facets && { facets }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -270,6 +278,11 @@ interface PathBuilders {
|
||||
/**
|
||||
* Get path builder based on draw style and interpolation
|
||||
*/
|
||||
/** Draws standalone markers, never a connecting line. */
|
||||
function isMarkerOnlyDrawStyle(drawStyle: DrawStyle): boolean {
|
||||
return drawStyle === DrawStyle.Points || drawStyle === DrawStyle.Scatter;
|
||||
}
|
||||
|
||||
function getPathBuilder({
|
||||
drawStyle,
|
||||
lineInterpolation,
|
||||
|
||||
@@ -120,6 +120,41 @@ describe('UPlotAxisBuilder', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('draws the axis line by default, styled like the grid', () => {
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ isDarkMode: true, grid: { width: 0.5 } }),
|
||||
).getConfig();
|
||||
|
||||
expect(config.border).toStrictEqual({
|
||||
show: true,
|
||||
stroke: config.grid?.stroke,
|
||||
width: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the axis line off when showBorder is false', () => {
|
||||
expect(
|
||||
new UPlotAxisBuilder(createAxisProps({ showBorder: false })).getConfig(),
|
||||
).not.toHaveProperty('border');
|
||||
});
|
||||
|
||||
it('leaves the axis line off when the grid is hidden', () => {
|
||||
expect(
|
||||
new UPlotAxisBuilder(createAxisProps({ grid: { show: false } })).getConfig(),
|
||||
).not.toHaveProperty('border');
|
||||
});
|
||||
|
||||
it('passes explicit splits and a label filter through to uPlot', () => {
|
||||
const splits = jest.fn();
|
||||
const filter = jest.fn();
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ splits, filter }),
|
||||
).getConfig();
|
||||
|
||||
expect(config.splits).toBe(splits);
|
||||
expect(config.filter).toBe(filter);
|
||||
});
|
||||
|
||||
it('uses provided ticks config when present and falls back to defaults otherwise', () => {
|
||||
const customTicks = { width: 1, show: false };
|
||||
const withTicks = new UPlotAxisBuilder(
|
||||
@@ -376,3 +411,51 @@ describe('UPlotAxisBuilder', () => {
|
||||
expect(config.values).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotAxisBuilder value x axis', () => {
|
||||
it('formats a non-time x axis with its unit', () => {
|
||||
(getToolTipValue as jest.Mock).mockReturnValue('1.2K req/s');
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'x', isTimeAxis: false, yAxisUnit: 'reqps' }),
|
||||
).getConfig();
|
||||
|
||||
const values = (config.values as uPlot.Axis.DynamicValues)(
|
||||
{} as uPlot,
|
||||
[1200],
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(values).toStrictEqual(['1.2K req/s']);
|
||||
expect(getToolTipValue).toHaveBeenCalledWith('1200', 'reqps', undefined);
|
||||
});
|
||||
|
||||
it('leaves a non-time x axis to uPlot when nothing says how to format it', () => {
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'x', isTimeAxis: false }),
|
||||
).getConfig();
|
||||
|
||||
expect(config.values).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotAxisBuilder out-of-range splits', () => {
|
||||
it('leaves a split the scale cannot place unlabelled', () => {
|
||||
(getToolTipValue as jest.Mock).mockImplementation((v: string) => `${v} ms`);
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', yAxisUnit: 'ms' }),
|
||||
).getConfig();
|
||||
const u = { scales: { y: { min: 0, max: 1000 } } } as unknown as uPlot;
|
||||
|
||||
const values = (config.values as uPlot.Axis.DynamicValues)(
|
||||
u,
|
||||
[-10, 0, 500, 5000],
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(values).toStrictEqual(['', '0 ms', '500 ms', '']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
STEP_INTERVAL_MULTIPLIER,
|
||||
} from '../../constants';
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
|
||||
import {
|
||||
DrawStyle,
|
||||
PlotMode,
|
||||
SelectionPreferencesSource,
|
||||
StackMode,
|
||||
} from '../types';
|
||||
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
|
||||
|
||||
// Mock only the real boundary that hits localStorage
|
||||
@@ -667,3 +672,14 @@ describe('UPlotConfigBuilder stacking', () => {
|
||||
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotConfigBuilder plot mode', () => {
|
||||
it('leaves mode unset for aligned data and emits 2 when faceted', () => {
|
||||
const aligned = new UPlotConfigBuilder({ id: 'aligned' });
|
||||
expect(aligned.getConfig().mode).toBeUndefined();
|
||||
|
||||
const faceted = new UPlotConfigBuilder({ id: 'faceted' });
|
||||
faceted.setMode(PlotMode.Faceted);
|
||||
expect(faceted.getConfig().mode).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -425,3 +425,39 @@ describe('UPlotSeriesBuilder', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotSeriesBuilder scatter', () => {
|
||||
it('draws through the given path builder and hides uPlot points', () => {
|
||||
const pathBuilder = jest.fn();
|
||||
const config = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Scatter,
|
||||
pathBuilder,
|
||||
facets: [{ scale: 'x' }, { scale: 'y' }],
|
||||
lineColor: '#ff0000',
|
||||
fillOpacity: 0.5,
|
||||
lineWidth: 1,
|
||||
pointSize: 8,
|
||||
}),
|
||||
).getConfig();
|
||||
|
||||
expect(config.paths).toBe(pathBuilder);
|
||||
expect(config.facets).toStrictEqual([{ scale: 'x' }, { scale: 'y' }]);
|
||||
expect(config.points?.show).toBe(false);
|
||||
expect(config.points?.size).toBe(8);
|
||||
expect(config.stroke).toBe('#ff0000');
|
||||
expect(config.width).toBe(1);
|
||||
expect(config.fill).toBe('#ff000080');
|
||||
});
|
||||
|
||||
it('draws nothing without a path builder', () => {
|
||||
const config = new UPlotSeriesBuilder(
|
||||
createBaseProps({ drawStyle: DrawStyle.Scatter }),
|
||||
).getConfig();
|
||||
|
||||
expect(
|
||||
(config.paths as uPlot.Series.PathBuilder)({} as uPlot, 1, 0, 0),
|
||||
).toBeNull();
|
||||
expect(config.facets).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,10 @@ export interface AxisProps {
|
||||
values?: uPlot.Axis.Values;
|
||||
/** Explicit axis splits, overriding the default tick calculation. */
|
||||
splits?: uPlot.Axis.Splits;
|
||||
/** Which splits get a label, replacing uPlot's per-distribution default. */
|
||||
filter?: uPlot.Axis.Filter;
|
||||
/** Draw the axis line along the plot edge, styled like the grid. Default true. */
|
||||
showBorder?: boolean;
|
||||
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
|
||||
gap?: number;
|
||||
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
|
||||
@@ -90,7 +94,7 @@ export interface AxisProps {
|
||||
isDarkMode?: boolean;
|
||||
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
|
||||
isLogScale?: boolean;
|
||||
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
|
||||
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`); a non-time x axis reads it too. */
|
||||
yAxisUnit?: string;
|
||||
/**
|
||||
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
|
||||
@@ -109,6 +113,13 @@ export interface AxisProps {
|
||||
export enum DistributionType {
|
||||
Linear = 'linear',
|
||||
Logarithmic = 'logarithmic',
|
||||
/** arcsinh: linear within ±`asinhThreshold`, log beyond; places zero and negatives. */
|
||||
SymmetricLog = 'symlog',
|
||||
}
|
||||
|
||||
export enum PlotMode {
|
||||
Aligned = 1,
|
||||
Faceted = 2,
|
||||
}
|
||||
|
||||
export interface ScaleProps {
|
||||
@@ -125,6 +136,8 @@ export interface ScaleProps {
|
||||
auto?: boolean;
|
||||
logBase?: uPlot.Scale.LogBase;
|
||||
distribution?: DistributionType;
|
||||
/** Half-width of a `SymmetricLog` scale's linear band around zero. Default 1. */
|
||||
asinhThreshold?: number;
|
||||
}
|
||||
|
||||
export enum DisconnectedValuesMode {
|
||||
@@ -146,6 +159,7 @@ export enum DrawStyle {
|
||||
Points = 'points',
|
||||
Bar = 'bar',
|
||||
Histogram = 'histogram',
|
||||
Scatter = 'scatter',
|
||||
}
|
||||
|
||||
export enum LineInterpolation {
|
||||
@@ -232,6 +246,8 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
metric?: { [key: string]: string };
|
||||
/** Epoch-second x values whose bucket doesn't cover a full step; marked with a dotted ring. */
|
||||
partialTimestamps?: Set<number>;
|
||||
/** Mode 2 only: the scales the series' own x and y columns are read against. */
|
||||
facets?: Series.Facet[];
|
||||
}
|
||||
|
||||
export interface LegendItem {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { thresholdsDrawHook } from '../useThresholdsDrawHook';
|
||||
|
||||
function mockPlot(ori: 0 | 1): { plot: uPlot; ctx: Record<string, jest.Mock> } {
|
||||
const ctx = {
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
beginPath: jest.fn(),
|
||||
moveTo: jest.fn(),
|
||||
lineTo: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
fillText: jest.fn(),
|
||||
measureText: jest.fn(() => ({ width: 40 })),
|
||||
};
|
||||
const plot = {
|
||||
ctx: { ...ctx, canvas: { height: 300 } },
|
||||
bbox: { left: 10, top: 20, width: 400, height: 200 },
|
||||
scales: { s: { ori } },
|
||||
valToPos: (): number => 100,
|
||||
} as unknown as uPlot;
|
||||
return { plot, ctx };
|
||||
}
|
||||
|
||||
const draw = (plot: uPlot): void =>
|
||||
(
|
||||
thresholdsDrawHook({
|
||||
scaleKey: 's',
|
||||
thresholds: [{ thresholdValue: 5, thresholdLabel: 'SLO' }],
|
||||
}) as (u: uPlot) => void
|
||||
)(plot);
|
||||
|
||||
describe('thresholdsDrawHook', () => {
|
||||
it('draws a horizontal line across a vertical scale', () => {
|
||||
const { plot, ctx } = mockPlot(1);
|
||||
draw(plot);
|
||||
|
||||
expect(ctx.moveTo).toHaveBeenCalledWith(10, 100);
|
||||
expect(ctx.lineTo).toHaveBeenCalledWith(410, 100);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith('SLO', 350, 85);
|
||||
});
|
||||
|
||||
it('draws a vertical line across a horizontal scale', () => {
|
||||
const { plot, ctx } = mockPlot(0);
|
||||
draw(plot);
|
||||
|
||||
expect(ctx.moveTo).toHaveBeenCalledWith(100, 20);
|
||||
expect(ctx.lineTo).toHaveBeenCalledWith(100, 220);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'SLO',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,13 +29,42 @@ export function thresholdsDrawHook(
|
||||
|
||||
const scaleVal = u.valToPos(Number(yValue), scaleKey, true);
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
|
||||
// A horizontal scale (e.g. a scatter plot's x) takes a vertical line.
|
||||
if (u.scales[scaleKey]?.ori === 0) {
|
||||
const x = Math.round(scaleVal);
|
||||
const top = Math.round(u.bbox.top);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, top);
|
||||
ctx.lineTo(x, Math.round(u.bbox.top + u.bbox.height));
|
||||
ctx.stroke();
|
||||
|
||||
if (threshold.thresholdLabel) {
|
||||
const gap = 6 * uPlot.pxRatio;
|
||||
const textWidth = ctx.measureText(threshold.thresholdLabel).width;
|
||||
const fitsRight = x + gap + textWidth <= u.bbox.left + u.bbox.width;
|
||||
// uPlot leaves the last axis's text alignment on the context.
|
||||
ctx.save();
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(
|
||||
threshold.thresholdLabel,
|
||||
fitsRight ? x + gap : x - gap - textWidth,
|
||||
top + gap,
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const x0 = Math.round(u.bbox.left);
|
||||
const y0 = Math.round(scaleVal);
|
||||
const x1 = Math.round(u.bbox.left + u.bbox.width);
|
||||
const y1 = Math.round(scaleVal);
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0, y0);
|
||||
ctx.lineTo(x1, y1);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { Quadtree } from '../../../utils/quadtree';
|
||||
import {
|
||||
resolveHit,
|
||||
resolvePointDiameter,
|
||||
resolveSizeDomain,
|
||||
} from '../geometry';
|
||||
import { ScatterHit, ScatterPointSize } from '../types';
|
||||
|
||||
const POINT_SIZE: ScatterPointSize = { fixed: 6, min: 4, max: 20 };
|
||||
|
||||
const asData = (columns: unknown[]): uPlot.AlignedData =>
|
||||
columns as unknown as uPlot.AlignedData;
|
||||
|
||||
describe('resolveSizeDomain', () => {
|
||||
it('spans the size columns of every series, skipping nulls', () => {
|
||||
const data = asData([
|
||||
null,
|
||||
[
|
||||
[1, 2],
|
||||
[1, 2],
|
||||
[10, null],
|
||||
],
|
||||
[[3], [3], [40]],
|
||||
]);
|
||||
|
||||
expect(resolveSizeDomain(data)).toStrictEqual({ min: 10, max: 40 });
|
||||
});
|
||||
|
||||
it('is null when no series carries sizes', () => {
|
||||
expect(resolveSizeDomain(asData([null, [[1], [1]]]))).toBeNull();
|
||||
expect(resolveSizeDomain(asData([null, [[1], [1], [null]]]))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePointDiameter', () => {
|
||||
it('uses the fixed diameter without a size or a domain', () => {
|
||||
expect(resolvePointDiameter(null, { min: 0, max: 10 }, POINT_SIZE)).toBe(6);
|
||||
expect(resolvePointDiameter(5, null, POINT_SIZE)).toBe(6);
|
||||
});
|
||||
|
||||
it('maps the domain ends to min and max', () => {
|
||||
const domain = { min: 0, max: 100 };
|
||||
expect(resolvePointDiameter(0, domain, POINT_SIZE)).toBe(4);
|
||||
expect(resolvePointDiameter(100, domain, POINT_SIZE)).toBe(20);
|
||||
});
|
||||
|
||||
it('scales by area, not diameter', () => {
|
||||
const midArea = (4 ** 2 + 20 ** 2) / 2;
|
||||
expect(
|
||||
resolvePointDiameter(50, { min: 0, max: 100 }, POINT_SIZE),
|
||||
).toBeCloseTo(Math.sqrt(midArea));
|
||||
});
|
||||
|
||||
it('clamps values outside the domain', () => {
|
||||
const domain = { min: 10, max: 20 };
|
||||
expect(resolvePointDiameter(-5, domain, POINT_SIZE)).toBe(4);
|
||||
expect(resolvePointDiameter(500, domain, POINT_SIZE)).toBe(20);
|
||||
});
|
||||
|
||||
it('uses the midpoint when every size is the same', () => {
|
||||
expect(resolvePointDiameter(7, { min: 7, max: 7 }, POINT_SIZE)).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHit', () => {
|
||||
const hit = (
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
x: number,
|
||||
y: number,
|
||||
d: number,
|
||||
): ScatterHit => ({ seriesIndex, dataIndex, x, y, w: d, h: d });
|
||||
|
||||
it('returns the disc under the cursor', () => {
|
||||
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
|
||||
tree.add(hit(1, 0, 10, 10, 6));
|
||||
tree.add(hit(2, 3, 50, 50, 6));
|
||||
|
||||
expect(resolveHit(tree, 13, 13, 0)).toMatchObject({
|
||||
seriesIndex: 1,
|
||||
dataIndex: 0,
|
||||
});
|
||||
expect(resolveHit(tree, 52, 52, 0)).toMatchObject({
|
||||
seriesIndex: 2,
|
||||
dataIndex: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('is null when the cursor is off every disc', () => {
|
||||
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
|
||||
tree.add(hit(1, 0, 10, 10, 6));
|
||||
|
||||
expect(resolveHit(tree, 30, 30, 0)).toBeNull();
|
||||
// Inside the disc's bounding square, outside the disc.
|
||||
expect(resolveHit(tree, 15.8, 15.8, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('tolerance widens each disc', () => {
|
||||
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
|
||||
tree.add(hit(1, 0, 10, 10, 6));
|
||||
|
||||
expect(resolveHit(tree, 18, 13, 0)).toBeNull();
|
||||
expect(resolveHit(tree, 18, 13, 3)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the disc whose centre is nearest when they overlap', () => {
|
||||
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
|
||||
tree.add(hit(1, 0, 10, 10, 10));
|
||||
tree.add(hit(1, 1, 14, 10, 10));
|
||||
|
||||
expect(resolveHit(tree, 13, 15, 0)?.dataIndex).toBe(0);
|
||||
expect(resolveHit(tree, 21, 15, 0)?.dataIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { UPlotConfigBuilder } from '../../../config/UPlotConfigBuilder';
|
||||
import {
|
||||
applyScatterPlugin,
|
||||
createScatterPlugin,
|
||||
SCATTER_FACETS,
|
||||
} from '../scatterPlugin';
|
||||
|
||||
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
}));
|
||||
|
||||
/** jsdom has no Path2D; the builder only needs something that takes the calls. */
|
||||
class FakePath2D {
|
||||
moveTo = jest.fn();
|
||||
arc = jest.fn();
|
||||
}
|
||||
|
||||
type OrientCallback = Parameters<typeof uPlot.orient>[2];
|
||||
|
||||
interface FakePlotArgs {
|
||||
series: Array<{ xs: number[]; ys: number[]; sizes?: Array<number | null> }>;
|
||||
cursor?: { left: number; top: number };
|
||||
scaleX?: { min: number; max: number };
|
||||
scaleY?: { min: number; max: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* A 100×100 plot at the canvas origin with identity scales: value 10 draws at
|
||||
* pixel 10 on x, and at 100 − 10 on y (uPlot's y grows downward).
|
||||
*/
|
||||
function createFakePlot({
|
||||
series,
|
||||
cursor = { left: -1, top: -1 },
|
||||
scaleX = { min: 0, max: 100 },
|
||||
scaleY = { min: 0, max: 100 },
|
||||
}: FakePlotArgs): uPlot {
|
||||
const data = [
|
||||
null,
|
||||
...series.map((entry) =>
|
||||
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
|
||||
),
|
||||
];
|
||||
return {
|
||||
data,
|
||||
series: [{}, ...series.map((_, index) => ({ label: `s${index + 1}` }))],
|
||||
bbox: { left: 0, top: 0, width: 100, height: 100 },
|
||||
cursor,
|
||||
scales: { x: scaleX, y: scaleY },
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
/** Stands in for `uPlot.orient`: identity x, flipped y, an `arc` that records. */
|
||||
function orientWithIdentityScales(
|
||||
u: uPlot,
|
||||
seriesIdx: number,
|
||||
cb: OrientCallback,
|
||||
): void {
|
||||
const columns = (u.data as unknown as Array<number[][] | null>)[seriesIdx];
|
||||
if (!columns) {
|
||||
return;
|
||||
}
|
||||
const scaleX = (u.scales as unknown as Record<string, uPlot.Scale>).x;
|
||||
const scaleY = (u.scales as unknown as Record<string, uPlot.Scale>).y;
|
||||
const valToPosX = (value: number): number => value;
|
||||
const valToPosY = (value: number): number => 100 - value;
|
||||
// Real uPlot's `arc` helper forwards to the path; the test counts those calls.
|
||||
const arc = (path: FakePath2D, ...args: number[]): void => {
|
||||
path.arc(...args);
|
||||
};
|
||||
cb(
|
||||
u.series[seriesIdx],
|
||||
columns[0],
|
||||
columns[1],
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX as unknown as uPlot.ValToPos,
|
||||
valToPosY as unknown as uPlot.ValToPos,
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100,
|
||||
jest.fn() as never,
|
||||
jest.fn() as never,
|
||||
jest.fn() as never,
|
||||
arc as never,
|
||||
jest.fn() as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('createScatterPlugin', () => {
|
||||
beforeAll(() => {
|
||||
(globalThis as { Path2D?: unknown }).Path2D = FakePath2D;
|
||||
});
|
||||
|
||||
let orient: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
orient = jest
|
||||
.spyOn(uPlot, 'orient')
|
||||
.mockImplementation(orientWithIdentityScales);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
orient.mockReset();
|
||||
});
|
||||
|
||||
function drawAll(
|
||||
u: uPlot,
|
||||
plugin: ReturnType<typeof createScatterPlugin>,
|
||||
): void {
|
||||
plugin.hooks.drawClear(u);
|
||||
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
|
||||
const columns = (u.data as unknown as number[][][])[seriesIdx];
|
||||
plugin.pathBuilder(u, seriesIdx, 0, columns[0].length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs the cursor scan the way uPlot does: every data series, in order. */
|
||||
function scan(
|
||||
u: uPlot,
|
||||
plugin: ReturnType<typeof createScatterPlugin>,
|
||||
): Array<number | null> {
|
||||
const dataIdx = plugin.cursor.dataIdx as NonNullable<uPlot.Cursor['dataIdx']>;
|
||||
const indexes: Array<number | null> = [null];
|
||||
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
|
||||
indexes.push(dataIdx(u, seriesIdx, 0, 0));
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
it('returns one path that strokes and fills the same discs', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = createFakePlot({ series: [{ xs: [10, 20], ys: [10, 20] }] });
|
||||
plugin.hooks.drawClear(u);
|
||||
|
||||
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
|
||||
|
||||
expect(paths.stroke).toBeInstanceOf(FakePath2D);
|
||||
expect(paths.fill).toBe(paths.stroke);
|
||||
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resolves the hovered point to its own series and index', () => {
|
||||
const plugin = createScatterPlugin({
|
||||
pointSize: { fixed: 6, min: 4, max: 20 },
|
||||
});
|
||||
const u = createFakePlot({
|
||||
series: [
|
||||
{ xs: [10, 50], ys: [10, 50] },
|
||||
{ xs: [80], ys: [80] },
|
||||
],
|
||||
// Over the second series' only point: x 80, y drawn at 100 − 80.
|
||||
cursor: { left: 80, top: 20 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
|
||||
expect(scan(u, plugin)).toStrictEqual([null, null, 0]);
|
||||
expect(plugin.getHit()).toMatchObject({ seriesIndex: 2, dataIndex: 0 });
|
||||
});
|
||||
|
||||
it('returns null for every series when the cursor is off the plot or off any disc', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = createFakePlot({
|
||||
series: [{ xs: [10], ys: [10] }],
|
||||
cursor: { left: -1, top: -1 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
|
||||
expect(scan(u, plugin)).toStrictEqual([null, null]);
|
||||
|
||||
(u.cursor as { left: number; top: number }).left = 50;
|
||||
(u.cursor as { left: number; top: number }).top = 50;
|
||||
expect(scan(u, plugin)).toStrictEqual([null, null]);
|
||||
});
|
||||
|
||||
it('skips points outside the visible scale range', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = createFakePlot({
|
||||
series: [{ xs: [10, 500], ys: [10, 10] }],
|
||||
cursor: { left: 10, top: 90 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
|
||||
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
|
||||
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(1);
|
||||
expect(scan(u, plugin)).toStrictEqual([null, 0]);
|
||||
});
|
||||
|
||||
it('rings the hit disc, in CSS pixels, with a gap around it', () => {
|
||||
const plugin = createScatterPlugin({
|
||||
pointSize: { fixed: 8, min: 4, max: 20 },
|
||||
});
|
||||
const u = createFakePlot({
|
||||
series: [{ xs: [10], ys: [10] }],
|
||||
cursor: { left: 10, top: 90 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
scan(u, plugin);
|
||||
|
||||
const bbox = plugin.cursor.points?.bbox;
|
||||
// The 8px disc spans 6..14; the ring sits 3px out and is 2px wide.
|
||||
expect(bbox?.(u, 1)).toStrictEqual({
|
||||
left: 1,
|
||||
top: 81,
|
||||
width: 18,
|
||||
height: 18,
|
||||
});
|
||||
expect(bbox?.(u, 2)).toMatchObject({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
it('snaps the ring to whole pixels without moving its centre', () => {
|
||||
const plugin = createScatterPlugin({
|
||||
pointSize: { fixed: 7, min: 4, max: 20 },
|
||||
});
|
||||
const u = createFakePlot({
|
||||
series: [{ xs: [10], ys: [10] }],
|
||||
cursor: { left: 10, top: 90 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
scan(u, plugin);
|
||||
|
||||
// The 7px disc starts at 6.5, so the ring's edge would land on 1.5.
|
||||
const ring = plugin.cursor.points?.bbox?.(u, 1);
|
||||
expect(ring).toMatchObject({ left: 1, width: 18 });
|
||||
expect((ring?.left ?? 0) + (ring?.width ?? 0) / 2).toBe(10);
|
||||
});
|
||||
|
||||
it('draws the hover marker as an outline in the series colour', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = {
|
||||
series: [{}, { stroke: (): string => '#E5484D' }, { stroke: '#30A46C' }],
|
||||
} as unknown as uPlot;
|
||||
const points = plugin.cursor.points as {
|
||||
fill: (u: uPlot, seriesIdx: number) => string;
|
||||
stroke: (u: uPlot, seriesIdx: number) => string;
|
||||
width: (u: uPlot, seriesIdx: number, size: number) => number;
|
||||
};
|
||||
|
||||
expect(points.fill(u, 1)).toBe('transparent');
|
||||
expect(points.stroke(u, 1)).toBe('#E5484D');
|
||||
expect(points.stroke(u, 2)).toBe('#30A46C');
|
||||
expect(points.width(u, 1, 0)).toBe(2);
|
||||
});
|
||||
|
||||
it('drawClear drops cached paths on data series only', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = createFakePlot({ series: [{ xs: [1], ys: [1] }] });
|
||||
const [xSeries, dataSeries] = u.series as Array<{ _paths?: unknown }>;
|
||||
xSeries._paths = 'x';
|
||||
dataSeries._paths = 'cached';
|
||||
|
||||
plugin.hooks.drawClear(u);
|
||||
|
||||
expect(xSeries._paths).toBe('x');
|
||||
expect(dataSeries._paths).toBeNull();
|
||||
});
|
||||
|
||||
it('focus distance is zero, so the hit series wins focus', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
expect(plugin.cursor.focus?.dist?.({} as uPlot, 1, 0, 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyScatterPlugin', () => {
|
||||
it('switches the builder to faceted mode and disables drag selection', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'scatter' });
|
||||
const plugin = createScatterPlugin();
|
||||
|
||||
applyScatterPlugin(builder, plugin);
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.mode).toBe(2);
|
||||
expect(config.cursor?.drag).toMatchObject({
|
||||
x: false,
|
||||
y: false,
|
||||
setScale: false,
|
||||
});
|
||||
expect(config.hooks?.drawClear).toHaveLength(1);
|
||||
expect(config.hooks?.destroy).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('facets read x and y against the shared scales', () => {
|
||||
expect(SCATTER_FACETS).toStrictEqual([
|
||||
{ scale: 'x', auto: true },
|
||||
{ scale: 'y', auto: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
89
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/geometry.ts
Normal file
89
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/geometry.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { Quadtree } from '../../utils/quadtree';
|
||||
import type { ScatterHit, ScatterPointSize, ScatterSeriesData } from './types';
|
||||
|
||||
export interface SizeDomain {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/** Size extent across all series, so equal values draw equal discs. */
|
||||
export function resolveSizeDomain(data: uPlot.AlignedData): SizeDomain | null {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (let seriesIndex = 1; seriesIndex < data.length; seriesIndex++) {
|
||||
const sizes = (data[seriesIndex] as unknown as ScatterSeriesData)[2];
|
||||
if (!sizes) {
|
||||
continue;
|
||||
}
|
||||
for (const size of sizes) {
|
||||
if (size == null || !Number.isFinite(size)) {
|
||||
continue;
|
||||
}
|
||||
min = Math.min(min, size);
|
||||
max = Math.max(max, size);
|
||||
}
|
||||
}
|
||||
return min <= max ? { min, max } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diameter for a size-channel value. Grows the disc's area, not its width,
|
||||
* linearly with the value, so a value twice as large reads as twice the ink.
|
||||
*/
|
||||
export function resolvePointDiameter(
|
||||
size: number | null | undefined,
|
||||
domain: SizeDomain | null,
|
||||
pointSize: ScatterPointSize,
|
||||
): number {
|
||||
if (size == null || domain == null || !Number.isFinite(size)) {
|
||||
return pointSize.fixed;
|
||||
}
|
||||
if (domain.max === domain.min) {
|
||||
return (pointSize.min + pointSize.max) / 2;
|
||||
}
|
||||
const t = Math.min(
|
||||
1,
|
||||
Math.max(0, (size - domain.min) / (domain.max - domain.min)),
|
||||
);
|
||||
const minArea = pointSize.min ** 2;
|
||||
const maxArea = pointSize.max ** 2;
|
||||
return Math.sqrt(minArea + (maxArea - minArea) * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* The disc under the cursor, counting `tolerance` px beyond its edge as a hit.
|
||||
* Where discs overlap, the one whose centre is closest wins; `null` if none.
|
||||
*/
|
||||
export function resolveHit(
|
||||
tree: Quadtree<ScatterHit>,
|
||||
cx: number,
|
||||
cy: number,
|
||||
tolerance: number,
|
||||
): ScatterHit | null {
|
||||
let best: ScatterHit | null = null;
|
||||
let bestDistance = Infinity;
|
||||
|
||||
tree.get(
|
||||
cx - tolerance,
|
||||
cy - tolerance,
|
||||
tolerance * 2,
|
||||
tolerance * 2,
|
||||
(hit) => {
|
||||
const dx = cx - (hit.x + hit.w / 2);
|
||||
const dy = cy - (hit.y + hit.h / 2);
|
||||
const distance = dx * dx + dy * dy;
|
||||
const reach = hit.w / 2 + tolerance;
|
||||
if (distance > reach * reach) {
|
||||
return;
|
||||
}
|
||||
if (distance < bestDistance) {
|
||||
best = hit;
|
||||
bestDistance = distance;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return best;
|
||||
}
|
||||
225
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin.ts
Normal file
225
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import uPlot, { Series } from 'uplot';
|
||||
|
||||
import { DEFAULT_FOCUS_PROXIMITY_VALUE } from '../../constants';
|
||||
import { PlotMode } from '../../config/types';
|
||||
import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
|
||||
import { Quadtree } from '../../utils/quadtree';
|
||||
import {
|
||||
resolveHit,
|
||||
resolvePointDiameter,
|
||||
resolveSizeDomain,
|
||||
SizeDomain,
|
||||
} from './geometry';
|
||||
import {
|
||||
DEFAULT_HOVER_TOLERANCE_PX,
|
||||
DEFAULT_SCATTER_POINT_SIZE,
|
||||
ScatterHit,
|
||||
ScatterPluginOptions,
|
||||
ScatterSeriesData,
|
||||
} from './types';
|
||||
|
||||
export const SCATTER_FACETS: Series.Facet[] = [
|
||||
{ scale: 'x', auto: true },
|
||||
{ scale: 'y', auto: true },
|
||||
];
|
||||
|
||||
const HIDDEN_BBOX: uPlot.BBox = { left: -10, top: -10, width: 0, height: 0 };
|
||||
|
||||
const TWO_PI = 2 * Math.PI;
|
||||
|
||||
/** CSS pixels between a hovered disc and the outline ring drawn around it. */
|
||||
const HOVER_RING_GAP_PX = 3;
|
||||
const HOVER_RING_WIDTH_PX = 2;
|
||||
|
||||
/** uPlot ceils a cursor point's left/top but not its size; snap so the ring stays centred. */
|
||||
function snapToPixel(start: number, length: number): [number, number] {
|
||||
const snapped = Math.floor(start);
|
||||
return [snapped, length + (start - snapped) * 2];
|
||||
}
|
||||
|
||||
export function getSeriesStroke(u: uPlot, seriesIdx: number): string {
|
||||
const { stroke } = u.series[seriesIdx];
|
||||
const color = typeof stroke === 'function' ? stroke(u, seriesIdx) : stroke;
|
||||
return typeof color === 'string' ? color : '';
|
||||
}
|
||||
|
||||
/** uPlot caches built paths on the series; the field is internal to it. */
|
||||
type SeriesWithPaths = Series & { _paths?: Series.Paths | null };
|
||||
|
||||
export interface ScatterPlugin {
|
||||
pathBuilder: Series.PathBuilder;
|
||||
/** Hover by disc rather than by nearest x: mode 2 has no shared x to scan. */
|
||||
cursor: uPlot.Cursor;
|
||||
hooks: {
|
||||
drawClear: (u: uPlot) => void;
|
||||
destroy: (u: uPlot) => void;
|
||||
};
|
||||
getHit: () => ScatterHit | null;
|
||||
}
|
||||
|
||||
export function createScatterPlugin({
|
||||
pointSize = DEFAULT_SCATTER_POINT_SIZE,
|
||||
hoverTolerance = DEFAULT_HOVER_TOLERANCE_PX,
|
||||
}: ScatterPluginOptions = {}): ScatterPlugin {
|
||||
let tree: Quadtree<ScatterHit> | null = null;
|
||||
let hit: ScatterHit | null = null;
|
||||
|
||||
// Spans every series; cached per dataset.
|
||||
let cachedData: uPlot.AlignedData | null = null;
|
||||
let cachedDomain: SizeDomain | null = null;
|
||||
|
||||
function getSizeDomain(u: uPlot): SizeDomain | null {
|
||||
if (cachedData !== u.data) {
|
||||
cachedDomain = resolveSizeDomain(u.data);
|
||||
cachedData = u.data;
|
||||
}
|
||||
return cachedDomain;
|
||||
}
|
||||
|
||||
const pathBuilder: Series.PathBuilder = (u, seriesIdx, idx0, idx1) => {
|
||||
const path = new Path2D();
|
||||
const sizes = (u.data[seriesIdx] as unknown as ScatterSeriesData)[2];
|
||||
const domain = getSizeDomain(u);
|
||||
const { pxRatio } = uPlot;
|
||||
|
||||
uPlot.orient(
|
||||
u,
|
||||
seriesIdx,
|
||||
(
|
||||
_series,
|
||||
dataX,
|
||||
dataY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX,
|
||||
valToPosY,
|
||||
xOff,
|
||||
yOff,
|
||||
xDim,
|
||||
yDim,
|
||||
_moveTo,
|
||||
_lineTo,
|
||||
_rect,
|
||||
arc,
|
||||
) => {
|
||||
const xMin = scaleX.min ?? -Infinity;
|
||||
const xMax = scaleX.max ?? Infinity;
|
||||
const yMin = scaleY.min ?? -Infinity;
|
||||
const yMax = scaleY.max ?? Infinity;
|
||||
|
||||
for (let i = idx0; i <= idx1; i++) {
|
||||
const x = dataX[i];
|
||||
const y = dataY[i];
|
||||
if (
|
||||
x == null ||
|
||||
y == null ||
|
||||
x < xMin ||
|
||||
x > xMax ||
|
||||
y < yMin ||
|
||||
y > yMax
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const diameter =
|
||||
resolvePointDiameter(sizes?.[i], domain, pointSize) * pxRatio;
|
||||
const radius = diameter / 2;
|
||||
const cx = valToPosX(x, scaleX, xDim, xOff);
|
||||
const cy = valToPosY(y, scaleY, yDim, yOff);
|
||||
|
||||
path.moveTo(cx + radius, cy);
|
||||
arc(path, cx, cy, radius, 0, TWO_PI);
|
||||
|
||||
tree?.add({
|
||||
x: cx - radius - u.bbox.left,
|
||||
y: cy - radius - u.bbox.top,
|
||||
w: diameter,
|
||||
h: diameter,
|
||||
seriesIndex: seriesIdx,
|
||||
dataIndex: i,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return { stroke: path, fill: path, clip: null };
|
||||
};
|
||||
|
||||
const cursor: uPlot.Cursor = {
|
||||
// Selection would set the dashboard time range; neither axis is time here.
|
||||
drag: { x: false, y: false, setScale: false },
|
||||
dataIdx: (u, seriesIdx): number | null => {
|
||||
// uPlot asks series 1..n in order on every cursor move; resolve once.
|
||||
if (seriesIdx === 1) {
|
||||
const { left = -1, top = -1 } = u.cursor;
|
||||
const { pxRatio } = uPlot;
|
||||
hit =
|
||||
tree && left >= 0 && top >= 0
|
||||
? resolveHit(
|
||||
tree,
|
||||
left * pxRatio,
|
||||
top * pxRatio,
|
||||
hoverTolerance * pxRatio,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
return hit?.seriesIndex === seriesIdx ? hit.dataIndex : null;
|
||||
},
|
||||
points: {
|
||||
bbox: (_u, seriesIdx): uPlot.BBox => {
|
||||
if (hit?.seriesIndex !== seriesIdx) {
|
||||
return HIDDEN_BBOX;
|
||||
}
|
||||
const { pxRatio } = uPlot;
|
||||
const inset = HOVER_RING_GAP_PX + HOVER_RING_WIDTH_PX;
|
||||
const [left, width] = snapToPixel(
|
||||
hit.x / pxRatio - inset,
|
||||
hit.w / pxRatio + inset * 2,
|
||||
);
|
||||
const [top, height] = snapToPixel(
|
||||
hit.y / pxRatio - inset,
|
||||
hit.h / pxRatio + inset * 2,
|
||||
);
|
||||
return { left, top, width, height };
|
||||
},
|
||||
fill: (): string => 'transparent',
|
||||
stroke: getSeriesStroke,
|
||||
width: (): number => HOVER_RING_WIDTH_PX,
|
||||
},
|
||||
// uPlot only measures series that returned a data index, i.e. the hit one.
|
||||
focus: { prox: DEFAULT_FOCUS_PROXIMITY_VALUE, dist: (): number => 0 },
|
||||
};
|
||||
|
||||
return {
|
||||
pathBuilder,
|
||||
cursor,
|
||||
hooks: {
|
||||
drawClear: (u: uPlot): void => {
|
||||
tree = new Quadtree<ScatterHit>(0, 0, u.bbox.width, u.bbox.height);
|
||||
// The tree holds only what the path builder drew, so force a rebuild.
|
||||
u.series.forEach((series, index) => {
|
||||
if (index > 0) {
|
||||
(series as SeriesWithPaths)._paths = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
destroy: (): void => {
|
||||
tree = null;
|
||||
hit = null;
|
||||
cachedData = null;
|
||||
cachedDomain = null;
|
||||
},
|
||||
},
|
||||
getHit: (): ScatterHit | null => hit,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyScatterPlugin(
|
||||
builder: UPlotConfigBuilder,
|
||||
plugin: ScatterPlugin,
|
||||
): void {
|
||||
builder.setMode(PlotMode.Faceted);
|
||||
builder.setCursor(plugin.cursor);
|
||||
builder.addHook('drawClear', plugin.hooks.drawClear);
|
||||
builder.addHook('destroy', plugin.hooks.destroy);
|
||||
}
|
||||
54
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/types.ts
Normal file
54
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { QuadtreeRect } from '../../utils/quadtree';
|
||||
|
||||
/** Diameters in CSS pixels. `min`/`max` bound the area scale when a size column is mapped. */
|
||||
export interface ScatterPointSize {
|
||||
fixed: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SCATTER_POINT_SIZE: ScatterPointSize = {
|
||||
fixed: 6,
|
||||
min: 4,
|
||||
max: 24,
|
||||
};
|
||||
|
||||
/** CSS pixels around a point's disc that still register as a hover. */
|
||||
export const DEFAULT_HOVER_TOLERANCE_PX = 3;
|
||||
|
||||
export interface ScatterPluginOptions {
|
||||
pointSize?: ScatterPointSize;
|
||||
hoverTolerance?: number;
|
||||
}
|
||||
|
||||
/** Parallel columns; a `null` size draws at `fixed`. */
|
||||
export type ScatterSeriesData = [
|
||||
xs: number[],
|
||||
ys: number[],
|
||||
sizes?: Array<number | null>,
|
||||
];
|
||||
|
||||
/** Mode-2 data: series 0 is uPlot's x placeholder and carries nothing. */
|
||||
export type ScatterChartData = [null, ...ScatterSeriesData[]];
|
||||
|
||||
/** A drawn point's disc, in canvas pixels relative to the plot area. */
|
||||
export interface ScatterHit extends QuadtreeRect {
|
||||
seriesIndex: number;
|
||||
dataIndex: number;
|
||||
}
|
||||
|
||||
export interface ScatterChannel {
|
||||
label: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface ScatterChannels {
|
||||
x: ScatterChannel;
|
||||
y: ScatterChannel;
|
||||
size?: ScatterChannel;
|
||||
}
|
||||
|
||||
export interface ScatterPointLabel {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { buildClickData } from '../utils';
|
||||
|
||||
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
|
||||
getFocusedSeriesAtPosition: jest.fn(() => ({ seriesIndex: 1 })),
|
||||
}));
|
||||
|
||||
const event = {
|
||||
offsetX: 10,
|
||||
offsetY: 20,
|
||||
clientX: 110,
|
||||
clientY: 220,
|
||||
} as MouseEvent;
|
||||
|
||||
describe('buildClickData', () => {
|
||||
it('skips the x-index lookups on a faceted plot, which has no shared x series', () => {
|
||||
const posToIdx = jest.fn(() => {
|
||||
throw new Error('faceted data[0] is null');
|
||||
});
|
||||
const plot = {
|
||||
data: [null],
|
||||
posToVal: (pos: number): number => pos * 2,
|
||||
posToIdx,
|
||||
} as unknown as uPlot;
|
||||
|
||||
expect(buildClickData(event, plot)).toStrictEqual({
|
||||
xValue: 20,
|
||||
yValue: 40,
|
||||
focusedSeries: null,
|
||||
clickedDataTimestamp: 20,
|
||||
mouseX: 10,
|
||||
mouseY: 20,
|
||||
absoluteMouseX: 110,
|
||||
absoluteMouseY: 220,
|
||||
});
|
||||
expect(posToIdx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves the focused series and clicked timestamp on an aligned plot', () => {
|
||||
const plot = {
|
||||
data: [[100, 200, 300]],
|
||||
posToVal: (pos: number): number => pos,
|
||||
posToIdx: (): number => 1,
|
||||
} as unknown as uPlot;
|
||||
|
||||
expect(buildClickData(event, plot)).toMatchObject({
|
||||
focusedSeries: { seriesIndex: 1 },
|
||||
clickedDataTimestamp: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -174,6 +174,24 @@ export function createLayoutObserver(
|
||||
export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
|
||||
const xValue = plot.posToVal(event.offsetX, 'x');
|
||||
const yValue = plot.posToVal(event.offsetY, 'y');
|
||||
const pointer = {
|
||||
mouseX: event.offsetX,
|
||||
mouseY: event.offsetY,
|
||||
absoluteMouseX: event.clientX,
|
||||
absoluteMouseY: event.clientY,
|
||||
};
|
||||
|
||||
// Faceted (scatter): no shared x, so `posToIdx` would throw.
|
||||
if (plot.data[0] == null) {
|
||||
return {
|
||||
xValue,
|
||||
yValue,
|
||||
focusedSeries: null,
|
||||
clickedDataTimestamp: xValue,
|
||||
...pointer,
|
||||
};
|
||||
}
|
||||
|
||||
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
|
||||
|
||||
const dataIndex = plot.posToIdx(event.offsetX);
|
||||
@@ -193,9 +211,6 @@ export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
|
||||
yValue,
|
||||
focusedSeries,
|
||||
clickedDataTimestamp,
|
||||
mouseX: event.offsetX,
|
||||
mouseY: event.offsetY,
|
||||
absoluteMouseX: event.clientX,
|
||||
absoluteMouseY: event.clientY,
|
||||
...pointer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { logScaleSplits, spacedLogLabels } from '../logGridSplits';
|
||||
|
||||
describe('logScaleSplits', () => {
|
||||
const splitsFor = (min: number, max: number): number[] =>
|
||||
(
|
||||
logScaleSplits as (
|
||||
u: uPlot,
|
||||
axisIdx: number,
|
||||
min: number,
|
||||
max: number,
|
||||
) => number[]
|
||||
)({} as uPlot, 0, min, max);
|
||||
|
||||
it('splits at 1, 2 and 5 × 10ⁿ inside an unsnapped range', () => {
|
||||
expect(splitsFor(10.6, 990)).toStrictEqual([20, 50, 100, 200, 500]);
|
||||
});
|
||||
|
||||
it('includes the ends when they fall on a split', () => {
|
||||
expect(splitsFor(0.1, 10)).toStrictEqual([0.1, 0.2, 0.5, 1, 2, 5, 10]);
|
||||
});
|
||||
|
||||
it('steps evenly through a range too narrow for two 1/2/5 splits', () => {
|
||||
expect(splitsFor(7.6, 9.4)).toStrictEqual([8, 9]);
|
||||
expect(splitsFor(8.12, 8.19)).toStrictEqual([
|
||||
8.12, 8.13, 8.14, 8.15, 8.16, 8.17, 8.18, 8.19,
|
||||
]);
|
||||
});
|
||||
|
||||
it('is empty for a range a log scale cannot hold', () => {
|
||||
expect(splitsFor(0, 10)).toStrictEqual([]);
|
||||
expect(splitsFor(5, 5)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spacedLogLabels', () => {
|
||||
// 100px per decade.
|
||||
const plot = {
|
||||
axes: [{ scale: 'x' }],
|
||||
valToPos: (value: number): number => Math.log10(value) * 100,
|
||||
} as unknown as uPlot;
|
||||
const label = (splits: number[], space: number): (number | null)[] =>
|
||||
spacedLogLabels(plot, splits, 0, space, 0) as (number | null)[];
|
||||
|
||||
it('always labels the powers of ten', () => {
|
||||
expect(label([10, 20, 50, 100], 200)).toStrictEqual([10, null, null, 100]);
|
||||
});
|
||||
|
||||
it('adds a 2 or 5 that clears the labels already kept', () => {
|
||||
// 20 is 30px from 10; 50 is 70px from 10 and 30px from 100.
|
||||
expect(label([10, 20, 50, 100], 25)).toStrictEqual([10, 20, 50, 100]);
|
||||
expect(label([10, 20, 50, 100], 40)).toStrictEqual([10, null, null, 100]);
|
||||
});
|
||||
|
||||
it('labels both ends of a range spanning under a decade', () => {
|
||||
// 100 sits at 200px; 20 (130px) and 500 (270px) clear it, 50 and 200 do not.
|
||||
expect(label([20, 50, 100, 200, 500], 60)).toStrictEqual([
|
||||
20,
|
||||
null,
|
||||
100,
|
||||
null,
|
||||
500,
|
||||
]);
|
||||
});
|
||||
});
|
||||
64
frontend/src/lib/uPlotV2/utils/__tests__/quadtree.test.ts
Normal file
64
frontend/src/lib/uPlotV2/utils/__tests__/quadtree.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Quadtree, QuadtreeRect } from '../quadtree';
|
||||
|
||||
interface Item extends QuadtreeRect {
|
||||
id: number;
|
||||
}
|
||||
|
||||
function collect(
|
||||
tree: Quadtree<Item>,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
tree.get(x, y, w, h, (item) => ids.add(item.id));
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe('Quadtree', () => {
|
||||
it('returns items in the queried region and not those far from it', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
tree.add({ id: 1, x: 10, y: 10, w: 5, h: 5 });
|
||||
tree.add({ id: 2, x: 80, y: 80, w: 5, h: 5 });
|
||||
|
||||
// Below the split threshold every item is visited; callers refine the hit.
|
||||
expect(collect(tree, 9, 9, 8, 8)).toStrictEqual(new Set([1, 2]));
|
||||
});
|
||||
|
||||
it('splits past the object limit and still finds every item', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
const total = 50;
|
||||
for (let id = 0; id < total; id++) {
|
||||
tree.add({ id, x: (id % 10) * 10, y: Math.floor(id / 10) * 10, w: 4, h: 4 });
|
||||
}
|
||||
|
||||
expect(collect(tree, 0, 0, 100, 100).size).toBe(total);
|
||||
});
|
||||
|
||||
it('after a split, a query in one quadrant skips items confined to another', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
for (let id = 0; id < 20; id++) {
|
||||
// All in the north-west quadrant.
|
||||
tree.add({ id, x: 1 + id, y: 1, w: 2, h: 2 });
|
||||
}
|
||||
tree.add({ id: 99, x: 90, y: 90, w: 2, h: 2 });
|
||||
|
||||
const northWest = collect(tree, 0, 0, 10, 10);
|
||||
expect(northWest.has(99)).toBe(false);
|
||||
expect(collect(tree, 85, 85, 10, 10).has(99)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports an item straddling the midline from either side', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
for (let id = 0; id < 20; id++) {
|
||||
tree.add({ id, x: 1, y: 1 + id, w: 2, h: 2 });
|
||||
}
|
||||
tree.add({ id: 99, x: 48, y: 48, w: 4, h: 4 });
|
||||
|
||||
expect(collect(tree, 40, 40, 5, 5).has(99)).toBe(true);
|
||||
expect(collect(tree, 55, 55, 5, 5).has(99)).toBe(true);
|
||||
// A zero-size query exactly on both midlines.
|
||||
expect(collect(tree, 50, 50, 0, 0).has(99)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -195,3 +195,22 @@ describe('scale utils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('symmetric log scale', () => {
|
||||
it('maps to uPlot arcsinh with the given linear threshold', () => {
|
||||
expect(
|
||||
scaleUtils.getDistributionConfig({
|
||||
time: false,
|
||||
distr: DistributionType.SymmetricLog,
|
||||
asinhThreshold: 0.01,
|
||||
}),
|
||||
).toStrictEqual({ distr: 4, log: 10, asinh: 0.01 });
|
||||
|
||||
expect(
|
||||
scaleUtils.getDistributionConfig({
|
||||
time: false,
|
||||
distr: DistributionType.SymmetricLog,
|
||||
}).asinh,
|
||||
).toBe(scaleUtils.DEFAULT_ASINH_THRESHOLD);
|
||||
});
|
||||
});
|
||||
|
||||
92
frontend/src/lib/uPlotV2/utils/logGridSplits.ts
Normal file
92
frontend/src/lib/uPlotV2/utils/logGridSplits.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
const DECADE_TOLERANCE = 1e-9;
|
||||
|
||||
function isDecade(value: number): boolean {
|
||||
if (value === 0) {
|
||||
return true;
|
||||
}
|
||||
const exponent = Math.log10(Math.abs(value));
|
||||
return Math.abs(exponent - Math.round(exponent)) < DECADE_TOLERANCE;
|
||||
}
|
||||
|
||||
const LOG_SPLIT_MULTIPLES = [1, 2, 5];
|
||||
|
||||
/** Decades finer than the range's own span that the fallback steps through. */
|
||||
const FALLBACK_STEP_DECADES = 3;
|
||||
|
||||
/** Rounded through toPrecision so 0.1 × 3 noise can't fall outside [min, max]. */
|
||||
function roundSplit(value: number): number {
|
||||
return Number(value.toPrecision(12));
|
||||
}
|
||||
|
||||
/** Evenly stepped splits for a range too narrow to hold two 1/2/5 splits. */
|
||||
function narrowRangeSplits(min: number, max: number): number[] {
|
||||
const coarsest = Math.floor(Math.log10(max - min));
|
||||
for (
|
||||
let exponent = coarsest;
|
||||
exponent > coarsest - FALLBACK_STEP_DECADES;
|
||||
exponent--
|
||||
) {
|
||||
const step = 10 ** exponent;
|
||||
const first = Math.ceil(roundSplit(min / step));
|
||||
const last = Math.floor(roundSplit(max / step));
|
||||
if (last > first) {
|
||||
return Array.from({ length: last - first + 1 }, (_, index) =>
|
||||
roundSplit((first + index) * step),
|
||||
);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 1, 2, 5 × 10ⁿ in range; uPlot's log splits step from an unsnapped min (10.79, 21.58…). */
|
||||
export const logScaleSplits: uPlot.Axis.Splits = (_u, _axisIdx, min, max) => {
|
||||
if (!(min > 0) || !(max > min)) {
|
||||
return [];
|
||||
}
|
||||
const splits: number[] = [];
|
||||
for (
|
||||
let exponent = Math.floor(Math.log10(min));
|
||||
exponent <= Math.ceil(Math.log10(max));
|
||||
exponent++
|
||||
) {
|
||||
LOG_SPLIT_MULTIPLES.forEach((multiple) => {
|
||||
const split = roundSplit(multiple * 10 ** exponent);
|
||||
if (split >= min && split <= max) {
|
||||
splits.push(split);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (splits.length >= 2) {
|
||||
return splits;
|
||||
}
|
||||
const narrow = narrowRangeSplits(min, max);
|
||||
return narrow.length > splits.length ? narrow : splits;
|
||||
};
|
||||
|
||||
/** Labels every decade, plus any 2 or 5 at least `space` px clear. */
|
||||
export const spacedLogLabels: uPlot.Axis.Filter = (
|
||||
u,
|
||||
splits,
|
||||
axisIdx,
|
||||
space,
|
||||
) => {
|
||||
const scaleKey = u.axes[axisIdx]?.scale ?? 'x';
|
||||
const positions = splits.map((split) =>
|
||||
split == null ? null : u.valToPos(split, scaleKey),
|
||||
);
|
||||
const kept = splits.map((split) => split != null && isDecade(split));
|
||||
const isClear = (index: number): boolean =>
|
||||
kept.every(
|
||||
(isKept, other) =>
|
||||
!isKept ||
|
||||
Math.abs((positions[other] ?? 0) - (positions[index] ?? 0)) >= space,
|
||||
);
|
||||
splits.forEach((split, index) => {
|
||||
if (split != null && !kept[index] && isClear(index)) {
|
||||
kept[index] = true;
|
||||
}
|
||||
});
|
||||
return splits.map((split, index) => (kept[index] ? split : null));
|
||||
};
|
||||
101
frontend/src/lib/uPlotV2/utils/quadtree.ts
Normal file
101
frontend/src/lib/uPlotV2/utils/quadtree.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
export interface QuadtreeRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
const MAX_OBJECTS = 10;
|
||||
const MAX_LEVELS = 4;
|
||||
|
||||
/** Rect index for cursor hit-testing; items straddling quadrants are stored in each, so `get` may repeat them. */
|
||||
export class Quadtree<T extends QuadtreeRect = QuadtreeRect> {
|
||||
private items: T[] = [];
|
||||
private quadrants: Quadtree<T>[] | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly x: number,
|
||||
private readonly y: number,
|
||||
private readonly w: number,
|
||||
private readonly h: number,
|
||||
private readonly level = 0,
|
||||
) {}
|
||||
|
||||
add(item: T): void {
|
||||
if (this.quadrants) {
|
||||
this.forEachQuadrant(item, (quadrant) => quadrant.add(item));
|
||||
return;
|
||||
}
|
||||
|
||||
this.items.push(item);
|
||||
|
||||
if (this.items.length > MAX_OBJECTS && this.level < MAX_LEVELS) {
|
||||
this.split();
|
||||
const items = this.items;
|
||||
this.items = [];
|
||||
for (const existing of items) {
|
||||
this.forEachQuadrant(existing, (quadrant) => quadrant.add(existing));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Visits every item whose quadrant overlaps the rectangle; callers refine the test. */
|
||||
get(
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
visit: (item: T) => void,
|
||||
): void {
|
||||
for (const item of this.items) {
|
||||
visit(item);
|
||||
}
|
||||
if (this.quadrants) {
|
||||
this.forEachQuadrant({ x, y, w, h }, (quadrant) =>
|
||||
quadrant.get(x, y, w, h, visit),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private split(): void {
|
||||
const w = this.w / 2;
|
||||
const h = this.h / 2;
|
||||
const level = this.level + 1;
|
||||
// North-east, north-west, south-west, south-east.
|
||||
this.quadrants = [
|
||||
new Quadtree<T>(this.x + w, this.y, w, h, level),
|
||||
new Quadtree<T>(this.x, this.y, w, h, level),
|
||||
new Quadtree<T>(this.x, this.y + h, w, h, level),
|
||||
new Quadtree<T>(this.x + w, this.y + h, w, h, level),
|
||||
];
|
||||
}
|
||||
|
||||
private forEachQuadrant(
|
||||
rect: QuadtreeRect,
|
||||
visit: (quadrant: Quadtree<T>) => void,
|
||||
): void {
|
||||
if (!this.quadrants) {
|
||||
return;
|
||||
}
|
||||
const midX = this.x + this.w / 2;
|
||||
const midY = this.y + this.h / 2;
|
||||
const startsNorth = rect.y < midY;
|
||||
const startsWest = rect.x < midX;
|
||||
// Inclusive, so a zero-size query on a midline still reaches a quadrant.
|
||||
const endsEast = rect.x + rect.w >= midX;
|
||||
const endsSouth = rect.y + rect.h >= midY;
|
||||
|
||||
if (startsNorth && endsEast) {
|
||||
visit(this.quadrants[0]);
|
||||
}
|
||||
if (startsWest && startsNorth) {
|
||||
visit(this.quadrants[1]);
|
||||
}
|
||||
if (startsWest && endsSouth) {
|
||||
visit(this.quadrants[2]);
|
||||
}
|
||||
if (endsEast && endsSouth) {
|
||||
visit(this.quadrants[3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,18 +58,23 @@ function normalizeLogLimit(
|
||||
return logBase ** exp;
|
||||
}
|
||||
|
||||
export const DEFAULT_ASINH_THRESHOLD = 1;
|
||||
|
||||
/**
|
||||
* Returns uPlot scale distribution options for the Y axis.
|
||||
* Time (X) scale gets no distr/log; Y scale gets distr 1 (linear) or 3 (log) and log base 2 or 10.
|
||||
* Returns uPlot scale distribution options for a value axis.
|
||||
* Time scales get no distr/log; value scales get distr 1 (linear), 3 (log) or
|
||||
* 4 (arcsinh, uPlot's symmetric log) and log base 2 or 10.
|
||||
*/
|
||||
export function getDistributionConfig({
|
||||
time,
|
||||
distr,
|
||||
logBase,
|
||||
asinhThreshold,
|
||||
}: {
|
||||
time: ScaleProps['time'];
|
||||
distr?: DistributionType;
|
||||
logBase?: number;
|
||||
asinhThreshold?: number;
|
||||
}): Partial<Scale> {
|
||||
if (time) {
|
||||
return {};
|
||||
@@ -77,6 +82,14 @@ export function getDistributionConfig({
|
||||
|
||||
const resolvedLogBase = (logBase ?? 10) === 2 ? 2 : 10;
|
||||
|
||||
if (distr === DistributionType.SymmetricLog) {
|
||||
return {
|
||||
distr: 4,
|
||||
log: resolvedLogBase,
|
||||
asinh: asinhThreshold ?? DEFAULT_ASINH_THRESHOLD,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
distr: distr === DistributionType.Logarithmic ? 3 : 1,
|
||||
log: resolvedLogBase,
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function ChartWrapper({
|
||||
syncKey,
|
||||
syncFilterMode,
|
||||
onDestroy = noop,
|
||||
plotRef,
|
||||
children,
|
||||
layoutChildren,
|
||||
yAxisUnit,
|
||||
@@ -123,6 +124,7 @@ export default function ChartWrapper({
|
||||
height={chartHeight}
|
||||
plotRef={(plot): void => {
|
||||
plotInstanceRef.current = plot;
|
||||
plotRef?.(plot);
|
||||
}}
|
||||
onDestroy={(plot: uPlot): void => {
|
||||
plotInstanceRef.current = null;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user