Compare commits
21 Commits
fix/error-
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2c0b50e92 | ||
|
|
62ba753c64 | ||
|
|
706c233958 | ||
|
|
8cac5d3ed4 | ||
|
|
e28ae2acb5 | ||
|
|
5e970c0d3c | ||
|
|
14a792f26e | ||
|
|
17c84c1ac1 | ||
|
|
74f92acd0e | ||
|
|
d6eddcd7f3 | ||
|
|
bf77b5cb5b | ||
|
|
9c85e798be | ||
|
|
ed901c884e | ||
|
|
e8c4f12a21 | ||
|
|
f8149cf42b | ||
|
|
967bed31ca | ||
|
|
12c5e4fd87 | ||
|
|
48aecdd2fe | ||
|
|
476a163c5e | ||
|
|
5a463a25a0 | ||
|
|
55541e810d |
@@ -1,142 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,114 +0,0 @@
|
||||
# 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>/`.
|
||||
@@ -1,155 +0,0 @@
|
||||
# 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`.
|
||||
@@ -14,7 +14,7 @@ Grep recipes, run against the page's directories:
|
||||
| endpoint URLs | the api module's `axios.get\|post` |
|
||||
| endpoint URLs behind a generated hook | the hook lives in `src/api/generated/services/<name>/index.ts` and the URL only appears in the fetcher body: `rg 'url: \`' src/api/generated/services/<name>/` |
|
||||
| url state | `useUrlQuery\|useUrlQueryData\|useUrlSearchState\|useQueryState\|QueryParams\.` |
|
||||
| navigation | `useSafeNavigate\|history.push\|<Link` |
|
||||
| navigation | `useSafeNavigate\|useAppNavigate\|navigate\|<AppLink` |
|
||||
| permissions | `useAuthZ\|AuthZGuard\|AuthZButton\|hasEditPermission\|routePermission` |
|
||||
| flags and prefs | `useFeatureFlag\|FeatureKeys\.\|USER_PREFERENCES\.\|userPreferences` |
|
||||
| empty and error branches | `isLoading\|isError\|isFetching\|length === 0\|!data` |
|
||||
|
||||
3758
docs/api/openapi.yml
@@ -7,66 +7,24 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
)
|
||||
|
||||
type gcpcloudprovider struct {
|
||||
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
|
||||
}
|
||||
|
||||
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
|
||||
return &gcpcloudprovider{
|
||||
serviceDefinitions: defStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) BuildIntegrationConfig(
|
||||
ctx context.Context,
|
||||
account *cloudintegrationtypes.Account,
|
||||
services []*cloudintegrationtypes.StorableCloudIntegrationService,
|
||||
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
|
||||
}
|
||||
|
||||
var metricNames []string
|
||||
var sinkFilters []string
|
||||
|
||||
for _, storedSvc := range services {
|
||||
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cloudintegrationtypes.CloudProviderTypeGCP, storedSvc.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
svcDef, err := g.GetServiceDefinition(ctx, storedSvc.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if svcDef.TelemetryCollectionStrategy.GCP == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
strategy := svcDef.TelemetryCollectionStrategy.GCP
|
||||
if svcCfg.IsMetricsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Metrics != nil {
|
||||
metricNames = append(metricNames, strategy.Metrics.MetricNames...)
|
||||
}
|
||||
|
||||
if svcCfg.IsLogsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Logs != nil {
|
||||
sinkFilters = append(sinkFilters, strategy.Logs.SinkFilter)
|
||||
}
|
||||
}
|
||||
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{
|
||||
GCP: cloudintegrationtypes.NewGCPIntegrationConfig(account.Config.GCP, metricNames, sinkFilters),
|
||||
}, nil
|
||||
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
// for manual flow we don't have any integration config to return, so returning empty config for now.
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
|
||||
return &cloudintegrationtypes.ConnectionArtifact{}, nil
|
||||
}
|
||||
|
||||
connectionArtifact, err := cloudintegrationtypes.NewGCPConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, account.Config.GCP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cloudintegrationtypes.ConnectionArtifact{
|
||||
GCP: connectionArtifact,
|
||||
}, nil
|
||||
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
|
||||
return &cloudintegrationtypes.ConnectionArtifact{}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
@@ -76,7 +34,3 @@ func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID c
|
||||
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
|
||||
}
|
||||
|
||||
type gcpcloudprovider struct {
|
||||
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package implcloudintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -170,6 +171,18 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If a different integration is already connected to this provider account ID, reject the check-in.
|
||||
// Allow re-check-in from the same integration (e.g. agent restarting).
|
||||
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
|
||||
errMessage := fmt.Sprintf("provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID)
|
||||
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
|
||||
}
|
||||
|
||||
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -182,36 +195,41 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
|
||||
syncState := account.NextSyncState(req.SyncedVersion)
|
||||
|
||||
// If account has been removed (disconnected), return a minimal response with empty integration config.
|
||||
if account.RemovedAt != nil {
|
||||
return module.checkInRemovedAccount(ctx, account, req.ProviderAccountID, syncState)
|
||||
}
|
||||
// Heartbeat stays frozen after removal, only the sync state is updated.
|
||||
if account.AgentReport != nil && syncState != nil {
|
||||
account.UpdateSyncState(syncState)
|
||||
|
||||
if err := account.ErrIfInvalidProviderAccountID(req.ProviderAccountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If a different integration is already connected to this provider account ID, reject the check-in.
|
||||
// Allow re-check-in from the same integration (e.g. agent restarting).
|
||||
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
|
||||
return nil, errors.Newf(
|
||||
errors.TypeAlreadyExists,
|
||||
cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected,
|
||||
"provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID,
|
||||
)
|
||||
return cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
req.ProviderAccountID,
|
||||
account.ID.StringValue(),
|
||||
new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
), nil
|
||||
}
|
||||
|
||||
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
|
||||
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -480,45 +498,11 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
stats["cloudintegration.azure.connectedaccounts.count"] = azureAccountsCount
|
||||
}
|
||||
|
||||
gcpAccountsCount, err := module.store.CountConnectedAccounts(ctx, orgID, cloudintegrationtypes.CloudProviderTypeGCP)
|
||||
if err == nil {
|
||||
stats["cloudintegration.gcp.connectedaccounts.count"] = gcpAccountsCount
|
||||
}
|
||||
// NOTE: not adding stats for services for now.
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// checkInRemovedAccount returns an empty integration config and saves cleanup acknowledgments without refreshing the heartbeat.
|
||||
func (module *module) checkInRemovedAccount(ctx context.Context, account *cloudintegrationtypes.Account, providerAccountID string, syncState *cloudintegrationtypes.SyncState) (*cloudintegrationtypes.AgentCheckInResponse, error) {
|
||||
response := cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
providerAccountID,
|
||||
account.ID.StringValue(),
|
||||
new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
)
|
||||
|
||||
if account.AgentReport == nil {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
if syncState == nil {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
account.UpdateSyncState(syncState)
|
||||
storableAccount, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (module *module) getCloudProvider(provider cloudintegrationtypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
|
||||
if cloudProviderModule, ok := module.cloudProvidersMap[provider]; ok {
|
||||
return cloudProviderModule, nil
|
||||
|
||||
@@ -294,6 +294,8 @@
|
||||
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
|
||||
"signoz/no-msw-in-story-file": "error",
|
||||
// Bans msw imports in *.stories.tsx; handlers/mock data belong in the sibling .stories.mocks.tsx
|
||||
"signoz/no-direct-react-router-import": "error",
|
||||
// Steers call sites to the src/lib/router facade (allowlisted in overrides below)
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
@@ -589,6 +591,23 @@
|
||||
"rules": {
|
||||
"no-redeclare": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
// The react-router allowlist: the facade itself, the route table, `useSafeNavigate`,
|
||||
// the history singleton and the test and story router mounts. Test files are
|
||||
// deliberately absent: they render at a route or use tests/router-mocks.
|
||||
"files": [
|
||||
"src/lib/router/**",
|
||||
"src/AppRoutes/**",
|
||||
"src/hooks/useSafeNavigate.ts",
|
||||
"src/lib/history.ts",
|
||||
"src/tests/router.tsx",
|
||||
"src/storybook/providers/StorybookProviders.tsx",
|
||||
"src/storybook/renderAtRoute.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"signoz/no-direct-react-router-import": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -163,9 +163,9 @@
|
||||
"@commitlint/config-conventional": "20.4.4",
|
||||
"@jest/globals": "30.4.1",
|
||||
"@jest/types": "30.2.0",
|
||||
"@storybook/addon-a11y": "10.6.3",
|
||||
"@storybook/addon-docs": "10.6.3",
|
||||
"@storybook/react-vite": "10.6.3",
|
||||
"@storybook/addon-a11y": "10.5.9",
|
||||
"@storybook/addon-docs": "10.5.9",
|
||||
"@storybook/react-vite": "10.5.9",
|
||||
"@storybook/test-runner": "0.24.5",
|
||||
"@testing-library/dom": "8.20.0",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
@@ -213,10 +213,10 @@
|
||||
"redux-mock-store": "1.5.4",
|
||||
"sass": "1.97.3",
|
||||
"sharp": "0.35.4",
|
||||
"storybook": "10.6.3",
|
||||
"storybook": "10.5.9",
|
||||
"stylelint": "17.15.0",
|
||||
"svgo": "4.1.0",
|
||||
"ts-jest": "29.4.15",
|
||||
"ts-jest": "29.4.9",
|
||||
"typescript-plugin-css-modules": "5.2.0",
|
||||
"use-sync-external-store": "1.6.0",
|
||||
"vite-plugin-checker": "0.12.0",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ruleTester } from './rule-tester.mjs';
|
||||
|
||||
const FACADE = 'src/lib/router facade';
|
||||
const HISTORY = 'lib/history singleton';
|
||||
const MOCK = 'tests/router-mocks helpers';
|
||||
|
||||
await ruleTester({
|
||||
rule: 'no-direct-react-router-import',
|
||||
valid: [
|
||||
{
|
||||
name: 'facade import',
|
||||
code: "import { useAppNavigate } from 'lib/router/useAppNavigate';",
|
||||
},
|
||||
{
|
||||
name:
|
||||
'the history package itself is a version-bump concern, not a facade one',
|
||||
code: "import { createBrowserHistory } from 'history';",
|
||||
},
|
||||
{
|
||||
name: 'unrelated module whose name contains history',
|
||||
code: "import { useHistoryPanel } from 'container/HistoryPanel';",
|
||||
},
|
||||
{
|
||||
name: 'mocking a facade module',
|
||||
code: "jest.mock('lib/router/navigation');",
|
||||
},
|
||||
{
|
||||
name: 'jest call that does not name a module',
|
||||
code: 'jest.fn();',
|
||||
},
|
||||
{
|
||||
name: 'require of an unrelated module',
|
||||
code: "const x = require('lib/dashboardVariables');",
|
||||
},
|
||||
{
|
||||
name: 'export without a source',
|
||||
code: 'const a = 1;\nexport { a };',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'jest.mock of react-router-dom',
|
||||
code: "jest.mock('react-router-dom', () => ({}));",
|
||||
errors: [{ message: MOCK, line: 1, column: 11 }],
|
||||
},
|
||||
{
|
||||
name: 'jest.requireActual of react-router-dom',
|
||||
code: "const actual = jest.requireActual('react-router-dom');",
|
||||
errors: [{ message: MOCK }],
|
||||
},
|
||||
{
|
||||
name: 'jest.mock of lib/history',
|
||||
code: "jest.mock('lib/history');",
|
||||
errors: [{ message: MOCK }],
|
||||
},
|
||||
{
|
||||
name: 'jest.doMock of react-router-dom-v5-compat',
|
||||
code: "jest.doMock('react-router-dom-v5-compat');",
|
||||
errors: [{ message: MOCK }],
|
||||
},
|
||||
{
|
||||
name: 'react-router-dom named import',
|
||||
code: "import { useHistory } from 'react-router-dom';",
|
||||
errors: [{ message: FACADE, line: 1, column: 28 }],
|
||||
},
|
||||
{
|
||||
name: 'react-router named import',
|
||||
code: "import { useLocation } from 'react-router';",
|
||||
errors: [{ message: FACADE }],
|
||||
},
|
||||
{
|
||||
name: 'react-router-dom type-only import',
|
||||
code: "import type { RouteProps } from 'react-router-dom';",
|
||||
errors: [{ message: FACADE }],
|
||||
},
|
||||
{
|
||||
name: 'react-router-dom-v5-compat',
|
||||
code: "import { useNavigate } from 'react-router-dom-v5-compat';",
|
||||
errors: [{ message: 'compat package is an implementation detail' }],
|
||||
},
|
||||
{
|
||||
name: 'lib/history default import',
|
||||
code: "import history from 'lib/history';",
|
||||
errors: [{ message: HISTORY }],
|
||||
},
|
||||
{
|
||||
name: 'lib/history through the @/ alias',
|
||||
code: "import history from '@/lib/history';",
|
||||
errors: [{ message: HISTORY }],
|
||||
},
|
||||
{
|
||||
name: 'jest.mock of lib/history through the @/ alias',
|
||||
code: "jest.mock('@/lib/history');",
|
||||
errors: [{ message: MOCK }],
|
||||
},
|
||||
{
|
||||
name: 'lib/history require',
|
||||
code: "const history = require('lib/history').default;",
|
||||
errors: [{ message: HISTORY }],
|
||||
},
|
||||
{
|
||||
name: 'dynamic import',
|
||||
code: "const mod = await import('react-router-dom');",
|
||||
errors: [{ message: FACADE }],
|
||||
},
|
||||
{
|
||||
name: 're-export',
|
||||
code: "export { Link } from 'react-router-dom';",
|
||||
errors: [{ message: FACADE }],
|
||||
},
|
||||
{
|
||||
name: 'export all',
|
||||
code: "export * from 'react-router-dom';",
|
||||
errors: [{ message: FACADE }],
|
||||
},
|
||||
{
|
||||
name: 'one report per import statement',
|
||||
code:
|
||||
"import { Link } from 'react-router-dom';\nimport history from 'lib/history';",
|
||||
errors: [
|
||||
{ message: FACADE, line: 1 },
|
||||
{ message: HISTORY, line: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
123
frontend/plugins/rules/no-direct-react-router-import.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Rule: no-direct-react-router-import
|
||||
*
|
||||
* Every router concern goes through the `src/lib/router/*` facade, so a router
|
||||
* upgrade is a change to one directory instead of ~300 call sites. This rule
|
||||
* keeps new call sites, and test mocks, from reaching past it.
|
||||
*
|
||||
* `history` (the package) is deliberately not flagged: it is a transitive concern of
|
||||
* the version bump, not something the facade replaces.
|
||||
*
|
||||
* The allowlist (the facade itself, the route table, `useSafeNavigate`, the history
|
||||
* singleton and the test and story router mounts) is applied via overrides in
|
||||
* .oxlintrc.json, not here, so the file list stays visible next to the severity.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
const HISTORY_MODULE_SUFFIX = path.join('src', 'lib', 'history');
|
||||
|
||||
const JEST_MODULE_METHODS = new Set([
|
||||
'mock',
|
||||
'doMock',
|
||||
'unmock',
|
||||
'dontMock',
|
||||
'setMock',
|
||||
'requireActual',
|
||||
'requireMock',
|
||||
'createMockFromModule',
|
||||
]);
|
||||
|
||||
const MESSAGE_IDS = {
|
||||
'react-router': 'v5Router',
|
||||
'react-router-dom': 'v5Router',
|
||||
'react-router-dom-v5-compat': 'v6Compat',
|
||||
'lib/history': 'historySingleton',
|
||||
'@/lib/history': 'historySingleton',
|
||||
};
|
||||
|
||||
/** Resolves `./history` / `../history` so files inside src/lib count too. */
|
||||
function isHistoryModule(specifier, filename) {
|
||||
if (!specifier.startsWith('.') || !filename) {
|
||||
return false;
|
||||
}
|
||||
const resolved = path.resolve(path.dirname(filename), specifier);
|
||||
return (
|
||||
resolved.endsWith(HISTORY_MODULE_SUFFIX) ||
|
||||
resolved.endsWith(`${HISTORY_MODULE_SUFFIX}.ts`)
|
||||
);
|
||||
}
|
||||
|
||||
function messageIdFor(specifier, filename) {
|
||||
if (MESSAGE_IDS[specifier] !== undefined) {
|
||||
return MESSAGE_IDS[specifier];
|
||||
}
|
||||
return isHistoryModule(specifier, filename) ? 'historySingleton' : null;
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow direct react-router / lib/history imports; import from src/lib/router instead',
|
||||
category: 'React Router migration',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
v5Router:
|
||||
'Do not import react-router-dom directly. Use the src/lib/router facade (useAppNavigate, useAppLocation, useAppParams, AppLink, Redirect, matchRoute) so a router upgrade stays contained.',
|
||||
v6Compat:
|
||||
'Do not import react-router-dom-v5-compat directly. Use the src/lib/router facade instead; the compat package is an implementation detail of the migration and disappears with it.',
|
||||
historySingleton:
|
||||
'Do not import the lib/history singleton. Use the helpers in src/lib/router/navigation.ts, or useAppNavigate() to follow the nearest router. history loses basename handling once the router owns it.',
|
||||
mock:
|
||||
'Do not mock the router directly. Render at a route with tests/test-utils, or spy on the facade with the tests/router-mocks helpers.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const report = (sourceNode, overrideMessageId) => {
|
||||
if (
|
||||
sourceNode === null ||
|
||||
sourceNode === undefined ||
|
||||
typeof sourceNode.value !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const messageId = messageIdFor(sourceNode.value, context.filename);
|
||||
if (messageId !== null) {
|
||||
context.report({
|
||||
node: sourceNode,
|
||||
messageId: overrideMessageId ?? messageId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ImportDeclaration: (node) => report(node.source),
|
||||
ImportExpression: (node) => report(node.source),
|
||||
ExportAllDeclaration: (node) => report(node.source),
|
||||
ExportNamedDeclaration: (node) => report(node.source),
|
||||
CallExpression(node) {
|
||||
const { callee } = node;
|
||||
if (
|
||||
(callee.type === 'Identifier' && callee.name === 'require') ||
|
||||
callee.type === 'Import'
|
||||
) {
|
||||
report(node.arguments[0]);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.object.name === 'jest' &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
JEST_MODULE_METHODS.has(callee.property.name)
|
||||
) {
|
||||
report(node.arguments[0], 'mock');
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root
|
||||
import noConditionalTextNodesWithSiblings from './rules/no-conditional-text-nodes-with-siblings.mjs';
|
||||
import noReturnTextNodes from './rules/no-return-text-nodes.mjs';
|
||||
import noMswInStoryFile from './rules/no-msw-in-story-file.mjs';
|
||||
import noDirectReactRouterImport from './rules/no-direct-react-router-import.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -33,5 +34,6 @@ export default {
|
||||
'no-conditional-text-nodes-with-siblings': noConditionalTextNodesWithSiblings,
|
||||
'no-return-text-nodes': noReturnTextNodes,
|
||||
'no-msw-in-story-file': noMswInStoryFile,
|
||||
'no-direct-react-router-import': noDirectReactRouterImport,
|
||||
},
|
||||
};
|
||||
|
||||
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.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'
|
||||
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'
|
||||
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.6.3
|
||||
version: 10.6.3(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
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))
|
||||
'@storybook/addon-docs':
|
||||
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))
|
||||
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))
|
||||
'@storybook/react-vite':
|
||||
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)
|
||||
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)
|
||||
'@storybook/test-runner':
|
||||
specifier: 0.24.5
|
||||
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))
|
||||
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))
|
||||
'@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.6.3
|
||||
version: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
specifier: 10.5.9
|
||||
version: 10.5.9(@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.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)
|
||||
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)
|
||||
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.0':
|
||||
resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==}
|
||||
'@emnapi/core@1.11.2':
|
||||
resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==}
|
||||
|
||||
'@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.0':
|
||||
resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==}
|
||||
'@emnapi/runtime@1.11.2':
|
||||
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
|
||||
|
||||
'@emnapi/runtime@1.11.3':
|
||||
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
|
||||
@@ -2170,9 +2170,6 @@ 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==}
|
||||
|
||||
@@ -2455,106 +2452,106 @@ packages:
|
||||
'@oxc-project/types@0.127.0':
|
||||
resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.21.2':
|
||||
resolution: {integrity: sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==}
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.24.2':
|
||||
resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxc-resolver/binding-android-arm64@11.21.2':
|
||||
resolution: {integrity: sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==}
|
||||
'@oxc-resolver/binding-android-arm64@11.24.2':
|
||||
resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxc-resolver/binding-darwin-arm64@11.21.2':
|
||||
resolution: {integrity: sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==}
|
||||
'@oxc-resolver/binding-darwin-arm64@11.24.2':
|
||||
resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-resolver/binding-darwin-x64@11.21.2':
|
||||
resolution: {integrity: sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==}
|
||||
'@oxc-resolver/binding-darwin-x64@11.24.2':
|
||||
resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-resolver/binding-freebsd-x64@11.21.2':
|
||||
resolution: {integrity: sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==}
|
||||
'@oxc-resolver/binding-freebsd-x64@11.24.2':
|
||||
resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2':
|
||||
resolution: {integrity: sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==}
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2':
|
||||
resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.21.2':
|
||||
resolution: {integrity: sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==}
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.24.2':
|
||||
resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.21.2':
|
||||
resolution: {integrity: sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==}
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.21.2':
|
||||
resolution: {integrity: sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==}
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.24.2':
|
||||
resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.21.2':
|
||||
resolution: {integrity: sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==}
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.21.2':
|
||||
resolution: {integrity: sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==}
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.21.2':
|
||||
resolution: {integrity: sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==}
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.24.2':
|
||||
resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.21.2':
|
||||
resolution: {integrity: sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==}
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.21.2':
|
||||
resolution: {integrity: sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==}
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.24.2':
|
||||
resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.21.2':
|
||||
resolution: {integrity: sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==}
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.24.2':
|
||||
resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.21.2':
|
||||
resolution: {integrity: sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==}
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.24.2':
|
||||
resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.21.2':
|
||||
resolution: {integrity: sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==}
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.24.2':
|
||||
resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.21.2':
|
||||
resolution: {integrity: sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==}
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.24.2':
|
||||
resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.21.2':
|
||||
resolution: {integrity: sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==}
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.24.2':
|
||||
resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -3663,26 +3660,44 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@storybook/addon-a11y@10.6.3':
|
||||
resolution: {integrity: sha512-nqvjpmu5g+PPtEKwgE0qAi4wfuQ9yngOb/1Gm24pBBZOuRsd3UMEBjKORSuAGrIAz9X3LCFfQi7sCcBFXvFbwg==}
|
||||
'@storybook/addon-a11y@10.5.9':
|
||||
resolution: {integrity: sha512-MvXkoJIVRcZrqhWi+wynYAmFkvTWm5aHdJfYohhgPeEJVDRF91zejtOsN7zPRE/lQvBDY+IiomKxeRN3UhWNVw==}
|
||||
peerDependencies:
|
||||
storybook: ^10.6.3
|
||||
storybook: ^10.5.9
|
||||
|
||||
'@storybook/addon-docs@10.6.3':
|
||||
resolution: {integrity: sha512-365f712au4w/xvFqEKY6TIvTeGGAY7FKRB8zP4CFa3VSv9oyxHaq/ublFg4YDbCeRagppwl1PZ6k4+PoUor3+A==}
|
||||
'@storybook/addon-docs@10.5.9':
|
||||
resolution: {integrity: sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
storybook: ^10.6.3
|
||||
storybook: ^10.5.9
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@storybook/builder-vite@10.6.3':
|
||||
resolution: {integrity: sha512-1+WCZZg/fPxxyHDB5sFKniFt9/4RFUIzoNqMujv+BLRROM+gQeyUHvdvoyZOZ51+P8ee+/PgnNKHjsTupV+nOA==}
|
||||
'@storybook/builder-vite@10.5.9':
|
||||
resolution: {integrity: sha512-Zg4JbGQiHFPGlFJ9HM+XPgzKmU/RFPCymhohVRJhBBYfmgaQgz0flWWzscseCDpl638MNd8/r/H+nwuoBgSYDg==}
|
||||
peerDependencies:
|
||||
storybook: ^10.6.3
|
||||
storybook: ^10.5.9
|
||||
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==}
|
||||
|
||||
@@ -3691,40 +3706,40 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@storybook/react-dom-shim@10.6.3':
|
||||
resolution: {integrity: sha512-cJUuPrieiTl06kzlgaU89iG+CtLAgUTt0Xdvm8qFT9yR2qwafSaswhwYWlZCNX4nWuOu7eK7uzWTwzKqyDBOWQ==}
|
||||
'@storybook/react-dom-shim@10.5.9':
|
||||
resolution: {integrity: sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==}
|
||||
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.6.3
|
||||
storybook: ^10.5.9
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@storybook/react-vite@10.6.3':
|
||||
resolution: {integrity: sha512-geWwLi9JnaRG2t9bYBfz+94skARzzXNT1Toaf0lk7Oog13rtNK7ay2pWNm9SP6/L2S7mEjoWu9+zHLpwxXvfIw==}
|
||||
'@storybook/react-vite@10.5.9':
|
||||
resolution: {integrity: sha512-mDI+UssrOEP8IwgBsefLG+gqW1gZbZEPbpH5PoHK/P1WPi8BfrLEqrFi9H9wmMkal/Vh8SmL1awgOi0ir3XwcQ==}
|
||||
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.6.3
|
||||
storybook: ^10.5.9
|
||||
typescript: '>= 4.9.x'
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@storybook/react@10.6.3':
|
||||
resolution: {integrity: sha512-3tfAkZNdUyx3I4IjM8dgbNWg4k0gj89Hi1MPiawfTybRRWhvnX2YqV9Ut4mrud7YzRv0v+U7+ZYEvj+maUPaow==}
|
||||
'@storybook/react@10.5.9':
|
||||
resolution: {integrity: sha512-kApGOuNT26NkpioTsr1iT/Q2c44tA7OIsNUSyFqtT7W8k3fRn/jQWfrDegYxty0WG0wxdNMZq2ndRfOOF8HaHw==}
|
||||
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.6.3
|
||||
storybook: ^10.5.9
|
||||
typescript: '>= 4.9.x'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
@@ -4746,14 +4761,14 @@ packages:
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
brace-expansion@1.1.21:
|
||||
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
|
||||
brace-expansion@1.1.18:
|
||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||
|
||||
brace-expansion@2.1.7:
|
||||
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
|
||||
brace-expansion@2.1.4:
|
||||
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
||||
|
||||
brace-expansion@5.0.12:
|
||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
@@ -6134,8 +6149,8 @@ packages:
|
||||
resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==}
|
||||
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
||||
|
||||
handlebars@4.7.10:
|
||||
resolution: {integrity: sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==}
|
||||
handlebars@4.7.9:
|
||||
resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==}
|
||||
engines: {node: '>=0.4.7'}
|
||||
hasBin: true
|
||||
|
||||
@@ -7192,9 +7207,6 @@ 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'}
|
||||
@@ -7770,8 +7782,8 @@ packages:
|
||||
resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
||||
oxc-resolver@11.21.2:
|
||||
resolution: {integrity: sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==}
|
||||
oxc-resolver@11.24.2:
|
||||
resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==}
|
||||
|
||||
oxfmt@0.73.0:
|
||||
resolution: {integrity: sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==}
|
||||
@@ -9163,8 +9175,8 @@ packages:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
storybook@10.6.3:
|
||||
resolution: {integrity: sha512-lNBvcZFG44C98KvQHYzfq1O46m+sl94zY1+na5/9E3ihtNMbvpOgYyMFMIcWYUvOMo3OM4q9jzvB73KU20bU8w==}
|
||||
storybook@10.5.9:
|
||||
resolution: {integrity: sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -9453,8 +9465,8 @@ packages:
|
||||
ts-easing@0.2.0:
|
||||
resolution: {integrity: sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==}
|
||||
|
||||
ts-jest@29.4.15:
|
||||
resolution: {integrity: sha512-s2qmEUYFRqbjz1wv0EmccNPVOb45HOFpFuhzQJLKShYVisuTbCFPIIAIV2EuK5rIxb4BejLDbH7dnBZfYjybUQ==}
|
||||
ts-jest@29.4.9:
|
||||
resolution: {integrity: sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -11339,7 +11351,7 @@ snapshots:
|
||||
react: 18.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@emnapi/core@1.11.0':
|
||||
'@emnapi/core@1.11.2':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.2
|
||||
tslib: 2.8.1
|
||||
@@ -11357,7 +11369,7 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.11.0':
|
||||
'@emnapi/runtime@1.11.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
@@ -12078,8 +12090,6 @@ 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
|
||||
@@ -12185,10 +12195,10 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)':
|
||||
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.0
|
||||
'@emnapi/runtime': 1.11.0
|
||||
'@emnapi/core': 1.11.2
|
||||
'@emnapi/runtime': 1.11.2
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
@@ -12401,65 +12411,65 @@ snapshots:
|
||||
|
||||
'@oxc-project/types@0.127.0': {}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.21.2':
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-android-arm64@11.21.2':
|
||||
'@oxc-resolver/binding-android-arm64@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-darwin-arm64@11.21.2':
|
||||
'@oxc-resolver/binding-darwin-arm64@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-darwin-x64@11.21.2':
|
||||
'@oxc-resolver/binding-darwin-x64@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-freebsd-x64@11.21.2':
|
||||
'@oxc-resolver/binding-freebsd-x64@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2':
|
||||
'@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.21.2':
|
||||
'@oxc-resolver/binding-linux-arm-musleabihf@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.21.2':
|
||||
'@oxc-resolver/binding-linux-arm64-gnu@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.21.2':
|
||||
'@oxc-resolver/binding-linux-arm64-musl@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.21.2':
|
||||
'@oxc-resolver/binding-linux-ppc64-gnu@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.21.2':
|
||||
'@oxc-resolver/binding-linux-riscv64-gnu@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.21.2':
|
||||
'@oxc-resolver/binding-linux-riscv64-musl@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.21.2':
|
||||
'@oxc-resolver/binding-linux-s390x-gnu@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.21.2':
|
||||
'@oxc-resolver/binding-linux-x64-gnu@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.21.2':
|
||||
'@oxc-resolver/binding-linux-x64-musl@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.21.2':
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.21.2':
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.24.2':
|
||||
dependencies:
|
||||
'@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)
|
||||
'@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)
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.21.2':
|
||||
'@oxc-resolver/binding-win32-arm64-msvc@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.21.2':
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.24.2':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.73.0':
|
||||
@@ -13402,32 +13412,49 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@storybook/addon-a11y@10.6.3(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
'@storybook/addon-a11y@10.5.9(storybook@10.5.9(@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.6.3(@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/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))':
|
||||
'@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))':
|
||||
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.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))
|
||||
'@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))
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
storybook: 10.6.3(@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)
|
||||
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.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/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))':
|
||||
dependencies:
|
||||
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(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: 10.5.9(@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': {}
|
||||
|
||||
@@ -13435,28 +13462,28 @@ snapshots:
|
||||
dependencies:
|
||||
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))':
|
||||
'@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))':
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
storybook: 10.6.3(@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)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@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)':
|
||||
'@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)':
|
||||
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.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)
|
||||
'@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)
|
||||
empathic: 2.0.1
|
||||
magic-string: 1.4.3
|
||||
magic-string: 0.30.8
|
||||
react: 18.2.0
|
||||
react-docgen: 8.0.3
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
resolve: 1.22.11
|
||||
storybook: 10.6.3(@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)
|
||||
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:
|
||||
@@ -13464,18 +13491,20 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
- esbuild
|
||||
- rollup
|
||||
- supports-color
|
||||
- webpack
|
||||
|
||||
'@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)':
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@storybook/global': 5.0.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))
|
||||
'@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))
|
||||
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.6.3(@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)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
@@ -13483,7 +13512,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@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))':
|
||||
'@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))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/generator': 7.29.7
|
||||
@@ -13505,7 +13534,7 @@ snapshots:
|
||||
playwright: 1.57.0
|
||||
playwright-core: 1.57.0
|
||||
rimraf: 3.0.2
|
||||
storybook: 10.6.3(@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)
|
||||
uuid: 8.3.2
|
||||
transitivePeerDependencies:
|
||||
- '@swc/helpers'
|
||||
@@ -13645,7 +13674,7 @@ snapshots:
|
||||
'@testing-library/jest-dom@6.9.1':
|
||||
dependencies:
|
||||
'@adobe/css-tools': 4.5.0
|
||||
aria-query: 5.3.0
|
||||
aria-query: 5.1.3
|
||||
css.escape: 1.5.1
|
||||
dom-accessibility-api: 0.6.3
|
||||
picocolors: 1.1.1
|
||||
@@ -14633,16 +14662,16 @@ snapshots:
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@1.1.21:
|
||||
brace-expansion@1.1.18:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.1.7:
|
||||
brace-expansion@2.1.4:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
brace-expansion@5.0.12:
|
||||
brace-expansion@5.0.9:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -16096,7 +16125,7 @@ snapshots:
|
||||
|
||||
graphql@16.8.1: {}
|
||||
|
||||
handlebars@4.7.10:
|
||||
handlebars@4.7.9:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
neo-async: 2.6.2
|
||||
@@ -17469,10 +17498,6 @@ 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
|
||||
@@ -17883,19 +17908,19 @@ snapshots:
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.12
|
||||
brace-expansion: 5.0.9
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.21
|
||||
brace-expansion: 1.1.18
|
||||
|
||||
minimatch@5.1.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.7
|
||||
brace-expansion: 2.1.4
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.7
|
||||
brace-expansion: 2.1.4
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -18279,27 +18304,27 @@ snapshots:
|
||||
'@oxc-parser/binding-win32-ia32-msvc': 0.127.0
|
||||
'@oxc-parser/binding-win32-x64-msvc': 0.127.0
|
||||
|
||||
oxc-resolver@11.21.2:
|
||||
oxc-resolver@11.24.2:
|
||||
optionalDependencies:
|
||||
'@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
|
||||
'@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
|
||||
|
||||
oxfmt@0.73.0:
|
||||
dependencies:
|
||||
@@ -19897,7 +19922,7 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
internal-slot: 1.1.0
|
||||
|
||||
storybook@10.6.3(@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):
|
||||
dependencies:
|
||||
'@storybook/global': 5.0.0
|
||||
'@storybook/icons': 2.1.0(react@18.2.0)
|
||||
@@ -19911,7 +19936,7 @@ snapshots:
|
||||
jsonc-parser: 3.3.1
|
||||
open: 10.2.0
|
||||
oxc-parser: 0.127.0
|
||||
oxc-resolver: 11.21.2
|
||||
oxc-resolver: 11.24.2
|
||||
recast: 0.23.21
|
||||
semver: 7.8.5
|
||||
use-sync-external-store: 1.6.0(react@18.2.0)
|
||||
@@ -20229,11 +20254,11 @@ snapshots:
|
||||
|
||||
ts-easing@0.2.0: {}
|
||||
|
||||
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):
|
||||
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):
|
||||
dependencies:
|
||||
bs-logger: 0.2.6
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
handlebars: 4.7.10
|
||||
handlebars: 4.7.9
|
||||
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.21: '>=1.1.21 <2'
|
||||
brace-expansion@<1.1.18: '>=1.1.18 <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.7': '>=2.1.7 <3'
|
||||
'brace-expansion@>=2.0.0 <2.1.4': '>=2.1.4 <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.12': '>=5.0.12 <6'
|
||||
'brace-expansion@>=5.0.0 <5.0.9': '>=5.0.9 <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
|
||||
|
||||
@@ -2,15 +2,6 @@ import { AxiosError, AxiosResponse, isCancel } from 'axios';
|
||||
import { ErrorResponse } from 'types/api';
|
||||
import { ErrorStatusCode } from 'types/common';
|
||||
|
||||
function getClientErrorMessage(data: AxiosResponse['data']): string {
|
||||
const { errors, error } = data;
|
||||
if (Array.isArray(errors) && errors.length >= 1) {
|
||||
return errors[0].msg;
|
||||
}
|
||||
// v2 endpoints send `error` as an `{ code, message }` envelope.
|
||||
return typeof error?.message === 'string' ? error.message : error;
|
||||
}
|
||||
|
||||
export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
|
||||
const { response, request } = error;
|
||||
if (response) {
|
||||
@@ -30,10 +21,15 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
|
||||
};
|
||||
}
|
||||
|
||||
const { errors, error } = data;
|
||||
|
||||
const errorMessage =
|
||||
Array.isArray(errors) && errors.length >= 1 ? errors[0].msg : error;
|
||||
|
||||
return {
|
||||
statusCode,
|
||||
payload: null,
|
||||
error: getClientErrorMessage(data),
|
||||
error: errorMessage,
|
||||
message: (response.data as any)?.status,
|
||||
body: JSON.stringify((response.data as any).data),
|
||||
};
|
||||
|
||||
@@ -21,19 +21,4 @@ describe('ErrorResponseHandler', () => {
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('flattens a v2 error envelope on a 4xx to its message', () => {
|
||||
const error = new AxiosError('Request failed', '400', undefined, undefined, {
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: {},
|
||||
config: {} as never,
|
||||
data: {
|
||||
status: 'error',
|
||||
error: { code: 'invalid_input', message: 'Syntax error: bad query' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(ErrorResponseHandler(error).error).toBe('Syntax error: bad query');
|
||||
});
|
||||
});
|
||||
|
||||
29
frontend/src/api/querySuggestions/getValueSuggestion.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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}`,
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import deleteLocalStorageKey from 'api/browser/localstorage/remove';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
|
||||
import deleteSession from './v2/sessions/delete';
|
||||
|
||||
@@ -21,5 +21,5 @@ export const Logout = async (): Promise<void> => {
|
||||
deleteLocalStorageKey(LOCALSTORAGE.CHAT_SUPPORT);
|
||||
deleteLocalStorageKey(LOCALSTORAGE.USER_ID);
|
||||
window.dispatchEvent(new CustomEvent('LOGOUT'));
|
||||
history.push(ROUTES.LOGIN);
|
||||
navigate(ROUTES.LOGIN);
|
||||
};
|
||||
|
||||
90
frontend/src/assets/CustomIcons/EmptyQuickFilterIcon.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
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;
|
||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Row, Select, Spin } from 'antd';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import {
|
||||
getValuesFromQueryParams,
|
||||
setQueryParamsFromOptions,
|
||||
@@ -35,8 +36,7 @@ export function FilterSelect({
|
||||
useCeleryFilterOptions(filterType);
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const location = useAppLocation();
|
||||
|
||||
// Add state to track the current search input
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
@@ -66,7 +66,7 @@ export function FilterSelect({
|
||||
setQueryParamsFromOptions(
|
||||
value as string[],
|
||||
urlQuery,
|
||||
history,
|
||||
navigate,
|
||||
location,
|
||||
queryParam,
|
||||
);
|
||||
@@ -77,7 +77,6 @@ export function FilterSelect({
|
||||
handleSearch,
|
||||
shouldSetQueryParams,
|
||||
urlQuery,
|
||||
history,
|
||||
location,
|
||||
queryParam,
|
||||
onChange,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SelectMaxTagPlaceholder } from 'components/MessagingQueues/MQCommon/MQCommon';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -16,8 +17,7 @@ import './CeleryTaskConfigOptions.styles.scss';
|
||||
function CeleryTaskConfigOptions(): JSX.Element {
|
||||
const { handleSearch, isFetching, options } =
|
||||
useCeleryFilterOptions('celery.task_name');
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const location = useAppLocation();
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
@@ -52,7 +52,7 @@ function CeleryTaskConfigOptions(): JSX.Element {
|
||||
setQueryParamsFromOptions(
|
||||
value,
|
||||
urlQuery,
|
||||
history,
|
||||
navigate,
|
||||
location,
|
||||
QueryParams.taskName,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ViewMenuAction } from 'container/WidgetCard/config';
|
||||
@@ -50,8 +51,7 @@ function CeleryTaskBar({
|
||||
queryEnabled: boolean;
|
||||
checkIfDataExists?: (isDataAvailable: boolean) => void;
|
||||
}): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -68,13 +68,13 @@ function CeleryTaskBar({
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
history.push(generatedUrl);
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, history, pathname, urlQuery],
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
const [barState, setBarState] = useState<CeleryTaskState>(CeleryTaskState.All);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ViewMenuAction } from 'container/WidgetCard/config';
|
||||
@@ -54,8 +55,7 @@ function CeleryTaskGraph({
|
||||
checkIfDataExists?: (isDataAvailable: boolean) => void;
|
||||
analyticsEvent?: string;
|
||||
}): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -83,13 +83,13 @@ function CeleryTaskGraph({
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
history.push(generatedUrl);
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, history, pathname, urlQuery],
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Col, Row } from 'antd';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -48,8 +49,7 @@ function CeleryTaskLatencyGraph({
|
||||
queryEnabled: boolean;
|
||||
checkIfDataExists?: (isDataAvailable: boolean) => void;
|
||||
}): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -80,13 +80,13 @@ function CeleryTaskLatencyGraph({
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
history.push(generatedUrl);
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, history, pathname, urlQuery],
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
const selectedFilters = useMemo(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { History, Location } from 'history';
|
||||
import type { AppLocation, NavigateFn } from 'lib/router/types';
|
||||
import getRenderer from 'lib/uPlotLib/utils/getRenderer';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
@@ -17,13 +17,13 @@ export function getValuesFromQueryParams(
|
||||
export function setQueryParamsFromOptions(
|
||||
value: string[],
|
||||
urlQuery: URLSearchParams,
|
||||
history: History<unknown>,
|
||||
location: Location<unknown>,
|
||||
navigate: NavigateFn,
|
||||
location: AppLocation,
|
||||
queryParams: QueryParams,
|
||||
): void {
|
||||
urlQuery.set(queryParams, value.join(','));
|
||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||
history.replace(generatedUrl);
|
||||
navigate(generatedUrl, { replace: true });
|
||||
}
|
||||
|
||||
export function getFiltersFromQueryParams(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Button } from 'antd';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -8,7 +8,7 @@ import { MessageSquareText } from '@signozhq/icons';
|
||||
export default function ChatSupportGateway(): JSX.Element {
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
useState(false);
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import { mockAppLocation } from 'tests/router-mocks';
|
||||
import * as timeUtils from 'utils/timeUtils';
|
||||
|
||||
import CustomTimePicker from './CustomTimePicker';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useLocation: jest.fn().mockReturnValue({
|
||||
pathname: '/test-path',
|
||||
}),
|
||||
};
|
||||
});
|
||||
mockAppLocation({ pathname: '/test-path' });
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input, InputRef, Popover, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
@@ -109,7 +109,7 @@ function CustomTimePicker({
|
||||
const [inputErrorDetails, setInputErrorDetails] = useState<
|
||||
TimeRangeValidationResult['errorDetails'] | null
|
||||
>(null);
|
||||
const location = useLocation();
|
||||
const location = useAppLocation();
|
||||
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const initialInputValueOnOpenRef = useRef<string>('');
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -114,7 +114,7 @@ function CustomTimePickerPopoverContent({
|
||||
customDateTimeInputStatus = CustomTimePickerInputStatus.UNSET,
|
||||
inputErrorDetails,
|
||||
}: CustomTimePickerPopoverContentProps): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
|
||||
const isLogsExplorerPage = useMemo(
|
||||
() => pathname === ROUTES.LOGS_EXPLORER,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { mockAppLocation } from 'tests/router-mocks';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import CustomTimePicker from '../CustomTimePicker';
|
||||
@@ -59,10 +60,6 @@ jest.mock('providers/Timezone', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
|
||||
}));
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const defaultProps = {
|
||||
@@ -85,6 +82,7 @@ describe('CustomTimePicker - zoom out button', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
|
||||
mockAppLocation({ pathname: '/logs-explorer' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -2,23 +2,15 @@ import { render, screen } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { mockAppLocation } from 'tests/router-mocks';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { viewMockData } from '../__mock__/viewData';
|
||||
import ExplorerCard from '../ExplorerCard';
|
||||
|
||||
const historyReplace = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
|
||||
}),
|
||||
useHistory: (): any => ({
|
||||
...jest.requireActual('react-router-dom').useHistory(),
|
||||
replace: historyReplace,
|
||||
}),
|
||||
}));
|
||||
mockAppLocation({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
|
||||
});
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { mockAppLocation } from 'tests/router-mocks';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { viewMockData } from '../__mock__/viewData';
|
||||
import MenuItemGenerator from '../MenuItemGenerator';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
|
||||
}),
|
||||
}));
|
||||
mockAppLocation({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
|
||||
});
|
||||
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SaveViewWithName from '../SaveViewWithName';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
|
||||
}),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Button, Input } from 'antd';
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
@@ -11,7 +11,7 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
|
||||
const [activeTab, setActiveTab] = useState('feedback');
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const location = useLocation();
|
||||
const location = useAppLocation();
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Dot } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
@@ -39,7 +39,7 @@ function HeaderRightSection({
|
||||
enableFeedback,
|
||||
shareModalExtraOption,
|
||||
}: HeaderRightSectionProps): JSX.Element | null {
|
||||
const location = useLocation();
|
||||
const location = useAppLocation();
|
||||
|
||||
const [openFeedbackModal, setOpenFeedbackModal] = useState(false);
|
||||
const [openShareURLModal, setOpenShareURLModal] = useState(false);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from 'antd';
|
||||
@@ -41,7 +42,7 @@ interface ShareURLModalProps {
|
||||
|
||||
function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
const location = useAppLocation();
|
||||
const { selectedTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
@@ -75,7 +76,7 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
|
||||
const isRouteToBeSharedWithTime = useMemo(
|
||||
() =>
|
||||
routesToBeSharedWithTime.some((route) =>
|
||||
matchPath(location.pathname, { path: route, exact: true }),
|
||||
matchRoute(location.pathname, route, { exact: true }),
|
||||
),
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Mock dependencies before imports
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -9,9 +9,8 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import FeedbackModal from '../FeedbackModal';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
jest.mock('lib/router/useAppLocation', () => ({
|
||||
useAppLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
@@ -30,7 +29,7 @@ jest.mock('container/Integrations/utils', () => ({
|
||||
handleContactSupport: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseAppLocation = useAppLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
const mockHandleContactSupport = handleContactSupport as jest.Mock;
|
||||
const mockToast = toast as jest.Mocked<typeof toast>;
|
||||
@@ -45,7 +44,7 @@ describe('FeedbackModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
logEventMock.mockReturnValue(Promise.resolve() as never);
|
||||
mockUseLocation.mockReturnValue(mockLocation);
|
||||
mockUseAppLocation.mockReturnValue(mockLocation);
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Mock dependencies before imports
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
@@ -7,9 +7,8 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import HeaderRightSection from '../HeaderRightSection';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
jest.mock('lib/router/useAppLocation', () => ({
|
||||
useAppLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../FeedbackModal', () => ({
|
||||
@@ -45,7 +44,7 @@ jest.mock('hooks/useIsAIAssistantEnabled', () => ({
|
||||
useIsAIAssistantEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseAppLocation = useAppLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
|
||||
const defaultProps = {
|
||||
@@ -61,7 +60,7 @@ const mockLocation = {
|
||||
describe('HeaderRightSection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseLocation.mockReturnValue(mockLocation);
|
||||
mockUseAppLocation.mockReturnValue(mockLocation);
|
||||
// Default to licensed user (Enterprise or Cloud)
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: true,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Mock dependencies before imports
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -12,10 +13,12 @@ import GetMinMax from 'lib/getMinMax';
|
||||
|
||||
import ShareURLModal from '../ShareURLModal';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
matchPath: jest.fn(),
|
||||
jest.mock('lib/router/useAppLocation', () => ({
|
||||
useAppLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/router/matchRoute', () => ({
|
||||
matchRoute: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
@@ -48,12 +51,12 @@ Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseAppLocation = useAppLocation as jest.Mock;
|
||||
const mockUseUrlQuery = useUrlQuery as jest.Mock;
|
||||
const mockUseSelector = useSelector as jest.Mock;
|
||||
const mockGetMinMax = GetMinMax as jest.Mock;
|
||||
const mockUseCopyToClipboard = useCopyToClipboard as jest.Mock;
|
||||
const mockMatchPath = matchPath as jest.Mock;
|
||||
const mockMatchRoute = matchRoute as jest.Mock;
|
||||
|
||||
const mockUrlQuery = {
|
||||
get: jest.fn(),
|
||||
@@ -71,7 +74,7 @@ describe('ShareURLModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseLocation.mockReturnValue({
|
||||
mockUseAppLocation.mockReturnValue({
|
||||
pathname: TEST_PATH,
|
||||
});
|
||||
|
||||
@@ -88,7 +91,7 @@ describe('ShareURLModal', () => {
|
||||
|
||||
mockUseCopyToClipboard.mockReturnValue([null, mockHandleCopyToClipboard]);
|
||||
|
||||
mockMatchPath.mockReturnValue(false);
|
||||
mockMatchRoute.mockReturnValue(null);
|
||||
|
||||
// Reset URL query mocks - all return null by default
|
||||
mockUrlQuery.get.mockReturnValue(null);
|
||||
@@ -126,7 +129,7 @@ describe('ShareURLModal', () => {
|
||||
});
|
||||
|
||||
it('should show absolute time toggle when on time-enabled route', () => {
|
||||
mockMatchPath.mockReturnValue(true); // Simulate being on a route that supports time
|
||||
mockMatchRoute.mockReturnValue({}); // Simulate being on a route that supports time
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
@@ -146,7 +149,7 @@ describe('ShareURLModal', () => {
|
||||
|
||||
it('should toggle absolute time switch', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
mockMatchRoute.mockReturnValue({});
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: '5min', // Non-custom time should enable absolute time by default
|
||||
});
|
||||
@@ -169,7 +172,7 @@ describe('ShareURLModal', () => {
|
||||
// Invalid - missing start and end time for custom
|
||||
mockUrlQuery.get.mockReturnValue(null);
|
||||
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
mockMatchRoute.mockReturnValue({});
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
@@ -181,7 +184,7 @@ describe('ShareURLModal', () => {
|
||||
|
||||
it('should process URL with absolute time for non-custom time', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
mockMatchRoute.mockReturnValue({});
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: '5min',
|
||||
});
|
||||
@@ -200,7 +203,7 @@ describe('ShareURLModal', () => {
|
||||
|
||||
it('should process URL with custom time parameters', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
mockMatchRoute.mockReturnValue({});
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: 'custom',
|
||||
});
|
||||
@@ -228,7 +231,7 @@ describe('ShareURLModal', () => {
|
||||
|
||||
it('should process URL with relative time when absolute time is disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockMatchPath.mockReturnValue(true);
|
||||
mockMatchRoute.mockReturnValue({});
|
||||
mockUseSelector.mockReturnValue({
|
||||
selectedTime: '5min',
|
||||
});
|
||||
@@ -249,12 +252,12 @@ describe('ShareURLModal', () => {
|
||||
|
||||
it('should handle routes that should be shared with time', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseLocation.mockReturnValue({
|
||||
mockUseAppLocation.mockReturnValue({
|
||||
pathname: ROUTES.LOGS_EXPLORER,
|
||||
});
|
||||
|
||||
mockMatchPath.mockImplementation(
|
||||
(pathname: string, options: any) => options.path === ROUTES.LOGS_EXPLORER,
|
||||
mockMatchRoute.mockImplementation((pathname: string, route: string) =>
|
||||
route === ROUTES.LOGS_EXPLORER ? {} : null,
|
||||
);
|
||||
|
||||
render(<ShareURLModal />);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -43,7 +43,7 @@ function LaunchChatSupport({
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
|
||||
const isChatSupportEnabled = useMemo(() => {
|
||||
if (!isFetchingFeatureFlags && (featureFlags || featureFlagsFetchError)) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
|
||||
import styles from './LogHighlights.module.scss';
|
||||
|
||||
@@ -8,7 +8,7 @@ interface TraceIdFieldProps {
|
||||
|
||||
function TraceIdField({ traceId }: TraceIdFieldProps): JSX.Element {
|
||||
return (
|
||||
<Link
|
||||
<AppLink
|
||||
to={{ pathname: `/trace/${traceId}` }}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@@ -16,7 +16,7 @@ function TraceIdField({ traceId }: TraceIdFieldProps): JSX.Element {
|
||||
title={traceId}
|
||||
>
|
||||
{traceId}
|
||||
</Link>
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import {
|
||||
OctagonAlert,
|
||||
Bolt,
|
||||
@@ -14,7 +13,7 @@ import { Modal, Select, Spin, Tooltip, Tree, TreeDataNode } from 'antd';
|
||||
import { OnboardingStatusResponse } from 'api/messagingQueues/onboarding/getOnboardingStatus';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History } from 'history';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import {
|
||||
KAFKA_SETUP_DOC_LINK,
|
||||
@@ -45,7 +44,6 @@ export enum AttributesFilters {
|
||||
function ErrorTitleAndKey({
|
||||
title,
|
||||
parentTitle,
|
||||
history,
|
||||
isCloudUserVal,
|
||||
errorMsg,
|
||||
isLeaf,
|
||||
@@ -53,7 +51,6 @@ function ErrorTitleAndKey({
|
||||
title: string;
|
||||
parentTitle: string;
|
||||
isCloudUserVal: boolean;
|
||||
history: History<unknown>;
|
||||
errorMsg?: string;
|
||||
isLeaf?: boolean;
|
||||
}): TreeDataNode {
|
||||
@@ -75,7 +72,7 @@ function ErrorTitleAndKey({
|
||||
}
|
||||
|
||||
if (isCloudUserVal && !!link) {
|
||||
history.push(link);
|
||||
navigate(link);
|
||||
} else {
|
||||
openInNewTab(KAFKA_SETUP_DOC_LINK);
|
||||
}
|
||||
@@ -149,7 +146,6 @@ function generateTreeDataNodes(
|
||||
response: OnboardingStatusResponse['data'],
|
||||
parentTitle: string,
|
||||
isCloudUserVal: boolean,
|
||||
history: History<unknown>,
|
||||
): TreeDataNode[] {
|
||||
return response
|
||||
.map((item) => {
|
||||
@@ -162,7 +158,6 @@ function generateTreeDataNodes(
|
||||
title: item.attribute,
|
||||
errorMsg: item.error_message || '',
|
||||
parentTitle,
|
||||
history,
|
||||
isCloudUserVal,
|
||||
});
|
||||
}
|
||||
@@ -185,7 +180,6 @@ function AttributeCheckList({
|
||||
setFilter(value);
|
||||
};
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
const filteredData = onboardingStatusResponses.map((response) => {
|
||||
@@ -195,7 +189,6 @@ function AttributeCheckList({
|
||||
errorMsg: response.errorMsg,
|
||||
isLeaf: true,
|
||||
parentTitle: response.title,
|
||||
history,
|
||||
isCloudUserVal,
|
||||
});
|
||||
}
|
||||
@@ -213,7 +206,6 @@ function AttributeCheckList({
|
||||
filteredData,
|
||||
response.title,
|
||||
isCloudUserVal,
|
||||
history,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render } from '@testing-library/react';
|
||||
import store from 'store';
|
||||
import { TestRouter } from 'tests/router';
|
||||
|
||||
import NotFound from './index';
|
||||
|
||||
describe('Not Found page test', () => {
|
||||
it('should render Not Found page without errors', () => {
|
||||
const { asFragment } = render(
|
||||
<MemoryRouter>
|
||||
<TestRouter>
|
||||
<Provider store={store}>
|
||||
<NotFound />
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
</TestRouter>,
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Button = styled(Link)`
|
||||
export const Button = styled(AppLink)`
|
||||
border: 2px solid #2f80ed;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/* 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,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
@@ -0,0 +1,77 @@
|
||||
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;
|
||||
@@ -0,0 +1,53 @@
|
||||
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;
|
||||
@@ -1 +0,0 @@
|
||||
export const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
|
||||
@@ -2,9 +2,10 @@ 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;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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,6 +6,44 @@
|
||||
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,7 +11,6 @@ import {
|
||||
QuickFiltersSource,
|
||||
} from '../../../types';
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
import { DEFAULT_VISIBLE_ITEMS_COUNT } from '../constants';
|
||||
|
||||
export const DEFAULT_FILTER: IQuickFiltersConfig = {
|
||||
type: FiltersType.CHECKBOX,
|
||||
@@ -31,13 +30,6 @@ 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,6 +1,7 @@
|
||||
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 {
|
||||
@@ -16,7 +17,6 @@ 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,8 +45,14 @@ 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);
|
||||
|
||||
@@ -115,14 +121,6 @@ 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
|
||||
@@ -168,22 +166,19 @@ 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}
|
||||
canReset={isSomeFilterPresentForCurrentAttribute}
|
||||
onToggleOpen={handleToggleOpen}
|
||||
actionsClassName={classNames(styles.sectionActions, {
|
||||
[styles.sectionActionsPinned]: isSearchOpen,
|
||||
})}
|
||||
resetActionClassName={
|
||||
isSearchOpen ? styles.sectionActionHoverOnly : undefined
|
||||
}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
onClear={onClear}
|
||||
/>
|
||||
{isOpen && isLoading && !hasLoadedOnce.current && (
|
||||
@@ -193,9 +188,10 @@ export default function CheckboxFilterV2(
|
||||
)}
|
||||
{isOpen && (!isLoading || hasLoadedOnce.current) && (
|
||||
<>
|
||||
{isSearchShown && (
|
||||
{isSearchOpen && (
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight, Undo2 } from '@signozhq/icons';
|
||||
import { ChevronDown, ChevronRight, Search, 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;
|
||||
canReset: boolean;
|
||||
actionsClassName?: string;
|
||||
resetActionClassName?: string;
|
||||
onToggleOpen: () => void;
|
||||
onToggleSearch: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Header({
|
||||
title,
|
||||
isOpen,
|
||||
canReset,
|
||||
actionsClassName,
|
||||
resetActionClassName,
|
||||
onToggleOpen,
|
||||
onToggleSearch,
|
||||
onClear,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
const [isTitleTruncated, setIsTitleTruncated] = useState(false);
|
||||
@@ -58,14 +64,21 @@ export function CheckboxFilterV2Header({
|
||||
)}
|
||||
<Tooltip title={isTitleTruncated ? title : undefined}>{titleText}</Tooltip>
|
||||
</section>
|
||||
{isOpen && canReset && (
|
||||
<section className={styles.rightAction}>
|
||||
{isOpen && (
|
||||
<section className={classNames(styles.rightAction, actionsClassName)}>
|
||||
<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,7 +10,6 @@ import {
|
||||
buildQueryBuilderOverrides,
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
EXTRA_VALUES,
|
||||
getFilterFromCall,
|
||||
mockFieldsValuesAPI,
|
||||
renderWithFilter,
|
||||
@@ -19,25 +18,6 @@ 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 () => {
|
||||
@@ -50,7 +30,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
const values =
|
||||
searchTextReceived === ''
|
||||
? ['production', 'staging', 'development', ...EXTRA_VALUES]
|
||||
? ['production', 'staging', 'development']
|
||||
: ['production'];
|
||||
|
||||
return res(
|
||||
@@ -80,6 +60,7 @@ 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');
|
||||
|
||||
@@ -92,41 +73,6 @@ 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 () => {
|
||||
@@ -146,7 +92,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging', 'development', ...EXTRA_VALUES],
|
||||
stringValues: ['staging', 'development'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -198,6 +144,7 @@ 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');
|
||||
|
||||
@@ -225,10 +172,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues:
|
||||
searchText === ''
|
||||
? ['prod', 'staging', ...EXTRA_VALUES]
|
||||
: ['prod-match'],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : ['prod-match'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -250,6 +194,7 @@ 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');
|
||||
|
||||
@@ -275,8 +220,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues:
|
||||
searchText === '' ? ['prod', 'staging', ...EXTRA_VALUES] : [],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : [],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -295,6 +239,7 @@ 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');
|
||||
|
||||
@@ -371,9 +316,7 @@ 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', ...EXTRA_VALUES]
|
||||
: ['pod-a-v2']; // filtered to match 'pod-a'
|
||||
searchText === '' ? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2'] : ['pod-a-v2']; // filtered to match 'pod-a'
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
@@ -404,6 +347,7 @@ 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');
|
||||
|
||||
@@ -481,113 +425,6 @@ 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', () => {
|
||||
@@ -683,7 +520,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides reset when the section has no filter applied', async () => {
|
||||
it('shows the reset action when expanded even with no active filter', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
@@ -698,9 +535,45 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
// 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();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when clear clicked', async () => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
EXTRA_VALUES,
|
||||
mockFieldsValuesAPI,
|
||||
mockFieldsValuesAPILoading,
|
||||
setupServer,
|
||||
@@ -90,7 +89,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: ['production', 'staging', ...EXTRA_VALUES],
|
||||
stringValues: ['production', 'staging'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -111,6 +110,7 @@ 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,9 +30,12 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
});
|
||||
|
||||
it('does not render reset when collapsed', () => {
|
||||
it('does not render the section actions 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();
|
||||
@@ -47,18 +50,13 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
});
|
||||
|
||||
it('renders reset when the section has a filter applied', () => {
|
||||
it('renders both search and reset actions when expanded', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
|
||||
|
||||
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();
|
||||
screen.getByTestId('checkbox-filter-search-toggle'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +99,25 @@ 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,6 +34,7 @@ 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';
|
||||
@@ -309,6 +310,25 @@ 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
|
||||
@@ -320,8 +340,9 @@ 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 (
|
||||
return useFieldApis ? (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
@@ -330,6 +351,14 @@ 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
@@ -411,4 +440,5 @@ QuickFilters.defaultProps = {
|
||||
config: [],
|
||||
showFilterCollapse: true,
|
||||
showQueryName: true,
|
||||
useFieldApis: undefined,
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ 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';
|
||||
|
||||
@@ -62,20 +61,12 @@ 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 : openViews}
|
||||
isViewsOpen={isViewsListOpen}
|
||||
onToggleViews={isViewsListPinned ? undefined : toggleViews}
|
||||
onOpenViews={
|
||||
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
|
||||
}
|
||||
onCollapse={hasQuickFilters ? undefined : onToggleFilters}
|
||||
/>
|
||||
) : undefined;
|
||||
@@ -86,7 +77,6 @@ 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}>
|
||||
@@ -108,7 +98,9 @@ function QuickFiltersLayout({
|
||||
{hasSavedViews && isViewsListVisible && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={isViewsListPinned ? undefined : closeViews}
|
||||
onClose={
|
||||
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,6 @@ jest.mock('../../QuickFilters', () => ({
|
||||
|
||||
const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
useFieldApis: { startUnixMilli: 0, endUnixMilli: 0 },
|
||||
};
|
||||
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
|
||||
|
||||
@@ -218,62 +217,6 @@ 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
|
||||
@@ -314,9 +257,6 @@ 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();
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
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,13 +11,11 @@ import {
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
fieldValuesResponse,
|
||||
} from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { attributeValuesResponse } from '@/storybook/msw/__story_mockdata__/attributes';
|
||||
import { fieldKeysResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { quickFiltersResponse } from '@/storybook/msw/__story_mockdata__/quickFilters';
|
||||
|
||||
import { FiltersType, QuickFilterCheckboxUseFieldApis } from '../types';
|
||||
import { FiltersType } from '../types';
|
||||
|
||||
const customFilters = [
|
||||
{
|
||||
@@ -63,15 +61,13 @@ export const checkboxConfig = [
|
||||
},
|
||||
];
|
||||
|
||||
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 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 handlers = [
|
||||
@@ -86,7 +82,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']))),
|
||||
),
|
||||
fieldValuesHandler(['checkout', 'frontend', 'payments']),
|
||||
attributeValuesHandler(['checkout', 'frontend', 'payments']),
|
||||
];
|
||||
|
||||
export const loadingFiltersHandlers = [
|
||||
|
||||
@@ -7,9 +7,8 @@ import type { GlobalMockArgs } from '@/storybook/globals';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import {
|
||||
attributeValuesHandler,
|
||||
checkboxConfig,
|
||||
fieldApis,
|
||||
fieldValuesHandler,
|
||||
handlers,
|
||||
LONG_FILTER_VALUES,
|
||||
loadingFiltersHandlers,
|
||||
@@ -35,7 +34,6 @@ const meta = {
|
||||
handleFilterVisibilityChange: (): void => undefined,
|
||||
signal: SignalType.LOGS,
|
||||
source: QuickFiltersSource.LOGS_EXPLORER,
|
||||
useFieldApis: fieldApis,
|
||||
},
|
||||
parameters: {
|
||||
msw: { handlers },
|
||||
@@ -116,7 +114,7 @@ export const SelectedExpandedCheckbox: Story = {
|
||||
export const Tooltips: TooltipsStory = {
|
||||
args: { signal: undefined, tooltipsOpen: true },
|
||||
parameters: {
|
||||
msw: { handlers: [fieldValuesHandler(LONG_FILTER_VALUES), ...handlers] },
|
||||
msw: { handlers: [attributeValuesHandler(LONG_FILTER_VALUES), ...handlers] },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import {
|
||||
otherFiltersResponse,
|
||||
quickFiltersFieldValuesResponse,
|
||||
quickFiltersAttributeValuesResponse,
|
||||
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, QuickFiltersFieldApis } from './constants';
|
||||
import { QuickFiltersConfig } from './constants';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
@@ -38,6 +38,7 @@ 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;
|
||||
@@ -70,8 +71,11 @@ 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(quickFiltersFieldValuesResponse)),
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -87,7 +91,6 @@ function renderWithSignal(): void {
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
signal={SIGNAL}
|
||||
handleFilterVisibilityChange={jest.fn()}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -98,7 +101,6 @@ 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,
|
||||
quickFiltersFieldValuesResponse,
|
||||
quickFiltersAttributeValuesResponse,
|
||||
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, QuickFiltersFieldApis } from './constants';
|
||||
import { QuickFiltersConfig } from './constants';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
@@ -37,6 +37,7 @@ 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';
|
||||
@@ -61,8 +62,11 @@ 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(quickFiltersFieldValuesResponse)),
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -79,7 +83,6 @@ function TestQuickFilters({
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
signal={signal}
|
||||
/>
|
||||
);
|
||||
@@ -102,7 +105,6 @@ 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, QuickFilterCheckboxUseFieldApis } from '../types';
|
||||
import { FiltersType } from '../types';
|
||||
|
||||
export const QuickFiltersConfig = [
|
||||
{
|
||||
@@ -24,9 +24,3 @@ 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,6 +75,9 @@ 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;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router } from 'react-router-dom';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import RouteTab from './index';
|
||||
@@ -10,6 +9,11 @@ jest.mock('./RouteTab.module.scss', () => ({
|
||||
default: { routeTab: 'routeTab' },
|
||||
}));
|
||||
|
||||
jest.mock('lib/router/navigation', () => ({
|
||||
...jest.requireActual('lib/router/navigation'),
|
||||
navigate: jest.fn(),
|
||||
}));
|
||||
|
||||
function DummyComponent1(): JSX.Element {
|
||||
return <div>Dummy Component 1</div>;
|
||||
}
|
||||
@@ -33,63 +37,39 @@ const testRoutes: RouteTabProps['routes'] = [
|
||||
];
|
||||
|
||||
describe('RouteTab component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly', () => {
|
||||
const history = createMemoryHistory();
|
||||
render(
|
||||
<Router history={history}>
|
||||
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
|
||||
</Router>,
|
||||
);
|
||||
render(<RouteTab routes={testRoutes} activeKey="Tab1" />);
|
||||
expect(screen.getByRole('tab', { name: 'Tab1' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Tab2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct number of tabs', () => {
|
||||
const history = createMemoryHistory();
|
||||
render(
|
||||
<Router history={history}>
|
||||
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
|
||||
</Router>,
|
||||
);
|
||||
render(<RouteTab routes={testRoutes} activeKey="Tab1" />);
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs).toHaveLength(testRoutes.length);
|
||||
});
|
||||
|
||||
it('sets provided activeKey as active tab', () => {
|
||||
const history = createMemoryHistory();
|
||||
render(
|
||||
<Router history={history}>
|
||||
<RouteTab history={history} routes={testRoutes} activeKey="Tab2" />
|
||||
</Router>,
|
||||
);
|
||||
render(<RouteTab routes={testRoutes} activeKey="Tab2" />);
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Tab2', selected: true }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to correct route on tab click', () => {
|
||||
const history = createMemoryHistory();
|
||||
render(
|
||||
<Router history={history}>
|
||||
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
|
||||
</Router>,
|
||||
);
|
||||
expect(history.location.pathname).toBe('/');
|
||||
render(<RouteTab routes={testRoutes} activeKey="Tab1" />);
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Tab2' }));
|
||||
expect(history.location.pathname).toBe('/tab2');
|
||||
expect(navigate).toHaveBeenCalledWith('/tab2');
|
||||
});
|
||||
|
||||
it('applies the layout class alongside a custom className', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab
|
||||
history={history}
|
||||
routes={testRoutes}
|
||||
activeKey="Tab1"
|
||||
className="custom-tabs"
|
||||
/>
|
||||
</Router>,
|
||||
<RouteTab routes={testRoutes} activeKey="Tab1" className="custom-tabs" />,
|
||||
);
|
||||
expect(container.querySelector('.ant-tabs')).toHaveClass(
|
||||
'routeTab',
|
||||
@@ -98,11 +78,8 @@ describe('RouteTab component', () => {
|
||||
});
|
||||
|
||||
it('renders the active tab content inside an overlay scrollbar', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
|
||||
</Router>,
|
||||
<RouteTab routes={testRoutes} activeKey="Tab1" />,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('.ant-tabs-tabpane-active > .overlay-scrollbar'),
|
||||
@@ -111,16 +88,12 @@ describe('RouteTab component', () => {
|
||||
|
||||
it('calls onChangeHandler on tab change', () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const history = createMemoryHistory();
|
||||
render(
|
||||
<Router history={history}>
|
||||
<RouteTab
|
||||
routes={testRoutes}
|
||||
activeKey="Tab1"
|
||||
onChangeHandler={onChangeHandler}
|
||||
history={history}
|
||||
/>
|
||||
</Router>,
|
||||
<RouteTab
|
||||
routes={testRoutes}
|
||||
activeKey="Tab1"
|
||||
onChangeHandler={onChangeHandler}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Tab2' }));
|
||||
expect(onChangeHandler).toHaveBeenCalled();
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import {
|
||||
generatePath,
|
||||
matchPath,
|
||||
useLocation,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { buildRoutePath } from 'lib/router/buildRoutePath';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
@@ -28,21 +27,17 @@ function RouteTab({
|
||||
routes,
|
||||
activeKey,
|
||||
onChangeHandler,
|
||||
history,
|
||||
showRightSection,
|
||||
className,
|
||||
...rest
|
||||
}: RouteTabProps & TabsProps): JSX.Element {
|
||||
const params = useParams<Params>();
|
||||
const location = useLocation();
|
||||
const params = useAppParams<Params>();
|
||||
const location = useAppLocation();
|
||||
|
||||
// Find the matching route for the current pathname
|
||||
const currentRoute = routes.find((route) => {
|
||||
const routePath = route.route.split('?')[0];
|
||||
return matchPath(location.pathname, {
|
||||
path: routePath,
|
||||
exact: true,
|
||||
});
|
||||
return matchRoute(location.pathname, routePath, { exact: true });
|
||||
});
|
||||
|
||||
const onChange = (activeRoute: string): void => {
|
||||
@@ -53,8 +48,7 @@ function RouteTab({
|
||||
const selectedRoute = routes.find((e) => e.key === activeRoute);
|
||||
|
||||
if (selectedRoute) {
|
||||
const resolvedRoute = generatePath(selectedRoute.route, params);
|
||||
history.push(resolvedRoute);
|
||||
navigate(buildRoutePath(selectedRoute.route, params));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { TabsProps } from 'antd';
|
||||
import { History } from 'history';
|
||||
|
||||
export type TabRoutes = {
|
||||
name: React.ReactNode;
|
||||
@@ -13,6 +12,5 @@ export interface RouteTabProps {
|
||||
routes: TabRoutes[];
|
||||
activeKey: TabsProps['activeKey'];
|
||||
onChangeHandler?: (key: string) => void;
|
||||
history: History<unknown>;
|
||||
showRightSection: boolean;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
// ---- Mocks (must run BEFORE importing the component) ----
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
@@ -24,61 +24,12 @@ afterAll(() => {
|
||||
delete (HTMLElement.prototype as any).scrollIntoView;
|
||||
});
|
||||
|
||||
// mock history.push / replace / go / location
|
||||
jest.mock('lib/history', () => {
|
||||
const location = { pathname: '/', search: '', hash: '' };
|
||||
jest.mock('lib/router/navigation', () => ({
|
||||
...jest.requireActual('lib/router/navigation'),
|
||||
navigate: jest.fn(),
|
||||
}));
|
||||
|
||||
const stack: { pathname: string; search: string }[] = [
|
||||
{ pathname: '/', search: '' },
|
||||
];
|
||||
|
||||
const push = jest.fn((path: string) => {
|
||||
const [rawPath, rawQuery] = path.split('?');
|
||||
const pathname = rawPath || '/';
|
||||
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
|
||||
|
||||
location.pathname = pathname;
|
||||
location.search = search;
|
||||
|
||||
stack.push({ pathname, search });
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const replace = jest.fn((path: string) => {
|
||||
const [rawPath, rawQuery] = path.split('?');
|
||||
const pathname = rawPath || '/';
|
||||
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
|
||||
|
||||
location.pathname = pathname;
|
||||
location.search = search;
|
||||
|
||||
if (stack.length > 0) {
|
||||
stack[stack.length - 1] = { pathname, search };
|
||||
} else {
|
||||
stack.push({ pathname, search });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const listen = jest.fn();
|
||||
const go = jest.fn((n: number) => {
|
||||
if (n < 0 && stack.length > 1) {
|
||||
stack.pop();
|
||||
}
|
||||
const top = stack[stack.length - 1] || { pathname: '/', search: '' };
|
||||
location.pathname = top.pathname;
|
||||
location.search = top.search;
|
||||
});
|
||||
|
||||
return {
|
||||
push,
|
||||
replace,
|
||||
listen,
|
||||
go,
|
||||
location,
|
||||
__stack: stack,
|
||||
};
|
||||
});
|
||||
const mockNavigate = navigate as jest.MockedFunction<typeof navigate>;
|
||||
|
||||
// Mock ResizeObserver for Jest/jsdom
|
||||
class ResizeObserver {
|
||||
@@ -159,14 +110,14 @@ describe('CmdKPalette', () => {
|
||||
expect(screen.getByText('Switch to Dark Mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking a navigation item calls history.push with correct route', async () => {
|
||||
it('clicking a navigation item navigates to the correct route', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const homeItem = screen.getByText(HOME_LABEL);
|
||||
await user.click(homeItem);
|
||||
|
||||
expect(history.push).toHaveBeenCalledWith(ROUTES.HOME);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(ROUTES.HOME);
|
||||
});
|
||||
|
||||
it('role-based filtering (basic smoke)', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
@@ -23,7 +22,8 @@ import {
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { IS_DEV } from 'lib/env';
|
||||
import history from 'lib/history';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { ROLES as UserRole } from 'types/roles';
|
||||
|
||||
import { createShortcutActions } from '../../constants/shortcutActions';
|
||||
@@ -77,7 +77,7 @@ export function CmdKPalette({
|
||||
const { open, setOpen } = useCmdK();
|
||||
|
||||
const { setAutoSwitch, setTheme, theme } = useThemeMode();
|
||||
const location = useLocation();
|
||||
const location = useAppLocation();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
@@ -120,7 +120,7 @@ export function CmdKPalette({
|
||||
}
|
||||
|
||||
function onClickHandler(key: string): void {
|
||||
history.push(key);
|
||||
navigate(key);
|
||||
}
|
||||
|
||||
const handleOpenAIAssistant = (): void => {
|
||||
|
||||
@@ -2,7 +2,6 @@ export const GlobalShortcuts = {
|
||||
NavigateToServices: 'shift+s',
|
||||
NavigateToDashboards: 'shift+d',
|
||||
NavigateToAlerts: 'shift+a',
|
||||
NavigateToNotificationChannels: 'shift+g+n',
|
||||
NavigateToExceptions: 'shift+e',
|
||||
NavigateToMessagingQueues: 'shift+q',
|
||||
ToggleSidebar: 'shift+b',
|
||||
@@ -24,6 +23,7 @@ export const GlobalShortcuts = {
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToSettingsServiceAccounts: 'shift+g+k',
|
||||
NavigateToSettingsRoles: 'shift+g+r',
|
||||
NavigateToSettingsMembers: 'shift+g+m',
|
||||
@@ -34,7 +34,6 @@ export const GlobalShortcutsName = {
|
||||
NavigateToTraces: 'shift+t',
|
||||
NavigateToDashboards: 'shift+d',
|
||||
NavigateToAlerts: 'shift+a',
|
||||
NavigateToNotificationChannels: 'shift+g+n',
|
||||
NavigateToExceptions: 'shift+e',
|
||||
NavigateToMessagingQueues: 'shift+q',
|
||||
ToggleSidebar: 'shift+b',
|
||||
@@ -45,6 +44,7 @@ export const GlobalShortcutsName = {
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToSettingsServiceAccounts: 'shift+g+k',
|
||||
NavigateToSettingsRoles: 'shift+g+r',
|
||||
NavigateToSettingsMembers: 'shift+g+m',
|
||||
@@ -59,7 +59,6 @@ export const GlobalShortcutsDescription = {
|
||||
NavigateToLogs: 'Navigate to Logs Explorer',
|
||||
NavigateToDashboards: 'Navigate to Dashboards List',
|
||||
NavigateToAlerts: 'Navigate to Alerts List',
|
||||
NavigateToNotificationChannels: 'Navigate to Notification Channels',
|
||||
NavigateToExceptions: 'Navigate to Exceptions List',
|
||||
NavigateToMessagingQueues: 'Navigate to Messaging Queues',
|
||||
ToggleSidebar: 'Toggle sidebar visibility',
|
||||
@@ -69,6 +68,8 @@ export const GlobalShortcutsDescription = {
|
||||
NavigateToSettings: 'Navigate to Settings',
|
||||
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
|
||||
NavigateToSettingsBilling: 'Navigate to Billing Settings',
|
||||
NavigateToSettingsNotificationChannels:
|
||||
'Navigate to Notification Channels Settings',
|
||||
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
|
||||
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
|
||||
NavigateToSettingsRoles: 'Navigate to Roles Settings',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Drawer } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -12,8 +12,6 @@ import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { VariantContext } from '../VariantContext';
|
||||
|
||||
export default function AIAssistantDrawer(): JSX.Element {
|
||||
const history = useHistory();
|
||||
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
@@ -28,10 +26,10 @@ export default function AIAssistantDrawer(): JSX.Element {
|
||||
return;
|
||||
}
|
||||
closeDrawer();
|
||||
history.push(
|
||||
navigate(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
);
|
||||
}, [activeConversationId, closeDrawer, history]);
|
||||
}, [activeConversationId, closeDrawer]);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
startNewConversation();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History, Maximize2, Minus, Plus, X } from '@signozhq/icons';
|
||||
@@ -31,8 +32,7 @@ import styles from './AIAssistantModal.module.scss';
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function AIAssistantModal(): JSX.Element | null {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const isOpen = useAIAssistantStore((s) => s.isModalOpen);
|
||||
@@ -94,11 +94,11 @@ export default function AIAssistantModal(): JSX.Element | null {
|
||||
// Router state tells AIAssistantPage to skip its mount-time Opened fire:
|
||||
// the assistant was already open in the modal, so this is a surface
|
||||
// switch, not a new open.
|
||||
history.push(
|
||||
navigate(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
{ fromInApp: true },
|
||||
{ state: { fromInApp: true } },
|
||||
);
|
||||
}, [activeConversationId, closeModal, history]);
|
||||
}, [activeConversationId, closeModal]);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
void logEvent(AIAssistantEvents.NewChatClicked, {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { matchPath, useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History, Maximize2, Plus, X } from '@signozhq/icons';
|
||||
@@ -21,13 +23,11 @@ const AI_ASSISTANT_PANEL_OPEN_CLASS = 'ai-assistant-panel-open';
|
||||
const AI_ASSISTANT_PANEL_WIDTH_VAR = '--ai-assistant-panel-width';
|
||||
|
||||
export default function AIAssistantPanel(): JSX.Element | null {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const isOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const isFullScreenPage = !!matchPath(pathname, {
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
const isFullScreenPage = !!matchRoute(pathname, ROUTES.AI_ASSISTANT, {
|
||||
exact: true,
|
||||
});
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
@@ -47,11 +47,11 @@ export default function AIAssistantPanel(): JSX.Element | null {
|
||||
// Router state tells AIAssistantPage to skip its mount-time Opened fire:
|
||||
// the assistant was already open in the drawer, so this is a surface
|
||||
// switch, not a new open.
|
||||
history.push(
|
||||
navigate(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
{ fromInApp: true },
|
||||
{ state: { fromInApp: true } },
|
||||
);
|
||||
}, [activeConversationId, closeDrawer, history]);
|
||||
}, [activeConversationId, closeDrawer]);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
void logEvent(AIAssistantEvents.NewChatClicked, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -21,12 +22,11 @@ import styles from './AIAssistantTrigger.module.scss';
|
||||
* Hidden when the panel is already open or when on the full-screen AI Assistant page.
|
||||
*/
|
||||
export default function AIAssistantTrigger(): JSX.Element | null {
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
|
||||
|
||||
const isFullScreenPage = !!matchPath(pathname, {
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
const isFullScreenPage = !!matchRoute(pathname, ROUTES.AI_ASSISTANT, {
|
||||
exact: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import cx from 'classnames';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function ConversationView({
|
||||
}: ConversationViewProps): JSX.Element {
|
||||
const variant = useVariant();
|
||||
const isCompact = variant === 'panel';
|
||||
const location = useLocation();
|
||||
const location = useAppLocation();
|
||||
|
||||
const conversation = useAIAssistantStore(
|
||||
(s) => s.conversations[conversationId],
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { MessageContext } from 'api/ai-assistant/chat';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { VariantContext } from 'container/AIAssistant/VariantContext';
|
||||
import { TestRouter } from 'tests/router';
|
||||
|
||||
const CHIP_ID = 'recent-errors';
|
||||
const CHIP_TEXT = 'Show me recent errors';
|
||||
@@ -87,11 +86,11 @@ function renderView(variant: 'panel' | 'page' | 'modal'): {
|
||||
getByTestId: (id: string) => HTMLElement;
|
||||
} {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/dashboard/dashboard-123']}>
|
||||
<TestRouter initialRoute="/dashboard/dashboard-123">
|
||||
<VariantContext.Provider value={variant}>
|
||||
<ConversationView conversationId={CONVERSATION_ID} />
|
||||
</VariantContext.Provider>
|
||||
</MemoryRouter>,
|
||||
</TestRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { matchPath, useHistory, useLocation } from 'react-router-dom';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
@@ -213,20 +215,11 @@ function signalMatchesPathname(
|
||||
): boolean {
|
||||
switch (signal) {
|
||||
case ApplyFilterSignalDTO.logs:
|
||||
return Boolean(
|
||||
matchPath(pathname, { path: ROUTES.LOGS_EXPLORER, exact: false }),
|
||||
);
|
||||
return Boolean(matchRoute(pathname, ROUTES.LOGS_EXPLORER));
|
||||
case ApplyFilterSignalDTO.traces:
|
||||
return Boolean(
|
||||
matchPath(pathname, { path: ROUTES.TRACES_EXPLORER, exact: false }),
|
||||
);
|
||||
return Boolean(matchRoute(pathname, ROUTES.TRACES_EXPLORER));
|
||||
case ApplyFilterSignalDTO.metrics:
|
||||
return Boolean(
|
||||
matchPath(pathname, {
|
||||
path: ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
exact: false,
|
||||
}),
|
||||
);
|
||||
return Boolean(matchRoute(pathname, ROUTES.METRICS_EXPLORER_EXPLORER));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -275,7 +268,6 @@ function explorerRouteForSignal(signal: ApplyFilterSignalDTO): string | null {
|
||||
}
|
||||
|
||||
interface ApplyFilterDeps {
|
||||
history: ReturnType<typeof useHistory>;
|
||||
pathname: string;
|
||||
redirectWithQueryBuilderData: ReturnType<
|
||||
typeof useQueryBuilder
|
||||
@@ -317,8 +309,8 @@ function withDerivedFilterExpressions(query: Query): Query {
|
||||
* page-action recipe — calling redirect alone is not sufficient because
|
||||
* the URL→state effect runs after the next render and the editor binds
|
||||
* to `filter.expression`, not `filters.items`.
|
||||
* - Off-page: use `history.push` so the landing explorer initializes from
|
||||
* the new URL on mount.
|
||||
* - Off-page: `navigate()` so the landing explorer initializes from the
|
||||
* new URL on mount.
|
||||
*/
|
||||
function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -360,9 +352,9 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] off-page → history.push', base);
|
||||
console.log('[apply_filter] off-page → navigate', base);
|
||||
const encoded = encodeURIComponent(JSON.stringify(normalized));
|
||||
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
|
||||
navigate(`${base}?${QueryParams.compositeQuery}=${encoded}`);
|
||||
}
|
||||
|
||||
/** Picks the right rollback API call for a given action kind. */
|
||||
@@ -391,8 +383,7 @@ export default function ActionsSection({
|
||||
actions,
|
||||
messageId,
|
||||
}: ActionsSectionProps): JSX.Element | null {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
|
||||
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
|
||||
@@ -424,19 +415,12 @@ export default function ActionsSection({
|
||||
}
|
||||
autoAppliedFilterKeys.add(key);
|
||||
applyFilter(action, {
|
||||
history,
|
||||
pathname,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
});
|
||||
});
|
||||
}, [
|
||||
actions,
|
||||
pathname,
|
||||
history,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
]);
|
||||
}, [actions, pathname, redirectWithQueryBuilderData, handleSetQueryData]);
|
||||
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
@@ -456,11 +440,7 @@ export default function ActionsSection({
|
||||
}
|
||||
setResult(key, { state: 'loading' });
|
||||
try {
|
||||
await openSavedViewByKey(
|
||||
resourceId,
|
||||
resolveSavedViewSourceHint(action),
|
||||
history,
|
||||
);
|
||||
await openSavedViewByKey(resourceId, resolveSavedViewSourceHint(action));
|
||||
void logEvent(AIAssistantEvents.ResourceOpened, {
|
||||
threadId,
|
||||
messageId,
|
||||
@@ -515,7 +495,7 @@ export default function ActionsSection({
|
||||
targetModule: targetModuleForResource(resourceType),
|
||||
resourceId,
|
||||
});
|
||||
history.push(path);
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const handleClick = (key: string, action: MessageActionDTO): void => {
|
||||
@@ -576,7 +556,6 @@ export default function ActionsSection({
|
||||
});
|
||||
}
|
||||
applyFilter(action, {
|
||||
history,
|
||||
pathname,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
|
||||
@@ -16,10 +16,10 @@ import {
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import type { History } from 'history';
|
||||
|
||||
import {
|
||||
buildExplorerNavigationUrl,
|
||||
@@ -39,6 +39,9 @@ import {
|
||||
import { resourceRoute, ResourceType } from '../resourceRoute';
|
||||
|
||||
jest.mock('api/generated/services/saved-view');
|
||||
jest.mock('lib/router/navigation', () => ({
|
||||
navigate: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
|
||||
@@ -60,6 +63,7 @@ const mockedListSavedViews = listSavedViews as jest.MockedFunction<
|
||||
const mockedGetSavedView = getSavedView as jest.MockedFunction<
|
||||
typeof getSavedView
|
||||
>;
|
||||
const mockedNavigate = navigate as jest.MockedFunction<typeof navigate>;
|
||||
|
||||
function makeView(
|
||||
id: string,
|
||||
@@ -247,15 +251,17 @@ describe('buildExplorerNavigationUrl', () => {
|
||||
});
|
||||
|
||||
describe('openSavedView', () => {
|
||||
it('navigates with history.push and view query params', () => {
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
beforeEach(() => {
|
||||
mockedNavigate.mockClear();
|
||||
});
|
||||
|
||||
it('navigates with the view query params', () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
|
||||
openSavedView(view, history);
|
||||
openSavedView(view);
|
||||
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
const pushedUrl = push.mock.calls[0][0] as string;
|
||||
expect(mockedNavigate).toHaveBeenCalledTimes(1);
|
||||
const pushedUrl = mockedNavigate.mock.calls[0][0] as string;
|
||||
expect(pushedUrl).toContain(ROUTES.LOGS_EXPLORER);
|
||||
const params = new URLSearchParams(pushedUrl.split('?')[1]);
|
||||
expect(params.get(QueryParams.viewKey)).toBe('"view-logs"');
|
||||
@@ -267,18 +273,14 @@ describe('openSavedView', () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
delete view.source;
|
||||
|
||||
expect(() =>
|
||||
openSavedView(view, { push: jest.fn() } as unknown as History),
|
||||
).toThrow('Unsupported saved view source');
|
||||
expect(() => openSavedView(view)).toThrow('Unsupported saved view source');
|
||||
});
|
||||
|
||||
it('throws when the view has no queries', () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
view.spec.queries = [];
|
||||
|
||||
expect(() =>
|
||||
openSavedView(view, { push: jest.fn() } as unknown as History),
|
||||
).toThrow('Saved view is missing query data');
|
||||
expect(() => openSavedView(view)).toThrow('Saved view is missing query data');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -286,44 +288,39 @@ describe('openSavedViewByKey', () => {
|
||||
beforeEach(() => {
|
||||
mockedListSavedViews.mockReset();
|
||||
mockedGetSavedView.mockReset();
|
||||
mockedNavigate.mockClear();
|
||||
});
|
||||
|
||||
it('prefers the direct view lookup endpoint', async () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
mockedGetSavedView.mockResolvedValueOnce(mockViewByIdResponse(view));
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
|
||||
await openSavedViewByKey('view-logs', DataSource.LOGS, history);
|
||||
await openSavedViewByKey('view-logs', DataSource.LOGS);
|
||||
|
||||
expect(mockedGetSavedView).toHaveBeenCalledWith({ id: 'view-logs' });
|
||||
expect(mockedListSavedViews).not.toHaveBeenCalled();
|
||||
expect(push).toHaveBeenCalled();
|
||||
expect(mockedNavigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to list probing when direct lookup fails', async () => {
|
||||
const view = makeView('view-traces', SavedviewtypesSourceDTO.traces);
|
||||
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedListSavedViews.mockResolvedValueOnce(mockViewsResponse([view]));
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
|
||||
await openSavedViewByKey('view-traces', DataSource.TRACES, history);
|
||||
await openSavedViewByKey('view-traces', DataSource.TRACES);
|
||||
|
||||
expect(mockedListSavedViews).toHaveBeenCalledWith({
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
});
|
||||
expect(push).toHaveBeenCalled();
|
||||
expect(mockedNavigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the saved view does not exist', async () => {
|
||||
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedListSavedViews.mockResolvedValue(mockViewsResponse([]));
|
||||
|
||||
await expect(
|
||||
openSavedViewByKey('missing', DataSource.LOGS, {
|
||||
push: jest.fn(),
|
||||
} as unknown as History),
|
||||
).rejects.toThrow('Saved view not found');
|
||||
await expect(openSavedViewByKey('missing', DataSource.LOGS)).rejects.toThrow(
|
||||
'Saved view not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,10 +9,10 @@ import { SavedViewSourcePage } from 'container/SavedViews/types';
|
||||
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { History } from 'history';
|
||||
|
||||
type SavedViewSourceHint = SavedViewSourcePage;
|
||||
|
||||
@@ -92,10 +92,7 @@ export function buildExplorerNavigationUrl(
|
||||
return `${route}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function openSavedView(
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
history: History,
|
||||
): void {
|
||||
export function openSavedView(view: SavedviewtypesSavedViewDTO): void {
|
||||
const route = view.source ? explorerRouteForSourcePage(view.source) : null;
|
||||
if (!route) {
|
||||
throw new Error('Unsupported saved view source');
|
||||
@@ -111,14 +108,13 @@ export function openSavedView(
|
||||
[QueryParams.viewName]: view.spec.displayName,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
history.push(url);
|
||||
navigate(url);
|
||||
}
|
||||
|
||||
export async function openSavedViewByKey(
|
||||
viewKey: string,
|
||||
sourceHint: SavedViewSourceHint | null | undefined,
|
||||
history: History,
|
||||
): Promise<void> {
|
||||
const view = await loadSavedView(viewKey, sourceHint);
|
||||
openSavedView(view, history);
|
||||
openSavedView(view);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ export const AIAssistantOpenSource = {
|
||||
Shortcut: 'shortcut',
|
||||
Cmdk: 'cmdk',
|
||||
TraceDetails: 'trace_details',
|
||||
BottomStrip: 'bottom_strip',
|
||||
} as const;
|
||||
export type AIAssistantOpenSource =
|
||||
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { MessageContext } from 'api/ai-assistant/chat';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { AlertListTabs } from 'pages/AlertList/types';
|
||||
import { NEW_PANEL_ID } from 'pages/DashboardPage/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Resolves the page the user is currently on into structured `MessageContext`
|
||||
@@ -33,9 +33,10 @@ export function getAutoContexts(
|
||||
|
||||
// Panel editor (V2). `panel/new` has no widget id yet and the schema requires
|
||||
// a non-empty `panel_edit.widgetId`, so it reports `panel_create` instead.
|
||||
const panelEditorMatch = matchPath<{ dashboardId: string; panelId: string }>(
|
||||
const panelEditorMatch = matchRoute<'dashboardId' | 'panelId'>(
|
||||
pathname,
|
||||
{ path: ROUTES.DASHBOARD_PANEL_EDITOR, exact: true },
|
||||
ROUTES.DASHBOARD_PANEL_EDITOR,
|
||||
{ exact: true },
|
||||
);
|
||||
if (panelEditorMatch) {
|
||||
const { dashboardId, panelId } = panelEditorMatch.params;
|
||||
@@ -55,8 +56,7 @@ export function getAutoContexts(
|
||||
// Dashboard detail — `/dashboard/:dashboardId`. The `expandedWidgetId`
|
||||
// query param signals the panel-fullscreen overlay; otherwise it's the
|
||||
// plain dashboard view.
|
||||
const dashboardMatch = matchPath<{ dashboardId: string }>(pathname, {
|
||||
path: ROUTES.DASHBOARD,
|
||||
const dashboardMatch = matchRoute<'dashboardId'>(pathname, ROUTES.DASHBOARD, {
|
||||
exact: true,
|
||||
});
|
||||
if (dashboardMatch) {
|
||||
@@ -89,7 +89,7 @@ export function getAutoContexts(
|
||||
}
|
||||
|
||||
// Dashboard list — `/dashboard`.
|
||||
if (matchPath(pathname, { path: ROUTES.ALL_DASHBOARD, exact: true })) {
|
||||
if (matchRoute(pathname, ROUTES.ALL_DASHBOARD, { exact: true })) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
@@ -106,8 +106,8 @@ export function getAutoContexts(
|
||||
// or `/alerts/history?ruleId=…`. Mirrors dashboard_detail: resourceId is the
|
||||
// rule id and shared metadata carries the URL time range when present.
|
||||
if (
|
||||
matchPath(pathname, { path: ROUTES.ALERT_OVERVIEW, exact: true }) ||
|
||||
matchPath(pathname, { path: ROUTES.ALERT_HISTORY, exact: true })
|
||||
matchRoute(pathname, ROUTES.ALERT_OVERVIEW, { exact: true }) ||
|
||||
matchRoute(pathname, ROUTES.ALERT_HISTORY, { exact: true })
|
||||
) {
|
||||
const ruleId = params.get(QueryParams.ruleId);
|
||||
if (ruleId) {
|
||||
@@ -129,7 +129,7 @@ export function getAutoContexts(
|
||||
// Alert edit — `/alerts/edit?ruleId=…`. The form syncs its query-builder
|
||||
// state to the URL (`useShareBuilderUrl`), so shared metadata carries the
|
||||
// alert's query + time range, mirroring the dashboard panel editor.
|
||||
if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) {
|
||||
if (matchRoute(pathname, ROUTES.EDIT_ALERTS, { exact: true })) {
|
||||
const ruleId = params.get(QueryParams.ruleId);
|
||||
if (ruleId) {
|
||||
return [
|
||||
@@ -145,7 +145,7 @@ export function getAutoContexts(
|
||||
|
||||
// Alert new — `/alerts/new`. No rule id yet (draft), but the query-builder
|
||||
// state is on the URL, so shared metadata carries the in-progress query.
|
||||
if (matchPath(pathname, { path: ROUTES.ALERTS_NEW, exact: true })) {
|
||||
if (matchRoute(pathname, ROUTES.ALERTS_NEW, { exact: true })) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
@@ -157,7 +157,7 @@ export function getAutoContexts(
|
||||
}
|
||||
|
||||
// Triggered-alerts index — `/alerts/history` without a rule id.
|
||||
if (matchPath(pathname, { path: ROUTES.ALERT_HISTORY, exact: true })) {
|
||||
if (matchRoute(pathname, ROUTES.ALERT_HISTORY, { exact: true })) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
@@ -172,7 +172,7 @@ export function getAutoContexts(
|
||||
}
|
||||
|
||||
// Alerts index — `/alerts` with tab query param (defaults to Alert Rules).
|
||||
if (matchPath(pathname, { path: ROUTES.LIST_ALL_ALERT, exact: true })) {
|
||||
if (matchRoute(pathname, ROUTES.LIST_ALL_ALERT, { exact: true })) {
|
||||
const page = resolveAlertsIndexPage(params.get(QueryParams.tab));
|
||||
return [
|
||||
{
|
||||
@@ -191,10 +191,10 @@ export function getAutoContexts(
|
||||
|
||||
// Service detail (covers sub-routes like top-level-operations) —
|
||||
// `/services/:servicename[/...]`.
|
||||
const serviceMatch = matchPath<{ servicename: string }>(pathname, {
|
||||
path: ROUTES.SERVICE_METRICS,
|
||||
exact: false,
|
||||
});
|
||||
const serviceMatch = matchRoute<'servicename'>(
|
||||
pathname,
|
||||
ROUTES.SERVICE_METRICS,
|
||||
);
|
||||
if (serviceMatch?.params.servicename) {
|
||||
return [
|
||||
{
|
||||
@@ -210,7 +210,7 @@ export function getAutoContexts(
|
||||
}
|
||||
|
||||
// Services list — `/services`.
|
||||
if (matchPath(pathname, { path: ROUTES.APPLICATION, exact: true })) {
|
||||
if (matchRoute(pathname, ROUTES.APPLICATION, { exact: true })) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
@@ -226,7 +226,7 @@ export function getAutoContexts(
|
||||
|
||||
// ── Logs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if (matchPath(pathname, { path: ROUTES.LOGS_EXPLORER, exact: false })) {
|
||||
if (matchRoute(pathname, ROUTES.LOGS_EXPLORER)) {
|
||||
const activeLogId = params.get(QueryParams.activeLogId);
|
||||
// `?activeLogId=…` indicates a log-detail panel is open. Per the
|
||||
// schema, log_detail requires payload fields (timestamp, service,
|
||||
@@ -251,8 +251,7 @@ export function getAutoContexts(
|
||||
|
||||
// Trace detail — `/trace/:id`. Treated as a detail-as-metadata page
|
||||
// (resourceId null, `traceId` lives in metadata).
|
||||
const traceMatch = matchPath<{ id: string }>(pathname, {
|
||||
path: ROUTES.TRACE_DETAIL,
|
||||
const traceMatch = matchRoute<'id'>(pathname, ROUTES.TRACE_DETAIL, {
|
||||
exact: true,
|
||||
});
|
||||
if (traceMatch?.params.id) {
|
||||
@@ -272,7 +271,7 @@ export function getAutoContexts(
|
||||
];
|
||||
}
|
||||
|
||||
if (matchPath(pathname, { path: ROUTES.TRACES_EXPLORER, exact: false })) {
|
||||
if (matchRoute(pathname, ROUTES.TRACES_EXPLORER)) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
@@ -289,9 +288,7 @@ export function getAutoContexts(
|
||||
// ── Metrics ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Metrics explorer — `/metrics-explorer` and sub-routes (summary, explorer, views).
|
||||
if (
|
||||
matchPath(pathname, { path: ROUTES.METRICS_EXPLORER_BASE, exact: false })
|
||||
) {
|
||||
if (matchRoute(pathname, ROUTES.METRICS_EXPLORER_BASE)) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
|
||||
import ROUTES from 'constants/routes';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { useVariant } from '../VariantContext';
|
||||
@@ -28,7 +28,7 @@ const ROUTE_TEMPLATES = Object.values(ROUTES).sort(
|
||||
|
||||
export function normalizePage(pathname: string): string {
|
||||
for (const template of ROUTE_TEMPLATES) {
|
||||
if (matchPath(pathname, { path: template, exact: true })) {
|
||||
if (matchRoute(pathname, template, { exact: true })) {
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function normalizePage(pathname: string): string {
|
||||
export function useAIAssistantAnalyticsContext(
|
||||
conversationId?: string,
|
||||
): AIAssistantAnalyticsContext {
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
const variant = useVariant();
|
||||
const threadId = useAIAssistantStore((s) => {
|
||||
const id = conversationId ?? s.activeConversationId;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PageTypeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
|
||||
import { getAutoContexts } from './getAutoContexts';
|
||||
|
||||
@@ -45,15 +45,10 @@ export function resolvePageType(
|
||||
// Pseudo-pages with no attachable resource: resolved straight from the
|
||||
// route. They deliberately emit no auto-context chip (see `getAutoContexts`),
|
||||
// so they can't be derived from `metadata.page` like the pages below.
|
||||
if (matchPath(pathname, { path: ROUTES.HOME, exact: true })) {
|
||||
if (matchRoute(pathname, ROUTES.HOME, { exact: true })) {
|
||||
return PageTypeDTO.homepage;
|
||||
}
|
||||
if (
|
||||
matchPath(pathname, {
|
||||
path: ROUTES.INFRASTRUCTURE_MONITORING_BASE,
|
||||
exact: false,
|
||||
})
|
||||
) {
|
||||
if (matchRoute(pathname, ROUTES.INFRASTRUCTURE_MONITORING_BASE)) {
|
||||
return PageTypeDTO.infra_entity_detail;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Popover } from 'antd';
|
||||
import LogsIcon from 'assets/AlertHistory/LogsIcon';
|
||||
@@ -26,7 +26,7 @@ function PopoverContent({
|
||||
return (
|
||||
<div className="contributor-row-popover-buttons">
|
||||
{!!relatedLogsLink && (
|
||||
<Link
|
||||
<AppLink
|
||||
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-logs"
|
||||
@@ -35,10 +35,10 @@ function PopoverContent({
|
||||
<LogsIcon />
|
||||
</div>
|
||||
<div className="text">View Logs</div>
|
||||
</Link>
|
||||
</AppLink>
|
||||
)}
|
||||
{!!relatedTracesLink && (
|
||||
<Link
|
||||
<AppLink
|
||||
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-traces"
|
||||
@@ -50,7 +50,7 @@ function PopoverContent({
|
||||
/>
|
||||
</div>
|
||||
<div className="text">View Traces</div>
|
||||
</Link>
|
||||
</AppLink>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||