Compare commits

..

18 Commits

Author SHA1 Message Date
Naman Verma
3aa53d67e3 test: follow test guidelines 2026-10-30 20:00:22 +05:30
Abhi Kumar
8f87ed3ed4 chore(api): regenerate frontend api client 2026-10-30 17:15:43 +05:30
Abhi kumar
1161c249f2 Merge branch 'main' into feat/scatter-plot-panel-spec 2026-10-30 17:01:48 +05:30
Abhi kumar
3778d23a41 feat(charts): add the scatter chart layer (#13050)
#### Description

- Adds the scatter chart layer: a uPlot faceted-mode (`mode: 2`)
`ScatterPlugin` with quadtree hover/click hit-testing and a
hovered-point outline, plus a `Scatter` chart with its own tooltip.
- Per-axis scales `auto` | `linear` | `log` | `symlog` and titles.
`auto` picks log (symlog with zero or negatives) when it spreads the
middle half of the points at least 2× more than linear, over ≥ 2
decades.
- Log axes tick at 1/2/5 × 10ⁿ since the scatter range is left
unsnapped; dots stay whole at plot edges.
- Click handlers report the scatter point hit; `buildClickData` skips x
lookups on faceted plots and `ChartWrapper` forwards `plotRef`.

#### Additional Information

- Shared uPlotV2 changes affecting every chart: axes now draw an axis
line in the grid's style (off when the grid is hidden), and value-axis
tick labels outside the scale's range are blanked.
- New shared config: `PlotMode`, `DrawStyle.Scatter`,
`DistributionType.SymmetricLog`.
- Chart library and Storybook stories only; the dashboard panel lands in
#13051.
2026-10-30 17:01:17 +05:30
Naman Verma
b9104596ca fix: add dot in comment 2026-10-30 16:03:46 +05:30
Naman Verma
e9ab776ced chore: add decsriptions and todo 2026-10-30 13:25:09 +05:30
Naman Verma
eaa922cabe fix: dont allow trace operator 2026-10-30 12:27:10 +05:30
Abhi Kumar
f64bc495cc fix(dashboards): key metric aggregations by query name and reject unknown point fields 2026-10-30 12:27:10 +05:30
Naman Verma
6a42b9af90 feat: add validation to ensure queries have same group by 2026-10-30 12:27:10 +05:30
Abhi Kumar
517cba0bee feat(dashboards): add an axis to scatter plot thresholds 2026-10-30 12:27:10 +05:30
Naman Verma
118ccb8f12 chore: add more validations for scatter plot 2026-10-30 12:27:10 +05:30
Abhi Kumar
3bd710deb7 feat(dashboards): rename scatter plot dimensions to colorBy and sizeBy 2026-10-30 12:27:10 +05:30
Abhi Kumar
44e31d7dd8 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-30 12:27:10 +05:30
Abhi Kumar
5d411a8221 feat(dashboards): add point size and opacity to scatter plot spec
The fixed size and the min/max range are both stored so binding or unbinding
the size dimension restores the other's last setting.

Assisted-by: Claude Opus 5.5
2026-10-30 12:27:10 +05:30
Abhi Kumar
effb7df6fa chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-30 12:27:10 +05:30
Abhi Kumar
12a0c3fd64 feat(dashboards): add axis labels and multi-key colour to scatter plot spec
An empty colour list colours by every group-by label.

Assisted-by: Claude Opus 5.5
2026-10-30 12:27:10 +05:30
Abhi Kumar
d34b7ee637 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-30 12:27:10 +05:30
Abhi Kumar
a802e1422e feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-30 12:27:10 +05:30
599 changed files with 13079 additions and 5442 deletions

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\|useAppNavigate\|navigate\|<AppLink` |
| navigation | `useSafeNavigate\|history.push\|<Link` |
| 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

@@ -4224,6 +4224,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -5250,6 +5257,7 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -5265,6 +5273,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -5278,6 +5287,7 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -5384,6 +5394,21 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
description: The panel kind.
enum:
- signoz/ScatterPlotPanel
type: string
spec:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
description: The options for this panel kind. {} accepts every default.
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -5494,6 +5519,16 @@ components:
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
description: The time range the slices are computed over.
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -5748,6 +5783,161 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
description: The axis dimensions.x is plotted along.
"y":
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
description: The axis dimensions.y is plotted along.
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none; the tooltip still names the column.
type: string
scale:
allOf:
- $ref: '#/components/schemas/DashboardtypesAxisScale'
description: auto (default) uses log, or symlog when values include zero
or negatives, when that spreads the dots clearly more than linear does;
otherwise linear. log cannot place a value at or below zero and leaves
that dot out; symlog is log-like but places it.
softMax:
description: The highest value the axis always shows; data above it still
extends the axis, as does a threshold on it. Null fits the data.
nullable: true
type: number
softMin:
description: The lowest value the axis always shows; data below it still
extends the axis, as does a threshold on it. Null fits the data. A log
axis ignores a bound at or below zero.
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
description: Dot size and opacity.
type: object
DashboardtypesScatterPlotDimensions:
properties:
colorBy:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values color the dots and drive the legend. Empty colors by every group-by
label.
items:
type: string
nullable: true
type: array
sizeBy:
description: Value column key that scales each dot's area between chartAppearance.points.minSize
and maxSize. Empty draws every dot at chartAppearance.points.size, as
does a dot with no value.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty, or a key the result
lacks, uses the first value column.
type: string
"y":
description: Value column key plotted on the y axis. Empty, or a key the
result lacks, uses the first value column x does not use.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
description: Title, bounds and scale of each axis.
chartAppearance:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
description: Dot drawing options.
dimensions:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
description: 'Which result columns place, size and color the dots. Each
dot is one row of the joined scalar result: one group.'
formatting:
allOf:
- $ref: '#/components/schemas/DashboardtypesTableFormatting'
description: Per-column units and decimal precision. The x and y columns'
units label their axes; every bound column's unit shows in the tooltip.
legend:
allOf:
- $ref: '#/components/schemas/DashboardtypesLegend'
description: Legend placement and series colors. A series is one color group,
labeled by its colorBy values joined with a comma, or All when nothing
is grouped.
thresholds:
description: Reference lines drawn at fixed values across the x or y axis.
items:
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
nullable: true
type: array
visualization:
allOf:
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
description: The time range the groups are computed over.
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
allOf:
- $ref: '#/components/schemas/DashboardtypesPointDiameter'
description: Diameter of the largest dot when dimensions.sizeBy is set,
in CSS pixels from 2 to 40. Null means 24.
minSize:
allOf:
- $ref: '#/components/schemas/DashboardtypesPointDiameter'
description: Diameter of the smallest dot when dimensions.sizeBy is set,
in CSS pixels from 2 to 40. Null means 4.
opacity:
allOf:
- $ref: '#/components/schemas/DashboardtypesPointOpacity'
description: Fill opacity of every dot, from 0.1 to 1. Null means 0.7.
size:
allOf:
- $ref: '#/components/schemas/DashboardtypesPointDiameter'
description: Diameter of every dot when dimensions.sizeBy is unset, in CSS
pixels from 2 to 40. Null means 6.
type: object
DashboardtypesScatterPlotThreshold:
properties:
axis:
allOf:
- $ref: '#/components/schemas/DashboardtypesThresholdAxis'
description: 'The axis the line marks: y (default) draws it horizontally,
x vertically. Unit converts against that axis''s column unit, and the
axis extends to keep the line in view.'
color:
description: 'The CSS color of the line, for example #F1575F, which matches
the SigNoz red preset; CSS named colors such as red also work.'
type: string
label:
description: The caption shown on the line.
type: string
unit:
description: The unit id of value, such as ms or percent. Empty means the
panel's unit, or the column's unit for a table threshold. Prefer the panel's
unit; a different unit works only when it converts to it, such as s against
a panel in ms.
type: string
value:
description: The value the line is drawn at, in unit.
format: double
type: number
required:
- value
- color
type: object
DashboardtypesSource:
enum:
- user
@@ -5914,6 +6104,11 @@ components:
- value
- name
type: object
DashboardtypesThresholdAxis:
enum:
- x
- "y"
type: string
DashboardtypesThresholdFormat:
enum:
- text

View File

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

View File

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

View File

@@ -294,8 +294,6 @@
// 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",
{
@@ -591,24 +589,6 @@
"rules": {
"no-redeclare": "off"
}
},
{
// The react-router allowlist: the facade itself, the route table, the router mount,
// `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/app/AppRouter.tsx",
"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

@@ -57,6 +57,7 @@ const mockPaths = {
const mockTzDate = jest.fn(
(date: Date, _timezone: string) => new Date(date.getTime()),
);
const mockOrient = jest.fn();
// Mock uPlot constructor - this needs to be a proper constructor function
function MockUPlot(
@@ -70,6 +71,7 @@ function MockUPlot(
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
MockUPlot.orient = mockOrient;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;

View File

@@ -1,6 +1,6 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const glob = require('glob');
function generateChecksum(str, algorithm, encoding) {
return crypto
@@ -9,17 +9,12 @@ function generateChecksum(str, algorithm, encoding) {
.digest(encoding || 'hex');
}
const localesDir = 'public/locales';
const result = {};
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);
});
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.writeFileSync('./i18n-translations-hash.json', JSON.stringify(result));

View File

@@ -5,8 +5,6 @@
/**
* Adds custom matchers from the react testing library to all tests
*/
import { TextDecoder, TextEncoder } from 'node:util';
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
import 'jest-styled-components';
@@ -16,14 +14,6 @@ import { server } from './src/mocks-server/server';
import './src/styles.scss';
// Establish API mocking before all tests.
// react-router@7's entry point pulls in its server-runtime cookie signing,
// which builds a TextEncoder at module scope. jsdom ships neither encoder, so
// importing anything from the router throws before a test starts.
Object.assign(globalThis, {
TextEncoder: globalThis.TextEncoder ?? TextEncoder,
TextDecoder: globalThis.TextDecoder ?? TextDecoder,
});
// Mock window.matchMedia
window.matchMedia =
window.matchMedia ||

View File

@@ -24,8 +24,8 @@
"jest": "jest",
"jest:coverage": "jest --coverage",
"jest:watch": "jest --watch",
"postinstall": "pnpm i18n:generate-hash && ([ -n \"$CI\" ] || pnpm husky:configure) && node scripts/update-registry.cjs",
"husky:configure": "cd .. && husky frontend/.husky",
"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/*",
"commitlint": "commitlint --edit $1",
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
@@ -83,7 +83,7 @@
"dompurify": "3.4.15",
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "5.3.0",
"history": "4.10.1",
"html-to-image": "1.11.13",
"http-status-codes": "2.3.0",
"i18next": "^21.6.12",
@@ -100,8 +100,10 @@
"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",
@@ -117,7 +119,8 @@
"react-query": "3.39.3",
"react-redux": "^7.2.2",
"react-rnd": "^10.5.3",
"react-router": "7.18.4",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.30.6",
"react-syntax-highlighter": "15.5.0",
"react-use": "^17.3.2",
"react-virtuoso": "4.0.3",
@@ -134,6 +137,7 @@
"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"
},
@@ -171,23 +175,29 @@
"@types/crypto-js": "4.2.2",
"@types/d3-hierarchy": "1.1.11",
"@types/event-source-polyfill": "^1.0.0",
"@types/history": "4.7.11",
"@types/jest": "30.0.0",
"@types/lodash-es": "^4.17.4",
"@types/node": "^22.20.5",
"@types/node": "^16.10.3",
"@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",
"@types/react-resizable": "3.0.3",
"@types/react-router-dom": "^5.1.6",
"@types/react-syntax-highlighter": "15.5.13",
"@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",
"husky": "^9.1.7",
"glob": "^13.0.6",
"husky": "^7.0.4",
"is-ci": "^3.0.1",
"jest-environment-jsdom": "29.7.0",
"jest-styled-components": "^7.2.0",
"json-schema-to-typescript": "^15.0.4",

View File

@@ -1,126 +0,0 @@
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',
code: "jest.doMock('react-router');",
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 is flagged as the v7 re-export shim',
code: "import { Outlet } from 'react-router-dom';",
errors: [{ message: 'only a re-export shim' }],
},
{
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

@@ -1,125 +0,0 @@
/**
* 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.
*
* `react-router-dom` is still flagged even though the package is gone: on v7 it is a
* re-export shim over `react-router`, so reinstalling it would split the tree again.
*
* `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, the router mount, `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': 'router',
'react-router-dom': 'routerDom',
'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: {
router:
'Do not import react-router directly. Use the src/lib/router facade (useAppNavigate, useAppLocation, useAppParams, AppLink, Redirect, matchRoute) so a router upgrade stays contained.',
routerDom:
'Do not import react-router-dom. The app is on react-router; on v7 react-router-dom is only a re-export shim, and installing it puts two copies of the router in the tree. Use the src/lib/router facade.',
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,7 +16,6 @@ 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: {
@@ -34,6 +33,5 @@ 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,
},
};

721
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -74,6 +74,11 @@ overrides:
# (postcss-selector-parser ^7.0.0)
# remove: blocked, 4.2.0 is latest and the range is open, so the floor is what pulls the fix
postcss-selector-parser@>=7.1.0 <7.1.3: '>=7.1.3 <8'
# via: @signozhq/ui > nuqs@2 (react-router ^6.4.0 || ^7)
# remove: blocked. GHSA-wrjc-x8rr-h8h6 and the deserializeErrors advisory are patched
# only in 7.18.0. Do NOT open the cap: react-router >=7 requires React 19 and breaks
# the app-wide CompatRouter, so those two moderates stay until the app moves to React 19
react-router@>=6.7.0 <6.30.6: '>=6.30.6 <7'
# via: msw@1.3.2 (devDep) > inquirer@8 > external-editor@3.1.0 (tmp ^0.0.33)
# remove: upgrade msw to >=2 (drops the inquirer/external-editor chain)
tmp@<0.2.6: '>=0.2.6 <0.3.0'
@@ -81,13 +86,6 @@ 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,4 +1,5 @@
import { ReactChild, useCallback, useMemo } from 'react';
import { matchPath, Redirect, useLocation } from 'react-router-dom';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useListUsers } from 'api/generated/services/users';
@@ -8,9 +9,6 @@ import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { isEmpty } from 'lodash-es';
import { matchRoute } from 'lib/router/matchRoute';
import { Redirect } from 'lib/router/Redirect';
import { useAppLocation } from 'lib/router/useAppLocation';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
@@ -30,7 +28,7 @@ import routes, {
// eslint-disable-next-line sonarjs/cognitive-complexity
function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const location = useAppLocation();
const location = useLocation();
const { pathname } = location;
const {
org,
@@ -49,11 +47,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
() =>
new Map(
[...routes, LIST_LICENSES, SUPPORT_ROUTE].map((e) => {
const patterns = Array.isArray(e.path) ? e.path : [e.path];
const matches = patterns.some(
(pattern) => matchRoute(pathname, pattern) !== null,
);
return [matches ? 'current' : null, e];
const currentPath = matchPath(pathname, {
path: e.path,
});
return [currentPath === null ? null : 'current', e];
}),
),
[pathname],
@@ -245,7 +242,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
);
if (fromPathname) {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
return <Redirect to={fromPathname} replace={false} />;
return <Redirect to={fromPathname} />;
}
if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
return <Redirect to={ROUTES.HOME} />;
@@ -259,7 +256,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
);
if (fromPathname) {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
return <Redirect to={fromPathname} replace={false} />;
return <Redirect to={fromPathname} />;
}
return <Redirect to={ROUTES.HOME} />;
} else {

View File

@@ -1,6 +1,6 @@
import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router';
import { MemoryRouter, Route, Switch, useLocation } from 'react-router-dom';
import { act, render, screen, waitFor } from '@testing-library/react';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
@@ -237,17 +237,12 @@ function buildPrivateRouteTree(
<MemoryRouter initialEntries={[initialRoute]}>
<AppContext.Provider value={contextValue}>
<PrivateRoute>
<Routes>
<Route
path="*"
element={
<>
<div data-testid="children-rendered">Content</div>
<LocationDisplay />
</>
}
/>
</Routes>
<Switch>
<Route path="*">
<div data-testid="children-rendered">Content</div>
<LocationDisplay />
</Route>
</Switch>
</PrivateRoute>
</AppContext.Provider>
</MemoryRouter>

View File

@@ -1,73 +0,0 @@
import { lazy, Suspense, useState } from 'react';
import { Routes } from 'react-router';
import { act, render, screen } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { navigate } from 'lib/router/navigation';
import { TestRouter } from 'tests/router';
import { resetTestRoute } from 'tests/router-utils';
import { routeElements } from '../routeElements';
import type { AppRoutes } from '../routes';
let mounts = 0;
function Page(): JSX.Element {
const [mount] = useState(() => {
mounts += 1;
return mounts;
});
return <div data-testid="page">mount {mount}</div>;
}
// Two lazy wrappers of one module, like the logs explorer and pipelines routes.
const Explorer = lazy(async () => ({ default: Page }));
const Pipelines = lazy(async () => ({ default: Page }));
const routes: AppRoutes[] = [
{ path: ROUTES.LOGS, component: Explorer, key: 'LOGS', isPrivate: true },
{
path: [ROUTES.LOGS_PIPELINES, ROUTES.LOGS_INDEX_FIELDS],
component: Pipelines,
key: 'LOGS_PIPELINES',
isPrivate: true,
},
];
function renderRoutes(): void {
render(
<TestRouter initialRoute={ROUTES.LOGS}>
<Suspense fallback={<div data-testid="fallback" />}>
<Routes>{routeElements(routes)}</Routes>
</Suspense>
</TestRouter>,
);
}
describe('routeElements', () => {
beforeEach(() => {
mounts = 0;
});
afterEach(() => {
resetTestRoute();
});
it('remounts the page when another route entry resolves to the same component', async () => {
renderRoutes();
await expect(screen.findByText('mount 1')).resolves.toBeInTheDocument();
act(() => navigate(ROUTES.LOGS_PIPELINES));
await expect(screen.findByText('mount 2')).resolves.toBeInTheDocument();
});
it('keeps the page mounted across the patterns of one route entry', async () => {
renderRoutes();
act(() => navigate(ROUTES.LOGS_PIPELINES));
await expect(screen.findByText('mount 2')).resolves.toBeInTheDocument();
act(() => navigate(ROUTES.LOGS_INDEX_FIELDS));
expect(screen.getByTestId('page')).toHaveTextContent('mount 2');
});
});

View File

@@ -1,5 +1,6 @@
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
import { Route, Routes } from 'react-router';
import { Route, Router, Switch } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
@@ -22,7 +23,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { StatusCodes } from 'http-status-codes';
import { IS_PROD } from 'lib/env';
import { getCurrentLocation, navigate, subscribe } from 'lib/router/navigation';
import history from 'lib/history';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import posthog from 'posthog-js';
import { useAppContext } from 'providers/App/App';
@@ -32,7 +33,6 @@ import { extractDomain } from 'utils/app';
import { Home } from './pageComponents';
import PrivateRoute from './Private';
import { routeElements } from './routeElements';
import defaultRoutes, {
AppRoutes,
LIST_LICENSES,
@@ -44,6 +44,12 @@ import {
SENTRY_IGNORE_ERRORS,
} from './sentryFilters';
const appRouter = (children: ReactNode): ReactNode => (
<Router history={history}>
<CompatRouter>{children}</CompatRouter>
</Router>
);
const appLayout = (children: ReactNode): ReactNode => (
<AppLayout>{children}</AppLayout>
);
@@ -68,16 +74,14 @@ function App(): JSX.Element {
const chatSupport = useChatSupport();
const { hostname } = window.location;
// Through the facade, not the raw history: the router owns the base path
// now, so `history.location.pathname` would still carry it.
const [pathname, setPathname] = useState(getCurrentLocation().pathname);
const [pathname, setPathname] = useState(history.location.pathname);
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const [isSentryInitialized, setIsSentryInitialized] = useState(false);
useEffect(() => {
return subscribe(({ location }) => {
return history.listen((location) => {
setPathname(location.pathname);
});
}, []);
@@ -394,7 +398,7 @@ function App(): JSX.Element {
// this needs to be on top of data missing error because if there is an error, data will never be loaded and it will
// move to indefinitive loading
if (userFetchError && pathname !== ROUTES.SOMETHING_WENT_WRONG) {
navigate(ROUTES.SOMETHING_WENT_WRONG, { replace: true });
history.replace(ROUTES.SOMETHING_WENT_WRONG);
}
// if all of the data is not set then return a spinner, this is required because there is some gap between loading states and data setting
@@ -410,6 +414,7 @@ function App(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<AppShell
router={appRouter}
overlays={
isLoggedInState && (
<>
@@ -422,11 +427,18 @@ function App(): JSX.Element {
<PrivateRoute>
<AppPageProviders layout={appLayout}>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Routes>
{routeElements(routes)}
<Route path="/" element={<Home />} />
<Route path="*" element={<NotFound />} />
</Routes>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</AppPageProviders>
</PrivateRoute>

View File

@@ -1,20 +0,0 @@
import { Route } from 'react-router';
import type { AppRoutes } from './routes';
/**
* The page is keyed by its route entry. `<Routes>` renders the match unkeyed, so
* two lazy pages that resolve to the same component would otherwise reuse the
* previous route's instance. The patterns of one entry share the key.
*/
export function routeElements(routes: AppRoutes[]): JSX.Element[] {
return routes.flatMap(({ path, component: Component, nested, key }) =>
(Array.isArray(path) ? path : [path]).map((pattern) => (
<Route
key={pattern}
path={nested ? `${pattern}/*` : pattern}
element={<Component key={key} />}
/>
)),
);
}

View File

@@ -1,4 +1,4 @@
import type { ComponentType } from 'react';
import { RouteProps } from 'react-router-dom';
import ROUTES from 'constants/routes';
import {
@@ -58,25 +58,27 @@ const routes: AppRoutes[] = [
{
component: SignupPage,
path: ROUTES.SIGN_UP,
exact: true,
isPrivate: false,
key: 'SIGN_UP',
},
{
path: ROUTES.GET_STARTED_WITH_CLOUD,
nested: true,
exact: false,
component: OnboardingV2,
isPrivate: true,
key: 'GET_STARTED_WITH_CLOUD',
},
{
path: ROUTES.HOME,
exact: true,
component: Home,
isPrivate: true,
key: 'HOME',
},
{
path: ROUTES.ONBOARDING,
nested: true,
exact: false,
component: OrgOnboarding,
isPrivate: true,
key: 'ONBOARDING',
@@ -84,23 +86,27 @@ const routes: AppRoutes[] = [
{
component: LogsIndexToFields,
path: ROUTES.LOGS_INDEX_FIELDS,
exact: true,
isPrivate: true,
key: 'LOGS_INDEX_FIELDS',
},
{
component: ServicesTablePage,
path: ROUTES.APPLICATION,
exact: true,
isPrivate: true,
key: 'APPLICATION',
},
{
path: ROUTES.SERVICE_METRICS,
exact: true,
component: ServiceMetricsPage,
isPrivate: true,
key: 'SERVICE_METRICS',
},
{
path: ROUTES.SERVICE_TOP_LEVEL_OPERATIONS,
exact: true,
component: ServiceTopLevelOperationsPage,
isPrivate: true,
key: 'SERVICE_TOP_LEVEL_OPERATIONS',
@@ -109,252 +115,292 @@ const routes: AppRoutes[] = [
path: ROUTES.SERVICE_MAP,
component: ServiceMapPage,
isPrivate: true,
exact: true,
key: 'SERVICE_MAP',
},
{
path: ROUTES.TRACE_DETAIL,
exact: true,
component: TraceDetailV3,
isPrivate: true,
key: 'TRACE_DETAIL',
},
{
path: ROUTES.SETTINGS,
nested: true,
exact: false,
component: SettingsPage,
isPrivate: true,
key: 'SETTINGS',
},
{
path: ROUTES.USAGE_EXPLORER,
exact: true,
component: UsageExplorerPage,
isPrivate: true,
key: 'USAGE_EXPLORER',
},
{
path: ROUTES.ALL_DASHBOARD,
exact: true,
component: DashboardsListPage,
isPrivate: true,
key: 'ALL_DASHBOARD',
},
{
path: ROUTES.DASHBOARD,
exact: true,
component: DashboardPage,
isPrivate: true,
key: 'DASHBOARD',
},
{
path: ROUTES.PUBLIC_DASHBOARD,
nested: true,
exact: false,
component: PublicDashboardPage,
isPrivate: false,
key: 'PUBLIC_DASHBOARD',
},
{
path: ROUTES.DASHBOARD_PANEL_EDITOR,
exact: true,
component: DashboardPanelEditorPage,
isPrivate: true,
key: 'DASHBOARD_PANEL_EDITOR',
},
{
path: ROUTES.EDIT_ALERTS,
exact: true,
component: EditRulesPage,
isPrivate: true,
key: 'EDIT_ALERTS',
},
{
path: ROUTES.LIST_ALL_ALERT,
exact: true,
component: ListAllALertsPage,
isPrivate: true,
key: 'LIST_ALL_ALERT',
},
{
path: ROUTES.ALERTS_NEW,
exact: true,
component: CreateNewAlerts,
isPrivate: true,
key: 'ALERTS_NEW',
},
{
path: ROUTES.ALERT_HISTORY,
exact: true,
component: AlertHistory,
isPrivate: true,
key: 'ALERT_HISTORY',
},
{
path: ROUTES.ALERT_OVERVIEW,
exact: true,
component: AlertOverview,
isPrivate: true,
key: 'ALERT_OVERVIEW',
},
{
path: ROUTES.TRACES_EXPLORER,
exact: true,
component: TracesExplorer,
isPrivate: true,
key: 'TRACES_EXPLORER',
},
{
path: ROUTES.TRACES_FUNNELS,
exact: true,
component: TracesFunnels,
isPrivate: true,
key: 'TRACES_FUNNELS',
},
{
path: ROUTES.TRACES_FUNNELS_DETAIL,
exact: true,
component: TracesFunnelDetails,
isPrivate: true,
key: 'TRACES_FUNNELS_DETAIL',
},
{
path: ROUTES.CHANNELS_NEW,
exact: true,
component: ChannelsNew,
isPrivate: true,
key: 'CHANNELS_NEW',
},
{
path: ROUTES.CHANNELS_EDIT,
exact: true,
component: ChannelsEdit,
isPrivate: true,
key: 'CHANNELS_EDIT',
},
{
path: ROUTES.ALL_ERROR,
exact: true,
isPrivate: true,
component: AllErrors,
key: 'ALL_ERROR',
},
{
path: ROUTES.ERROR_DETAIL,
exact: true,
component: ErrorDetails,
isPrivate: true,
key: 'ERROR_DETAIL',
},
{
path: ROUTES.VERSION,
exact: true,
component: StatusPage,
isPrivate: true,
key: 'VERSION',
},
{
path: ROUTES.LOGS,
exact: true,
component: Logs,
key: 'LOGS',
isPrivate: true,
},
{
path: ROUTES.LOGS_PIPELINES,
exact: true,
component: PipelinePage,
key: 'LOGS_PIPELINES',
isPrivate: true,
},
{
path: ROUTES.LOGIN,
exact: true,
component: Login,
isPrivate: false,
key: 'LOGIN',
},
{
path: ROUTES.FORGOT_PASSWORD,
exact: true,
component: ForgotPassword,
isPrivate: false,
key: 'FORGOT_PASSWORD',
},
{
path: ROUTES.UN_AUTHORIZED,
exact: true,
component: UnAuthorized,
key: 'UN_AUTHORIZED',
isPrivate: true,
},
{
path: ROUTES.PASSWORD_RESET,
exact: true,
component: PasswordReset,
key: 'PASSWORD_RESET',
isPrivate: false,
},
{
path: ROUTES.SOMETHING_WENT_WRONG,
exact: true,
component: SomethingWentWrong,
key: 'SOMETHING_WENT_WRONG',
isPrivate: false,
},
{
path: ROUTES.WORKSPACE_LOCKED,
exact: true,
component: WorkspaceBlocked,
isPrivate: true,
key: 'WORKSPACE_LOCKED',
},
{
path: ROUTES.WORKSPACE_SUSPENDED,
exact: true,
component: WorkspaceSuspended,
isPrivate: true,
key: 'WORKSPACE_SUSPENDED',
},
{
path: ROUTES.WORKSPACE_ACCESS_RESTRICTED,
exact: true,
component: WorkspaceAccessRestricted,
isPrivate: true,
key: 'WORKSPACE_ACCESS_RESTRICTED',
},
{
path: ROUTES.INTEGRATIONS_DETAIL,
exact: true,
component: IntegrationsDetailsPage,
isPrivate: true,
key: 'INTEGRATIONS_DETAIL',
},
{
path: ROUTES.INTEGRATIONS,
exact: true,
component: Integrations,
isPrivate: true,
key: 'INTEGRATIONS',
},
{
path: ROUTES.MESSAGING_QUEUES_KAFKA,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_KAFKA',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_CELERY_TASK,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_CELERY_TASK',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_OVERVIEW,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_KAFKA_DETAIL,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_KAFKA_DETAIL',
isPrivate: true,
},
{
path: ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
exact: true,
component: InfrastructureMonitoring,
key: 'INFRASTRUCTURE_MONITORING_HOSTS',
isPrivate: true,
},
{
path: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
exact: true,
component: InfrastructureMonitoring,
key: 'INFRASTRUCTURE_MONITORING_KUBERNETES',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_EXPLORER,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_VOLUME_CONTROL',
isPrivate: true,
@@ -362,48 +408,56 @@ const routes: AppRoutes[] = [
{
path: ROUTES.METER,
exact: true,
component: MeterExplorerPage,
key: 'METER',
isPrivate: true,
},
{
path: ROUTES.METER_EXPLORER,
exact: true,
component: MeterExplorerPage,
key: 'METER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.API_MONITORING,
exact: true,
component: ApiMonitoring,
key: 'API_MONITORING',
isPrivate: true,
},
{
path: [ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT],
exact: true,
component: AIAssistantPage,
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_ATTRIBUTE_MAPPING',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_OVERVIEW,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_EXPLORER,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_CONFIGURATION,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_CONFIGURATION',
isPrivate: true,
@@ -412,6 +466,7 @@ const routes: AppRoutes[] = [
export const SUPPORT_ROUTE: AppRoutes = {
path: ROUTES.SUPPORT,
exact: true,
component: SupportPage,
key: 'SUPPORT',
isPrivate: true,
@@ -419,6 +474,7 @@ export const SUPPORT_ROUTE: AppRoutes = {
export const LIST_LICENSES: AppRoutes = {
path: ROUTES.LIST_LICENSES,
exact: true,
component: LicensePage,
isPrivate: true,
key: 'LIST_LICENSES',
@@ -450,15 +506,9 @@ export const ROUTES_NOT_TO_BE_OVERRIDEN: string[] = [
];
export interface AppRoutes {
component: ComponentType;
/** An array registers the same component under each pattern. */
path: string | string[];
/**
* The route renders its own child routes, so it matches as a prefix. v6
* matches the whole path by default, so the mount appends a `/*` splat for
* these and nothing for the rest.
*/
nested?: boolean;
component: RouteProps['component'];
path: RouteProps['path'];
exact: RouteProps['exact'];
isPrivate: boolean;
key: keyof typeof ROUTES;
}

View File

@@ -5584,6 +5584,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -6716,6 +6722,205 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesHeatmapPanelSpecDTO;
}
/**
* The panel kind.
*/
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind {
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
export interface DashboardtypesScatterPlotAxisDTO {
/**
* @type string
* @description Axis title. Empty draws none; the tooltip still names the column.
*/
label?: string;
/**
* @description auto (default) uses log, or symlog when values include zero or negatives, when that spreads the dots clearly more than linear does; otherwise linear. log cannot place a value at or below zero and leaves that dot out; symlog is log-like but places it.
*/
scale?: DashboardtypesAxisScaleDTO;
/**
* @type number,null
* @description The highest value the axis always shows; data above it still extends the axis, as does a threshold on it. Null fits the data.
*/
softMax?: number | null;
/**
* @type number,null
* @description The lowest value the axis always shows; data below it still extends the axis, as does a threshold on it. Null fits the data. A log axis ignores a bound at or below zero.
*/
softMin?: number | null;
}
export interface DashboardtypesScatterPlotAxesDTO {
/**
* @description The axis dimensions.x is plotted along.
*/
x?: DashboardtypesScatterPlotAxisDTO;
/**
* @description The axis dimensions.y is plotted along.
*/
y?: DashboardtypesScatterPlotAxisDTO;
}
/**
* @minimum 2
* @maximum 40
* @nullable
*/
export type DashboardtypesPointDiameterDTO = number | null;
/**
* @minimum 0.1
* @maximum 1
* @nullable
*/
export type DashboardtypesPointOpacityDTO = number | null;
/**
* Diameter of the largest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 24.
*/
export type DashboardtypesScatterPlotPointsDTOMaxSize = DashboardtypesPointDiameterDTO | null;
/**
* Diameter of the smallest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 4.
*/
export type DashboardtypesScatterPlotPointsDTOMinSize = DashboardtypesPointDiameterDTO | null;
/**
* Fill opacity of every dot, from 0.1 to 1. Null means 0.7.
*/
export type DashboardtypesScatterPlotPointsDTOOpacity = DashboardtypesPointOpacityDTO | null;
/**
* Diameter of every dot when dimensions.sizeBy is unset, in CSS pixels from 2 to 40. Null means 6.
*/
export type DashboardtypesScatterPlotPointsDTOSize = DashboardtypesPointDiameterDTO | null;
export interface DashboardtypesScatterPlotPointsDTO {
/**
* @description Diameter of the largest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 24.
*/
maxSize?: DashboardtypesScatterPlotPointsDTOMaxSize;
/**
* @description Diameter of the smallest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 4.
*/
minSize?: DashboardtypesScatterPlotPointsDTOMinSize;
/**
* @description Fill opacity of every dot, from 0.1 to 1. Null means 0.7.
*/
opacity?: DashboardtypesScatterPlotPointsDTOOpacity;
/**
* @description Diameter of every dot when dimensions.sizeBy is unset, in CSS pixels from 2 to 40. Null means 6.
*/
size?: DashboardtypesScatterPlotPointsDTOSize;
}
export interface DashboardtypesScatterPlotChartAppearanceDTO {
/**
* @description Dot size and opacity.
*/
points?: DashboardtypesScatterPlotPointsDTO;
}
export interface DashboardtypesScatterPlotDimensionsDTO {
/**
* @type array,null
* @description Group-by label names (e.g. k8s.namespace.name) whose combined values color the dots and drive the legend. Empty colors by every group-by label.
*/
colorBy?: string[] | null;
/**
* @type string
* @description Value column key that scales each dot's area between chartAppearance.points.minSize and maxSize. Empty draws every dot at chartAppearance.points.size, as does a dot with no value.
*/
sizeBy?: string;
/**
* @type string
* @description Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty, or a key the result lacks, uses the first value column.
*/
x?: string;
/**
* @type string
* @description Value column key plotted on the y axis. Empty, or a key the result lacks, uses the first value column x does not use.
*/
y?: string;
}
export enum DashboardtypesThresholdAxisDTO {
x = 'x',
y = 'y',
}
export interface DashboardtypesScatterPlotThresholdDTO {
/**
* @description The axis the line marks: y (default) draws it horizontally, x vertically. Unit converts against that axis's column unit, and the axis extends to keep the line in view.
*/
axis?: DashboardtypesThresholdAxisDTO;
/**
* @type string
* @description The CSS color of the line, for example #F1575F, which matches the SigNoz red preset; CSS named colors such as red also work.
*/
color: string;
/**
* @type string
* @description The caption shown on the line.
*/
label?: string;
/**
* @type string
* @description The unit id of value, such as ms or percent. Empty means the panel's unit, or the column's unit for a table threshold. Prefer the panel's unit; a different unit works only when it converts to it, such as s against a panel in ms.
*/
unit?: string;
/**
* @type number
* @format double
* @description The value the line is drawn at, in unit.
*/
value: number;
}
export interface DashboardtypesScatterPlotPanelSpecDTO {
/**
* @description Title, bounds and scale of each axis.
*/
axes?: DashboardtypesScatterPlotAxesDTO;
/**
* @description Dot drawing options.
*/
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
/**
* @description Which result columns place, size and color the dots. Each dot is one row of the joined scalar result: one group.
*/
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
/**
* @description Per-column units and decimal precision. The x and y columns' units label their axes; every bound column's unit shows in the tooltip.
*/
formatting?: DashboardtypesTableFormattingDTO;
/**
* @description Legend placement and series colors. A series is one color group, labeled by its colorBy values joined with a comma, or All when nothing is grouped.
*/
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
* @description Reference lines drawn at fixed values across the x or y axis.
*/
thresholds?: DashboardtypesScatterPlotThresholdDTO[] | null;
/**
* @description The time range the groups are computed over.
*/
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
/**
* @enum signoz/ScatterPlotPanel
* @type string
* @description The panel kind.
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
/**
* @description The options for this panel kind. {} accepts every default.
*/
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -6726,7 +6931,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -8006,6 +8212,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
/**
* The RFC 6902 operations, applied in order against the dashboard's postable shape.

View File

@@ -1,7 +1,7 @@
import deleteLocalStorageKey from 'api/browser/localstorage/remove';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { navigate } from 'lib/router/navigation';
import history from 'lib/history';
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'));
navigate(ROUTES.LOGIN);
history.push(ROUTES.LOGIN);
};

View File

@@ -21,6 +21,7 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
convertBuilderQueriesToV5,
mapPanelTypeToRequestType,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';
@@ -904,6 +905,12 @@ describe('prepareQueryRangePayloadV5', () => {
});
});
describe('mapPanelTypeToRequestType', () => {
it('sends a scatter plot as scalar, like a table', () => {
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
});
});
describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(

View File

@@ -51,6 +51,7 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
case PANEL_TYPES.BAR:
return 'time_series';
case PANEL_TYPES.TABLE:
case PANEL_TYPES.SCATTER:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return 'scalar';
@@ -274,6 +275,7 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE);

View File

@@ -16,7 +16,6 @@ export interface AppProvidersProps {
store: Store;
queryClient: QueryClient;
appContext: AppLayer;
router: AppLayer;
searchParams: AppLayer;
}
@@ -28,34 +27,27 @@ export interface AppProvidersProps {
* A new provider belongs here only if it holds process-wide state that does not
* depend on the user, the license or the route. One that fetches on mount would
* fire unauthenticated from here; put it in `AppShell` or lower.
*
* `router` is the outermost layer because `searchParams` (nuqs) reads the
* router's `useNavigate` / `useSearchParams`. Everything below, including the
* boot spinner, therefore renders inside the router.
*/
function AppProviders({
children,
store,
queryClient,
appContext,
router,
searchParams,
}: AppProvidersProps): JSX.Element {
return (
<HelmetProvider>
{router(
searchParams(
<ThemeProvider>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<GlobalTimeStoreAdapter />
{appContext(children)}
</Provider>
</QueryClientProvider>
</TimezoneProvider>
</ThemeProvider>,
),
{searchParams(
<ThemeProvider>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<GlobalTimeStoreAdapter />
{appContext(children)}
</Provider>
</QueryClientProvider>
</TimezoneProvider>
</ThemeProvider>,
)}
</HelmetProvider>
);

View File

@@ -1,26 +0,0 @@
import { ReactNode } from 'react';
import { unstable_HistoryRouter as HistoryRouter } from 'react-router';
import history from 'lib/history';
import { getBasePath } from 'utils/basePath';
/**
* The outermost layer. It is above `NuqsAdapter` on purpose:
* `nuqs/adapters/react-router/v7` calls `useNavigate` and `useSearchParams`, so
* it only works inside a router.
*
* Transitions are off on purpose: under one React keeps the previous screen up
* instead of committing the Suspense fallback, so a route whose chunk is not
* cached yet renders no loader at all. Rendering pending UI under it needs
* `useNavigation()` and a data router.
*/
export function appRouter(children: ReactNode): ReactNode {
return (
<HistoryRouter
basename={getBasePath()}
history={history}
useTransitions={false}
>
{children}
</HistoryRouter>
);
}

View File

@@ -5,8 +5,11 @@ import { NotificationProvider } from 'hooks/useNotifications';
import { CmdKProvider } from 'providers/cmdKProvider';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { AppLayer } from './types';
export interface AppShellProps {
children: ReactNode;
router: AppLayer;
/** Mounted beside the routed content: the command palette and its siblings. */
overlays?: ReactNode;
}
@@ -16,26 +19,28 @@ export interface AppShellProps {
* fetches settle, above `PrivateRoute`, so it also covers the redirects and the
* not-found route, and it survives every navigation.
*
* A new provider belongs here if it needs the user and has to outlive the page:
* a global overlay, a shortcut host, anything one route opens and the next one
* keeps. The router itself is higher up, in `AppProviders`.
* A new provider belongs here if it needs the router or the user and has to
* outlive the page: a global overlay, a shortcut host, anything one route opens
* and the next one keeps.
*
* `ConfigProvider` reads `useThemeConfig`, which needs `ThemeProvider` above it,
* so the antd theme is settled here instead of by the caller.
*/
function AppShell({ children, overlays }: AppShellProps): JSX.Element {
function AppShell({ children, router, overlays }: AppShellProps): JSX.Element {
const themeConfig = useThemeConfig();
return (
<ConfigProvider theme={themeConfig}>
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{overlays}
{children}
</ErrorModalProvider>
</NotificationProvider>
</CmdKProvider>
{router(
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{overlays}
{children}
</ErrorModalProvider>
</NotificationProvider>
</CmdKProvider>,
)}
</ConfigProvider>
);
}

View File

@@ -1,7 +1,6 @@
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,
@@ -36,7 +35,8 @@ export function FilterSelect({
useCeleryFilterOptions(filterType);
const urlQuery = useUrlQuery();
const location = useAppLocation();
const history = useHistory();
const location = useLocation();
// 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,
navigate,
history,
location,
queryParam,
);
@@ -77,6 +77,7 @@ export function FilterSelect({
handleSearch,
shouldSetQueryParams,
urlQuery,
history,
location,
queryParam,
onChange,

View File

@@ -1,6 +1,5 @@
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';
@@ -17,7 +16,8 @@ import './CeleryTaskConfigOptions.styles.scss';
function CeleryTaskConfigOptions(): JSX.Element {
const { handleSearch, isFetching, options } =
useCeleryFilterOptions('celery.task_name');
const location = useAppLocation();
const history = useHistory();
const location = useLocation();
const urlQuery = useUrlQuery();
@@ -52,7 +52,7 @@ function CeleryTaskConfigOptions(): JSX.Element {
setQueryParamsFromOptions(
value,
urlQuery,
navigate,
history,
location,
QueryParams.taskName,
);

View File

@@ -1,9 +1,8 @@
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';
@@ -51,7 +50,8 @@ function CeleryTaskBar({
queryEnabled: boolean;
checkIfDataExists?: (isDataAvailable: boolean) => void;
}): JSX.Element {
const { pathname } = useAppLocation();
const history = useHistory();
const { pathname } = useLocation();
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()}`;
navigate(generatedUrl);
history.push(generatedUrl);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, urlQuery],
[dispatch, history, pathname, urlQuery],
);
const [barState, setBarState] = useState<CeleryTaskState>(CeleryTaskState.All);

View File

@@ -1,9 +1,8 @@
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';
@@ -55,7 +54,8 @@ function CeleryTaskGraph({
checkIfDataExists?: (isDataAvailable: boolean) => void;
analyticsEvent?: string;
}): JSX.Element {
const { pathname } = useAppLocation();
const history = useHistory();
const { pathname } = useLocation();
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()}`;
navigate(generatedUrl);
history.push(generatedUrl);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, urlQuery],
[dispatch, history, pathname, urlQuery],
);
return (

View File

@@ -1,9 +1,8 @@
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';
@@ -49,7 +48,8 @@ function CeleryTaskLatencyGraph({
queryEnabled: boolean;
checkIfDataExists?: (isDataAvailable: boolean) => void;
}): JSX.Element {
const { pathname } = useAppLocation();
const history = useHistory();
const { pathname } = useLocation();
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()}`;
navigate(generatedUrl);
history.push(generatedUrl);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, urlQuery],
[dispatch, history, pathname, urlQuery],
);
const selectedFilters = useMemo(

View File

@@ -1,5 +1,5 @@
import { QueryParams } from 'constants/query';
import type { AppLocation, NavigateFn } from 'lib/router/types';
import { History, Location } from 'history';
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,
navigate: NavigateFn,
location: AppLocation,
history: History<unknown>,
location: Location<unknown>,
queryParams: QueryParams,
): void {
urlQuery.set(queryParams, value.join(','));
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
navigate(generatedUrl, { replace: true });
history.replace(generatedUrl);
}
export function getFiltersFromQueryParams(

View File

@@ -1,12 +1,20 @@
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';
mockAppLocation({ pathname: '/test-path' });
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
return {
...actual,
useLocation: jest.fn().mockReturnValue({
pathname: '/test-path',
}),
};
});
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),

View File

@@ -6,7 +6,7 @@ import {
useRef,
useState,
} from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { useLocation } from 'react-router-dom';
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 = useAppLocation();
const location = useLocation();
const inputRef = useRef<InputRef>(null);
const initialInputValueOnOpenRef = useRef<string>('');

View File

@@ -6,7 +6,7 @@ import {
useMemo,
useState,
} from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { useLocation } from 'react-router-dom';
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 } = useAppLocation();
const { pathname } = useLocation();
const isLogsExplorerPage = useMemo(
() => pathname === ROUTES.LOGS_EXPLORER,

View File

@@ -1,7 +1,6 @@
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';
@@ -60,6 +59,10 @@ 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 = {
@@ -82,7 +85,6 @@ 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 { useAppLocation } from 'lib/router/useAppLocation';
import { useLocation } from 'react-router-dom';
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 = useAppLocation();
const location = useLocation();
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const [isLoading, setIsLoading] = useState(false);

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { useLocation } from 'react-router-dom';
import { Dot } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
@@ -39,7 +39,7 @@ function HeaderRightSection({
enableFeedback,
shareModalExtraOption,
}: HeaderRightSectionProps): JSX.Element | null {
const location = useAppLocation();
const location = useLocation();
const [openFeedbackModal, setOpenFeedbackModal] = useState(false);
const [openShareURLModal, setOpenShareURLModal] = useState(false);

View File

@@ -1,8 +1,7 @@
import { useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useAppLocation } from 'lib/router/useAppLocation';
import { matchRoute } from 'lib/router/matchRoute';
import { matchPath, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { Color } from '@signozhq/design-tokens';
import { Button } from 'antd';
@@ -42,7 +41,7 @@ interface ShareURLModalProps {
function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
const urlQuery = useUrlQuery();
const location = useAppLocation();
const location = useLocation();
const { selectedTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
@@ -76,7 +75,7 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
const isRouteToBeSharedWithTime = useMemo(
() =>
routesToBeSharedWithTime.some((route) =>
matchRoute(location.pathname, route, { exact: true }),
matchPath(location.pathname, { path: route, exact: true }),
),
[location.pathname],
);

View File

@@ -1,5 +1,5 @@
// Mock dependencies before imports
import { useAppLocation } from 'lib/router/useAppLocation';
import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -9,8 +9,9 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import FeedbackModal from '../FeedbackModal';
jest.mock('lib/router/useAppLocation', () => ({
useAppLocation: jest.fn(),
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
jest.mock('@signozhq/ui/sonner', () => ({
@@ -29,7 +30,7 @@ jest.mock('container/Integrations/utils', () => ({
handleContactSupport: jest.fn(),
}));
const mockUseAppLocation = useAppLocation as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
const mockHandleContactSupport = handleContactSupport as jest.Mock;
const mockToast = toast as jest.Mocked<typeof toast>;
@@ -44,7 +45,7 @@ describe('FeedbackModal', () => {
beforeEach(() => {
jest.clearAllMocks();
logEventMock.mockReturnValue(Promise.resolve() as never);
mockUseAppLocation.mockReturnValue(mockLocation);
mockUseLocation.mockReturnValue(mockLocation);
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: false,
});

View File

@@ -1,5 +1,5 @@
// Mock dependencies before imports
import { useAppLocation } from 'lib/router/useAppLocation';
import { useLocation } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
@@ -7,8 +7,9 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import HeaderRightSection from '../HeaderRightSection';
jest.mock('lib/router/useAppLocation', () => ({
useAppLocation: jest.fn(),
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
jest.mock('../FeedbackModal', () => ({
@@ -44,7 +45,7 @@ jest.mock('hooks/useIsAIAssistantEnabled', () => ({
useIsAIAssistantEnabled: (): boolean => false,
}));
const mockUseAppLocation = useAppLocation as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
const defaultProps = {
@@ -60,7 +61,7 @@ const mockLocation = {
describe('HeaderRightSection', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseAppLocation.mockReturnValue(mockLocation);
mockUseLocation.mockReturnValue(mockLocation);
// Default to licensed user (Enterprise or Cloud)
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: true,

View File

@@ -1,8 +1,7 @@
// Mock dependencies before imports
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useAppLocation } from 'lib/router/useAppLocation';
import { matchRoute } from 'lib/router/matchRoute';
import { matchPath, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -13,12 +12,10 @@ import GetMinMax from 'lib/getMinMax';
import ShareURLModal from '../ShareURLModal';
jest.mock('lib/router/useAppLocation', () => ({
useAppLocation: jest.fn(),
}));
jest.mock('lib/router/matchRoute', () => ({
matchRoute: jest.fn(),
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
matchPath: jest.fn(),
}));
jest.mock('hooks/useUrlQuery', () => ({
@@ -51,12 +48,12 @@ Object.defineProperty(window, 'location', {
writable: true,
});
const mockUseAppLocation = useAppLocation as jest.Mock;
const mockUseLocation = useLocation 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 mockMatchRoute = matchRoute as jest.Mock;
const mockMatchPath = matchPath as jest.Mock;
const mockUrlQuery = {
get: jest.fn(),
@@ -74,7 +71,7 @@ describe('ShareURLModal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseAppLocation.mockReturnValue({
mockUseLocation.mockReturnValue({
pathname: TEST_PATH,
});
@@ -91,7 +88,7 @@ describe('ShareURLModal', () => {
mockUseCopyToClipboard.mockReturnValue([null, mockHandleCopyToClipboard]);
mockMatchRoute.mockReturnValue(null);
mockMatchPath.mockReturnValue(false);
// Reset URL query mocks - all return null by default
mockUrlQuery.get.mockReturnValue(null);
@@ -129,7 +126,7 @@ describe('ShareURLModal', () => {
});
it('should show absolute time toggle when on time-enabled route', () => {
mockMatchRoute.mockReturnValue({}); // Simulate being on a route that supports time
mockMatchPath.mockReturnValue(true); // Simulate being on a route that supports time
render(<ShareURLModal />);
@@ -149,7 +146,7 @@ describe('ShareURLModal', () => {
it('should toggle absolute time switch', async () => {
const user = userEvent.setup();
mockMatchRoute.mockReturnValue({});
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: '5min', // Non-custom time should enable absolute time by default
});
@@ -172,7 +169,7 @@ describe('ShareURLModal', () => {
// Invalid - missing start and end time for custom
mockUrlQuery.get.mockReturnValue(null);
mockMatchRoute.mockReturnValue({});
mockMatchPath.mockReturnValue(true);
render(<ShareURLModal />);
@@ -184,7 +181,7 @@ describe('ShareURLModal', () => {
it('should process URL with absolute time for non-custom time', async () => {
const user = userEvent.setup();
mockMatchRoute.mockReturnValue({});
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: '5min',
});
@@ -203,7 +200,7 @@ describe('ShareURLModal', () => {
it('should process URL with custom time parameters', async () => {
const user = userEvent.setup();
mockMatchRoute.mockReturnValue({});
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: 'custom',
});
@@ -231,7 +228,7 @@ describe('ShareURLModal', () => {
it('should process URL with relative time when absolute time is disabled', async () => {
const user = userEvent.setup();
mockMatchRoute.mockReturnValue({});
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: '5min',
});
@@ -252,12 +249,12 @@ describe('ShareURLModal', () => {
it('should handle routes that should be shared with time', async () => {
const user = userEvent.setup();
mockUseAppLocation.mockReturnValue({
mockUseLocation.mockReturnValue({
pathname: ROUTES.LOGS_EXPLORER,
});
mockMatchRoute.mockImplementation((pathname: string, route: string) =>
route === ROUTES.LOGS_EXPLORER ? {} : null,
mockMatchPath.mockImplementation(
(pathname: string, options: any) => options.path === ROUTES.LOGS_EXPLORER,
);
render(<ShareURLModal />);

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { useLocation } from 'react-router-dom';
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 } = useAppLocation();
const { pathname } = useLocation();
const isChatSupportEnabled = useMemo(() => {
if (!isFetchingFeatureFlags && (featureFlags || featureFlagsFetchError)) {

View File

@@ -1,4 +1,4 @@
import { AppLink } from 'lib/router/AppLink';
import { Link } from 'react-router-dom';
import styles from './LogHighlights.module.scss';
@@ -8,7 +8,7 @@ interface TraceIdFieldProps {
function TraceIdField({ traceId }: TraceIdFieldProps): JSX.Element {
return (
<AppLink
<Link
to={{ pathname: `/trace/${traceId}` }}
target="_blank"
rel="noreferrer"
@@ -16,7 +16,7 @@ function TraceIdField({ traceId }: TraceIdFieldProps): JSX.Element {
title={traceId}
>
{traceId}
</AppLink>
</Link>
);
}

View File

@@ -1,5 +1,6 @@
import { Typography } from '@signozhq/ui/typography';
import { ReactNode, useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import {
OctagonAlert,
Bolt,
@@ -13,7 +14,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 { navigate } from 'lib/router/navigation';
import { History } from 'history';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import {
KAFKA_SETUP_DOC_LINK,
@@ -44,6 +45,7 @@ export enum AttributesFilters {
function ErrorTitleAndKey({
title,
parentTitle,
history,
isCloudUserVal,
errorMsg,
isLeaf,
@@ -51,6 +53,7 @@ function ErrorTitleAndKey({
title: string;
parentTitle: string;
isCloudUserVal: boolean;
history: History<unknown>;
errorMsg?: string;
isLeaf?: boolean;
}): TreeDataNode {
@@ -72,7 +75,7 @@ function ErrorTitleAndKey({
}
if (isCloudUserVal && !!link) {
navigate(link);
history.push(link);
} else {
openInNewTab(KAFKA_SETUP_DOC_LINK);
}
@@ -146,6 +149,7 @@ function generateTreeDataNodes(
response: OnboardingStatusResponse['data'],
parentTitle: string,
isCloudUserVal: boolean,
history: History<unknown>,
): TreeDataNode[] {
return response
.map((item) => {
@@ -158,6 +162,7 @@ function generateTreeDataNodes(
title: item.attribute,
errorMsg: item.error_message || '',
parentTitle,
history,
isCloudUserVal,
});
}
@@ -180,6 +185,7 @@ function AttributeCheckList({
setFilter(value);
};
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const history = useHistory();
useEffect(() => {
const filteredData = onboardingStatusResponses.map((response) => {
@@ -189,6 +195,7 @@ function AttributeCheckList({
errorMsg: response.errorMsg,
isLeaf: true,
parentTitle: response.title,
history,
isCloudUserVal,
});
}
@@ -206,6 +213,7 @@ 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, type RefSelectProps, Select } from 'antd';
import { Button, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Tooltip } from '@signozhq/ui/tooltip';
import cx from 'classnames';
@@ -26,6 +26,7 @@ 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';
@@ -83,7 +84,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// ===== State & Refs =====
const [isOpen, setIsOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const selectRef = useRef<RefSelectProps>(null);
const selectRef = useRef<BaseSelectRef>(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,12 +16,13 @@ import {
X,
} from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { type RefSelectProps, Select } from 'antd';
import { 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';
@@ -74,7 +75,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const isDarkMode = useIsDarkMode();
// Refs for element access and scroll behavior
const selectRef = useRef<RefSelectProps>(null);
const selectRef = useRef<BaseSelectRef>(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(
<TestRouter>
<MemoryRouter>
<Provider store={store}>
<NotFound />
</Provider>
</TestRouter>,
</MemoryRouter>,
);
expect(asFragment()).toMatchSnapshot();
});

View File

@@ -121,7 +121,6 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `
</div>
<a
class="c3"
data-discover="true"
href="/home"
tabindex="0"
>

View File

@@ -1,7 +1,7 @@
import { AppLink } from 'lib/router/AppLink';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
export const Button = styled(AppLink)`
export const Button = styled(Link)`
border: 2px solid #2f80ed;
box-sizing: border-box;
border-radius: 10px;

View File

@@ -106,9 +106,17 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[resolvedConfig],
);
const traceOperatorField = useMemo(
() =>
resolveQueryBuilderField(QueryBuilderField.TraceOperator, resolvedConfig),
[resolvedConfig],
);
const canShowTraceOperator = showTraceOperator && !traceOperatorField.hidden;
const isMultiQueryAllowed = useMemo(
() => !additionalQueries.hidden && (!isRawQuery || showTraceOperator),
[additionalQueries.hidden, showTraceOperator, isRawQuery],
() => !additionalQueries.hidden && (!isRawQuery || canShowTraceOperator),
[additionalQueries.hidden, canShowTraceOperator, isRawQuery],
);
const queryDataSources = useMemo(
@@ -146,13 +154,14 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
);
const hasTraceOperator = useMemo(
() => showTraceOperator && hasAtLeastOneTraceQuery && Boolean(traceOperator),
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
() =>
canShowTraceOperator && hasAtLeastOneTraceQuery && Boolean(traceOperator),
[canShowTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
);
const showQueryList = useMemo(
() => (!showOnlyWhereClause && !isRawQuery) || showTraceOperator,
[isRawQuery, showOnlyWhereClause, showTraceOperator],
() => (!showOnlyWhereClause && !isRawQuery) || canShowTraceOperator,
[isRawQuery, showOnlyWhereClause, canShowTraceOperator],
);
const showFormula = useMemo(() => {
@@ -168,8 +177,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
}, [formula.hidden, isRawQuery, currentDataSource]);
const showAddTraceOperator = useMemo(
() => showTraceOperator && !traceOperator && hasAtLeastOneTraceQuery,
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
() => canShowTraceOperator && !traceOperator && hasAtLeastOneTraceQuery,
[canShowTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
);
// Nothing left to add means no footer at all, rather than an empty bar under the
@@ -178,13 +187,13 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
() =>
(!additionalQueries.hidden || showFormula || showAddTraceOperator) &&
((!showOnlyWhereClause && !isRawQuery) ||
(currentDataSource === DataSource.TRACES && showTraceOperator)),
(currentDataSource === DataSource.TRACES && canShowTraceOperator)),
[
additionalQueries.hidden,
showFormula,
showAddTraceOperator,
isRawQuery,
showTraceOperator,
canShowTraceOperator,
showOnlyWhereClause,
currentDataSource,
],
@@ -222,7 +231,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
fieldsConfig={fieldsConfig}
allowedDataSources={queryDataSources}
isMultiQueryAllowed={isMultiQueryAllowed}
showTraceOperator={showTraceOperator}
showTraceOperator={canShowTraceOperator}
hasTraceOperator={hasTraceOperator}
version={version}
isAvailableToDisable={false}
@@ -247,7 +256,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
version={version}
isMultiQueryAllowed={isMultiQueryAllowed}
isAvailableToDisable={false}
showTraceOperator={showTraceOperator}
showTraceOperator={canShowTraceOperator}
hasTraceOperator={hasTraceOperator}
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}

View File

@@ -6,7 +6,8 @@ export type NonAddOnField =
| QueryBuilderField.StepInterval
| QueryBuilderField.Functions
| QueryBuilderField.Formula
| QueryBuilderField.AdditionalQueries;
| QueryBuilderField.AdditionalQueries
| QueryBuilderField.TraceOperator;
export type AddOnField = Exclude<QueryBuilderField, NonAddOnField>;

View File

@@ -18,6 +18,7 @@ import {
STEP_INTERVAL_PLACEHOLDER,
} from '../../queryBuilderFields.utils';
import { isScalarPanelType } from '../panelTypes';
import QueryAggregationSelect from './QueryAggregationSelect';
import './QueryAggregation.styles.scss';
@@ -45,10 +46,7 @@ function QueryAggregationOptions({
const isNonMetricSource =
dataSource === DataSource.TRACES || dataSource === DataSource.LOGS;
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
) {
if (isNonMetricSource && isScalarPanelType(panelType)) {
return { hidden: true, disabled: false, pinned: false };
}

View File

@@ -47,29 +47,23 @@ export function QuerySearchV2Provider({
store.getState().setInitialExpression(initialExpression);
}, [initialExpression, store]);
// The URL owns the expression, in both directions. A provider can outlive the
// navigation away from the page it belongs to (a route-driven tab keeps the
// leaving pane mounted until its animation ends), so treating a param that
// disappeared as something to restore republishes it onto the URL of the page
// being entered.
const isInitialized = useRef(false);
useEffect(() => {
const userExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
if (userExpression !== store.getState().committedExpression) {
store.getState().initializeFromUrl(userExpression);
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
store.getState().initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, store]);
const publishedExpression = useRef(committedExpression);
useEffect(() => {
if (committedExpression === publishedExpression.current) {
return;
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
}
publishedExpression.current = committedExpression;
setUrlExpression(committedExpression || null);
}, [committedExpression, setUrlExpression]);
}, [committedExpression, setUrlExpression, urlExpression]);
useEffect(() => {
return (): void => {

View File

@@ -110,22 +110,6 @@ describe('QuerySearchExpressionProvider', () => {
expect(result.current.expression).toBe('status = 500');
});
it('should follow the URL when the param is dropped', () => {
mockUrlValue = 'status = 500';
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
expect(result.current.expression).toBe('status = 500');
mockSetQueryState.mockClear();
mockUrlValue = null;
rerender();
expect(result.current.expression).toBe('');
expect(mockSetQueryState).not.toHaveBeenCalledWith('status = 500');
});
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useExpression());

View File

@@ -9,7 +9,6 @@ import {
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import cx from 'classnames';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QBEntityOptions from 'container/QueryBuilder/components/QBEntityOptions/QBEntityOptions';
import { QueryProps } from 'container/QueryBuilder/type';
import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearchV2/SpanScopeSelector';
@@ -27,6 +26,7 @@ import {
resolveQueryBuilderField,
} from '../queryBuilderFields.utils';
import { isScalarPanelType } from './panelTypes';
import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection';
import { MetricsSelect } from './MetricsSelect/MetricsSelect';
import QueryAddOns from './QueryAddOns/QueryAddOns';
@@ -112,11 +112,7 @@ export const QueryV2 = forwardRef(function QueryV2(
}
const showReduceTo = useMemo(
() =>
dataSource === DataSource.METRICS &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE),
() => dataSource === DataSource.METRICS && isScalarPanelType(panelType),
[dataSource, panelType],
);

View File

@@ -0,0 +1,13 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
/** Panels that reduce each query to one value per group over the whole window. */
const SCALAR_PANEL_TYPES: ReadonlySet<string> = new Set<string>([
PANEL_TYPES.TABLE,
PANEL_TYPES.SCATTER,
PANEL_TYPES.PIE,
PANEL_TYPES.VALUE,
]);
export function isScalarPanelType(panelType?: string | null): boolean {
return panelType != null && SCALAR_PANEL_TYPES.has(panelType);
}

View File

@@ -14,6 +14,17 @@ jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
function renderFor(panelType: PANEL_TYPES, dataSource: DataSource): void {
render(
<QueryAggregationOptions
dataSource={dataSource}
panelType={panelType}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
}
describe('QueryAggregationOptions step interval placeholder', () => {
it('shows the configured placeholder', () => {
render(
@@ -34,14 +45,27 @@ describe('QueryAggregationOptions step interval placeholder', () => {
});
it('falls back to Auto', () => {
render(
<QueryAggregationOptions
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TIME_SERIES}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByPlaceholderText('Auto')).toBeInTheDocument();
});
});
describe('QueryAggregationOptions step interval', () => {
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER, PANEL_TYPES.PIE])(
'is hidden for a %s panel over traces, which reduces the whole window',
(panelType) => {
renderFor(panelType, DataSource.TRACES);
expect(screen.queryByText('every')).not.toBeInTheDocument();
},
);
it('stays for a scatter plot over metrics, where it sets the series step', () => {
renderFor(PANEL_TYPES.SCATTER, DataSource.METRICS);
expect(screen.getByText('every')).toBeInTheDocument();
});
it('stays for a time series over traces', () => {
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByText('every')).toBeInTheDocument();
});
});

View File

@@ -20,6 +20,7 @@ export enum QueryBuilderField {
// Builder level
Formula = 'formula',
AdditionalQueries = 'additional_queries',
TraceOperator = 'trace_operator',
}
/** Per-query builder fields that can be kept equal across a panel's queries. */

View File

@@ -65,13 +65,13 @@ export function resolveQueryBuilderFields(
/**
* The surface a raw-row builder starts from, layered under a caller's own config.
* `AdditionalQueries` is deliberately absent — a raw trace builder still takes several
* queries when trace matching is on. Omit rather than Partial, so a field added to the
* enum has to be placed on one side.
* `AdditionalQueries` and `TraceOperator` are deliberately absent — a raw trace builder
* still takes several queries, and their trace operator, when trace matching is on.
* Omit rather than Partial, so a field added to the enum has to be placed on one side.
*/
export const RAW_QUERY_FIELDS: Omit<
Record<QueryBuilderField, QueryBuilderFieldRule>,
QueryBuilderField.AdditionalQueries
QueryBuilderField.AdditionalQueries | QueryBuilderField.TraceOperator
> = {
[QueryBuilderField.Aggregation]: { state: 'hidden' },
[QueryBuilderField.StepInterval]: { state: 'hidden' },

View File

@@ -1,4 +1,5 @@
import { navigate } from 'lib/router/navigation';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { fireEvent, render, screen } from 'tests/test-utils';
import RouteTab from './index';
@@ -9,11 +10,6 @@ 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>;
}
@@ -37,39 +33,63 @@ const testRoutes: RouteTabProps['routes'] = [
];
describe('RouteTab component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', () => {
render(<RouteTab routes={testRoutes} activeKey="Tab1" />);
const history = createMemoryHistory();
render(
<Router history={history}>
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
</Router>,
);
expect(screen.getByRole('tab', { name: 'Tab1' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Tab2' })).toBeInTheDocument();
});
it('renders correct number of tabs', () => {
render(<RouteTab routes={testRoutes} activeKey="Tab1" />);
const history = createMemoryHistory();
render(
<Router history={history}>
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
</Router>,
);
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(testRoutes.length);
});
it('sets provided activeKey as active tab', () => {
render(<RouteTab routes={testRoutes} activeKey="Tab2" />);
const history = createMemoryHistory();
render(
<Router history={history}>
<RouteTab history={history} routes={testRoutes} activeKey="Tab2" />
</Router>,
);
expect(
screen.getByRole('tab', { name: 'Tab2', selected: true }),
).toBeInTheDocument();
});
it('navigates to correct route on tab click', () => {
render(<RouteTab routes={testRoutes} activeKey="Tab1" />);
expect(navigate).not.toHaveBeenCalled();
const history = createMemoryHistory();
render(
<Router history={history}>
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
</Router>,
);
expect(history.location.pathname).toBe('/');
fireEvent.click(screen.getByRole('tab', { name: 'Tab2' }));
expect(navigate).toHaveBeenCalledWith('/tab2');
expect(history.location.pathname).toBe('/tab2');
});
it('applies the layout class alongside a custom className', () => {
const history = createMemoryHistory();
const { container } = render(
<RouteTab routes={testRoutes} activeKey="Tab1" className="custom-tabs" />,
<Router history={history}>
<RouteTab
history={history}
routes={testRoutes}
activeKey="Tab1"
className="custom-tabs"
/>
</Router>,
);
expect(container.querySelector('.ant-tabs')).toHaveClass(
'routeTab',
@@ -78,8 +98,11 @@ describe('RouteTab component', () => {
});
it('renders the active tab content inside an overlay scrollbar', () => {
const history = createMemoryHistory();
const { container } = render(
<RouteTab routes={testRoutes} activeKey="Tab1" />,
<Router history={history}>
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
</Router>,
);
expect(
container.querySelector('.ant-tabs-tabpane-active > .overlay-scrollbar'),
@@ -88,12 +111,16 @@ describe('RouteTab component', () => {
it('calls onChangeHandler on tab change', () => {
const onChangeHandler = jest.fn();
const history = createMemoryHistory();
render(
<RouteTab
routes={testRoutes}
activeKey="Tab1"
onChangeHandler={onChangeHandler}
/>,
<Router history={history}>
<RouteTab
routes={testRoutes}
activeKey="Tab1"
onChangeHandler={onChangeHandler}
history={history}
/>
</Router>,
);
fireEvent.click(screen.getByRole('tab', { name: 'Tab2' }));
expect(onChangeHandler).toHaveBeenCalled();

View File

@@ -1,12 +1,13 @@
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';
@@ -27,17 +28,21 @@ function RouteTab({
routes,
activeKey,
onChangeHandler,
history,
showRightSection,
className,
...rest
}: RouteTabProps & TabsProps): JSX.Element {
const params = useAppParams<Params>();
const location = useAppLocation();
const params = useParams<Params>();
const location = useLocation();
// Find the matching route for the current pathname
const currentRoute = routes.find((route) => {
const routePath = route.route.split('?')[0];
return matchRoute(location.pathname, routePath, { exact: true });
return matchPath(location.pathname, {
path: routePath,
exact: true,
});
});
const onChange = (activeRoute: string): void => {
@@ -48,7 +53,8 @@ function RouteTab({
const selectedRoute = routes.find((e) => e.key === activeRoute);
if (selectedRoute) {
navigate(buildRoutePath(selectedRoute.route, params));
const resolvedRoute = generatePath(selectedRoute.route, params);
history.push(resolvedRoute);
}
};

View File

@@ -1,5 +1,6 @@
import { ComponentType } from 'react';
import { TabsProps } from 'antd';
import { History } from 'history';
export type TabRoutes = {
name: React.ReactNode;
@@ -12,5 +13,6 @@ 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 { navigate } from 'lib/router/navigation';
import history from 'lib/history';
import { render, screen, userEvent } from 'tests/test-utils';
import '@testing-library/jest-dom/extend-expect';
@@ -24,12 +24,61 @@ afterAll(() => {
delete (HTMLElement.prototype as any).scrollIntoView;
});
jest.mock('lib/router/navigation', () => ({
...jest.requireActual('lib/router/navigation'),
navigate: jest.fn(),
}));
// mock history.push / replace / go / location
jest.mock('lib/history', () => {
const location = { pathname: '/', search: '', hash: '' };
const mockNavigate = navigate as jest.MockedFunction<typeof navigate>;
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,
};
});
// Mock ResizeObserver for Jest/jsdom
class ResizeObserver {
@@ -110,14 +159,14 @@ describe('CmdKPalette', () => {
expect(screen.getByText('Switch to Dark Mode')).toBeInTheDocument();
});
it('clicking a navigation item navigates to the correct route', async () => {
it('clicking a navigation item calls history.push with correct route', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CmdKPalette userRole="ADMIN" />);
const homeItem = screen.getByText(HOME_LABEL);
await user.click(homeItem);
expect(mockNavigate).toHaveBeenCalledWith(ROUTES.HOME);
expect(history.push).toHaveBeenCalledWith(ROUTES.HOME);
});
it('role-based filtering (basic smoke)', () => {

View File

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import cx from 'classnames';
import { useLocation } from 'react-router-dom';
import {
CommandDialog,
CommandEmpty,
@@ -22,8 +23,7 @@ import {
import { useThemeMode } from 'hooks/useDarkMode';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { IS_DEV } from 'lib/env';
import { navigate } from 'lib/router/navigation';
import { useAppLocation } from 'lib/router/useAppLocation';
import history from 'lib/history';
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 = useAppLocation();
const location = useLocation();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
@@ -120,7 +120,7 @@ export function CmdKPalette({
}
function onClickHandler(key: string): void {
navigate(key);
history.push(key);
}
const handleOpenAIAssistant = (): void => {

View File

@@ -36,6 +36,7 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HEATMAP]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -337,6 +337,7 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',

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,6 +12,8 @@ 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,
@@ -26,10 +28,10 @@ export default function AIAssistantDrawer(): JSX.Element {
return;
}
closeDrawer();
navigate(
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
);
}, [activeConversationId, closeDrawer]);
}, [activeConversationId, closeDrawer, history]);
const handleNewConversation = useCallback(() => {
startNewConversation();

View File

@@ -1,8 +1,7 @@
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';
@@ -32,7 +31,8 @@ import styles from './AIAssistantModal.module.scss';
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function AIAssistantModal(): JSX.Element | null {
const { pathname } = useAppLocation();
const history = useHistory();
const { pathname } = useLocation();
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.
navigate(
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
{ state: { fromInApp: true } },
{ fromInApp: true },
);
}, [activeConversationId, closeModal]);
}, [activeConversationId, closeModal, history]);
const handleNew = useCallback(() => {
void logEvent(AIAssistantEvents.NewChatClicked, {

View File

@@ -1,8 +1,6 @@
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';
@@ -23,11 +21,13 @@ 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 { pathname } = useAppLocation();
const history = useHistory();
const { pathname } = useLocation();
const [showHistory, setShowHistory] = useState(false);
const isOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isFullScreenPage = !!matchRoute(pathname, ROUTES.AI_ASSISTANT, {
const isFullScreenPage = !!matchPath(pathname, {
path: 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.
navigate(
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
{ state: { fromInApp: true } },
{ fromInApp: true },
);
}, [activeConversationId, closeDrawer]);
}, [activeConversationId, closeDrawer, history]);
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 = useAppLocation();
const location = useLocation();
const conversation = useAIAssistantStore(
(s) => s.conversations[conversationId],

View File

@@ -1,8 +1,9 @@
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';
@@ -86,11 +87,11 @@ function renderView(variant: 'panel' | 'page' | 'modal'): {
getByTestId: (id: string) => HTMLElement;
} {
return render(
<TestRouter initialRoute="/dashboard/dashboard-123">
<MemoryRouter initialEntries={['/dashboard/dashboard-123']}>
<VariantContext.Provider value={variant}>
<ConversationView conversationId={CONVERSATION_ID} />
</VariantContext.Provider>
</TestRouter>,
</MemoryRouter>,
);
}

View File

@@ -1,8 +1,6 @@
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';
@@ -215,11 +213,20 @@ function signalMatchesPathname(
): boolean {
switch (signal) {
case ApplyFilterSignalDTO.logs:
return Boolean(matchRoute(pathname, ROUTES.LOGS_EXPLORER));
return Boolean(
matchPath(pathname, { path: ROUTES.LOGS_EXPLORER, exact: false }),
);
case ApplyFilterSignalDTO.traces:
return Boolean(matchRoute(pathname, ROUTES.TRACES_EXPLORER));
return Boolean(
matchPath(pathname, { path: ROUTES.TRACES_EXPLORER, exact: false }),
);
case ApplyFilterSignalDTO.metrics:
return Boolean(matchRoute(pathname, ROUTES.METRICS_EXPLORER_EXPLORER));
return Boolean(
matchPath(pathname, {
path: ROUTES.METRICS_EXPLORER_EXPLORER,
exact: false,
}),
);
default:
return false;
}
@@ -268,6 +275,7 @@ function explorerRouteForSignal(signal: ApplyFilterSignalDTO): string | null {
}
interface ApplyFilterDeps {
history: ReturnType<typeof useHistory>;
pathname: string;
redirectWithQueryBuilderData: ReturnType<
typeof useQueryBuilder
@@ -309,8 +317,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: `navigate()` so the landing explorer initializes from the
* new URL on mount.
* - Off-page: use `history.push` so the landing explorer initializes from
* the new URL on mount.
*/
function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
// eslint-disable-next-line no-console
@@ -352,9 +360,9 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
return;
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → navigate', base);
console.log('[apply_filter] off-page → history.push', base);
const encoded = encodeURIComponent(JSON.stringify(normalized));
navigate(`${base}?${QueryParams.compositeQuery}=${encoded}`);
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
}
/** Picks the right rollback API call for a given action kind. */
@@ -383,7 +391,8 @@ export default function ActionsSection({
actions,
messageId,
}: ActionsSectionProps): JSX.Element | null {
const { pathname } = useAppLocation();
const history = useHistory();
const { pathname } = useLocation();
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
@@ -415,12 +424,19 @@ export default function ActionsSection({
}
autoAppliedFilterKeys.add(key);
applyFilter(action, {
history,
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
});
});
}, [actions, pathname, redirectWithQueryBuilderData, handleSetQueryData]);
}, [
actions,
pathname,
history,
redirectWithQueryBuilderData,
handleSetQueryData,
]);
if (actions.length === 0) {
return null;
@@ -440,7 +456,11 @@ export default function ActionsSection({
}
setResult(key, { state: 'loading' });
try {
await openSavedViewByKey(resourceId, resolveSavedViewSourceHint(action));
await openSavedViewByKey(
resourceId,
resolveSavedViewSourceHint(action),
history,
);
void logEvent(AIAssistantEvents.ResourceOpened, {
threadId,
messageId,
@@ -495,7 +515,7 @@ export default function ActionsSection({
targetModule: targetModuleForResource(resourceType),
resourceId,
});
navigate(path);
history.push(path);
};
const handleClick = (key: string, action: MessageActionDTO): void => {
@@ -556,6 +576,7 @@ export default function ActionsSection({
});
}
applyFilter(action, {
history,
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,

View File

@@ -16,10 +16,10 @@ import {
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { navigate } from 'lib/router/navigation';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import type { History } from 'history';
import {
buildExplorerNavigationUrl,
@@ -39,9 +39,6 @@ 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',
@@ -63,7 +60,6 @@ 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,
@@ -250,17 +246,15 @@ describe('buildExplorerNavigationUrl', () => {
});
describe('openSavedView', () => {
beforeEach(() => {
mockedNavigate.mockClear();
});
it('navigates with the view query params', () => {
it('navigates with history.push and view query params', () => {
const push = jest.fn();
const history = { push } as unknown as History;
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
openSavedView(view);
openSavedView(view, history);
expect(mockedNavigate).toHaveBeenCalledTimes(1);
const pushedUrl = mockedNavigate.mock.calls[0][0] as string;
expect(push).toHaveBeenCalledTimes(1);
const pushedUrl = push.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"');
@@ -272,14 +266,18 @@ describe('openSavedView', () => {
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
delete view.source;
expect(() => openSavedView(view)).toThrow('Unsupported saved view source');
expect(() =>
openSavedView(view, { push: jest.fn() } as unknown as History),
).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)).toThrow('Saved view is missing query data');
expect(() =>
openSavedView(view, { push: jest.fn() } as unknown as History),
).toThrow('Saved view is missing query data');
});
});
@@ -287,39 +285,44 @@ 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);
await openSavedViewByKey('view-logs', DataSource.LOGS, history);
expect(mockedGetSavedView).toHaveBeenCalledWith({ id: 'view-logs' });
expect(mockedListSavedViews).not.toHaveBeenCalled();
expect(mockedNavigate).toHaveBeenCalled();
expect(push).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);
await openSavedViewByKey('view-traces', DataSource.TRACES, history);
expect(mockedListSavedViews).toHaveBeenCalledWith({
source: SavedviewtypesSourceDTO.traces,
});
expect(mockedNavigate).toHaveBeenCalled();
expect(push).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)).rejects.toThrow(
'Saved view not found',
);
await expect(
openSavedViewByKey('missing', DataSource.LOGS, {
push: jest.fn(),
} as unknown as History),
).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';
type SavedViewSourceHint = SavedViewSourcePage;
@@ -92,7 +92,10 @@ export function buildExplorerNavigationUrl(
return `${route}?${params.toString()}`;
}
export function openSavedView(view: SavedviewtypesSavedViewDTO): void {
export function openSavedView(
view: SavedviewtypesSavedViewDTO,
history: History,
): void {
const route = view.source ? explorerRouteForSourcePage(view.source) : null;
if (!route) {
throw new Error('Unsupported saved view source');
@@ -107,13 +110,14 @@ export function openSavedView(view: SavedviewtypesSavedViewDTO): void {
[QueryParams.panelTypes]: view.spec.panelType as unknown as PANEL_TYPES,
[QueryParams.viewKey]: view.id,
});
navigate(url);
history.push(url);
}
export async function openSavedViewByKey(
viewKey: string,
sourceHint: SavedViewSourceHint | null | undefined,
history: History,
): Promise<void> {
const view = await loadSavedView(viewKey, sourceHint);
openSavedView(view);
openSavedView(view, history);
}

View File

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

View File

@@ -1,30 +0,0 @@
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,10 +33,9 @@ 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 = matchRoute<'dashboardId' | 'panelId'>(
const panelEditorMatch = matchPath<{ dashboardId: string; panelId: string }>(
pathname,
ROUTES.DASHBOARD_PANEL_EDITOR,
{ exact: true },
{ path: ROUTES.DASHBOARD_PANEL_EDITOR, exact: true },
);
if (panelEditorMatch) {
const { dashboardId, panelId } = panelEditorMatch.params;
@@ -56,7 +55,8 @@ 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 = matchRoute<'dashboardId'>(pathname, ROUTES.DASHBOARD, {
const dashboardMatch = matchPath<{ dashboardId: string }>(pathname, {
path: ROUTES.DASHBOARD,
exact: true,
});
if (dashboardMatch) {
@@ -89,7 +89,7 @@ export function getAutoContexts(
}
// Dashboard list — `/dashboard`.
if (matchRoute(pathname, ROUTES.ALL_DASHBOARD, { exact: true })) {
if (matchPath(pathname, { path: 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 (
matchRoute(pathname, ROUTES.ALERT_OVERVIEW, { exact: true }) ||
matchRoute(pathname, ROUTES.ALERT_HISTORY, { exact: true })
matchPath(pathname, { path: ROUTES.ALERT_OVERVIEW, exact: true }) ||
matchPath(pathname, { path: 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 (matchRoute(pathname, ROUTES.EDIT_ALERTS, { exact: true })) {
if (matchPath(pathname, { path: 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 (matchRoute(pathname, ROUTES.ALERTS_NEW, { exact: true })) {
if (matchPath(pathname, { path: 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 (matchRoute(pathname, ROUTES.ALERT_HISTORY, { exact: true })) {
if (matchPath(pathname, { path: 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 (matchRoute(pathname, ROUTES.LIST_ALL_ALERT, { exact: true })) {
if (matchPath(pathname, { path: 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 = matchRoute<'servicename'>(
pathname,
ROUTES.SERVICE_METRICS,
);
const serviceMatch = matchPath<{ servicename: string }>(pathname, {
path: ROUTES.SERVICE_METRICS,
exact: false,
});
if (serviceMatch?.params.servicename) {
return [
{
@@ -210,7 +210,7 @@ export function getAutoContexts(
}
// Services list — `/services`.
if (matchRoute(pathname, ROUTES.APPLICATION, { exact: true })) {
if (matchPath(pathname, { path: ROUTES.APPLICATION, exact: true })) {
return [
{
source: 'auto',
@@ -226,7 +226,7 @@ export function getAutoContexts(
// ── Logs ──────────────────────────────────────────────────────────────────
if (matchRoute(pathname, ROUTES.LOGS_EXPLORER)) {
if (matchPath(pathname, { path: ROUTES.LOGS_EXPLORER, exact: false })) {
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,7 +251,8 @@ export function getAutoContexts(
// Trace detail — `/trace/:id`. Treated as a detail-as-metadata page
// (resourceId null, `traceId` lives in metadata).
const traceMatch = matchRoute<'id'>(pathname, ROUTES.TRACE_DETAIL, {
const traceMatch = matchPath<{ id: string }>(pathname, {
path: ROUTES.TRACE_DETAIL,
exact: true,
});
if (traceMatch?.params.id) {
@@ -271,7 +272,7 @@ export function getAutoContexts(
];
}
if (matchRoute(pathname, ROUTES.TRACES_EXPLORER)) {
if (matchPath(pathname, { path: ROUTES.TRACES_EXPLORER, exact: false })) {
return [
{
source: 'auto',
@@ -288,7 +289,9 @@ export function getAutoContexts(
// ── Metrics ───────────────────────────────────────────────────────────────
// Metrics explorer — `/metrics-explorer` and sub-routes (summary, explorer).
if (matchRoute(pathname, ROUTES.METRICS_EXPLORER_BASE)) {
if (
matchPath(pathname, { path: ROUTES.METRICS_EXPLORER_BASE, exact: false })
) {
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 (matchRoute(pathname, template, { exact: true })) {
if (matchPath(pathname, { path: template, exact: true })) {
return template;
}
}
@@ -46,7 +46,7 @@ export function normalizePage(pathname: string): string {
export function useAIAssistantAnalyticsContext(
conversationId?: string,
): AIAssistantAnalyticsContext {
const { pathname } = useAppLocation();
const { pathname } = useLocation();
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 { matchRoute } from 'lib/router/matchRoute';
import { matchPath } from 'react-router-dom';
import { getAutoContexts } from './getAutoContexts';
@@ -45,10 +45,15 @@ 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 (matchRoute(pathname, ROUTES.HOME, { exact: true })) {
if (matchPath(pathname, { path: ROUTES.HOME, exact: true })) {
return PageTypeDTO.homepage;
}
if (matchRoute(pathname, ROUTES.INFRASTRUCTURE_MONITORING_BASE)) {
if (
matchPath(pathname, {
path: ROUTES.INFRASTRUCTURE_MONITORING_BASE,
exact: false,
})
) {
return PageTypeDTO.infra_entity_detail;
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { AppLink } from 'lib/router/AppLink';
import { Link } from 'react-router-dom';
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 && (
<AppLink
<Link
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>
</AppLink>
</Link>
)}
{!!relatedTracesLink && (
<AppLink
<Link
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>
</AppLink>
</Link>
)}
</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 { useAppLocation } from 'lib/router/useAppLocation';
import { navigate } from 'lib/router/navigation';
import history from 'lib/history';
import { ArrowRight } from '@signozhq/icons';
import TopContributorsContent from './TopContributorsContent';
@@ -16,7 +16,7 @@ function TopContributorsCard({
topContributorsData,
totalCurrentTriggers,
}: TopContributorsCardProps): JSX.Element {
const { search } = useAppLocation();
const { search } = useLocation();
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
const viewAllTopContributorsParam = searchParams.get('viewAllTopContributors');
@@ -43,7 +43,7 @@ function TopContributorsCard({
return newState;
});
navigate({ search: searchParams.toString() });
history.push({ 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 { navigate } from 'lib/router/navigation';
import history from 'lib/history';
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());
navigate({
history.push({
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 { useAppLocation } from 'lib/router/useAppLocation';
import { navigate } from 'lib/router/navigation';
import history from 'lib/history';
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 } = useAppLocation();
const { search } = useLocation();
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);
navigate({ search: searchParams.toString() });
history.push({ search: searchParams.toString() });
};
const tabs = [

View File

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

View File

@@ -1,5 +1,6 @@
// 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';
@@ -7,7 +8,6 @@ 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 (
<TestRouter initialRoute={initUrl ?? '/exceptions'}>
<MemoryRouter initialEntries={initUrl ?? ['/exceptions']}>
<TimezoneProvider>
<Provider store={store}>
<MockQueryClientProvider>
@@ -65,12 +65,12 @@ function Exceptions({ initUrl }: { initUrl?: string }): JSX.Element {
</MockQueryClientProvider>
</Provider>
</TimezoneProvider>
</TestRouter>
</MemoryRouter>
);
}
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,7 +143,11 @@ 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
@@ -167,7 +171,11 @@ 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');
@@ -180,7 +188,11 @@ 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,14 +4,16 @@ import {
getApiMonitoringParams,
setApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { mockNavigate } from 'tests/router-mocks';
const navigateSpy = mockNavigate();
function navigatedSearch(): string | undefined {
const [to] = navigateSpy.mock.calls[0];
return typeof to === 'string' ? to : to.search;
}
// 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(),
};
});
describe('API Monitoring Query Params', () => {
describe('getApiMonitoringParams', () => {
@@ -56,21 +58,25 @@ 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, false);
setApiMonitoringParams(newParams, search, history as any, false);
expect(navigateSpy).toHaveBeenCalledWith(
{ search: expect.stringContaining('apiMonitoringParams') },
{ replace: false },
);
expect(history.push).toHaveBeenCalledWith({
search: expect.stringContaining('apiMonitoringParams'),
});
expect(history.replace).not.toHaveBeenCalled();
// Verify that the search string contains the expected encoded params
const searchArg = navigatedSearch();
const searchArg = history.push.mock.calls[0][0].search;
const params = new URLSearchParams(searchArg);
const decoded = JSON.parse(
decodeURIComponent(params.get('apiMonitoringParams') || ''),
@@ -82,21 +88,30 @@ 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, true);
setApiMonitoringParams(newParams, search, history as any, true);
expect(navigateSpy).toHaveBeenCalledWith(
{ search: expect.stringContaining('apiMonitoringParams') },
{ replace: true },
);
expect(history.replace).toHaveBeenCalledWith({
search: expect.stringContaining('apiMonitoringParams'),
});
expect(history.push).not.toHaveBeenCalled();
});
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,
@@ -117,10 +132,10 @@ describe('API Monitoring Query Params', () => {
selectedEndPointName: '/api/test',
};
setApiMonitoringParams(newParams, search, false);
setApiMonitoringParams(newParams, search, history as any, false);
// Verify merged params
const searchArg = navigatedSearch();
const searchArg = history.push.mock.calls[0][0].search;
const params = new URLSearchParams(searchArg);
const decoded = JSON.parse(
decodeURIComponent(params.get('apiMonitoringParams') || ''),
@@ -146,7 +161,27 @@ describe('API Monitoring Query Params', () => {
state: null,
};
return { location };
// 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 };
};
it('retrieves URL params correctly from location', () => {
@@ -172,7 +207,7 @@ describe('API Monitoring Query Params', () => {
});
it('updates URL correctly with new params', () => {
const { location } = mockUseLocationAndHistory();
const { location, history } = mockUseLocationAndHistory();
const newParams: Partial<ApiMonitoringParams> = {
selectedDomain: 'new-domain',
@@ -180,14 +215,13 @@ describe('API Monitoring Query Params', () => {
};
// Manually execute the core logic of the hook's setParams function
setApiMonitoringParams(newParams, location.search);
setApiMonitoringParams(newParams, location.search, history as any);
expect(navigateSpy).toHaveBeenCalledWith(
{ search: expect.stringContaining('apiMonitoringParams') },
{ replace: false },
);
expect(history.push).toHaveBeenCalledWith({
search: expect.stringContaining('apiMonitoringParams'),
});
const searchArg = navigatedSearch();
const searchArg = history.push.mock.calls[0][0].search;
const params = new URLSearchParams(searchArg);
const decoded = JSON.parse(
decodeURIComponent(params.get('apiMonitoringParams') || ''),
@@ -213,16 +247,20 @@ describe('API Monitoring Query Params', () => {
const initialSearch = `?${urlParams.toString()}`;
// Set up mocks
const { location } = mockUseLocationAndHistory(initialSearch);
const { location, history } = mockUseLocationAndHistory(initialSearch);
// Manually execute the core logic
setApiMonitoringParams({ selectedView: 'new-view' }, location.search);
setApiMonitoringParams(
{ selectedView: 'new-view' },
location.search,
history as any,
);
// Verify history was updated
expect(navigateSpy).toHaveBeenCalled();
expect(history.push).toHaveBeenCalled();
// Parse the new query params from the URL
const searchArg = navigatedSearch();
const searchArg = history.push.mock.calls[0][0].search;
const params = new URLSearchParams(searchArg);
const decoded = JSON.parse(
decodeURIComponent(params.get('apiMonitoringParams') || ''),

View File

@@ -1,6 +1,5 @@
import { useCallback } from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { navigate } from 'lib/router/navigation';
import { useHistory, useLocation } from 'react-router-dom';
// --- Types for all API Monitoring query params ---
export interface ApiMonitoringParams {
@@ -56,6 +55,7 @@ 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,7 +63,11 @@ export function setApiMonitoringParams(
const merged = { ...current, ...newParams };
urlParams.set(PARAM_KEY, encodeParams(merged));
const newSearch = `?${urlParams.toString()}`;
navigate({ search: newSearch }, { replace });
if (replace) {
history.replace({ search: newSearch });
} else {
history.push({ search: newSearch });
}
}
// --- React hook to use query params in a component ---
@@ -71,14 +75,15 @@ export function useApiMonitoringParams(): [
ApiMonitoringParams,
(newParams: Partial<ApiMonitoringParams>, replace?: boolean) => void,
] {
const location = useAppLocation();
const location = useLocation();
const history = useHistory();
const params = getApiMonitoringParams(location.search);
const setParams = useCallback(
(newParams: Partial<ApiMonitoringParams>, replace = false) => {
setApiMonitoringParams(newParams, location.search, replace);
setApiMonitoringParams(newParams, location.search, history, replace);
},
[location.search],
[location.search, history],
);
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 { useAppLocation } from 'lib/router/useAppLocation';
import { useLocation } from 'react-router-dom';
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 { navigate } from 'lib/router/navigation';
import history from 'lib/history';
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 } = useAppLocation();
const { pathname } = useLocation();
const { t } = useTranslation(['titles']);
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
@@ -464,7 +464,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [isLoggedIn]);
const handleUpgrade = useCallback((): void => {
navigate(ROUTES.BILLING);
history.push(ROUTES.BILLING);
}, []);
const handleFailedPayment = useCallback((): void => {

View File

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

View File

@@ -1,4 +1,5 @@
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';
@@ -15,7 +16,6 @@ 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 } = useAppLocation();
const { pathname } = useLocation();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isDrawerOpen = useAIAssistantStore((state) => state.isDrawerOpen);
const isModalOpen = useAIAssistantStore((state) => state.isModalOpen);

View File

@@ -6,6 +6,18 @@ 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,3 +1,4 @@
import { MemoryRouter, Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
import CreateAlertPage from 'pages/CreateAlert';
@@ -6,6 +7,27 @@ 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(() => ({
@@ -37,10 +59,13 @@ describe('Anomaly Alert Documentation Redirection', () => {
});
it('should handle anomaly alert documentation redirection correctly', () => {
// 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 { getByRole } = render(
<MemoryRouter initialEntries={['/alerts/new']}>
<Route path={ROUTES.ALERTS_NEW}>
<CreateAlertPage />
</Route>
</MemoryRouter>,
);
const alertType = AlertTypes.ANOMALY_BASED_ALERT;

View File

@@ -16,6 +16,18 @@ 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(
<TestRouter>
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<AlertThreshold {...mockProps} />
</CreateAlertProvider>
</QueryClientProvider>
</TestRouter>,
</MemoryRouter>,
);
};

View File

@@ -4,7 +4,6 @@ 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';
@@ -43,7 +42,12 @@ jest.mock('uplot', () => {
};
});
mockAppLocation({ search: '' });
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { search: string } => ({
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 { useAppLocation } from 'lib/router/useAppLocation';
import { useLocation } from 'react-router-dom';
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 = useAppLocation();
const location = useLocation();
const yAxisUnitFromURL = new URLSearchParams(location.search).get(
QueryParams.yAxisUnit,
);

View File

@@ -1,6 +1,7 @@
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 {
@@ -9,7 +10,6 @@ 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}>
<TestRouter>
<MemoryRouter>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<ChartPreview alertDef={mockAlertDef} />
</CreateAlertProvider>
</TestRouter>
</MemoryRouter>
</QueryClientProvider>
</Provider>,
);

View File

@@ -1,6 +1,7 @@
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';
@@ -10,7 +11,6 @@ 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}>
<TestRouter initialRoute={`/alerts/new${search}`}>
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<QuerySection />
</CreateAlertProvider>
</TestRouter>
</MemoryRouter>
</QueryClientProvider>
</Provider>,
);
@@ -427,11 +427,11 @@ describe('QuerySection', () => {
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<TestRouter>
<MemoryRouter>
<CreateAlertProvider initialAlertType={AlertTypes.LOGS_BASED_ALERT}>
<QuerySection />
</CreateAlertProvider>
</TestRouter>
</MemoryRouter>
</QueryClientProvider>
</Provider>,
);
@@ -481,11 +481,11 @@ describe('QuerySection', () => {
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<TestRouter>
<MemoryRouter>
<CreateAlertProvider initialAlertType={AlertTypes.TRACES_BASED_ALERT}>
<QuerySection />
</CreateAlertProvider>
</TestRouter>
</MemoryRouter>
</QueryClientProvider>
</Provider>,
);

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