Compare commits

...

6 Commits

Author SHA1 Message Date
Vinícius Lourenço
2c3cafdaf7 chore(claude): replace cursor rules and add lessons from greptile reviews 2026-10-30 15:22:59 -03:00
Vinícius Lourenço
ef0f732d3b fix(tests): render children in the test harness wrapper 2026-10-30 15:22:50 -03:00
Vinicius Lourenço
b37231f31a fix(saved-views): remove loading effect from authz checks (#13287)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

This basically does two fixes:
- Remove loading effect
- The effect was not looking good, this was implemented during the
migration of components.
- Remove read checks for each item
- We should not have loading for each item in the list, against our
guidelines for authz
- The list already returns the info needed in read, redundant to have
both checks
- Replace the button with a link
  - To improve UX of this views, they were suppose to act as links.

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

Closes https://github.com/SigNoz/uforge-pod/issues/6394

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

Normal view:
<img width="1680" height="1224"
alt="dark--components-saved-views-authz--no-read-open-view--blink (1)"
src="https://github.com/user-attachments/assets/68772cda-11f1-4798-8012-79bfe685cbcd"
/>

No read only:
<img width="1680" height="1224"
alt="dark--components-saved-views-overview--unsaved-changes--blink (1)"
src="https://github.com/user-attachments/assets/898ab59a-f870-4d7c-8c2a-e20843f98444"
/>

Long names:
<img width="1680" height="1224"
alt="dark--components-saved-views-overview--long-names--blink (1)"
src="https://github.com/user-attachments/assets/48a4bd61-8335-4047-8c2a-eca42c43653e"
/>

No read only 2:
<img width="1680" height="1224"
alt="dark--components-saved-views-authz--no-read--blink (1)"
src="https://github.com/user-attachments/assets/af04bb90-1ce7-4c36-818b-49e26933ea0b"
/>


Full diff:
https://claude.ai/artifact/S43YyxbMmCdtZLmEXVNsgm?sk=LXctCOiBiN5kGigap2M_JA
2026-10-30 16:53:33 +00:00
Vinicius Lourenço
bd4bcdb6ce refactor(react-router): route every react-router call through a src/lib/router facade (#13173)
#### Description

> TLDR: You can take a look at
https://pr-docs.viniciusl.com.br/signoz-router-facade/

- Prepares the react-router v5 to v6 upgrade. Every router call now goes
through `src/lib/router`, so the upgrade touches one directory instead
of ~300 call sites. Still on v5, no behavior change intended.
- The facade is typed to v6 semantics (`string | undefined` params,
history@5-style blocker with `retry`, `replace`-by-default redirects).
The v5 implementations adapt up to it.
- `signoz/no-direct-react-router-import` (error) stops new code from
importing `react-router-dom`, `react-router-dom-v5-compat` or
`lib/history`, and from mocking them in tests.

What replaced what:

| Before | After |
| --- | --- |
| `useHistory().push/replace` | `useAppNavigate()` |
| `lib/history` singleton outside components | `navigate`, `back`,
`getCurrentLocation`, `subscribe`, `blockNavigation` in
`lib/router/navigation` |
| `useLocation`, `useParams` | `useAppLocation`, `useAppParams` |
| `Link`, `Redirect` | `AppLink`, `Redirect` |
| `matchPath` | `matchRoute` |
| v5-compat `useSearchParams`, `useNavigationType` |
`useAppSearchParams`, `useAppNavigationType` |
| `withRouter` HOCs (3) | removed |
| `jest.mock('react-router-dom')` (48 files), `MemoryRouter` |
`render(ui, undefined, { initialRoute, routePath })`, or
`tests/router-mocks` spies |

Direct `react-router-dom` importers go from 268 to 19, all in the facade
or the lint allowlist.

#### Issues closed by this PR

Closes https://github.com/SigNoz/uforge-pod/issues/6279

#### Additional Information

Reviewing: 397 files, but most are mechanical import swaps. Read by
commit.

- Read closely: `src/lib/router/` (facade plus its tests). Check the v5
to v6 adaptations: `AppLink`/`Redirect` state handling and `replace`
defaultng `state: undefined`, `blockNavigation` returning `false`,
`useAppParams` typing.
- Read closely: the test harness (`tests/router.tsx`, `router-mocks.ts`,
`test-utils.tsx`). Tests now mount on the `lib/history` singleton, so
imperative `navigate()` moves the test router.
- Read closely: the lint rule and the allowlist in `.oxlintrc.json`.
- Not mechanical: `useNavigationBlocker` (moved to `blockNavigation`),
the `withRouter` removals, and no longer threading `History` through
props.
- Skim: the `refactor(router): route ... through the facade` and
`test(router)` commits. Verify with `git grep "react-router-dom" -- src`
instead of

Testing:

- Unit tests for each facade module, plus the repointed Jest suites.
- The routing e2e suite from #13078 runs unchanged against this branch
and covers what the facade owns: router/nuqs param coexistence,
imperative namatching and redirects, auth guards, the unsaved-changes
blocker, router mount.
2026-10-30 13:41:51 +00:00
Gaurav Tewari
90db0e9bd7 fix: noz mention email (#13319)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Typing an email (e.g. `user@example.com`) in the NOZ composer opened
the `@` context picker mid-word and interrupted typing.
- `@` now opens the picker only at the start of a word (start of input
or after whitespace).
- Added tests for both cases.

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

https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=257450003&issue=SigNoz%7Cnerve-pod%7C381

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

before - 
https://www.loom.com/share/d09ee6fa24234f148e9a929a28af64cc 

Now - 


https://github.com/user-attachments/assets/934f3a6e-ece9-4863-9064-6daa2c73db8f




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

- No story change: typing behaves differently, but nothing renders
differently.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-30 13:37:26 +00:00
Vinicius Lourenço
74bab69f15 chore(frontend): remove unused dependencies (#13312)
#### Description

- Removes frontend dependencies that nothing uses: `react-beautiful-dnd`
and its types, `vite-plugin-html`, `rc-select` (only a type came from
it, and antd exports it as `RefSelectProps`), `@types/uuid` (uuid 14
ships its own types), `glob` (the i18n hash script reads the folder with
`fs`) and `is-ci` (`postinstall` checks `$CI`, which the Makefile and
the Docker build already set).
- Upgrades `husky` from 7 to 9. The hook files drop the v7 header lines.
- Upgrades `@types/node` from 16 to 22 to match `engines.node`. Its
dependency `undici-types@6.21.0` has no provenance, which pnpm's trust
policy reads as a downgrade, so it is listed in `trustPolicyExclude`.
- Deletes `hooks/useInterval`, which only its own test imported.

#### Issues closed by this PR

Closes https://github.com/SigNoz/uforge-pod/issues/6406

#### Additional Information

- Overlaps #12601 (draft), which replaces husky with `.githooks`.
Whichever merges second rebases `postinstall` and `.husky/`.
- Verify with `pnpm install && pnpm build`, then commit with a bad
message: the commit-msg hook still rejects it.
2026-10-30 13:22:03 +00:00
451 changed files with 5539 additions and 4145 deletions

View File

@@ -4,9 +4,9 @@ Applies to everything in the repo — code, config, workflows.
- **No unnecessary comments.** Do not comment where the code is self-explanatory; never restate what the code already says.
- **Document only** non-obvious behavior, constraints, formats, and edge cases.
- **Rationale goes in prose, not source.** Why a version is pinned, why a job exists, how a subsystem fits together — that belongs in the README or the PR.
- **Never remove pre-existing comments** when editing code. The bar above applies to comments you write, not comments already there.
- **Never talk to the reviewer.** No comments about where a change came from, what was changed, or why the change is correct — that belongs in the PR description and is noise the moment it merges.
- **Rationale goes in prose, not source.** Why a job exists, how a subsystem fits together: that belongs in the README or the PR. Exception: a config entry that would look like a mistake without its reason (a pinned version, a trust or lint exclusion) keeps a short reason next to it.
- **Never remove pre-existing comments** when editing code. The bar above applies to comments you write, not comments already there. When the code a comment describes changes, update the comment; when that code is deleted, delete the comment with it.
- **Never talk to the reviewer.** No comments about where a change came from, what was changed, or why the change is correct — that belongs in the PR description and is noise the moment it merges. A banner a skill requires (the AI-owned banner on story mocks and `tests/e2e/drivers/`) tells maintainers how the file is made; keep it.
- **Less is more.** When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more.
Language rules build on this one: [`go-comments`](go-comments.md), [`py-comments`](py-comments.md).

View File

@@ -0,0 +1,40 @@
---
paths:
- "frontend/src/**/*.ts"
- "frontend/src/**/*.tsx"
---
# Frontend state
Pick the store by what the state is. Never add Redux or React Context for shared state; to move existing Redux or Context state, use the `migrate-state-management` skill ([`frontend/.claude/skills/migrate-state-management/SKILL.md`](../../frontend/.claude/skills/migrate-state-management/SKILL.md)).
| State | Use | Never |
|---|---|---|
| Server / API data | React Query, generated hooks from `src/api/generated` | a copy in Redux, Context or Zustand |
| Shareable: filters, time range, selection, page | nuqs (`useQueryState` with a parser) | Redux, Context, hand-parsed `useSearchParams` |
| Global client state | a small Zustand store per domain | Redux, Context |
| One component's UI state | `useState` / `useReducer` | any store |
## Server state
- Expose a query through a hook that returns its result plus memoized derived values. Never dispatch a response into another store.
- **The query key holds every value the request reads.** A field change that alters the request (name, context, data type) must change the key; a lossy signature serves stale rows.
## URL state
- Keep the URL small (Chrome trims near 2k characters). No large datasets, no sensitive data.
- **A rewrite touches only the params it owns.** Repairing or normalising one param keeps the rest (`activeLogId`, pagination) as they were.
- **Links carry the current time range.** A row link, "open in new tab" and "copy link" land on the same period as an in-app click.
- **A preset read from the URL applies once.** A later user change or reset must not re-apply it.
## Zustand
- One `create()` per module; split a large store into slice factories combined in that one `create()`.
- Always read through a selector: `useDashboardStore((s) => s.isDashboardLocked)`, never `useDashboardStore()`.
- Update only through `set`; never mutate state. State fields before actions in the store object.
- Hooks never call `useStore.getState()` (`signoz/no-zustand-getstate-in-hooks`); export standalone actions instead.
## Local and form state
- **Editable state starts from saved data once.** Initialise the form from the saved value, then let the user own it; never re-derive it from saved data on each render.
- **A mode switch owns its in-flight work.** While a request from one mode runs, disable the switch or drop the result when the mode changes, so it can't close or overwrite the new mode.

View File

@@ -0,0 +1,20 @@
---
paths:
- "frontend/src/**/*.test.ts"
- "frontend/src/**/*.test.tsx"
---
# Frontend tests
Examples and the harness API live in [`frontend/src/tests/README.md`](../../frontend/src/tests/README.md).
- **Import from the harness.** `render`, `screen`, `userEvent`, `waitFor` and the rest come from `tests/test-utils`, never from `@testing-library/react`. The harness mounts the router, React Query, Redux, app context and preferences. When it can't do what a test needs, extend `tests/test-utils` instead of importing around it.
- **Find elements by test ID.** Query with `getByTestId`/`findByTestId` on the `data-testid` or `testId` the component exposes, and add one to the component when it has none. Fall back to role or text only for markup the app doesn't own.
- **Interact like a user.** `const user = userEvent.setup({ pointerEventsCheck: 0 })`, then an awaited `user.click`, `hover`, `keyboard` or `type`. `fireEvent` only for events `userEvent` has no equivalent for: scroll, resize, setting `scrollTop`.
- **Fake timers per test only**, for debounce or throttle, with `userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) })`. Never global.
- **Network through MSW.** The global server runs for every test; override per test with `server.use(...)` from `mocks-server/server`. Large fixtures go in `mocks-server/__mockdata__`.
- **Router through the harness.** Pass `initialRoute`/`routePath` to `render` and assert with `getCurrentLocation()` from `lib/router/navigation`. Never mock `react-router`, `react-router-dom` or `lib/history`; when a test can't render at a route, use the `lib/router` spies in `tests/router-mocks`.
- **Mock only business boundaries.** Local `jest.mock` for API modules, custom hooks and domain components. Never re-mock what the harness provides or what `jest.config.ts` maps (`uplot`, `@signozhq/icons`, `useSafeNavigate`, `logEvent`).
- **Type mocks and fixtures.** `jest.mocked(fn)` for module mocks, `jest.fn() as jest.MockedFunction<T>` for a standalone mock you configure or assert on. Fixtures carry their real type, from `src/api/generated` where it exists. No `any`, no `as unknown as`. An inline `onX={jest.fn()}` on a typed prop that is never asserted needs no annotation.
- **A test fails without its change.** Revert the change, or break the line under test, and run the test before you finish: it must fail. Assert the specific outcome (exact text, request payload, call arguments), never only that an element exists.
- **No limit on tests per file.** Split a file when it mixes unrelated behaviour, not to hit a count.

View File

@@ -9,3 +9,5 @@
- **Keep the commit body short and human readable** focused on decision made if any. Commit body must not re-iterate the changes done, skip if title is sufficient in conveying the change.
- **Use convensional commit format** for commits and PR title.
- **Do not amend the commits once pushed.** Always create a new commit once changes are pushed to remote.
- **Review fixes are new commits too.** A force push hides what changed from human reviewers and makes Greptile post its findings again. Bring in `main` with a merge; only a stacked branch (`gh stack`) rebases, since the stack moves as a unit.
- **Answer every review thread.** Reply with the commit that fixes it (`Fixed in 4df6f3198d: <what changed>`) or the reason it stays, then resolve. Never resolve a thread with neither: Greptile withdraws a finding, and remembers it, only when told why.

View File

@@ -10,6 +10,9 @@ Page stories are written with the `signoz-page-story` skill ([`.claude/skills/si
- **A UI change lands with its story.** Any change that alters what a page renders — a new page, a new state, a new branch, a changed layout — updates the story of every page that renders it, or creates one where the page has none. Go through the `signoz-page-story` skill; never hand-write a page story outside it.
- **New page, new story. New state, new control or variant.** The skill decides which: a control on a response, a variant worth linking to, or a `play` story for a state only a click reaches.
- **A shared component is covered through its pages.** A change under `components/` or `container/` with no story of its own updates the stories of the pages that render it.
- **Plays live with the mocks.** A `play` is a named function exported from `stories/__story_mockdata__/<page>.play.ts`, which is collapsed for review like the rest of the AI-owned mocks. The story file only names it: `play: openEditDrawer`. A story file that still holds play bodies moves them there the first time you edit it.
- **A play passes for every control value.** Branch each check on the args it depends on (panel kind, data state, row count, hidden header). A value the page supports must never fail the play.
- **Mocks answer every request a reachable state makes.** Selecting a row, opening a view or switching a tab that fetches more hits a handler, never the 501 catch-all.
- **Already covered, say so.** When the existing story and its controls already reach the changed state, no story edit is needed; state that in the PR description.
- **Opt out only when there is no visual diff.** Skip stories only when the change renders nothing differently: types, api clients, hooks with no UI branch, tests. Deleting a page deletes its stories.
- **Prove it with a visual diff when asked.** Before/after screenshots and "did this touch other screens" go through `sbshot-visual-diff`, which can only show what a story renders.

View File

@@ -115,8 +115,8 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
- **A variant earns a story only when it is worth linking to**: an empty workspace, a viewer, a page mid-load. Everything else stays a control.
- **Endpoints the page owns go through `response.json`**, so the Data control covers loaded, loading and failed in one declaration. Endpoints the page cannot render without (ingestion detection, preferences, feature payloads) take a plain resolver so the shell survives the loading and error states.
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param navigation works inside a story; a different pathname is blocked and reported by the overlay. A control for a param is worth it only when the param is a page mode someone would want to flip.
- **File layout**: every story file for a page lives under `src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`, payload builders in `stories/__story_mockdata__/<page>.ts`. Nothing page-specific in `src/storybook/controls/`. A page that is a tab strip over several routes gets one story file per tab, in its own folder under the module page (`LogsModulePage/Pipelines/stories/Pipelines.stories.tsx`), each with its own mocks and `__story_mockdata__/`; the builders more than one tab needs stay in the module page's own `stories/__story_mockdata__/` (`AlertList/stories/__story_mockdata__/alerts.ts`), which a tab reaches as `../../stories/__story_mockdata__/alerts`. Every one of them renders the module page, so the tab strip is there, and the `route` its mocks return decides which tab is open. A page's permission stories go one level further down, in `stories/authz/<Page>.authz.stories.tsx`, on the page's own mocks: see **Permission stories**.
- **A state only a click reaches is a story with a `play` function**, not a control: a drawer, a modal, an edit mode the page holds in component state. Drive it with `userEvent` and the queries from `storybook/test`, take the first of a repeated row action, and wait on the state's own text. The page fetches before it renders a row, so the finder needs a timeout past the 1s default. A state the app drops again on its own, such as one keyed on an array identity that a refetch replaces, does not get a story: it would not survive being looked at. A _sequence_ of such states, a wizard's steps or a questionnaire's pages, is still a control: declare the steps in the mocks module and walk them from a `play` on the meta that destructures `mount`, which is what makes Storybook replay it on an arg change. See [references/controls.md](references/controls.md).
- **File layout**: every story file for a page lives under `src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`, payload builders in `stories/__story_mockdata__/<page>.ts`, play functions in `stories/__story_mockdata__/<page>.play.ts`. Nothing page-specific in `src/storybook/controls/`. A page that is a tab strip over several routes gets one story file per tab, in its own folder under the module page (`LogsModulePage/Pipelines/stories/Pipelines.stories.tsx`), each with its own mocks and `__story_mockdata__/`; the builders more than one tab needs stay in the module page's own `stories/__story_mockdata__/` (`AlertList/stories/__story_mockdata__/alerts.ts`), which a tab reaches as `../../stories/__story_mockdata__/alerts`. Every one of them renders the module page, so the tab strip is there, and the `route` its mocks return decides which tab is open. A page's permission stories go one level further down, in `stories/authz/<Page>.authz.stories.tsx`, on the page's own mocks: see **Permission stories**.
- **A state only a click reaches is a story with a `play` function**, not a control: a drawer, a modal, an edit mode the page holds in component state. Drive it with `userEvent` and the queries from `storybook/test`, take the first of a repeated row action, and wait on the state's own text. The page fetches before it renders a row, so the finder needs a timeout past the 1s default. The play is a named function exported from `__story_mockdata__/<page>.play.ts` and the story file only names it (`play: openEditDrawer`), so the reviewer reads states, not clicks; a story file that still holds play bodies moves them there the first time you edit it. It branches every check on the args it depends on: a control value the page supports never fails it. A state the app drops again on its own, such as one keyed on an array identity that a refetch replaces, does not get a story: it would not survive being looked at. A _sequence_ of such states, a wizard's steps or a questionnaire's pages, is still a control: declare the steps in the mocks module and walk them from a `play` on the meta that destructures `mount`, which is what makes Storybook replay it on an arg change. See [references/controls.md](references/controls.md).
- **The mocks are AI-owned and say so.** `<Page>.stories.mocks.tsx` and every file under a `__story_mockdata__/` open with this banner, above the imports:
```ts
@@ -143,6 +143,8 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
- [ ] a page tagged `authz` has its `Authz` folder: one story per permission it reads, each reached by `revoked`, none of them a role preset, and each one checked by the `disabled` state of what the permission gates
- [ ] the mocks module and every `__story_mockdata__` file carry the AI-owned banner
- [ ] every control flipped once, its effect seen on screen
- [ ] every play passes with each control value its story exposes, and the story file holds no play body
- [ ] every request a reachable state makes (a selected row, an opened view, a switched tab) has a handler
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled request, no React warning
- [ ] no navigation overlay on mount
- [ ] `pnpm tsgo --noEmit`, `pnpm exec oxlint <files>`, `pnpm exec oxfmt --check <files>` all clean. The repo has no prettier: `pnpm exec prettier` prints a pass while exiting 254

View File

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

View File

@@ -146,5 +146,5 @@ Do not use Zustand, Redux, or Context for purely local UI state.
## Additional resources
- Project rule: [.cursor/rules/state-management.mdc](../../rules/state-management.mdc)
- Project rule: [.claude/rules/frontend-state.md](../../../../.claude/rules/frontend-state.md)
- Detailed patterns and rationale: [reference.md](reference.md)

View File

@@ -1,97 +0,0 @@
---
globs: **/*.store.ts
alwaysApply: false
---
# State Management: React Query, nuqs, Zustand
Use the following stack. Do **not** introduce or recommend Redux or React Context for shared/global state.
## Server state → React Query
- **Use for:** API responses, time-series data, caching, background refetch, retries, stale/refresh.
- **Do not use Redux/Context** to store or mirror data that comes from React Query (e.g. do not dispatch API results into Redux).
- Prefer generated React Query hooks from `frontend/src/api/generated` when available.
- Keep server state in React Query; expose it via hooks that return the query result (and optionally memoized derived values). Do not duplicate it in Redux or Context.
```tsx
// ✅ GOOD: single source of truth from React Query
export function useAppStateHook() {
const { data, isError } = useQuery(...)
const memoizedConfigs = useMemo(() => ({ ... }), [data?.configs])
return { configs: memoizedConfigs, isError, ... }
}
// ❌ BAD: copying React Query result into Redux
dispatch({ type: UPDATE_LATEST_VERSION, payload: queryResponse.data })
```
## URL state → nuqs
- **Use for:** shareable state, filters, time range, selected values, pagination, view state that belongs in the URL.
- **Do not use Redux/Context** for state that should be shareable or reflected in the URL.
- Use [nuqs](https://nuqs.dev/docs/basic-usage) for typed, type-safe URL search params. Avoid ad-hoc `useSearchParams` encoding/decoding.
- Keep URL payload small; respect browser URL length limits (e.g. Chrome ~2k chars). Do not put large datasets or sensitive data in query params.
```tsx
// ✅ GOOD: nuqs for filters / time range / selection
const [timeRange, setTimeRange] = useQueryState('timeRange', parseAsString.withDefault('1h'))
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
// ❌ BAD: Redux/Context for shareable or URL-synced state
const { timeRange } = useContext(SomeContext)
```
## Client state → Zustand
- **Use for:** global/client state, cross-component state, feature flags, complex or large client objects (e.g. dashboard state, query builder state).
- **Do not use Redux or React Context** for global or feature-level client state.
- Prefer small, domain-scoped stores (e.g. DashboardStore, QueryBuilderStore).
### Zustand best practices (align with eslint-plugin-zustand-rules)
- **One store per module.** Do not define multiple `create()` calls in the same file; use one store per module (or compose slices into one store).
- **Always use selectors.** Call the store hook with a selector so only the used slice triggers re-renders. Never use `useStore()` with no selector.
```tsx
// ✅ GOOD: selector — re-renders only when isDashboardLocked changes
const isLocked = useDashboardStore(state => state.isDashboardLocked)
// ❌ BAD: no selector — re-renders on any store change
const state = useDashboardStore()
```
- **Never mutate state directly.** Update only via `set` or `setState` (or `getState()` + `set` for reads). No `state.foo = x` or `state.bears += 1` inside actions.
```tsx
// ✅ GOOD: use set
increment: () => set(state => ({ bears: state.bears + 1 }))
// ❌ BAD: direct mutation
increment: () => { state.bears += 1 }
```
- **State properties before actions.** In the store object, list all state fields first, then action functions.
- **Split into slices when state is large.** If a store has many top-level properties (e.g. more than 5–10), split into slice factories and combine with one `create()`.
```tsx
// ✅ GOOD: slices for large state
const createBearSlice = set => ({ bears: 0, addBear: () => set(s => ({ bears: s.bears + 1 })) })
const createFishSlice = set => ({ fish: 0, addFish: () => set(s => ({ fish: s.fish + 1 })) })
const useStore = create(set => ({ ...createBearSlice(set), ...createFishSlice(set) }))
```
- **In projects using Zustand:** add `eslint-plugin-zustand-rules` and extend `plugin:zustand-rules/recommended` to enforce these rules automatically.
## Local state → React state only
- **Use useState/useReducer** for: component-local UI state, form inputs, toggles, hover state, data that never leaves the component.
- Do not use Zustand, Redux, or Context for state that is purely local to one component or a small subtree.
## Summary
| State type | Use | Avoid |
|-------------------|------------------|--------------------|
| Server / API | React Query | Redux, Context |
| URL / shareable | nuqs | Redux, Context |
| Global client | Zustand | Redux, Context |
| Local UI | useState/useReducer | Zustand, Redux, Context |

View File

@@ -1,74 +0,0 @@
---
description: Core testing conventions - imports, rendering, MSW, interactions, queries
globs: **/*.test.{ts,tsx}
alwaysApply: false
---
# Testing Conventions
## Imports
Always import from the test harness, never directly from `@testing-library/react`:
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
```
## Router
Use the built-in router in `render`:
```ts
render(<Page />, undefined, { initialRoute: '/traces-explorer' });
```
Only mock `useLocation` / `useParams` if the test depends on their values.
## MSW
Global MSW server runs automatically. Override per-test:
```ts
server.use(
rest.get('*/api/v1/foo', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ ok: true })))
);
```
Keep large response fixtures in `mocks-server/__mockdata_`.
## Interactions
- Prefer `userEvent` for real user interactions (click, type, select, tab).
- Use `fireEvent` only for low-level events not covered by `userEvent` (e.g., scroll, resize). Wrap in `act(...)` if needed.
- Always `await` interactions:
```ts
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /save/i }));
```
## Timers
No global fake timers. Per-test only, for debounce/throttle:
```ts
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) });
await user.type(screen.getByRole('textbox'), 'query');
jest.advanceTimersByTime(400);
jest.useRealTimers();
```
## Queries
Prefer accessible queries: `getByRole` > `findByRole` > `getByLabelText` > visible text > `data-testid` (last resort).
## Anti-patterns
- Never import from `@testing-library/react` directly
- Never use global fake timers
- Never wrap `render` in `act(...)`
- Never mock infra dependencies locally (router, react-query)
- Limit to 3-5 focused tests per file

View File

@@ -1,54 +0,0 @@
---
description: When to use global vs local mocks in tests
globs: **/*.test.{ts,tsx}
alwaysApply: false
---
# Mock Strategy
## Use Global Mocks For
High-frequency dependencies (20+ test files):
- Core infrastructure: react-router-dom, react-query, antd
- Browser APIs: ResizeObserver, matchMedia, localStorage
- Utility libraries: date-fns, lodash
Available global mock files (from jest.config.ts):
- `uplot` -> `__mocks__/uplotMock.ts`
## Use Local Mocks For
- Business logic dependencies (API endpoints, custom hooks, domain components)
- Test-specific behavior (different data per test, error scenarios, loading states)
## Decision Tree
```
Used in 20+ test files?
YES -> Global mock
NO -> Business logic or test-specific?
YES -> Local mock
NO -> Consider global if usage grows
```
## Correct Usage
```ts
// Global mocks are already available - just import
import { useLocation } from 'react-router-dom';
// Local mocks for business logic
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => mockTracesData),
}));
```
## Anti-patterns
```ts
// Never re-mock globally mocked dependencies locally
jest.mock('react-router-dom', () => ({ ... }));
// Never put test-specific data in global mocks
jest.mock('../api/tracesService', () => ({ getTraces: jest.fn(() => specificTestData) }));
```

View File

@@ -1,54 +0,0 @@
---
description: TypeScript type safety requirements for Jest tests
globs: **/*.test.{ts,tsx}
alwaysApply: false
---
# TypeScript Type Safety in Tests
All Jest tests must be fully type-safe. Never use `any`.
## Mock Function Typing
```ts
// Use jest.mocked for module mocks
import useFoo from 'hooks/useFoo';
jest.mock('hooks/useFoo');
const mockUseFoo = jest.mocked(useFoo);
// Use jest.MockedFunction for standalone mocks
const mockFetch = jest.fn() as jest.MockedFunction<(id: number) => Promise<User>>;
```
## Mock Data
Define interfaces for all mock data:
```ts
const mockUser: User = { id: 1, name: 'John', email: 'john@example.com' };
const mockProps: ComponentProps = {
title: 'Test',
data: [mockUser],
onSelect: jest.fn() as jest.MockedFunction<(user: User) => void>,
};
```
## Hook Mocking Pattern
```ts
import useFoo from 'hooks/useFoo';
jest.mock('hooks/useFoo');
const mockUseFoo = jest.mocked(useFoo);
mockUseFoo.mockReturnValue(/* minimal shape */);
```
Prefer helpers (`rqSuccess`, `rqLoading`, `rqError`) for React Query results.
## Checklist
- All mock functions use `jest.MockedFunction<T>` or `jest.mocked()`
- All mock data has proper interfaces
- No `any` types in test files
- Component props are typed
- API response types are defined

View File

@@ -1,19 +0,0 @@
---
description: Prefer SigNoz UI and icons across frontend code
globs: **/*.{ts,tsx,js,jsx}
alwaysApply: true
---
# UI Components and Icons Source of Truth
For all frontend implementation work in this repository:
- Always use UI primitives/components from `@signozhq/ui`.
- Always use icons from `@signozhq/icons`.
- Do not introduce new usage of icon libraries directly (for example `lucide-react`) in app code.
- Do not mix multiple component systems for the same UI surface when an equivalent exists in `@signozhq/ui`.
## Migration guidance
- If touching a file that already uses non-`@signozhq/icons` icons, prefer migrating that file to `@signozhq/icons` as part of the same change when practical.
- If a required component or icon is missing from SigNoz packages, call this out explicitly in the PR/summary before introducing alternatives.

View File

@@ -1,6 +1,3 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd frontend && pnpm run commitlint --edit $1
branch="$(git rev-parse --abbrev-ref HEAD)"

View File

@@ -1,4 +1 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd frontend && pnpm lint-staged

View File

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

View File

@@ -25,8 +25,14 @@ You are operating within a constrained context window and strict system prompts.
- Never create barrel files.
- When writing new css, prefer CSS Modules
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
- UI primitives come from `@signozhq/ui` (subpath imports) and icons from `@signozhq/icons`. No new `antd` component or direct icon library (`lucide-react`) where `@signozhq/ui` has an equivalent; `signoz/no-antd-components` enforces part of this list.
- Exception: a control added beside `antd` siblings of the same kind (a tab in an `antd` tab strip) matches them until that surface migrates. Say so in the PR.
- When `@signozhq/ui` or `@signozhq/icons` lacks what you need, say so in the PR before reaching for another library.
- Before styling a `@signozhq/ui` component, check its props in `node_modules/@signozhq/ui/dist/<component>/*.d.ts` for `className`/`style` support; some omit them. Avoid overriding the component's CSS vars unless explicitly needed.
- When writing code that could need authorization checks, read ./src/lib/authz/README.md
- A flow that gains a request also gates on that request's permission: a rename that now reads the view first needs `read` as well as `update`.
- Identity keys (legend groups, color groups, CSV headers, React keys) come from structured values, never from joined display text: `['us, east', 'prod']` and `['us', 'east, prod']` both join to `us, east, prod`.
- A payload saved under another kind or schema copies only the fields that schema accepts, and drops bindings to columns the user removed. The server rejects unknown or stale fields, so the preview works and the save fails.
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
- Run `pnpm tsgo --noEmit`
@@ -34,6 +40,8 @@ You are operating within a constrained context window and strict system prompts.
- Run `pnpm oxlint <file1> <file2>` and fix all warnings
- Run `pnpm build`
- Find if the file has tests for it, or if there's `__test__` folder or the parent folder has tests, and run.
- For every test you added or changed, revert the change it covers and confirm the test fails
- On a stacked branch (`gh stack`), run `pnpm tsgo --noEmit` on every branch above it before pushing: a renamed prop or moved export breaks them
- Fixed ALL resulting errors
4. BEHAVIOR CHANGE DETECTION: When modifying existing behavior:
@@ -41,6 +49,14 @@ You are operating within a constrained context window and strict system prompts.
- Update test assertions to match new behavior
- If no tests exist, add them BEFORE changing behavior
## Repo Rules
Claude Code loads `../.claude/rules/` on its own. Other agents read the matching file before editing:
- Every file: [comments](../.claude/rules/comments.md), [pull requests](../.claude/rules/pull-requests.md)
- `src/**/*.ts(x)` that holds state, a query, URL params or a form: [frontend state](../.claude/rules/frontend-state.md)
- `*.test.ts(x)`: [frontend tests](../.claude/rules/frontend-tests.md)
- `*.tsx` that changes what a page renders: [storybook](../.claude/rules/storybook.md)
## Context Management
1. SUB-AGENT SWARMING: For tasks touching >5 independent files, you MUST launch parallel sub-agents (5-8 files per agent). Each agent gets its own context window. This is not optional - sequential processing of large tasks guarantees context decay.

View File

@@ -1,6 +1,6 @@
const crypto = require('crypto');
const fs = require('fs');
const glob = require('glob');
const path = require('path');
function generateChecksum(str, algorithm, encoding) {
return crypto
@@ -9,12 +9,17 @@ function generateChecksum(str, algorithm, encoding) {
.digest(encoding || 'hex');
}
const localesDir = 'public/locales';
const result = {};
glob.sync(`public/locales/**/*.json`).forEach((path) => {
const [_, lang] = path.split('public/locales');
const content = fs.readFileSync(path, { encoding: 'utf-8' });
result[lang.replace('.json', '')] = generateChecksum(content);
});
fs
.readdirSync(localesDir, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.forEach((entry) => {
const file = path.join(entry.parentPath, entry.name);
const content = fs.readFileSync(file, { encoding: 'utf-8' });
const key = path.relative(localesDir, file).replace(/\.json$/, '');
result[`/${key}`] = generateChecksum(content);
});
fs.writeFileSync('./i18n-translations-hash.json', JSON.stringify(result));

View File

@@ -24,8 +24,8 @@
"jest": "jest",
"jest:coverage": "jest --coverage",
"jest:watch": "jest --watch",
"postinstall": "pnpm i18n:generate-hash && (is-ci || pnpm husky:configure) && node scripts/update-registry.cjs",
"husky:configure": "cd .. && husky install frontend/.husky && cd frontend && chmod ug+x .husky/*",
"postinstall": "pnpm i18n:generate-hash && ([ -n \"$CI\" ] || pnpm husky:configure) && node scripts/update-registry.cjs",
"husky:configure": "cd .. && husky frontend/.husky",
"commitlint": "commitlint --edit $1",
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
@@ -100,10 +100,8 @@
"overlayscrollbars-react": "^0.5.6",
"papaparse": "5.4.1",
"posthog-js": "1.298.0",
"rc-select": "14.10.0",
"react": "18.2.0",
"react-addons-update": "15.6.3",
"react-beautiful-dnd": "13.1.1",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "18.2.0",
@@ -137,7 +135,6 @@
"uplot": "1.6.31",
"uuid": "14.0.1",
"vite": "npm:rolldown-vite@7.3.1",
"vite-plugin-html": "3.2.2",
"zod": "4.3.6",
"zustand": "5.0.11"
},
@@ -178,11 +175,10 @@
"@types/history": "4.7.11",
"@types/jest": "30.0.0",
"@types/lodash-es": "^4.17.4",
"@types/node": "^16.10.3",
"@types/node": "^22.20.5",
"@types/papaparse": "5.3.7",
"@types/react": "18.0.26",
"@types/react-addons-update": "0.14.21",
"@types/react-beautiful-dnd": "13.1.8",
"@types/react-dom": "18.0.10",
"@types/react-grid-layout": "^1.1.2",
"@types/react-redux": "^7.1.11",
@@ -192,12 +188,9 @@
"@types/redux-mock-store": "1.0.4",
"@types/styled-components": "^5.1.4",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/uuid": "^8.3.1",
"@typescript/native-preview": "7.0.0-dev.20260430.1",
"eslint-plugin-sonarjs": "4.0.2",
"glob": "^13.0.6",
"husky": "^7.0.4",
"is-ci": "^3.0.1",
"husky": "^9.1.7",
"jest-environment-jsdom": "29.7.0",
"jest-styled-components": "^7.2.0",
"json-schema-to-typescript": "^15.0.4",

View File

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

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

View File

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

601
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -86,6 +86,13 @@ overrides:
# typescript-plugin-css-modules > postcss-load-config@3 (^1.10.2)
# remove: blocked — babel-plugin-macros 3.1.0 (latest) still uses cosmiconfig@7
yaml@>=1.0.0 <1.10.3: '>=1.10.3 <2'
# via: jest (@jest/core, @jest/console, jest-config) declares @types/node '*',
# which kept 16.18.25 next to the direct ^22
# remove: once `pnpm dedupe` passes trustPolicy and leaves one @types/node
'@types/node': '$@types/node'
trustPolicy: no-downgrade
trustPolicyExclude:
- 'semver@6.3.1 || 5.7.2'
# via: @types/node@22 (undici-types ~6.21.0, no provenance)
# remove: upgrade @types/node to >=24 (undici-types 7.x has provenance)
- 'undici-types@6.21.0'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Dot } from '@signozhq/icons';
import { useAppLocation } from 'lib/router/useAppLocation';
import { Dot, Globe, Inbox, SquarePen } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import Noz from 'components/Noz/Noz';
@@ -16,7 +16,6 @@ import {
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { Globe, Inbox, SquarePen } from '@signozhq/icons';
import AnnouncementsModal from './AnnouncementsModal';
import FeedbackModal from './FeedbackModal';
@@ -39,7 +38,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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ import {
RefreshCw,
} from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { Button, Select } from 'antd';
import { Button, type RefSelectProps, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Tooltip } from '@signozhq/ui/tooltip';
import cx from 'classnames';
@@ -26,7 +26,6 @@ import TextToolTip from 'components/TextToolTip/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { capitalize, isEmpty } from 'lodash-es';
import type { BaseSelectRef } from 'rc-select';
import { popupContainer } from 'utils/selectPopupContainer';
import TruncatedTooltip from './TruncatedTooltip';
@@ -84,7 +83,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// ===== State & Refs =====
const [isOpen, setIsOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const selectRef = useRef<BaseSelectRef>(null);
const selectRef = useRef<RefSelectProps>(null);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [activeChipIndex, setActiveChipIndex] = useState<number>(-1); // For tracking active chip/tag
const [selectionStart, setSelectionStart] = useState<number>(-1);

View File

@@ -16,13 +16,12 @@ import {
X,
} from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { Select } from 'antd';
import { type RefSelectProps, Select } from 'antd';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { capitalize, isEmpty } from 'lodash-es';
import type { BaseSelectRef } from 'rc-select';
import { popupContainer } from 'utils/selectPopupContainer';
import TruncatedTooltip from './TruncatedTooltip';
@@ -75,7 +74,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const isDarkMode = useIsDarkMode();
// Refs for element access and scroll behavior
const selectRef = useRef<BaseSelectRef>(null);
const selectRef = useRef<RefSelectProps>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
// Flag to track if dropdown just opened

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,13 +16,11 @@ 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,
findSavedViewInLists,
openSavedView,
openSavedViewByKey,
@@ -39,6 +37,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 +61,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,
@@ -228,33 +230,18 @@ describe('findSavedViewInLists', () => {
});
});
describe('buildExplorerNavigationUrl', () => {
it('encodes composite query and view selectors', () => {
const url = buildExplorerNavigationUrl(
ROUTES.LOGS_EXPLORER,
{ queryType: 'builder' } as never,
{
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
[QueryParams.viewKey]: 'view-1',
},
);
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain(`${QueryParams.compositeQuery}=`);
expect(url).toContain(`${QueryParams.viewKey}=`);
});
});
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"');
@@ -266,18 +253,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');
});
});
@@ -285,44 +268,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',
);
});
});

View File

@@ -10,9 +10,9 @@ import { findSavedView } from 'container/SavedViews/utils/findSavedView';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
import { SOURCEPAGE_VS_ROUTES } from 'container/SavedViews/constants';
import { navigate } from 'lib/router/navigation';
import { DataSource } from 'types/common/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { History } from 'history';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
type SavedViewSourceHint = SavedViewSourcePage;
@@ -72,30 +72,7 @@ export function explorerRouteForSourcePage(
return SOURCEPAGE_VS_ROUTES[sourcePage] ?? null;
}
/**
* Builds an explorer URL the same way `redirectWithQueryBuilderData` does —
* without inheriting stale query params from the current page's `urlQuery`.
*/
export function buildExplorerNavigationUrl(
route: string,
query: Query,
searchParams: Record<string, unknown>,
): string {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
Object.entries(searchParams).forEach(([key, value]) => {
params.set(key, JSON.stringify(value));
});
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');
@@ -110,14 +87,13 @@ export function openSavedView(
[QueryParams.panelTypes]: view.spec.panelType as unknown as PANEL_TYPES,
[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);
}

View File

@@ -298,7 +298,8 @@ export default function ChatInput({
(value: string, caret: number) => {
const beforeCaret = value.slice(0, caret);
const atIndex = beforeCaret.lastIndexOf('@');
if (atIndex < 0) {
// `@` only triggers at a word start, so words containing `@` stay plain text.
if (atIndex < 0 || /\S/.test(beforeCaret.charAt(atIndex - 1))) {
setIsContextPickerOpen(false);
setMentionRange(null);
return;

View File

@@ -0,0 +1,30 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ChatInput from '../ChatInput';
function getComposer(): HTMLTextAreaElement {
return screen.getByPlaceholderText(/Ask anything/i) as HTMLTextAreaElement;
}
describe('ChatInput — @ mention trigger', () => {
it('opens the context picker when @ starts a word', async () => {
render(<ChatInput onSend={jest.fn()} />);
await userEvent.type(getComposer(), 'check @');
await expect(
screen.findByPlaceholderText(/search dashboards/i),
).resolves.toBeInTheDocument();
});
it('keeps typing an email without opening the context picker', async () => {
render(<ChatInput onSend={jest.fn()} />);
await userEvent.type(getComposer(), 'mail user@example.com');
expect(getComposer()).toHaveValue('mail user@example.com');
expect(
screen.queryByPlaceholderText(/search dashboards/i),
).not.toBeInTheDocument();
});
});

View File

@@ -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).
if (
matchPath(pathname, { path: ROUTES.METRICS_EXPLORER_BASE, exact: false })
) {
if (matchRoute(pathname, ROUTES.METRICS_EXPLORER_BASE)) {
return [
{
source: 'auto',

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import { useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
import { Button } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import history from 'lib/history';
import { useAppLocation } from 'lib/router/useAppLocation';
import { navigate } from 'lib/router/navigation';
import { ArrowRight } from '@signozhq/icons';
import TopContributorsContent from './TopContributorsContent';
@@ -16,7 +16,7 @@ function TopContributorsCard({
topContributorsData,
totalCurrentTriggers,
}: TopContributorsCardProps): JSX.Element {
const { search } = useLocation();
const { search } = useAppLocation();
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
const viewAllTopContributorsParam = searchParams.get('viewAllTopContributors');
@@ -43,7 +43,7 @@ function TopContributorsCard({
return newState;
});
history.push({ search: searchParams.toString() });
navigate({ search: searchParams.toString() });
};
return (

View File

@@ -7,7 +7,7 @@ import { QueryParams } from 'constants/query';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { navigate } from 'lib/router/navigation';
import heatmapPlugin from 'lib/uPlotLib/plugins/heatmapPlugin';
import timelinePlugin from 'lib/uPlotLib/plugins/timelinePlugin';
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
@@ -103,7 +103,7 @@ function HorizontalTimelineGraph({
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
history.push({
navigate({
search: urlQuery.toString(),
});
}

View File

@@ -1,8 +1,8 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
import { TimelineFilter, TimelineTab } from 'container/AlertHistory/types';
import history from 'lib/history';
import { useAppLocation } from 'lib/router/useAppLocation';
import { navigate } from 'lib/router/navigation';
import { Info } from '@signozhq/icons';
import Tabs2 from 'periscope/components/Tabs2';
@@ -42,7 +42,7 @@ function TimelineTabs(): JSX.Element {
}
function TimelineFilters(): JSX.Element {
const { search } = useLocation();
const { search } = useAppLocation();
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
const initialSelectedTab = useMemo(
@@ -52,7 +52,7 @@ function TimelineFilters(): JSX.Element {
const handleFilter = (value: TimelineFilter): void => {
searchParams.set('timelineFilter', value);
history.push({ search: searchParams.toString() });
navigate({ search: searchParams.toString() });
};
const tabs = [

View File

@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { useQueries } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Link, useLocation } from 'react-router-dom';
import { AppLink } from 'lib/router/AppLink';
import { useAppLocation } from 'lib/router/useAppLocation';
import { Button, Card, Input, Space, TableProps, Tooltip, Flex } from 'antd';
import { Search } from '@signozhq/icons';
import type { ColumnType, TablePaginationConfig } from 'antd/es/table';
@@ -28,7 +29,7 @@ import {
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { navigate } from 'lib/router/navigation';
import { isUndefined } from 'lodash-es';
import { useAllErrorsQueryState } from 'pages/AllErrors/QueryStateContext';
import { useTimezone } from 'providers/Timezone';
@@ -67,7 +68,7 @@ function AllErrors(): JSX.Element {
const { maxTime, minTime, loading } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { pathname } = useLocation();
const { pathname } = useAppLocation();
const params = useUrlQuery();
const { t } = useTranslation(['common']);
const {
@@ -228,7 +229,9 @@ function AllErrors(): JSX.Element {
queryParams.serviceName = serviceFilterValue;
}
history.replace(`${pathname}?${createQueryParams(queryParams)}`);
navigate(`${pathname}?${createQueryParams(queryParams)}`, {
replace: true,
});
confirm();
},
[
@@ -336,13 +339,13 @@ function AllErrors(): JSX.Element {
...getFilter(onExceptionTypeFilter, 'Search By Exception', 'exceptionType'),
render: (value, record): JSX.Element => (
<Tooltip overlay={(): JSX.Element => value}>
<Link
<AppLink
to={`${ROUTES.ERROR_DETAIL}?groupId=${
record.groupID
}&timestamp=${getNanoSeconds(record.lastSeen)}`}
>
{value}
</Link>
</AppLink>
</Tooltip>
),
sorter: true,
@@ -438,7 +441,7 @@ function AllErrors(): JSX.Element {
exceptionType: getFilterString(params.get(urlKey.exceptionType)),
});
const compositeQuery = params.get(urlKey.compositeQuery) || '';
history.replace(
navigate(
`${pathname}?${createQueryParams({
order: updatedOrder,
offset: (current - 1) * pageSize,
@@ -448,6 +451,7 @@ function AllErrors(): JSX.Element {
serviceName,
compositeQuery,
})}`,
{ replace: true },
);
}
},

View File

@@ -1,6 +1,5 @@
// eslint-disable-next-line no-restricted-imports
import { Provider, useSelector } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
@@ -8,6 +7,7 @@ import { rest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import TimezoneProvider from 'providers/Timezone';
import store from 'store';
import { TestRouter } from 'tests/router';
import '@testing-library/jest-dom';
@@ -55,9 +55,9 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
},
} as any);
function Exceptions({ initUrl }: { initUrl?: string[] }): JSX.Element {
function Exceptions({ initUrl }: { initUrl?: string }): JSX.Element {
return (
<MemoryRouter initialEntries={initUrl ?? ['/exceptions']}>
<TestRouter initialRoute={initUrl ?? '/exceptions'}>
<TimezoneProvider>
<Provider store={store}>
<MockQueryClientProvider>
@@ -65,12 +65,12 @@ function Exceptions({ initUrl }: { initUrl?: string[] }): JSX.Element {
</MockQueryClientProvider>
</Provider>
</TimezoneProvider>
</MemoryRouter>
</TestRouter>
);
}
Exceptions.defaultProps = {
initUrl: ['/exceptions'],
initUrl: '/exceptions',
};
const BASE_URL = ENVIRONMENT.baseURL;
@@ -130,7 +130,7 @@ describe('Exceptions - All Errors', () => {
});
it('should call useQueries with exact composite query object', async () => {
render(<Exceptions initUrl={[INIT_URL_WITH_COMMON_QUERY]} />);
render(<Exceptions initUrl={INIT_URL_WITH_COMMON_QUERY} />);
await screen.findByText(/redis timeout/i);
expect(postListErrorsSpy).toHaveBeenCalledWith(
expect.objectContaining({
@@ -143,11 +143,7 @@ describe('Exceptions - All Errors', () => {
it('should navigate to page 2 when pageSize=100 and clicking next', async () => {
// Arrange: start with pageSize=100 and offset=0
render(
<Exceptions
initUrl={[
`/exceptions?pageSize=100&offset=0&order=ascending&orderParam=serviceName`,
]}
/>,
<Exceptions initUrl="/exceptions?pageSize=100&offset=0&order=ascending&orderParam=serviceName" />,
);
// Wait for initial load
@@ -171,11 +167,7 @@ describe('Exceptions - All Errors', () => {
it('initializes current page from URL (offset/pageSize)', async () => {
// offset=100, pageSize=100 => current page should be 2
render(
<Exceptions
initUrl={[
`/exceptions?pageSize=100&offset=100&order=ascending&orderParam=serviceName`,
]}
/>,
<Exceptions initUrl="/exceptions?pageSize=100&offset=100&order=ascending&orderParam=serviceName" />,
);
await screen.findByText(/redis timeout/i);
const activeItem = document.querySelector('.ant-pagination-item-active');
@@ -188,11 +180,7 @@ describe('Exceptions - All Errors', () => {
it('clicking a numbered page updates offset correctly', async () => {
// pageSize=100, click page 3 => offset = 200
render(
<Exceptions
initUrl={[
`/exceptions?pageSize=100&offset=0&order=ascending&orderParam=serviceName`,
]}
/>,
<Exceptions initUrl="/exceptions?pageSize=100&offset=0&order=ascending&orderParam=serviceName" />,
);
await screen.findByText(/redis timeout/i);
const page3Item = screen.getByTitle('3');

View File

@@ -4,16 +4,14 @@ import {
getApiMonitoringParams,
setApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { mockNavigate } from 'tests/router-mocks';
// Mock react-router-dom hooks
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
return {
...originalModule,
useLocation: jest.fn(),
useHistory: jest.fn(),
};
});
const navigateSpy = mockNavigate();
function navigatedSearch(): string | undefined {
const [to] = navigateSpy.mock.calls[0];
return typeof to === 'string' ? to : to.search;
}
describe('API Monitoring Query Params', () => {
describe('getApiMonitoringParams', () => {
@@ -58,25 +56,21 @@ describe('API Monitoring Query Params', () => {
describe('setApiMonitoringParams', () => {
it('updates URL with new params (push mode)', () => {
const history = {
push: jest.fn(),
replace: jest.fn(),
};
const search = '';
const newParams: Partial<ApiMonitoringParams> = {
showIP: false,
selectedDomain: 'updated-domain',
};
setApiMonitoringParams(newParams, search, history as any, false);
setApiMonitoringParams(newParams, search, false);
expect(history.push).toHaveBeenCalledWith({
search: expect.stringContaining('apiMonitoringParams'),
});
expect(history.replace).not.toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(
{ search: expect.stringContaining('apiMonitoringParams') },
{ replace: false },
);
// Verify that the search string contains the expected encoded params
const searchArg = history.push.mock.calls[0][0].search;
const searchArg = navigatedSearch();
const params = new URLSearchParams(searchArg);
const decoded = JSON.parse(
decodeURIComponent(params.get('apiMonitoringParams') || ''),
@@ -88,30 +82,21 @@ describe('API Monitoring Query Params', () => {
});
it('updates URL with new params (replace mode)', () => {
const history = {
push: jest.fn(),
replace: jest.fn(),
};
const search = '';
const newParams: Partial<ApiMonitoringParams> = {
showIP: false,
selectedDomain: 'updated-domain',
};
setApiMonitoringParams(newParams, search, history as any, true);
setApiMonitoringParams(newParams, search, true);
expect(history.replace).toHaveBeenCalledWith({
search: expect.stringContaining('apiMonitoringParams'),
});
expect(history.push).not.toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(
{ search: expect.stringContaining('apiMonitoringParams') },
{ replace: true },
);
});
it('merges new params with existing params', () => {
const history = {
push: jest.fn(),
replace: jest.fn(),
};
// Start with some existing params
const existingParams: Partial<ApiMonitoringParams> = {
showIP: true,
@@ -132,10 +117,10 @@ describe('API Monitoring Query Params', () => {
selectedEndPointName: '/api/test',
};
setApiMonitoringParams(newParams, search, history as any, false);
setApiMonitoringParams(newParams, search, false);
// Verify merged params
const searchArg = history.push.mock.calls[0][0].search;
const searchArg = navigatedSearch();
const params = new URLSearchParams(searchArg);
const decoded = JSON.parse(
decodeURIComponent(params.get('apiMonitoringParams') || ''),
@@ -161,27 +146,7 @@ describe('API Monitoring Query Params', () => {
state: null,
};
// Create mock history object
const history = {
push: jest.fn((args) => {
// Simulate updating the location search
location.search = args.search;
}),
replace: jest.fn((args) => {
location.search = args.search;
}),
length: 1,
location,
};
// Set up mocks for useLocation and useHistory
const useLocationMock = jest.requireMock('react-router-dom').useLocation;
const useHistoryMock = jest.requireMock('react-router-dom').useHistory;
useLocationMock.mockReturnValue(location);
useHistoryMock.mockReturnValue(history);
return { location, history };
return { location };
};
it('retrieves URL params correctly from location', () => {
@@ -207,7 +172,7 @@ describe('API Monitoring Query Params', () => {
});
it('updates URL correctly with new params', () => {
const { location, history } = mockUseLocationAndHistory();
const { location } = mockUseLocationAndHistory();
const newParams: Partial<ApiMonitoringParams> = {
selectedDomain: 'new-domain',
@@ -215,13 +180,14 @@ describe('API Monitoring Query Params', () => {
};
// Manually execute the core logic of the hook's setParams function
setApiMonitoringParams(newParams, location.search, history as any);
setApiMonitoringParams(newParams, location.search);
expect(history.push).toHaveBeenCalledWith({
search: expect.stringContaining('apiMonitoringParams'),
});
expect(navigateSpy).toHaveBeenCalledWith(
{ search: expect.stringContaining('apiMonitoringParams') },
{ replace: false },
);
const searchArg = history.push.mock.calls[0][0].search;
const searchArg = navigatedSearch();
const params = new URLSearchParams(searchArg);
const decoded = JSON.parse(
decodeURIComponent(params.get('apiMonitoringParams') || ''),
@@ -247,20 +213,16 @@ describe('API Monitoring Query Params', () => {
const initialSearch = `?${urlParams.toString()}`;
// Set up mocks
const { location, history } = mockUseLocationAndHistory(initialSearch);
const { location } = mockUseLocationAndHistory(initialSearch);
// Manually execute the core logic
setApiMonitoringParams(
{ selectedView: 'new-view' },
location.search,
history as any,
);
setApiMonitoringParams({ selectedView: 'new-view' }, location.search);
// Verify history was updated
expect(history.push).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalled();
// Parse the new query params from the URL
const searchArg = history.push.mock.calls[0][0].search;
const searchArg = navigatedSearch();
const params = new URLSearchParams(searchArg);
const decoded = JSON.parse(
decodeURIComponent(params.get('apiMonitoringParams') || ''),

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useAppLocation } from 'lib/router/useAppLocation';
import { navigate } from 'lib/router/navigation';
// --- Types for all API Monitoring query params ---
export interface ApiMonitoringParams {
@@ -55,7 +56,6 @@ export function getApiMonitoringParams(search: string): ApiMonitoringParams {
export function setApiMonitoringParams(
newParams: Partial<ApiMonitoringParams>,
search: string,
history: ReturnType<typeof useHistory>,
replace = false,
): void {
const urlParams = new URLSearchParams(search);
@@ -63,11 +63,7 @@ export function setApiMonitoringParams(
const merged = { ...current, ...newParams };
urlParams.set(PARAM_KEY, encodeParams(merged));
const newSearch = `?${urlParams.toString()}`;
if (replace) {
history.replace({ search: newSearch });
} else {
history.push({ search: newSearch });
}
navigate({ search: newSearch }, { replace });
}
// --- React hook to use query params in a component ---
@@ -75,15 +71,14 @@ export function useApiMonitoringParams(): [
ApiMonitoringParams,
(newParams: Partial<ApiMonitoringParams>, replace?: boolean) => void,
] {
const location = useLocation();
const history = useHistory();
const location = useAppLocation();
const params = getApiMonitoringParams(location.search);
const setParams = useCallback(
(newParams: Partial<ApiMonitoringParams>, replace = false) => {
setApiMonitoringParams(newParams, location.search, history, replace);
setApiMonitoringParams(newParams, location.search, replace);
},
[location.search, history],
[location.search],
);
return [params, setParams];

View File

@@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQueries } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { useAppLocation } from 'lib/router/useAppLocation';
import * as Sentry from '@sentry/react';
import { Toaster } from '@signozhq/ui/sonner';
import { TooltipProvider } from '@signozhq/ui/tooltip';
@@ -51,7 +51,7 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useNotifications } from 'hooks/useNotifications';
import useTabVisibility from 'hooks/useTabFocus';
import history from 'lib/history';
import { navigate } from 'lib/router/navigation';
import { isNull } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useAppContext } from 'providers/App/App';
@@ -190,7 +190,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { pathname } = useLocation();
const { pathname } = useAppLocation();
const { t } = useTranslation(['titles']);
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
@@ -464,7 +464,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [isLoggedIn]);
const handleUpgrade = useCallback((): void => {
history.push(ROUTES.BILLING);
navigate(ROUTES.BILLING);
}, []);
const handleFailedPayment = useCallback((): void => {

View File

@@ -9,8 +9,7 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
jest.mock('utils/basePath', () => ({
getBasePath: (): string => '/',
withBasePath: (path: string): string => path,
...jest.requireActual('utils/basePath'),
getAbsoluteUrl: (path: string): string => `https://test.signoz.io${path}`,
getBaseUrl: (): string => 'https://test.signoz.io',
}));

View File

@@ -1,5 +1,4 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
@@ -16,6 +15,7 @@ import {
useAIAssistantStore,
} from 'container/AIAssistant/store/useAIAssistantStore';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useAppLocation } from 'lib/router/useAppLocation';
import ROUTES from 'constants/routes';
import { Dot } from '@signozhq/icons';
@@ -30,7 +30,7 @@ import styles from './AskNoz.module.scss';
* hiding the header button would remove a notification rather than move it.
*/
function AskNoz(): JSX.Element | null {
const { pathname } = useLocation();
const { pathname } = useAppLocation();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isDrawerOpen = useAIAssistantStore((state) => state.isDrawerOpen);
const isModalOpen = useAIAssistantStore((state) => state.isModalOpen);

View File

@@ -6,18 +6,6 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_TO_TITLE, ALERT_TYPE_URL_MAP } from './constants';
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: '',
hash: '',
state: null,
})),
useSearchParams: jest.fn(() => [new URLSearchParams(), jest.fn()]),
}));
jest
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
.mockReturnValue({

View File

@@ -1,4 +1,3 @@
import { MemoryRouter, Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
import CreateAlertPage from 'pages/CreateAlert';
@@ -7,27 +6,6 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_URL_MAP } from './constants';
// The setup guide button only exists in the classic form.
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERTS_NEW}`,
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
}),
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
hash: '',
state: null,
})),
useSearchParams: jest.fn(() => [new URLSearchParams(), jest.fn()]),
}));
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
@@ -59,13 +37,10 @@ describe('Anomaly Alert Documentation Redirection', () => {
});
it('should handle anomaly alert documentation redirection correctly', () => {
const { getByRole } = render(
<MemoryRouter initialEntries={['/alerts/new']}>
<Route path={ROUTES.ALERTS_NEW}>
<CreateAlertPage />
</Route>
</MemoryRouter>,
);
// The setup guide button only exists in the classic form.
const { getByRole } = render(<CreateAlertPage />, undefined, {
initialRoute: `${ROUTES.ALERTS_NEW}?ruleType=anomaly_rule&showClassicCreateAlertsPage=true`,
});
const alertType = AlertTypes.ANOMALY_BASED_ALERT;

View File

@@ -16,18 +16,6 @@ import { DataSource } from 'types/common/queryBuilder';
import CreateAlertRule from '../index';
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: '',
hash: '',
state: null,
})),
useSearchParams: jest.fn(() => [new URLSearchParams(), jest.fn()]),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: function MockDateTimeSelector(): JSX.Element {

View File

@@ -1,6 +1,6 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { TestRouter } from 'tests/router';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
@@ -129,13 +129,13 @@ const createTestQueryClient = (): QueryClient =>
const renderAlertThreshold = (): ReturnType<typeof render> => {
const queryClient = createTestQueryClient();
return render(
<MemoryRouter>
<TestRouter>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<AlertThreshold {...mockProps} />
</CreateAlertProvider>
</QueryClientProvider>
</MemoryRouter>,
</TestRouter>,
);
};

View File

@@ -4,6 +4,7 @@ import ROUTES from 'constants/routes';
import { defaultPostableAlertRuleV2 } from 'container/CreateAlertV2/constants';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import * as useSafeNavigateHook from 'hooks/useSafeNavigate';
import { mockAppLocation } from 'tests/router-mocks';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import * as rulesHook from '../../../../api/generated/services/rules';
@@ -42,12 +43,7 @@ jest.mock('uplot', () => {
};
});
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { search: string } => ({
search: '',
}),
}));
mockAppLocation({ search: '' });
const ENTER_ALERT_RULE_NAME_PLACEHOLDER = 'Enter alert rule name';

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { useAppLocation } from 'lib/router/useAppLocation';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -41,7 +41,7 @@ function ChartPreview({
const yAxisUnit = alertState.yAxisUnit || '';
const location = useLocation();
const location = useAppLocation();
const yAxisUnitFromURL = new URLSearchParams(location.search).get(
QueryParams.yAxisUnit,
);

View File

@@ -1,7 +1,6 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
@@ -10,6 +9,7 @@ import {
} from 'container/CreateAlertV2/context/constants';
import { buildInitialAlertDef } from 'container/CreateAlertV2/context/utils';
import store from 'store';
import { TestRouter } from 'tests/router';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { EQueryType } from 'types/common/dashboard';
@@ -130,11 +130,11 @@ const renderChartPreview = (): ReturnType<typeof render> =>
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<TestRouter>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<ChartPreview alertDef={mockAlertDef} />
</CreateAlertProvider>
</MemoryRouter>
</TestRouter>
</QueryClientProvider>
</Provider>,
);

View File

@@ -1,7 +1,6 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryParams } from 'constants/query';
@@ -11,6 +10,7 @@ import {
} from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import store from 'store';
import { TestRouter } from 'tests/router';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -133,11 +133,11 @@ const renderQuerySection = (search = ''): ReturnType<typeof render> =>
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
<TestRouter initialRoute={`/alerts/new${search}`}>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<QuerySection />
</CreateAlertProvider>
</MemoryRouter>
</TestRouter>
</QueryClientProvider>
</Provider>,
);
@@ -427,11 +427,11 @@ describe('QuerySection', () => {
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<TestRouter>
<CreateAlertProvider initialAlertType={AlertTypes.LOGS_BASED_ALERT}>
<QuerySection />
</CreateAlertProvider>
</MemoryRouter>
</TestRouter>
</QueryClientProvider>
</Provider>,
);
@@ -481,11 +481,11 @@ describe('QuerySection', () => {
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<TestRouter>
<CreateAlertProvider initialAlertType={AlertTypes.TRACES_BASED_ALERT}>
<QuerySection />
</CreateAlertProvider>
</MemoryRouter>
</TestRouter>
</QueryClientProvider>
</Provider>,
);

View File

@@ -1,4 +1,4 @@
import { useHistory } from 'react-router-dom';
import { navigate } from 'lib/router/navigation';
import { render, screen, userEvent } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -125,12 +125,11 @@ function SearchMutator({
}: {
to?: string;
}): JSX.Element {
const routerHistory = useHistory();
return (
<button
type="button"
data-testid="mutate-search"
onClick={(): void => routerHistory.replace(to)}
onClick={(): void => navigate(to, { replace: true })}
>
change search
</button>

View File

@@ -10,7 +10,7 @@ import {
useRef,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { useAppLocation } from 'lib/router/useAppLocation';
import {
useCreateRule,
useTestRule,
@@ -129,7 +129,7 @@ export function CreateAlertProvider(
[setCreateAlertState],
);
const location = useLocation();
const location = useAppLocation();
const queryParams = new URLSearchParams(location.search);
const ruleNameFromURL = queryParams.get(QueryParams.ruleName);
const yAxisUnitFromURL = queryParams.get(QueryParams.yAxisUnit);

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { useLocation } from 'react-router-dom';
import { useAppLocation } from 'lib/router/useAppLocation';
import { Button, Space } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
@@ -13,7 +13,7 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getNanoSeconds } from 'container/AllError/utils';
import { useNotifications } from 'hooks/useNotifications';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { navigate } from 'lib/router/navigation';
import { isUndefined } from 'lodash-es';
import { urlKey } from 'pages/ErrorDetails/utils';
import { useTimezone } from 'providers/Timezone';
@@ -29,7 +29,7 @@ import './styles.scss';
function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
const { idPayload } = props;
const { t } = useTranslation(['errorDetails', 'common']);
const { search, pathname } = useLocation();
const { search, pathname } = useAppLocation();
const params = useMemo(() => new URLSearchParams(search), [search]);
@@ -100,7 +100,7 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
errorId: id,
};
history.replace(`${pathname}?${createQueryParams(queryParams)}`);
navigate(`${pathname}?${createQueryParams(queryParams)}`, { replace: true });
} catch (error) {
notifications.error({
message: t('something_went_wrong'),
@@ -126,7 +126,7 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
if (isModifierKeyPressed(event)) {
openInNewTab(path);
} else {
history.push(path);
navigate(path);
}
};

View File

@@ -1,9 +1,9 @@
import { useHistory } from 'react-router-dom';
import { ConciergeBell } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useAppNavigate } from 'lib/router/useAppNavigate';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -22,7 +22,7 @@ function CreateAlertButton({
sourcepage: DataSource;
isOneChartPerQuery?: boolean;
}): JSX.Element {
const history = useHistory();
const navigate = useAppNavigate();
const { panelType } = useQueryBuilder();
const createAlert = (): void => {
@@ -33,7 +33,7 @@ function CreateAlertButton({
EXPLORER_ACTION_EVENTS[sourcepage].createAlert,
getExplorerActionEventPayload({ sourcepage, panelType, isOneChartPerQuery }),
);
history.push(getCreateAlertLink({ query, panelType }));
navigate(getCreateAlertLink({ query, panelType }));
};
return isOneChartPerQuery ? (

View File

@@ -1,4 +1,3 @@
import { useHistory } from 'react-router-dom';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
@@ -9,16 +8,13 @@ import {
} from 'container/LogsExplorerViews/explorerUtils';
import { getQueryByPanelType as getTracesQueryByPanelType } from 'container/TracesExplorer/explorerUtils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mockAppNavigate } from 'tests/router-mocks';
import { render, screen, userEvent } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import CreateAlertButton from '../CreateAlertButton';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: jest.fn(),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
@@ -27,8 +23,7 @@ jest.mock('api/common/logEvent', () => ({
default: jest.fn(() => Promise.resolve()),
}));
const mockPush = jest.fn();
const mockedUseHistory = jest.mocked(useHistory);
const mockedNavigate = mockAppNavigate();
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const mockedLogEvent = jest.mocked(logEvent);
@@ -61,8 +56,8 @@ function stagedQuery(
}
function pushedQuery(): Query {
expect(mockPush).toHaveBeenCalledTimes(1);
const [path, search] = (mockPush.mock.calls[0][0] as string).split('?');
expect(mockedNavigate).toHaveBeenCalledTimes(1);
const [path, search] = (mockedNavigate.mock.calls[0][0] as string).split('?');
expect(path).toBe(ROUTES.ALERTS_NEW);
const raw = new URLSearchParams(search).get(QueryParams.compositeQuery);
return JSON.parse(raw as string);
@@ -86,11 +81,8 @@ async function clickCreateAlert(
describe('CreateAlertButton', () => {
beforeEach(() => {
mockPush.mockReset();
mockedNavigate.mockReset();
mockedLogEvent.mockClear();
mockedUseHistory.mockReturnValue({ push: mockPush } as unknown as ReturnType<
typeof useHistory
>);
});
it('is disabled and does nothing without a query', async () => {
@@ -100,7 +92,7 @@ describe('CreateAlertButton', () => {
'aria-disabled',
'true',
);
expect(mockPush).not.toHaveBeenCalled();
expect(mockedNavigate).not.toHaveBeenCalled();
});
it("logs the explorer's own create alert event", async () => {

View File

@@ -1,5 +1,5 @@
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { navigate } from 'lib/router/navigation';
import {
createErrorResponse,
handleInternalServerError,
@@ -12,19 +12,12 @@ import { OrgSessionContext } from 'types/api/v2/sessions/context/get';
import ForgotPassword, { ForgotPasswordRouteState } from '../index';
// Mock dependencies
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: jest.fn(),
location: {
search: '',
},
},
jest.mock('lib/router/navigation', () => ({
...jest.requireActual('lib/router/navigation'),
navigate: jest.fn(),
}));
const mockHistoryPush = history.push as jest.MockedFunction<
typeof history.push
>;
const mockNavigate = navigate as jest.MockedFunction<typeof navigate>;
const FORGOT_PASSWORD_ENDPOINT = '*/api/v2/factor_password/forgot';
@@ -236,7 +229,7 @@ describe('ForgotPassword Component', () => {
const backToLoginButton = screen.getByTestId('back-to-login');
await user.click(backToLoginButton);
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
expect(mockNavigate).toHaveBeenCalledWith(ROUTES.LOGIN);
});
});
@@ -328,7 +321,7 @@ describe('ForgotPassword Component', () => {
const backButton = screen.getByTestId('forgot-password-back');
await user.click(backButton);
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
expect(mockNavigate).toHaveBeenCalledWith(ROUTES.LOGIN);
});
});

View File

@@ -7,7 +7,7 @@ import { ErrorResponseHandlerForGeneratedAPIs } from 'api/ErrorResponseHandlerFo
import { useForgotPassword } from 'api/generated/services/users';
import AuthError from 'components/AuthError/AuthError';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { navigate } from 'lib/router/navigation';
import APIError from 'types/api/error';
import { OrgSessionContext } from 'types/api/v2/sessions/context/get';
import { getBaseUrl } from 'utils/basePath';
@@ -108,7 +108,7 @@ function ForgotPassword({
}, [form, forgotPasswordMutate, initialOrgId, hasMultipleOrgs]);
const handleBackToLogin = useCallback((): void => {
history.push(ROUTES.LOGIN);
navigate(ROUTES.LOGIN);
}, []);
// Success screen

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { useAppLocation } from 'lib/router/useAppLocation';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import Spinner from 'components/Spinner';
@@ -28,7 +28,7 @@ import { useResizeObserver } from 'hooks/useDimensions';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { navigate } from 'lib/router/navigation';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { isEmpty } from 'lodash-es';
import { useIsAnomalyDetectionAvailable } from 'container/CreateAlertV2/useIsAnomalyDetectionAvailable';
@@ -223,7 +223,7 @@ function ChartPreview({
const isDarkMode = useIsDarkMode();
const urlQuery = useUrlQuery();
const location = useLocation();
const location = useAppLocation();
const optionName =
getFormatNameByOptionId(alertDef?.condition.targetUnit || '') || '';
@@ -245,7 +245,7 @@ function ChartPreview({
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.push(generatedUrl);
navigate(generatedUrl);
},
[dispatch, location.pathname, urlQuery],
);

View File

@@ -15,7 +15,7 @@ const TEST_MAPPINGS = {
},
};
jest.mock('react-router-dom-v5-compat', () => {
jest.mock('lib/router/useAppLocation', () => {
const mockThreshold1 = {
index: '0d11f426-a02e-48da-867c-b79c6ef1ff06',
isEditEnabled: false,
@@ -43,8 +43,7 @@ jest.mock('react-router-dom-v5-compat', () => {
thresholdValue: 900,
};
return {
...jest.requireActual('react-router-dom-v5-compat'),
useLocation: jest.fn().mockReturnValue({
useAppLocation: jest.fn().mockReturnValue({
state: {
thresholds: [mockThreshold1, mockThreshold2],
},

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useQueryClient } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { useAppLocation } from 'lib/router/useAppLocation';
import { BellDot, CircleAlert, ExternalLink, Save } from '@signozhq/icons';
import { Button, FormInstance, SelectProps } from 'antd';
import { ConfirmDialog } from '@signozhq/ui/dialog';
@@ -107,7 +107,7 @@ function FormAlertRules({
>((state) => state.globalTime);
const urlQuery = useUrlQuery();
const location = useLocation();
const location = useAppLocation();
const queryParams = new URLSearchParams(location.search);
const dataSource = useMemo(

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom-v5-compat';
import { useAppLocation } from 'lib/router/useAppLocation';
import { ThresholdProps } from 'types/api/widgets/threshold';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -13,7 +13,7 @@ export const usePrefillAlertConditions = (
target: number | undefined;
targetUnit: string | undefined;
} => {
const location = useLocation();
const location = useAppLocation();
// Extract and set match type
const reduceTo = useMemo(() => {

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { AppLink } from 'lib/router/AppLink';
import { Callout } from '@signozhq/ui/callout';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
@@ -55,21 +55,21 @@ function LicenseRowDismissibleCallout(): JSX.Element | null {
{' '}
Find your{' '}
{hasServiceAccountsAccess && (
<Link
<AppLink
to={ROUTES.SERVICE_ACCOUNTS_SETTINGS}
className="license-key-callout__link"
>
Service account here
</Link>
</AppLink>
)}
{hasServiceAccountsAccess && hasIngestionAccess && ' and '}
{hasIngestionAccess && (
<Link
<AppLink
to={ROUTES.INGESTION_SETTINGS}
className="license-key-callout__link"
>
Ingestion key here
</Link>
</AppLink>
)}
.
</>

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Button, Skeleton } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import logEvent from 'api/common/logEvent';
@@ -7,7 +6,9 @@ import { useListRules } from 'api/generated/services/rules';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { AppLink } from 'lib/router/AppLink';
import { navigate } from 'lib/router/navigation';
import { useAppLocation } from 'lib/router/useAppLocation';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
import Card from 'periscope/components/Card/Card';
@@ -33,7 +34,7 @@ export default function AlertRules({
[],
);
const location = useLocation();
const location = useAppLocation();
const params = new URLSearchParams(location.search);
// Fetch Alerts
@@ -88,7 +89,7 @@ export default function AlertRules({
{user?.role !== USER_ROLES.VIEWER && (
<div className="empty-actions-container">
<Link to={ROUTES.ALERTS_NEW}>
<AppLink to={ROUTES.ALERTS_NEW}>
<Button
type="default"
className="periscope-btn secondary"
@@ -99,7 +100,7 @@ export default function AlertRules({
>
Create Alert Rule
</Button>
</Link>
</AppLink>
<Button
type="link"
@@ -146,7 +147,7 @@ export default function AlertRules({
params.set(QueryParams.ruleId, record.id);
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
};
const renderAlertRules = (): JSX.Element => (
@@ -228,7 +229,7 @@ export default function AlertRules({
{rulesExist && (
<Card.Footer>
<div className="alert-rules-footer home-data-card-footer">
<Link to={ROUTES.LIST_ALL_ALERT}>
<AppLink to={ROUTES.LIST_ALL_ALERT}>
<Button
type="link"
className="periscope-btn link learn-more-link"
@@ -238,7 +239,7 @@ export default function AlertRules({
>
All Alert Rules <ArrowRight size={12} />
</Button>
</Link>
</AppLink>
</div>
</Card.Footer>
)}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Button, Skeleton } from 'antd';
import { AppLink } from 'lib/router/AppLink';
import { Badge } from '@signozhq/ui/badge';
import logEvent from 'api/common/logEvent';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
@@ -79,7 +79,7 @@ export default function Dashboards({
{user?.role !== USER_ROLES.VIEWER && (
<div className="empty-actions-container">
<Link to={ROUTES.ALL_DASHBOARD}>
<AppLink to={ROUTES.ALL_DASHBOARD}>
<Button
type="default"
className="periscope-btn secondary"
@@ -90,7 +90,7 @@ export default function Dashboards({
>
New Dashboard
</Button>
</Link>
</AppLink>
<Button
type="link"
@@ -212,7 +212,7 @@ export default function Dashboards({
{dashboardsExist && (
<Card.Footer>
<div className="dashboards-footer home-data-card-footer">
<Link to={ROUTES.ALL_DASHBOARD}>
<AppLink to={ROUTES.ALL_DASHBOARD}>
<Button
type="link"
className="periscope-btn link learn-more-link"
@@ -222,7 +222,7 @@ export default function Dashboards({
>
All Dashboards <ArrowRight size={12} />
</Button>
</Link>
</AppLink>
</div>
</Card.Footer>
)}

Some files were not shown because too many files have changed in this diff Show More