Compare commits

..

48 Commits

Author SHA1 Message Date
Abhi Kumar
c66c32724d refactor(dashboard): move top list helpers under utils 2026-11-02 00:10:33 +05:30
Abhi Kumar
abf09773f1 fix(dashboard): use the @signozhq/ui 0.2.0 tooltip in the top list header 2026-11-01 23:08:44 +05:30
Abhi Kumar
715d060796 fix(dashboard): declare top list query modes 2026-11-01 23:08:44 +05:30
Abhi Kumar
1391f30533 feat(dashboard): head the top list's columns and let users pick its bar color
Bar color lives in the spec. An unset color resolves to the robin default
server-side, so existing panels stay valid.
2026-11-01 23:08:44 +05:30
Abhi Kumar
30a46fb687 feat(dashboard): warn when a top list shows shares of a non-additive value
Formulas and ClickHouse SQL get no warning, since their aggregation can't be read.
2026-11-01 23:08:44 +05:30
Abhi Kumar
53789e17e9 fix(dashboard): hide the top list tooltip off its rows
The empty space below the rows is still inside the list, so the tooltip stayed on the last row.
2026-11-01 23:08:44 +05:30
Abhi Kumar
68f5007393 feat(dashboard): hint the top list's default order 2026-11-01 23:08:44 +05:30
Abhi Kumar
e33e472e60 feat(dashboard): show rank, share and a row tooltip in top lists
Share is of the listed rows' total, not the whole result, to avoid a second query.
Labels are cut in the middle so both ends stay readable; the tooltip has them in full.
2026-11-01 23:08:44 +05:30
Abhi Kumar
62ed862e51 feat(dashboard): add a top list appearance section with rank and share toggles 2026-11-01 23:08:44 +05:30
Abhi Kumar
86e8b30b75 feat(charts): let a tooltip footer leave out the pin hint 2026-11-01 23:08:44 +05:30
Abhi Kumar
24152358bf fix(charts): use theme text colours in the tooltip card
The vanilla tokens are white in both themes, so the text vanished in light mode.
2026-11-01 23:08:44 +05:30
Abhi Kumar
a9b171ac69 refactor(charts): extract the scatter tooltip card for reuse 2026-11-01 23:08:44 +05:30
Abhi Kumar
5c9a63c661 feat(dashboard): pin order by and limit in the top list query builder
Top or bottom N is picked on the server, so the order and limit are the panel's sort controls.
2026-11-01 23:08:44 +05:30
Abhi Kumar
f9213c04a0 feat(query-builder): show the default limit placeholder only for a lone query
A panel's default limit applies only to a lone query, so the placeholder falls back to
'Enter limit' once formulas or more queries are added.
2026-11-01 23:08:44 +05:30
Abhi Kumar
5faf1de719 chore(api): regenerate client types 2026-11-01 23:08:44 +05:30
Abhi Kumar
c43ac02fc7 feat(dashboard): rank top lists across multiple queries and formulas
Ranks the first enabled result in builder order, since the response lists
results in no particular order. Rows follow the query's value order, so an
ascending order gives a bottom N.
2026-11-01 23:08:44 +05:30
Abhi Kumar
c42c92992b feat(dashboard): fill top list bars with their labels and fit row density
Rows pick the roomiest of 32, 28 and 24px that fits every row in the panel.
2026-11-01 23:08:44 +05:30
Abhi Kumar
b99527a4f4 feat(dashboard): download top list panels as csv 2026-11-01 22:35:43 +05:30
Abhi Kumar
743f6a3da4 feat(dashboard): warn when a top list can't rank its result 2026-11-01 22:35:43 +05:30
Abhi Kumar
3055aaaf10 feat(dashboard): add the top list panel
Ranks one query's single aggregation for now. Rows share one grid so bars
align, and the list is virtualized.
2026-11-01 22:35:43 +05:30
Abhi Kumar
a7d8cabac8 chore(api): regenerate client types 2026-11-01 22:35:43 +05:30
Abhi Kumar
4cb301cf97 chore(openapi): regenerate specs 2026-11-01 22:35:43 +05:30
Abhi Kumar
80055c8b96 feat(dashboards): add a bar color to the top list spec
An unset color resolves to the robin default, so existing panels stay valid.
2026-11-01 22:35:43 +05:30
Abhi Kumar
01998966a5 chore(openapi): regenerate specs 2026-11-01 22:08:23 +05:30
Abhi Kumar
f70faf0f45 feat(dashboards): add rank and share toggles to the top list spec 2026-11-01 22:08:23 +05:30
Abhi Kumar
1d3bf22f32 feat(dashboards): allow formulas and trace operators in top list panels 2026-11-01 22:08:23 +05:30
Abhi Kumar
ae599d4a03 chore(openapi): regenerate specs 2026-11-01 22:08:12 +05:30
Abhi Kumar
f4be84ae1c feat(dashboards): add plugin schema for top list panel 2026-11-01 22:08:11 +05:30
Abhi Kumar
9cd6aa7e40 test(dashboards): type scatter prune fixtures 2026-10-30 17:25:31 +05:30
Abhi Kumar
fa3d01e0fc fix(dashboards): drop stale scatter bindings on save
Bindings the edited query no longer produces are cleared so the save matches the
preview's fallback columns instead of failing API validation.
2026-10-30 17:15:59 +05:30
Abhi Kumar
61a9c1dba1 fix(dashboards): compare scatter point size bounds at their defaults 2026-10-30 17:15:58 +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
624 changed files with 17488 additions and 5265 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,9 +5257,11 @@ 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'
signoz/TopListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -5265,6 +5274,8 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -5278,6 +5289,8 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
- signoz/TopListPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -5384,6 +5397,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:
@@ -5429,6 +5457,21 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec:
properties:
kind:
description: The panel kind.
enum:
- signoz/TopListPanel
type: string
spec:
allOf:
- $ref: '#/components/schemas/DashboardtypesTopListPanelSpec'
description: The options for this panel kind. {} accepts every default.
required:
- kind
- spec
type: object
DashboardtypesPanelSpec:
properties:
display:
@@ -5494,6 +5537,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 +5801,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 +6122,11 @@ components:
- value
- name
type: object
DashboardtypesThresholdAxis:
enum:
- x
- "y"
type: string
DashboardtypesThresholdFormat:
enum:
- text
@@ -6021,6 +6234,43 @@ components:
last_6_hr, last_1_day, last_3_days, last_1_week or last_1_month pin a
fixed window.'
type: object
DashboardtypesTopListAppearance:
properties:
barColor:
description: Hex color of every bar; a matching background threshold overrides
it.
type: string
showRank:
description: Numbers each row by its position in the list.
type: boolean
showShare:
description: Shows each row's share of the listed rows' total.
type: boolean
required:
- barColor
type: object
DashboardtypesTopListPanelSpec:
properties:
appearance:
allOf:
- $ref: '#/components/schemas/DashboardtypesTopListAppearance'
description: Rank, share and bar color options.
formatting:
allOf:
- $ref: '#/components/schemas/DashboardtypesPanelFormatting'
description: Unit and decimal precision of the values.
thresholds:
description: Rules that recolor a row when its value compares to a threshold
as specified.
items:
$ref: '#/components/schemas/DashboardtypesComparisonThreshold'
nullable: true
type: array
visualization:
allOf:
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
description: The time range the rows are computed over.
type: object
DashboardtypesUpdatableDashboardV2:
properties:
image:

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,23 +589,6 @@
"rules": {
"no-redeclare": "off"
}
},
{
// The react-router allowlist: the facade itself, the route table, `useSafeNavigate`,
// the history singleton and the test and story router mounts. Test files are
// deliberately absent: they render at a route or use tests/router-mocks.
"files": [
"src/lib/router/**",
"src/AppRoutes/**",
"src/hooks/useSafeNavigate.ts",
"src/lib/history.ts",
"src/tests/router.tsx",
"src/storybook/providers/StorybookProviders.tsx",
"src/storybook/renderAtRoute.tsx"
],
"rules": {
"signoz/no-direct-react-router-import": "off"
}
}
]
}

View File

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

@@ -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",
@@ -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",
@@ -135,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"
},
@@ -175,10 +178,11 @@
"@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",
@@ -188,9 +192,12 @@
"@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-dom-v5-compat',
code: "jest.doMock('react-router-dom-v5-compat');",
errors: [{ message: MOCK }],
},
{
name: 'react-router-dom named import',
code: "import { useHistory } from 'react-router-dom';",
errors: [{ message: FACADE, line: 1, column: 28 }],
},
{
name: 'react-router named import',
code: "import { useLocation } from 'react-router';",
errors: [{ message: FACADE }],
},
{
name: 'react-router-dom type-only import',
code: "import type { RouteProps } from 'react-router-dom';",
errors: [{ message: FACADE }],
},
{
name: 'react-router-dom-v5-compat',
code: "import { useNavigate } from 'react-router-dom-v5-compat';",
errors: [{ message: 'compat package is an implementation detail' }],
},
{
name: 'lib/history default import',
code: "import history from 'lib/history';",
errors: [{ message: HISTORY }],
},
{
name: 'lib/history through the @/ alias',
code: "import history from '@/lib/history';",
errors: [{ message: HISTORY }],
},
{
name: 'jest.mock of lib/history through the @/ alias',
code: "jest.mock('@/lib/history');",
errors: [{ message: MOCK }],
},
{
name: 'lib/history require',
code: "const history = require('lib/history').default;",
errors: [{ message: HISTORY }],
},
{
name: 'dynamic import',
code: "const mod = await import('react-router-dom');",
errors: [{ message: FACADE }],
},
{
name: 're-export',
code: "export { Link } from 'react-router-dom';",
errors: [{ message: FACADE }],
},
{
name: 'export all',
code: "export * from 'react-router-dom';",
errors: [{ message: FACADE }],
},
{
name: 'one report per import statement',
code:
"import { Link } from 'react-router-dom';\nimport history from 'lib/history';",
errors: [
{ message: FACADE, line: 1 },
{ message: HISTORY, line: 2 },
],
},
],
});

View File

@@ -1,123 +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.
*
* `history` (the package) is deliberately not flagged: it is a transitive concern of
* the version bump, not something the facade replaces.
*
* The allowlist (the facade itself, the route table, `useSafeNavigate`, the history
* singleton and the test and story router mounts) is applied via overrides in
* .oxlintrc.json, not here, so the file list stays visible next to the severity.
*/
import path from 'node:path';
const HISTORY_MODULE_SUFFIX = path.join('src', 'lib', 'history');
const JEST_MODULE_METHODS = new Set([
'mock',
'doMock',
'unmock',
'dontMock',
'setMock',
'requireActual',
'requireMock',
'createMockFromModule',
]);
const MESSAGE_IDS = {
'react-router': 'v5Router',
'react-router-dom': 'v5Router',
'react-router-dom-v5-compat': 'v6Compat',
'lib/history': 'historySingleton',
'@/lib/history': 'historySingleton',
};
/** Resolves `./history` / `../history` so files inside src/lib count too. */
function isHistoryModule(specifier, filename) {
if (!specifier.startsWith('.') || !filename) {
return false;
}
const resolved = path.resolve(path.dirname(filename), specifier);
return (
resolved.endsWith(HISTORY_MODULE_SUFFIX) ||
resolved.endsWith(`${HISTORY_MODULE_SUFFIX}.ts`)
);
}
function messageIdFor(specifier, filename) {
if (MESSAGE_IDS[specifier] !== undefined) {
return MESSAGE_IDS[specifier];
}
return isHistoryModule(specifier, filename) ? 'historySingleton' : null;
}
export default {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow direct react-router / lib/history imports; import from src/lib/router instead',
category: 'React Router migration',
},
schema: [],
messages: {
v5Router:
'Do not import react-router-dom directly. Use the src/lib/router facade (useAppNavigate, useAppLocation, useAppParams, AppLink, Redirect, matchRoute) so a router upgrade stays contained.',
v6Compat:
'Do not import react-router-dom-v5-compat directly. Use the src/lib/router facade instead; the compat package is an implementation detail of the migration and disappears with it.',
historySingleton:
'Do not import the lib/history singleton. Use the helpers in src/lib/router/navigation.ts, or useAppNavigate() to follow the nearest router. history loses basename handling once the router owns it.',
mock:
'Do not mock the router directly. Render at a route with tests/test-utils, or spy on the facade with the tests/router-mocks helpers.',
},
},
create(context) {
const report = (sourceNode, overrideMessageId) => {
if (
sourceNode === null ||
sourceNode === undefined ||
typeof sourceNode.value !== 'string'
) {
return;
}
const messageId = messageIdFor(sourceNode.value, context.filename);
if (messageId !== null) {
context.report({
node: sourceNode,
messageId: overrideMessageId ?? messageId,
});
}
};
return {
ImportDeclaration: (node) => report(node.source),
ImportExpression: (node) => report(node.source),
ExportAllDeclaration: (node) => report(node.source),
ExportNamedDeclaration: (node) => report(node.source),
CallExpression(node) {
const { callee } = node;
if (
(callee.type === 'Identifier' && callee.name === 'require') ||
callee.type === 'Import'
) {
report(node.arguments[0]);
return;
}
if (
callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'jest' &&
callee.property.type === 'Identifier' &&
JEST_MODULE_METHODS.has(callee.property.name)
) {
report(node.arguments[0], 'mock');
}
},
};
},
};

View File

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

601
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

@@ -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,262 @@ 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;
}
/**
* The panel kind.
*/
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTOKind {
'signoz/TopListPanel' = 'signoz/TopListPanel',
}
export interface DashboardtypesTopListAppearanceDTO {
/**
* @type string
* @description Hex color of every bar; a matching background threshold overrides it.
*/
barColor: string;
/**
* @type boolean
* @description Numbers each row by its position in the list.
*/
showRank?: boolean;
/**
* @type boolean
* @description Shows each row's share of the listed rows' total.
*/
showShare?: boolean;
}
export interface DashboardtypesTopListPanelSpecDTO {
/**
* @description Rank, share and bar color options.
*/
appearance?: DashboardtypesTopListAppearanceDTO;
/**
* @description Unit and decimal precision of the values.
*/
formatting?: DashboardtypesPanelFormattingDTO;
/**
* @type array,null
* @description Rules that recolor a row when its value compares to a threshold as specified.
*/
thresholds?: DashboardtypesComparisonThresholdDTO[] | null;
/**
* @description The time range the rows are computed over.
*/
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTO {
/**
* @enum signoz/TopListPanel
* @type string
* @description The panel kind.
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTOKind;
/**
* @description The options for this panel kind. {} accepts every default.
*/
spec: DashboardtypesTopListPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -6726,7 +6988,9 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -8006,6 +8270,8 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
'signoz/TopListPanel' = 'signoz/TopListPanel',
}
/**
* 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,8 +51,10 @@ 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:
case PANEL_TYPES.TOP_LIST:
return 'scalar';
case PANEL_TYPES.TRACE:
return 'trace';
@@ -274,8 +276,10 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE);
panelType === PANEL_TYPES.VALUE ||
panelType === PANEL_TYPES.TOP_LIST);
if (queryData.dataSource === DataSource.METRICS) {
return [

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

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

@@ -12,7 +12,13 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { get, isEmpty } from 'lodash-es';
import { ChevronUp } from '@signozhq/icons';
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5BucketOptionsDTO,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeFormulaDTOType,
Querybuildertypesv5QueryEnvelopeTraceOperatorDTOType,
} from 'api/generated/services/sigNoz.schemas';
import { isLoneBuilderQuery } from 'lib/query/builderEnvelope';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -146,10 +152,34 @@ function QueryAddOns({
[resolvedFields],
);
// A panel's default limit applies only to a lone query: per-query limits can keep
// different groups and break a formula's join.
const isLoneQuery = useMemo(
() =>
isLoneBuilderQuery([
...(currentQuery.builder?.queryData ?? [query]).map((data) => ({
type:
data.builderQueryType ??
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
})),
...(currentQuery.builder?.queryFormulas ?? []).map(() => ({
type: Querybuildertypesv5QueryEnvelopeFormulaDTOType.builder_formula,
})),
...(currentQuery.builder?.queryTraceOperator ?? []).map(() => ({
type:
Querybuildertypesv5QueryEnvelopeTraceOperatorDTOType.builder_trace_operator,
})),
]),
[currentQuery.builder, query],
);
const placeholderOf = useCallback(
(key: InputAddOnField): string =>
resolvedFields.get(key)?.placeholder ?? ADD_ON_DEFINITIONS[key].placeholder,
[resolvedFields],
(key === QueryBuilderField.Limit && !isLoneQuery
? undefined
: resolvedFields.get(key)?.placeholder) ??
ADD_ON_DEFINITIONS[key].placeholder,
[resolvedFields, isLoneQuery],
);
const isDisabled = useCallback(

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 };
}
@@ -69,7 +67,9 @@ function QueryAggregationOptions({
onChange={onChange}
queryData={queryData}
maxAggregations={
panelType === PANEL_TYPES.VALUE || panelType === PANEL_TYPES.PIE
panelType === PANEL_TYPES.VALUE ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.TOP_LIST
? 1
: undefined
}

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,14 @@
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,
PANEL_TYPES.TOP_LIST,
]);
export function isScalarPanelType(panelType?: string | null): boolean {
return panelType != null && SCALAR_PANEL_TYPES.has(panelType);
}

View File

@@ -15,6 +15,9 @@ import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
// Mocks: only what is required for this component to render and for us to assert handler calls
const mockHandleChangeQueryData = jest.fn();
const mockHandleSetQueryData = jest.fn();
let mockCurrentQuery: { unit: string | undefined; builder?: unknown } = {
unit: undefined,
};
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
useQueryOperations: (): {
@@ -27,10 +30,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
handleSetQueryData: typeof mockHandleSetQueryData;
currentQuery: { unit: string | undefined };
currentQuery: typeof mockCurrentQuery;
} => ({
handleSetQueryData: mockHandleSetQueryData,
currentQuery: { unit: undefined },
currentQuery: mockCurrentQuery,
}),
}));
@@ -222,6 +225,77 @@ describe('QueryAddOns', () => {
);
});
describe('a pinned limit with a default', () => {
const renderPinnedLimit = (): void => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TOP_LIST}
index={0}
fieldsConfig={{
[QueryBuilderField.Limit]: {
state: 'pinned',
placeholder: 'Default 10',
},
}}
isForTraceOperator={false}
/>,
);
};
afterEach(() => {
mockCurrentQuery = { unit: undefined };
});
it('is open with its default as the placeholder', () => {
renderPinnedLimit();
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Default 10',
);
});
it('drops the default for an AI query, which the request leaves unlimited', () => {
mockCurrentQuery = {
unit: undefined,
builder: {
queryData: [{ ...baseQuery(), builderQueryType: 'builder_ai_query' }],
queryFormulas: [],
queryTraceOperator: [],
},
};
renderPinnedLimit();
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Enter limit',
);
});
it('drops the default once a formula joins the query', () => {
mockCurrentQuery = {
unit: undefined,
builder: {
queryData: [baseQuery()],
queryFormulas: [{ expression: 'A * 2' }],
queryTraceOperator: [],
},
};
renderPinnedLimit();
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Enter limit',
);
});
});
it('auto-opens Order By and Limit when present in query', () => {
const query = baseQuery({
orderBy: [{ columnName: 'duration', order: 'desc' }],

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

@@ -34,8 +34,10 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HISTOGRAM]: Uplot,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
[PANEL_TYPES.TOP_LIST]: 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,9 +337,11 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
TOP_LIST = 'top_list',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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