mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-31 13:31:00 +00:00
Compare commits
1 Commits
feat/react
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b37231f31a |
@@ -593,18 +593,16 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
// The react-router allowlist: the facade itself, the route table, the router mount,
|
||||
// `useSafeNavigate` and the test and story router mounts. Test files are
|
||||
// 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/app/AppRouter.tsx",
|
||||
"src/hooks/useSafeNavigate.ts",
|
||||
"src/lib/history.ts",
|
||||
"src/tests/router.tsx",
|
||||
"src/tests/router-utils.ts",
|
||||
"src/storybook/navigation/containment.ts",
|
||||
"src/storybook/navigation/pageScope.ts",
|
||||
"src/storybook/providers/StorybookProviders.tsx",
|
||||
"src/storybook/renderAtRoute.tsx"
|
||||
],
|
||||
"rules": {
|
||||
|
||||
@@ -8,8 +8,8 @@ const srcPath = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
|
||||
/**
|
||||
* Modules replaced for every story. Same idea as `moduleNameMapper` in
|
||||
* `jest.config.ts`: the app keeps importing its own paths, Storybook resolves
|
||||
* them to a mock. Regexes so only exact specifiers match: `store` must not
|
||||
* catch `store/actions`.
|
||||
* them to a mock. Regexes so only exact specifiers match: `lib/history` must
|
||||
* not catch `lib/historyUtils`.
|
||||
*
|
||||
* Each replacement is typed as the module it stands in for, so drift is a
|
||||
* compile error rather than a story that fails at render. The `jest` note on
|
||||
@@ -17,6 +17,11 @@ const srcPath = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
|
||||
* only diverge where the runner needs them to.
|
||||
*/
|
||||
const mockAliases = [
|
||||
{
|
||||
// jest: not replaced, jsdom drives a real browser history.
|
||||
find: /^(?:src\/)?lib\/history$/,
|
||||
replacement: `${srcPath}/storybook/navigation/history.alias.ts`,
|
||||
},
|
||||
{
|
||||
// jest: src/__tests__/logEventMock.ts
|
||||
find: /^(?:src\/)?api\/common\/logEvent$/,
|
||||
|
||||
@@ -64,7 +64,7 @@ const config: Config.InitialOptions = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
moduleDirectories: ['node_modules', 'src'],
|
||||
testEnvironment: '<rootDir>/jest.environment.cjs',
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 80,
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
const { TestEnvironment } = require('jest-environment-jsdom');
|
||||
|
||||
/**
|
||||
* jsdom plus the fetch `Request` a data router builds for every navigation,
|
||||
* which jsdom does not ship. Node's own class is used, except that it keeps the
|
||||
* `signal` it is handed: the router passes jsdom's `AbortSignal`, and Node's
|
||||
* `Request` rejects one from another realm.
|
||||
*/
|
||||
class JSDOMWithRequestEnvironment extends TestEnvironment {
|
||||
constructor(config, context) {
|
||||
super(config, context);
|
||||
|
||||
this.global.Request = class extends Request {
|
||||
constructor(input, { signal, ...init } = {}) {
|
||||
super(input, init);
|
||||
Object.defineProperty(this, 'signal', { value: signal ?? super.signal });
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = JSDOMWithRequestEnvironment;
|
||||
@@ -5,8 +5,6 @@
|
||||
/**
|
||||
* Adds custom matchers from the react testing library to all tests
|
||||
*/
|
||||
import { TextDecoder, TextEncoder } from 'node:util';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import 'jest-styled-components';
|
||||
@@ -16,14 +14,6 @@ import { server } from './src/mocks-server/server';
|
||||
import './src/styles.scss';
|
||||
// Establish API mocking before all tests.
|
||||
|
||||
// react-router@7's entry point pulls in its server-runtime cookie signing,
|
||||
// which builds a TextEncoder at module scope. jsdom ships neither encoder, so
|
||||
// importing anything from the router throws before a test starts.
|
||||
Object.assign(globalThis, {
|
||||
TextEncoder: globalThis.TextEncoder ?? TextEncoder,
|
||||
TextDecoder: globalThis.TextDecoder ?? TextDecoder,
|
||||
});
|
||||
|
||||
// Mock window.matchMedia
|
||||
window.matchMedia =
|
||||
window.matchMedia ||
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"dompurify": "3.4.15",
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
"html-to-image": "1.11.13",
|
||||
"http-status-codes": "2.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
@@ -116,7 +117,8 @@
|
||||
"react-query": "3.39.3",
|
||||
"react-redux": "^7.2.2",
|
||||
"react-rnd": "^10.5.3",
|
||||
"react-router": "7.18.4",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router-dom-v5-compat": "6.30.6",
|
||||
"react-syntax-highlighter": "15.5.0",
|
||||
"react-use": "^17.3.2",
|
||||
"react-virtuoso": "4.0.3",
|
||||
@@ -170,6 +172,7 @@
|
||||
"@types/crypto-js": "4.2.2",
|
||||
"@types/d3-hierarchy": "1.1.11",
|
||||
"@types/event-source-polyfill": "^1.0.0",
|
||||
"@types/history": "4.7.11",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
"@types/node": "^22.20.5",
|
||||
@@ -180,6 +183,7 @@
|
||||
"@types/react-grid-layout": "^1.1.2",
|
||||
"@types/react-redux": "^7.1.11",
|
||||
"@types/react-resizable": "3.0.3",
|
||||
"@types/react-router-dom": "^5.1.6",
|
||||
"@types/react-syntax-highlighter": "15.5.13",
|
||||
"@types/redux-mock-store": "1.0.4",
|
||||
"@types/styled-components": "^5.1.4",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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({
|
||||
@@ -10,6 +11,11 @@ await ruleTester({
|
||||
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';",
|
||||
@@ -43,8 +49,13 @@ await ruleTester({
|
||||
errors: [{ message: MOCK }],
|
||||
},
|
||||
{
|
||||
name: 'jest.doMock of react-router',
|
||||
code: "jest.doMock('react-router');",
|
||||
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 }],
|
||||
},
|
||||
{
|
||||
@@ -63,14 +74,29 @@ await ruleTester({
|
||||
errors: [{ message: FACADE }],
|
||||
},
|
||||
{
|
||||
name: 'react-router-dom is flagged as the v7 re-export shim',
|
||||
code: "import { Outlet } from 'react-router-dom';",
|
||||
errors: [{ message: 'only a re-export shim' }],
|
||||
name: 'react-router-dom-v5-compat',
|
||||
code: "import { useNavigate } from 'react-router-dom-v5-compat';",
|
||||
errors: [{ message: 'compat package is an implementation detail' }],
|
||||
},
|
||||
{
|
||||
name: 'require',
|
||||
code: "const router = require('react-router');",
|
||||
errors: [{ message: FACADE }],
|
||||
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',
|
||||
@@ -90,10 +116,10 @@ await ruleTester({
|
||||
{
|
||||
name: 'one report per import statement',
|
||||
code:
|
||||
"import { Link } from 'react-router';\nimport { Outlet } from 'react-router';",
|
||||
"import { Link } from 'react-router-dom';\nimport history from 'lib/history';",
|
||||
errors: [
|
||||
{ message: FACADE, line: 1 },
|
||||
{ message: FACADE, line: 2 },
|
||||
{ message: HISTORY, line: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,14 +5,18 @@
|
||||
* upgrade is a change to one directory instead of ~300 call sites. This rule
|
||||
* keeps new call sites, and test mocks, from reaching past it.
|
||||
*
|
||||
* `react-router-dom` is still flagged even though the package is gone: on v7 it is a
|
||||
* re-export shim over `react-router`, so reinstalling it would split the tree again.
|
||||
* `history` (the package) is deliberately not flagged: it is a transitive concern of
|
||||
* the version bump, not something the facade replaces.
|
||||
*
|
||||
* The allowlist (the facade itself, the route table, the router mount,
|
||||
* `useSafeNavigate` 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.
|
||||
* 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',
|
||||
@@ -25,12 +29,30 @@ const JEST_MODULE_METHODS = new Set([
|
||||
]);
|
||||
|
||||
const MESSAGE_IDS = {
|
||||
'react-router': 'router',
|
||||
'react-router-dom': 'routerDom',
|
||||
'react-router': 'v5Router',
|
||||
'react-router-dom': 'v5Router',
|
||||
'react-router-dom-v5-compat': 'v6Compat',
|
||||
'lib/history': 'historySingleton',
|
||||
'@/lib/history': 'historySingleton',
|
||||
};
|
||||
|
||||
function messageIdFor(specifier) {
|
||||
return MESSAGE_IDS[specifier] ?? null;
|
||||
/** 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 {
|
||||
@@ -38,15 +60,17 @@ export default {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow direct react-router imports; import from src/lib/router instead',
|
||||
'Disallow direct react-router / lib/history imports; import from src/lib/router instead',
|
||||
category: 'React Router migration',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
router:
|
||||
'Do not import react-router directly. Use the src/lib/router facade (useAppNavigate, useAppLocation, useAppParams, AppLink, Redirect, matchRoute) so a router upgrade stays contained.',
|
||||
routerDom:
|
||||
'Do not import react-router-dom. The app is on react-router; on v7 react-router-dom is only a re-export shim, and installing it puts two copies of the router in the tree. Use the src/lib/router facade.',
|
||||
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.',
|
||||
},
|
||||
@@ -61,7 +85,7 @@ export default {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const messageId = messageIdFor(sourceNode.value);
|
||||
const messageId = messageIdFor(sourceNode.value, context.filename);
|
||||
if (messageId !== null) {
|
||||
context.report({
|
||||
node: sourceNode,
|
||||
|
||||
126
frontend/pnpm-lock.yaml
generated
126
frontend/pnpm-lock.yaml
generated
@@ -24,6 +24,7 @@ overrides:
|
||||
nanoid@<3.3.18: '>=3.3.18 <4'
|
||||
prismjs@<1.30.0: '>=1.30.0 <2'
|
||||
postcss-selector-parser@>=7.1.0 <7.1.3: '>=7.1.3 <8'
|
||||
react-router@>=6.7.0 <6.30.6: '>=6.30.6 <7'
|
||||
tmp@<0.2.6: '>=0.2.6 <0.3.0'
|
||||
yaml@>=1.0.0 <1.10.3: '>=1.10.3 <2'
|
||||
'@types/node': ^22.20.5
|
||||
@@ -82,7 +83,7 @@ importers:
|
||||
version: 0.4.0
|
||||
'@signozhq/ui':
|
||||
specifier: 0.2.0
|
||||
version: 0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
|
||||
version: 0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
|
||||
'@tanstack/react-table':
|
||||
specifier: 8.21.3
|
||||
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -164,6 +165,9 @@ importers:
|
||||
eventemitter3:
|
||||
specifier: 5.0.1
|
||||
version: 5.0.1
|
||||
history:
|
||||
specifier: 4.10.1
|
||||
version: 4.10.1
|
||||
html-to-image:
|
||||
specifier: 1.11.13
|
||||
version: 1.11.13
|
||||
@@ -199,7 +203,7 @@ importers:
|
||||
version: 12.4.13(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
nuqs:
|
||||
specifier: 2.8.8
|
||||
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
|
||||
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
|
||||
overlayscrollbars:
|
||||
specifier: ^2.16.0
|
||||
version: 2.16.0
|
||||
@@ -263,9 +267,12 @@ importers:
|
||||
react-rnd:
|
||||
specifier: ^10.5.3
|
||||
version: 10.5.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
react-router:
|
||||
specifier: 7.18.4
|
||||
version: 7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
react-router-dom:
|
||||
specifier: ^5.2.0
|
||||
version: 5.3.4(react@18.2.0)
|
||||
react-router-dom-v5-compat:
|
||||
specifier: 6.30.6
|
||||
version: 6.30.6(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react@18.2.0)
|
||||
react-syntax-highlighter:
|
||||
specifier: 15.5.0
|
||||
version: 15.5.0(react@18.2.0)
|
||||
@@ -384,6 +391,9 @@ importers:
|
||||
'@types/event-source-polyfill':
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.1
|
||||
'@types/history':
|
||||
specifier: 4.7.11
|
||||
version: 4.7.11
|
||||
'@types/jest':
|
||||
specifier: 30.0.0
|
||||
version: 30.0.0
|
||||
@@ -414,6 +424,9 @@ importers:
|
||||
'@types/react-resizable':
|
||||
specifier: 3.0.3
|
||||
version: 3.0.3
|
||||
'@types/react-router-dom':
|
||||
specifier: ^5.1.6
|
||||
version: 5.3.3
|
||||
'@types/react-syntax-highlighter':
|
||||
specifier: 15.5.13
|
||||
version: 15.5.13
|
||||
@@ -3315,6 +3328,10 @@ packages:
|
||||
'@react-dnd/shallowequal@4.0.2':
|
||||
resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==}
|
||||
|
||||
'@remix-run/router@1.23.4':
|
||||
resolution: {integrity: sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-beta.53':
|
||||
resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -3996,6 +4013,9 @@ packages:
|
||||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
'@types/history@4.7.11':
|
||||
resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==}
|
||||
|
||||
'@types/hoist-non-react-statics@3.3.1':
|
||||
resolution: {integrity: sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==}
|
||||
|
||||
@@ -4077,6 +4097,12 @@ packages:
|
||||
'@types/react-resizable@3.0.3':
|
||||
resolution: {integrity: sha512-W/QsUOZoXBAIBQNhNm95A5ohoaiUA874lWQytO2UP9dOjp5JHO9+a0cwYNabea7sA12ZDJnGVUFZxcNaNksAWA==}
|
||||
|
||||
'@types/react-router-dom@5.3.3':
|
||||
resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==}
|
||||
|
||||
'@types/react-router@5.1.20':
|
||||
resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==}
|
||||
|
||||
'@types/react-syntax-highlighter@15.5.13':
|
||||
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
|
||||
|
||||
@@ -5009,10 +5035,6 @@ packages:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
cookie@1.1.1:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
copy-anything@3.0.5:
|
||||
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
|
||||
engines: {node: '>=12.13'}
|
||||
@@ -6096,6 +6118,9 @@ packages:
|
||||
history@4.10.1:
|
||||
resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==}
|
||||
|
||||
history@5.3.0:
|
||||
resolution: {integrity: sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
@@ -7490,7 +7515,7 @@ packages:
|
||||
'@tanstack/react-router': ^1
|
||||
next: '>=14.2.0'
|
||||
react: '>=18.2.0 || ^19.0.0-0'
|
||||
react-router: ^5 || ^6 || ^7
|
||||
react-router: '>=6.30.6 <7'
|
||||
react-router-dom: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
'@remix-run/react':
|
||||
@@ -7511,7 +7536,7 @@ packages:
|
||||
'@tanstack/react-router': ^1
|
||||
next: '>=14.2.0'
|
||||
react: '>=18.2.0 || ^19.0.0-0'
|
||||
react-router: ^5 || ^6 || ^7 || ^8
|
||||
react-router: '>=6.30.6 <7'
|
||||
react-router-dom: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
'@remix-run/react':
|
||||
@@ -8435,6 +8460,14 @@ packages:
|
||||
react: '>=16.3.0'
|
||||
react-dom: '>=16.3.0'
|
||||
|
||||
react-router-dom-v5-compat@6.30.6:
|
||||
resolution: {integrity: sha512-I54amXpivD3V0r1BYUIj1qLOljXx+UpbLG63Bqbm3BdKH62z4H3znC0L7SyMTYAMEDZuUjDSlqtkBRG/OEDXBg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
react-router-dom: 4 || 5
|
||||
|
||||
react-router-dom@5.3.4:
|
||||
resolution: {integrity: sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==}
|
||||
peerDependencies:
|
||||
@@ -8445,15 +8478,11 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=15'
|
||||
|
||||
react-router@7.18.4:
|
||||
resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
react-router@6.30.6:
|
||||
resolution: {integrity: sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react: '>=16.8'
|
||||
|
||||
react-style-singleton@2.2.3:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
@@ -12899,6 +12928,8 @@ snapshots:
|
||||
|
||||
'@react-dnd/shallowequal@4.0.2': {}
|
||||
|
||||
'@remix-run/router@1.23.4': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-beta.53':
|
||||
optional: true
|
||||
|
||||
@@ -13131,7 +13162,7 @@ snapshots:
|
||||
|
||||
'@signozhq/icons@0.4.0': {}
|
||||
|
||||
'@signozhq/ui@0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)':
|
||||
'@signozhq/ui@0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@base-ui/react': 1.7.0(@date-fns/tz@1.4.1)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@chenglou/pretext': 0.0.8
|
||||
@@ -13155,7 +13186,7 @@ snapshots:
|
||||
lodash-es: 4.18.1
|
||||
motion: 11.18.2(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
next-themes: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
nuqs: 2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
|
||||
nuqs: 2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-compiler-runtime: 1.0.0(react@18.2.0)
|
||||
react-day-picker: 9.9.0(react@18.2.0)
|
||||
@@ -13607,6 +13638,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.2
|
||||
|
||||
'@types/history@4.7.11': {}
|
||||
|
||||
'@types/hoist-non-react-statics@3.3.1':
|
||||
dependencies:
|
||||
'@types/react': 18.0.26
|
||||
@@ -13703,6 +13736,17 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/react': 18.0.26
|
||||
|
||||
'@types/react-router-dom@5.3.3':
|
||||
dependencies:
|
||||
'@types/history': 4.7.11
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-router': 5.1.20
|
||||
|
||||
'@types/react-router@5.1.20':
|
||||
dependencies:
|
||||
'@types/history': 4.7.11
|
||||
'@types/react': 18.0.26
|
||||
|
||||
'@types/react-syntax-highlighter@15.5.13':
|
||||
dependencies:
|
||||
'@types/react': 18.0.26
|
||||
@@ -14744,8 +14788,6 @@ snapshots:
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
copy-anything@3.0.5:
|
||||
dependencies:
|
||||
is-what: 4.1.16
|
||||
@@ -15933,6 +15975,10 @@ snapshots:
|
||||
tiny-warning: 1.0.3
|
||||
value-equal: 1.0.1
|
||||
|
||||
history@5.3.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
@@ -16252,8 +16298,7 @@ snapshots:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
isarray@0.0.1:
|
||||
optional: true
|
||||
isarray@0.0.1: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
@@ -17748,20 +17793,20 @@ snapshots:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
|
||||
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
react: 18.2.0
|
||||
optionalDependencies:
|
||||
react-router: 7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
react-router: 6.30.6(react@18.2.0)
|
||||
react-router-dom: 5.3.4(react@18.2.0)
|
||||
|
||||
nuqs@2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
|
||||
nuqs@2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
react: 18.2.0
|
||||
optionalDependencies:
|
||||
react-router: 7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
react-router: 6.30.6(react@18.2.0)
|
||||
react-router-dom: 5.3.4(react@18.2.0)
|
||||
|
||||
nwsapi@2.2.23: {}
|
||||
@@ -18137,7 +18182,6 @@ snapshots:
|
||||
path-to-regexp@1.9.0:
|
||||
dependencies:
|
||||
isarray: 0.0.1
|
||||
optional: true
|
||||
|
||||
path-to-regexp@6.3.0: {}
|
||||
|
||||
@@ -18907,9 +18951,18 @@ snapshots:
|
||||
react-draggable: 4.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
tslib: 2.6.2
|
||||
|
||||
react-router-dom-v5-compat@6.30.6(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@remix-run/router': 1.23.4
|
||||
history: 5.3.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
react-router: 6.30.6(react@18.2.0)
|
||||
react-router-dom: 5.3.4(react@18.2.0)
|
||||
|
||||
react-router-dom@5.3.4(react@18.2.0):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@babel/runtime': 7.28.2
|
||||
history: 4.10.1
|
||||
loose-envify: 1.4.0
|
||||
prop-types: 15.8.1
|
||||
@@ -18917,11 +18970,10 @@ snapshots:
|
||||
react-router: 5.3.4(react@18.2.0)
|
||||
tiny-invariant: 1.3.3
|
||||
tiny-warning: 1.0.3
|
||||
optional: true
|
||||
|
||||
react-router@5.3.4(react@18.2.0):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@babel/runtime': 7.28.2
|
||||
history: 4.10.1
|
||||
hoist-non-react-statics: 3.3.2
|
||||
loose-envify: 1.4.0
|
||||
@@ -18931,15 +18983,11 @@ snapshots:
|
||||
react-is: 16.13.1
|
||||
tiny-invariant: 1.3.3
|
||||
tiny-warning: 1.0.3
|
||||
optional: true
|
||||
|
||||
react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
|
||||
react-router@6.30.6(react@18.2.0):
|
||||
dependencies:
|
||||
cookie: 1.1.1
|
||||
'@remix-run/router': 1.23.4
|
||||
react: 18.2.0
|
||||
set-cookie-parser: 2.6.0
|
||||
optionalDependencies:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@18.0.26)(react@18.2.0):
|
||||
dependencies:
|
||||
|
||||
@@ -74,6 +74,11 @@ overrides:
|
||||
# (postcss-selector-parser ^7.0.0)
|
||||
# remove: blocked, 4.2.0 is latest and the range is open, so the floor is what pulls the fix
|
||||
postcss-selector-parser@>=7.1.0 <7.1.3: '>=7.1.3 <8'
|
||||
# via: @signozhq/ui > nuqs@2 (react-router ^6.4.0 || ^7)
|
||||
# remove: blocked. GHSA-wrjc-x8rr-h8h6 and the deserializeErrors advisory are patched
|
||||
# only in 7.18.0. Do NOT open the cap: react-router >=7 requires React 19 and breaks
|
||||
# the app-wide CompatRouter, so those two moderates stay until the app moves to React 19
|
||||
react-router@>=6.7.0 <6.30.6: '>=6.30.6 <7'
|
||||
# via: msw@1.3.2 (devDep) > inquirer@8 > external-editor@3.1.0 (tmp ^0.0.33)
|
||||
# remove: upgrade msw to >=2 (drops the inquirer/external-editor chain)
|
||||
tmp@<0.2.6: '>=0.2.6 <0.3.0'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactChild, useCallback, useMemo } from 'react';
|
||||
import { matchPath, Redirect, useLocation } from 'react-router-dom';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
@@ -8,9 +9,6 @@ import ROUTES from 'constants/routes';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { Redirect } from 'lib/router/Redirect';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
@@ -30,7 +28,7 @@ import routes, {
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
const location = useAppLocation();
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
const {
|
||||
org,
|
||||
@@ -49,11 +47,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
() =>
|
||||
new Map(
|
||||
[...routes, LIST_LICENSES, SUPPORT_ROUTE].map((e) => {
|
||||
const patterns = Array.isArray(e.path) ? e.path : [e.path];
|
||||
const matches = patterns.some(
|
||||
(pattern) => matchRoute(pathname, pattern) !== null,
|
||||
);
|
||||
return [matches ? 'current' : null, e];
|
||||
const currentPath = matchPath(pathname, {
|
||||
path: e.path,
|
||||
});
|
||||
return [currentPath === null ? null : 'current', e];
|
||||
}),
|
||||
),
|
||||
[pathname],
|
||||
@@ -245,7 +242,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
);
|
||||
if (fromPathname) {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
return <Redirect to={fromPathname} replace={false} />;
|
||||
return <Redirect to={fromPathname} />;
|
||||
}
|
||||
if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
@@ -259,7 +256,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
);
|
||||
if (fromPathname) {
|
||||
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
|
||||
return <Redirect to={fromPathname} replace={false} />;
|
||||
return <Redirect to={fromPathname} />;
|
||||
}
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router';
|
||||
import { MemoryRouter, Route, Switch, useLocation } from 'react-router-dom';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
@@ -237,17 +237,12 @@ function buildPrivateRouteTree(
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<AppContext.Provider value={contextValue}>
|
||||
<PrivateRoute>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<>
|
||||
<div data-testid="children-rendered">Content</div>
|
||||
<LocationDisplay />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<Switch>
|
||||
<Route path="*">
|
||||
<div data-testid="children-rendered">Content</div>
|
||||
<LocationDisplay />
|
||||
</Route>
|
||||
</Switch>
|
||||
</PrivateRoute>
|
||||
</AppContext.Provider>
|
||||
</MemoryRouter>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { lazy, Suspense, useState } from 'react';
|
||||
import {
|
||||
createBrowserRouter,
|
||||
type DataRouter,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
} from 'react-router';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { resetTestRoute } from 'tests/router-utils';
|
||||
|
||||
import { createGatedRoutes, type EnabledRouteKeys } from '../routeTable';
|
||||
import type { AppRoutes } from '../routes';
|
||||
|
||||
let mounts = 0;
|
||||
|
||||
function Page(): JSX.Element {
|
||||
const [mount] = useState(() => {
|
||||
mounts += 1;
|
||||
return mounts;
|
||||
});
|
||||
return <div data-testid="page">mount {mount}</div>;
|
||||
}
|
||||
|
||||
// Two lazy wrappers of one module, like the logs explorer and pipelines routes.
|
||||
const Explorer = lazy(async () => ({ default: Page }));
|
||||
const Pipelines = lazy(async () => ({ default: Page }));
|
||||
|
||||
const routes: AppRoutes[] = [
|
||||
{ path: ROUTES.LOGS, component: Explorer, key: 'LOGS', isPrivate: true },
|
||||
{
|
||||
path: [ROUTES.LOGS_PIPELINES, ROUTES.LOGS_INDEX_FIELDS],
|
||||
component: Pipelines,
|
||||
key: 'LOGS_PIPELINES',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
const enabledRouteKeys: EnabledRouteKeys = new Set(['LOGS', 'LOGS_PIPELINES']);
|
||||
|
||||
function renderRoutes(): DataRouter {
|
||||
window.history.replaceState(null, '', ROUTES.LOGS);
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: (
|
||||
<Suspense fallback={<div data-testid="fallback" />}>
|
||||
<Outlet context={enabledRouteKeys} />
|
||||
</Suspense>
|
||||
),
|
||||
children: createGatedRoutes(routes),
|
||||
},
|
||||
]);
|
||||
render(<RouterProvider router={router} useTransitions={false} />);
|
||||
return router;
|
||||
}
|
||||
|
||||
describe('createGatedRoutes', () => {
|
||||
beforeEach(() => {
|
||||
mounts = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetTestRoute();
|
||||
});
|
||||
|
||||
it('remounts the page when another route entry resolves to the same component', async () => {
|
||||
const router = renderRoutes();
|
||||
await expect(screen.findByText('mount 1')).resolves.toBeInTheDocument();
|
||||
|
||||
await act(() => router.navigate(ROUTES.LOGS_PIPELINES));
|
||||
|
||||
await expect(screen.findByText('mount 2')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the page mounted across the patterns of one route entry', async () => {
|
||||
const router = renderRoutes();
|
||||
await act(() => router.navigate(ROUTES.LOGS_PIPELINES));
|
||||
await expect(screen.findByText('mount 2')).resolves.toBeInTheDocument();
|
||||
|
||||
await act(() => router.navigate(ROUTES.LOGS_INDEX_FIELDS));
|
||||
|
||||
expect(screen.getByTestId('page')).toHaveTextContent('mount 2');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,6 @@
|
||||
import {
|
||||
ReactNode,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
|
||||
import { Route, Router, Switch } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
@@ -15,6 +9,7 @@ import AppPageProviders from 'app/AppPageProviders';
|
||||
import AppShell from 'app/AppShell';
|
||||
import AppLoading from 'components/AppLoading/AppLoading';
|
||||
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import NotFound from 'components/NotFound';
|
||||
import { ShiftHoldOverlayController } from 'components/ShiftOverlay/ShiftHoldOverlayController';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -28,7 +23,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { IS_PROD } from 'lib/env';
|
||||
import { getCurrentLocation, navigate, subscribe } from 'lib/router/navigation';
|
||||
import history from 'lib/history';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import posthog from 'posthog-js';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -36,19 +31,25 @@ import { IUser } from 'providers/App/types';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { extractDomain } from 'utils/app';
|
||||
|
||||
import { Home } from './pageComponents';
|
||||
import PrivateRoute from './Private';
|
||||
import defaultRoutes, {
|
||||
AppRoutes,
|
||||
LIST_LICENSES,
|
||||
SUPPORT_ROUTE,
|
||||
} from './routes';
|
||||
import type { EnabledRouteKeys } from './routeTable';
|
||||
import {
|
||||
hasNoAppFrames,
|
||||
isExternalIntegrationError,
|
||||
SENTRY_IGNORE_ERRORS,
|
||||
} from './sentryFilters';
|
||||
|
||||
const appRouter = (children: ReactNode): ReactNode => (
|
||||
<Router history={history}>
|
||||
<CompatRouter>{children}</CompatRouter>
|
||||
</Router>
|
||||
);
|
||||
|
||||
const appLayout = (children: ReactNode): ReactNode => (
|
||||
<AppLayout>{children}</AppLayout>
|
||||
);
|
||||
@@ -69,24 +70,18 @@ function App(): JSX.Element {
|
||||
isPreflightLoading,
|
||||
} = useAppContext();
|
||||
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
|
||||
const enabledRouteKeys = useMemo<EnabledRouteKeys>(
|
||||
() => new Set(routes.map(({ key }) => key)),
|
||||
[routes],
|
||||
);
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const chatSupport = useChatSupport();
|
||||
|
||||
const { hostname } = window.location;
|
||||
// Through the facade, not the raw history: the router owns the base path
|
||||
// now, so `history.location.pathname` would still carry it.
|
||||
const [pathname, setPathname] = useState(getCurrentLocation().pathname);
|
||||
const [pathname, setPathname] = useState(history.location.pathname);
|
||||
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
const [isSentryInitialized, setIsSentryInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribe(({ location }) => {
|
||||
return history.listen((location) => {
|
||||
setPathname(location.pathname);
|
||||
});
|
||||
}, []);
|
||||
@@ -403,7 +398,7 @@ function App(): JSX.Element {
|
||||
// this needs to be on top of data missing error because if there is an error, data will never be loaded and it will
|
||||
// move to indefinitive loading
|
||||
if (userFetchError && pathname !== ROUTES.SOMETHING_WENT_WRONG) {
|
||||
navigate(ROUTES.SOMETHING_WENT_WRONG, { replace: true });
|
||||
history.replace(ROUTES.SOMETHING_WENT_WRONG);
|
||||
}
|
||||
|
||||
// if all of the data is not set then return a spinner, this is required because there is some gap between loading states and data setting
|
||||
@@ -419,6 +414,7 @@ function App(): JSX.Element {
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<AppShell
|
||||
router={appRouter}
|
||||
overlays={
|
||||
isLoggedInState && (
|
||||
<>
|
||||
@@ -431,7 +427,18 @@ function App(): JSX.Element {
|
||||
<PrivateRoute>
|
||||
<AppPageProviders layout={appLayout}>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Outlet context={enabledRouteKeys} />
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</AppPageProviders>
|
||||
</PrivateRoute>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import { type RouteObject, useOutletContext } from 'react-router';
|
||||
import NotFound from 'components/NotFound';
|
||||
import { RethrowRouteError } from 'lib/router/RethrowRouteError';
|
||||
|
||||
import { Home } from './pageComponents';
|
||||
import routes, { AppRoutes, LIST_LICENSES, SUPPORT_ROUTE } from './routes';
|
||||
|
||||
/**
|
||||
* The routes the plan, the license and the feature flags enable. The root
|
||||
* layout computes it and hands it down as outlet context.
|
||||
*/
|
||||
export type EnabledRouteKeys = ReadonlySet<AppRoutes['key']>;
|
||||
|
||||
interface RouteGateProps {
|
||||
routeKey: AppRoutes['key'];
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* The table is created once, so a route the session may not use is still in
|
||||
* it. It renders the not-found page instead, which is what the old dynamic
|
||||
* `<Switch>` fell through to.
|
||||
*/
|
||||
function RouteGate({
|
||||
routeKey,
|
||||
component: Component,
|
||||
}: RouteGateProps): JSX.Element {
|
||||
const enabledRouteKeys = useOutletContext<EnabledRouteKeys>();
|
||||
return enabledRouteKeys.has(routeKey) ? <Component /> : <NotFound />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate is keyed by its route entry. The outlet renders the match unkeyed,
|
||||
* so every route would otherwise reuse the previous route's gate, and a lazy
|
||||
* page that resolves to the same component would keep its instance. The
|
||||
* patterns of one entry share the key.
|
||||
*/
|
||||
export function createGatedRoutes(appRoutes: AppRoutes[]): RouteObject[] {
|
||||
return appRoutes.flatMap(({ path, component, nested, key }) =>
|
||||
(Array.isArray(path) ? path : [path]).map((pattern) => ({
|
||||
path: nested ? `${pattern}/*` : pattern,
|
||||
element: <RouteGate key={key} routeKey={key} component={component} />,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const gatedRoutes = createGatedRoutes([
|
||||
...routes,
|
||||
LIST_LICENSES,
|
||||
SUPPORT_ROUTE,
|
||||
]);
|
||||
|
||||
/** `root` is the layout every route renders in; it has to render an `<Outlet>`. */
|
||||
export function createRouteTable(root: ReactNode): RouteObject[] {
|
||||
return [
|
||||
{
|
||||
element: root,
|
||||
// An error above `Sentry.ErrorBoundary` in the root layout crashes the
|
||||
// root and reaches Sentry, as it did before the data router.
|
||||
errorElement: <RethrowRouteError />,
|
||||
children: [
|
||||
...gatedRoutes,
|
||||
{ path: '/', element: <Home /> },
|
||||
{ path: '*', element: <NotFound /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { RouteProps } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import {
|
||||
@@ -58,25 +58,27 @@ const routes: AppRoutes[] = [
|
||||
{
|
||||
component: SignupPage,
|
||||
path: ROUTES.SIGN_UP,
|
||||
exact: true,
|
||||
isPrivate: false,
|
||||
key: 'SIGN_UP',
|
||||
},
|
||||
{
|
||||
path: ROUTES.GET_STARTED_WITH_CLOUD,
|
||||
nested: true,
|
||||
exact: false,
|
||||
component: OnboardingV2,
|
||||
isPrivate: true,
|
||||
key: 'GET_STARTED_WITH_CLOUD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.HOME,
|
||||
exact: true,
|
||||
component: Home,
|
||||
isPrivate: true,
|
||||
key: 'HOME',
|
||||
},
|
||||
{
|
||||
path: ROUTES.ONBOARDING,
|
||||
nested: true,
|
||||
exact: false,
|
||||
component: OrgOnboarding,
|
||||
isPrivate: true,
|
||||
key: 'ONBOARDING',
|
||||
@@ -84,23 +86,27 @@ const routes: AppRoutes[] = [
|
||||
{
|
||||
component: LogsIndexToFields,
|
||||
path: ROUTES.LOGS_INDEX_FIELDS,
|
||||
exact: true,
|
||||
isPrivate: true,
|
||||
key: 'LOGS_INDEX_FIELDS',
|
||||
},
|
||||
{
|
||||
component: ServicesTablePage,
|
||||
path: ROUTES.APPLICATION,
|
||||
exact: true,
|
||||
isPrivate: true,
|
||||
key: 'APPLICATION',
|
||||
},
|
||||
{
|
||||
path: ROUTES.SERVICE_METRICS,
|
||||
exact: true,
|
||||
component: ServiceMetricsPage,
|
||||
isPrivate: true,
|
||||
key: 'SERVICE_METRICS',
|
||||
},
|
||||
{
|
||||
path: ROUTES.SERVICE_TOP_LEVEL_OPERATIONS,
|
||||
exact: true,
|
||||
component: ServiceTopLevelOperationsPage,
|
||||
isPrivate: true,
|
||||
key: 'SERVICE_TOP_LEVEL_OPERATIONS',
|
||||
@@ -109,252 +115,292 @@ const routes: AppRoutes[] = [
|
||||
path: ROUTES.SERVICE_MAP,
|
||||
component: ServiceMapPage,
|
||||
isPrivate: true,
|
||||
exact: true,
|
||||
key: 'SERVICE_MAP',
|
||||
},
|
||||
{
|
||||
path: ROUTES.TRACE_DETAIL,
|
||||
exact: true,
|
||||
component: TraceDetailV3,
|
||||
isPrivate: true,
|
||||
key: 'TRACE_DETAIL',
|
||||
},
|
||||
{
|
||||
path: ROUTES.SETTINGS,
|
||||
nested: true,
|
||||
exact: false,
|
||||
component: SettingsPage,
|
||||
isPrivate: true,
|
||||
key: 'SETTINGS',
|
||||
},
|
||||
{
|
||||
path: ROUTES.USAGE_EXPLORER,
|
||||
exact: true,
|
||||
component: UsageExplorerPage,
|
||||
isPrivate: true,
|
||||
key: 'USAGE_EXPLORER',
|
||||
},
|
||||
{
|
||||
path: ROUTES.ALL_DASHBOARD,
|
||||
exact: true,
|
||||
component: DashboardsListPage,
|
||||
isPrivate: true,
|
||||
key: 'ALL_DASHBOARD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.DASHBOARD,
|
||||
exact: true,
|
||||
component: DashboardPage,
|
||||
isPrivate: true,
|
||||
key: 'DASHBOARD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.PUBLIC_DASHBOARD,
|
||||
nested: true,
|
||||
exact: false,
|
||||
component: PublicDashboardPage,
|
||||
isPrivate: false,
|
||||
key: 'PUBLIC_DASHBOARD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.DASHBOARD_PANEL_EDITOR,
|
||||
exact: true,
|
||||
component: DashboardPanelEditorPage,
|
||||
isPrivate: true,
|
||||
key: 'DASHBOARD_PANEL_EDITOR',
|
||||
},
|
||||
{
|
||||
path: ROUTES.EDIT_ALERTS,
|
||||
exact: true,
|
||||
component: EditRulesPage,
|
||||
isPrivate: true,
|
||||
key: 'EDIT_ALERTS',
|
||||
},
|
||||
{
|
||||
path: ROUTES.LIST_ALL_ALERT,
|
||||
exact: true,
|
||||
component: ListAllALertsPage,
|
||||
isPrivate: true,
|
||||
key: 'LIST_ALL_ALERT',
|
||||
},
|
||||
{
|
||||
path: ROUTES.ALERTS_NEW,
|
||||
exact: true,
|
||||
component: CreateNewAlerts,
|
||||
isPrivate: true,
|
||||
key: 'ALERTS_NEW',
|
||||
},
|
||||
{
|
||||
path: ROUTES.ALERT_HISTORY,
|
||||
exact: true,
|
||||
component: AlertHistory,
|
||||
isPrivate: true,
|
||||
key: 'ALERT_HISTORY',
|
||||
},
|
||||
{
|
||||
path: ROUTES.ALERT_OVERVIEW,
|
||||
exact: true,
|
||||
component: AlertOverview,
|
||||
isPrivate: true,
|
||||
key: 'ALERT_OVERVIEW',
|
||||
},
|
||||
{
|
||||
path: ROUTES.TRACES_EXPLORER,
|
||||
exact: true,
|
||||
component: TracesExplorer,
|
||||
isPrivate: true,
|
||||
key: 'TRACES_EXPLORER',
|
||||
},
|
||||
{
|
||||
path: ROUTES.TRACES_FUNNELS,
|
||||
exact: true,
|
||||
component: TracesFunnels,
|
||||
isPrivate: true,
|
||||
key: 'TRACES_FUNNELS',
|
||||
},
|
||||
{
|
||||
path: ROUTES.TRACES_FUNNELS_DETAIL,
|
||||
exact: true,
|
||||
component: TracesFunnelDetails,
|
||||
isPrivate: true,
|
||||
key: 'TRACES_FUNNELS_DETAIL',
|
||||
},
|
||||
{
|
||||
path: ROUTES.CHANNELS_NEW,
|
||||
exact: true,
|
||||
component: ChannelsNew,
|
||||
isPrivate: true,
|
||||
key: 'CHANNELS_NEW',
|
||||
},
|
||||
{
|
||||
path: ROUTES.CHANNELS_EDIT,
|
||||
exact: true,
|
||||
component: ChannelsEdit,
|
||||
isPrivate: true,
|
||||
key: 'CHANNELS_EDIT',
|
||||
},
|
||||
{
|
||||
path: ROUTES.ALL_ERROR,
|
||||
exact: true,
|
||||
isPrivate: true,
|
||||
component: AllErrors,
|
||||
key: 'ALL_ERROR',
|
||||
},
|
||||
{
|
||||
path: ROUTES.ERROR_DETAIL,
|
||||
exact: true,
|
||||
component: ErrorDetails,
|
||||
isPrivate: true,
|
||||
key: 'ERROR_DETAIL',
|
||||
},
|
||||
{
|
||||
path: ROUTES.VERSION,
|
||||
exact: true,
|
||||
component: StatusPage,
|
||||
isPrivate: true,
|
||||
key: 'VERSION',
|
||||
},
|
||||
{
|
||||
path: ROUTES.LOGS,
|
||||
exact: true,
|
||||
component: Logs,
|
||||
key: 'LOGS',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LOGS_PIPELINES,
|
||||
exact: true,
|
||||
component: PipelinePage,
|
||||
key: 'LOGS_PIPELINES',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LOGIN,
|
||||
exact: true,
|
||||
component: Login,
|
||||
isPrivate: false,
|
||||
key: 'LOGIN',
|
||||
},
|
||||
{
|
||||
path: ROUTES.FORGOT_PASSWORD,
|
||||
exact: true,
|
||||
component: ForgotPassword,
|
||||
isPrivate: false,
|
||||
key: 'FORGOT_PASSWORD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.UN_AUTHORIZED,
|
||||
exact: true,
|
||||
component: UnAuthorized,
|
||||
key: 'UN_AUTHORIZED',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.PASSWORD_RESET,
|
||||
exact: true,
|
||||
component: PasswordReset,
|
||||
key: 'PASSWORD_RESET',
|
||||
isPrivate: false,
|
||||
},
|
||||
{
|
||||
path: ROUTES.SOMETHING_WENT_WRONG,
|
||||
exact: true,
|
||||
component: SomethingWentWrong,
|
||||
key: 'SOMETHING_WENT_WRONG',
|
||||
isPrivate: false,
|
||||
},
|
||||
{
|
||||
path: ROUTES.WORKSPACE_LOCKED,
|
||||
exact: true,
|
||||
component: WorkspaceBlocked,
|
||||
isPrivate: true,
|
||||
key: 'WORKSPACE_LOCKED',
|
||||
},
|
||||
{
|
||||
path: ROUTES.WORKSPACE_SUSPENDED,
|
||||
exact: true,
|
||||
component: WorkspaceSuspended,
|
||||
isPrivate: true,
|
||||
key: 'WORKSPACE_SUSPENDED',
|
||||
},
|
||||
{
|
||||
path: ROUTES.WORKSPACE_ACCESS_RESTRICTED,
|
||||
exact: true,
|
||||
component: WorkspaceAccessRestricted,
|
||||
isPrivate: true,
|
||||
key: 'WORKSPACE_ACCESS_RESTRICTED',
|
||||
},
|
||||
{
|
||||
path: ROUTES.INTEGRATIONS_DETAIL,
|
||||
exact: true,
|
||||
component: IntegrationsDetailsPage,
|
||||
isPrivate: true,
|
||||
key: 'INTEGRATIONS_DETAIL',
|
||||
},
|
||||
{
|
||||
path: ROUTES.INTEGRATIONS,
|
||||
exact: true,
|
||||
component: Integrations,
|
||||
isPrivate: true,
|
||||
key: 'INTEGRATIONS',
|
||||
},
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_KAFKA,
|
||||
exact: true,
|
||||
component: MessagingQueuesMainPage,
|
||||
key: 'MESSAGING_QUEUES_KAFKA',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_CELERY_TASK,
|
||||
exact: true,
|
||||
component: MessagingQueuesMainPage,
|
||||
key: 'MESSAGING_QUEUES_CELERY_TASK',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_OVERVIEW,
|
||||
exact: true,
|
||||
component: MessagingQueuesMainPage,
|
||||
key: 'MESSAGING_QUEUES_OVERVIEW',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_KAFKA_DETAIL,
|
||||
exact: true,
|
||||
component: MessagingQueuesMainPage,
|
||||
key: 'MESSAGING_QUEUES_KAFKA_DETAIL',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
|
||||
exact: true,
|
||||
component: InfrastructureMonitoring,
|
||||
key: 'INFRASTRUCTURE_MONITORING_HOSTS',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
|
||||
exact: true,
|
||||
component: InfrastructureMonitoring,
|
||||
key: 'INFRASTRUCTURE_MONITORING_KUBERNETES',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METRICS_EXPLORER,
|
||||
exact: true,
|
||||
component: MetricsExplorer,
|
||||
key: 'METRICS_EXPLORER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
exact: true,
|
||||
component: MetricsExplorer,
|
||||
key: 'METRICS_EXPLORER_EXPLORER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
exact: true,
|
||||
component: MetricsExplorer,
|
||||
key: 'METRICS_EXPLORER_VOLUME_CONTROL',
|
||||
isPrivate: true,
|
||||
@@ -362,48 +408,56 @@ const routes: AppRoutes[] = [
|
||||
|
||||
{
|
||||
path: ROUTES.METER,
|
||||
exact: true,
|
||||
component: MeterExplorerPage,
|
||||
key: 'METER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METER_EXPLORER,
|
||||
exact: true,
|
||||
component: MeterExplorerPage,
|
||||
key: 'METER_EXPLORER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.API_MONITORING,
|
||||
exact: true,
|
||||
component: ApiMonitoring,
|
||||
key: 'API_MONITORING',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: [ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT],
|
||||
exact: true,
|
||||
component: AIAssistantPage,
|
||||
key: 'AI_ASSISTANT',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'AI_OBSERVABILITY_ATTRIBUTE_MAPPING',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'AI_OBSERVABILITY_OVERVIEW',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.AI_OBSERVABILITY_EXPLORER,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'AI_OBSERVABILITY_EXPLORER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.AI_OBSERVABILITY_CONFIGURATION,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'AI_OBSERVABILITY_CONFIGURATION',
|
||||
isPrivate: true,
|
||||
@@ -412,6 +466,7 @@ const routes: AppRoutes[] = [
|
||||
|
||||
export const SUPPORT_ROUTE: AppRoutes = {
|
||||
path: ROUTES.SUPPORT,
|
||||
exact: true,
|
||||
component: SupportPage,
|
||||
key: 'SUPPORT',
|
||||
isPrivate: true,
|
||||
@@ -419,6 +474,7 @@ export const SUPPORT_ROUTE: AppRoutes = {
|
||||
|
||||
export const LIST_LICENSES: AppRoutes = {
|
||||
path: ROUTES.LIST_LICENSES,
|
||||
exact: true,
|
||||
component: LicensePage,
|
||||
isPrivate: true,
|
||||
key: 'LIST_LICENSES',
|
||||
@@ -450,15 +506,9 @@ export const ROUTES_NOT_TO_BE_OVERRIDEN: string[] = [
|
||||
];
|
||||
|
||||
export interface AppRoutes {
|
||||
component: ComponentType;
|
||||
/** An array registers the same component under each pattern. */
|
||||
path: string | string[];
|
||||
/**
|
||||
* The route renders its own child routes, so it matches as a prefix. v6
|
||||
* matches the whole path by default, so the mount appends a `/*` splat for
|
||||
* these and nothing for the rest.
|
||||
*/
|
||||
nested?: boolean;
|
||||
component: RouteProps['component'];
|
||||
path: RouteProps['path'];
|
||||
exact: RouteProps['exact'];
|
||||
isPrivate: boolean;
|
||||
key: keyof typeof ROUTES;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ export interface AppProvidersProps {
|
||||
* A new provider belongs here only if it holds process-wide state that does not
|
||||
* depend on the user, the license or the route. One that fetches on mount would
|
||||
* fire unauthenticated from here; put it in `AppShell` or lower.
|
||||
*
|
||||
* The whole stack is the root route's element, so it renders inside the data
|
||||
* router: `searchParams` (nuqs) reads the router's `useNavigate` /
|
||||
* `useSearchParams`, and the boot spinner renders at a route too.
|
||||
*/
|
||||
function AppProviders({
|
||||
children,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
createBrowserRouter,
|
||||
type DataRouter,
|
||||
RouterProvider,
|
||||
} from 'react-router';
|
||||
import { createRouteTable } from 'AppRoutes/routeTable';
|
||||
import { attachRouter } from 'lib/router/navigation';
|
||||
import { getBasePath } from 'utils/basePath';
|
||||
|
||||
/**
|
||||
* Creates the app's data router and attaches it to `lib/router/navigation`, so
|
||||
* code outside a component navigates through it. `root` is the layout route:
|
||||
* every provider the pages need, `nuqs/adapters/react-router/v7` included,
|
||||
* renders inside it, because a data router owns everything below it.
|
||||
*/
|
||||
export function createAppRouter(root: ReactNode): DataRouter {
|
||||
const router = createBrowserRouter(createRouteTable(root), {
|
||||
basename: getBasePath(),
|
||||
});
|
||||
attachRouter(router);
|
||||
return router;
|
||||
}
|
||||
|
||||
interface AppRouterProviderProps {
|
||||
router: DataRouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitions are off on purpose: under one React keeps the previous screen up
|
||||
* instead of committing the Suspense fallback, so a route whose chunk is not
|
||||
* cached yet renders no loader at all. Pending UI under transitions needs
|
||||
* route `lazy` and `useNavigation()`.
|
||||
*/
|
||||
export function AppRouterProvider({
|
||||
router,
|
||||
}: AppRouterProviderProps): JSX.Element {
|
||||
return <RouterProvider router={router} useTransitions={false} />;
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import { NotificationProvider } from 'hooks/useNotifications';
|
||||
import { CmdKProvider } from 'providers/cmdKProvider';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
|
||||
import { AppLayer } from './types';
|
||||
|
||||
export interface AppShellProps {
|
||||
children: ReactNode;
|
||||
router: AppLayer;
|
||||
/** Mounted beside the routed content: the command palette and its siblings. */
|
||||
overlays?: ReactNode;
|
||||
}
|
||||
@@ -16,26 +19,28 @@ export interface AppShellProps {
|
||||
* fetches settle, above `PrivateRoute`, so it also covers the redirects and the
|
||||
* not-found route, and it survives every navigation.
|
||||
*
|
||||
* A new provider belongs here if it needs the user and has to outlive the page:
|
||||
* a global overlay, a shortcut host, anything one route opens and the next one
|
||||
* keeps. The router itself is higher up, in `AppProviders`.
|
||||
* A new provider belongs here if it needs the router or the user and has to
|
||||
* outlive the page: a global overlay, a shortcut host, anything one route opens
|
||||
* and the next one keeps.
|
||||
*
|
||||
* `ConfigProvider` reads `useThemeConfig`, which needs `ThemeProvider` above it,
|
||||
* so the antd theme is settled here instead of by the caller.
|
||||
*/
|
||||
function AppShell({ children, overlays }: AppShellProps): JSX.Element {
|
||||
function AppShell({ children, router, overlays }: AppShellProps): JSX.Element {
|
||||
const themeConfig = useThemeConfig();
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={themeConfig}>
|
||||
<CmdKProvider>
|
||||
<NotificationProvider>
|
||||
<ErrorModalProvider>
|
||||
{overlays}
|
||||
{children}
|
||||
</ErrorModalProvider>
|
||||
</NotificationProvider>
|
||||
</CmdKProvider>
|
||||
{router(
|
||||
<CmdKProvider>
|
||||
<NotificationProvider>
|
||||
<ErrorModalProvider>
|
||||
{overlays}
|
||||
{children}
|
||||
</ErrorModalProvider>
|
||||
</NotificationProvider>
|
||||
</CmdKProvider>,
|
||||
)}
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Dot } from '@signozhq/icons';
|
||||
import { Dot, Globe, Inbox, SquarePen } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { Globe, Inbox, SquarePen } from '@signozhq/icons';
|
||||
|
||||
import AnnouncementsModal from './AnnouncementsModal';
|
||||
import FeedbackModal from './FeedbackModal';
|
||||
|
||||
@@ -121,7 +121,6 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `
|
||||
</div>
|
||||
<a
|
||||
class="c3"
|
||||
data-discover="true"
|
||||
href="/home"
|
||||
tabindex="0"
|
||||
>
|
||||
|
||||
@@ -47,29 +47,23 @@ export function QuerySearchV2Provider({
|
||||
store.getState().setInitialExpression(initialExpression);
|
||||
}, [initialExpression, store]);
|
||||
|
||||
// The URL owns the expression, in both directions. A provider can outlive the
|
||||
// navigation away from the page it belongs to (a route-driven tab keeps the
|
||||
// leaving pane mounted until its animation ends), so treating a param that
|
||||
// disappeared as something to restore republishes it onto the URL of the page
|
||||
// being entered.
|
||||
const isInitialized = useRef(false);
|
||||
useEffect(() => {
|
||||
const userExpression = getUserExpressionFromCombined(
|
||||
initialExpression,
|
||||
urlExpression,
|
||||
);
|
||||
if (userExpression !== store.getState().committedExpression) {
|
||||
store.getState().initializeFromUrl(userExpression);
|
||||
if (!isInitialized.current && urlExpression) {
|
||||
const cleanedExpression = getUserExpressionFromCombined(
|
||||
initialExpression,
|
||||
urlExpression,
|
||||
);
|
||||
store.getState().initializeFromUrl(cleanedExpression);
|
||||
isInitialized.current = true;
|
||||
}
|
||||
}, [urlExpression, initialExpression, store]);
|
||||
|
||||
const publishedExpression = useRef(committedExpression);
|
||||
useEffect(() => {
|
||||
if (committedExpression === publishedExpression.current) {
|
||||
return;
|
||||
if (isInitialized.current || !urlExpression) {
|
||||
setUrlExpression(committedExpression || null);
|
||||
}
|
||||
publishedExpression.current = committedExpression;
|
||||
setUrlExpression(committedExpression || null);
|
||||
}, [committedExpression, setUrlExpression]);
|
||||
}, [committedExpression, setUrlExpression, urlExpression]);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
|
||||
@@ -110,22 +110,6 @@ describe('QuerySearchExpressionProvider', () => {
|
||||
expect(result.current.expression).toBe('status = 500');
|
||||
});
|
||||
|
||||
it('should follow the URL when the param is dropped', () => {
|
||||
mockUrlValue = 'status = 500';
|
||||
|
||||
const { result, rerender } = renderHook(() => useTestHooks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
expect(result.current.expression).toBe('status = 500');
|
||||
|
||||
mockSetQueryState.mockClear();
|
||||
mockUrlValue = null;
|
||||
rerender();
|
||||
|
||||
expect(result.current.expression).toBe('');
|
||||
expect(mockSetQueryState).not.toHaveBeenCalledWith('status = 500');
|
||||
});
|
||||
|
||||
it('should throw error when used outside provider', () => {
|
||||
expect(() => {
|
||||
renderHook(() => useExpression());
|
||||
|
||||
@@ -18,11 +18,9 @@ import {
|
||||
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 {
|
||||
buildExplorerNavigationUrl,
|
||||
findSavedViewInLists,
|
||||
openSavedView,
|
||||
openSavedViewByKey,
|
||||
@@ -232,23 +230,6 @@ describe('findSavedViewInLists', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExplorerNavigationUrl', () => {
|
||||
it('encodes composite query and view selectors', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
{ queryType: 'builder' } as never,
|
||||
{
|
||||
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
|
||||
[QueryParams.viewKey]: 'view-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain(`${QueryParams.compositeQuery}=`);
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openSavedView', () => {
|
||||
beforeEach(() => {
|
||||
mockedNavigate.mockClear();
|
||||
|
||||
@@ -12,7 +12,7 @@ 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 { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
|
||||
|
||||
type SavedViewSourceHint = SavedViewSourcePage;
|
||||
|
||||
@@ -72,26 +72,6 @@ export function explorerRouteForSourcePage(
|
||||
return SOURCEPAGE_VS_ROUTES[sourcePage] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an explorer URL the same way `redirectWithQueryBuilderData` does —
|
||||
* without inheriting stale query params from the current page's `urlQuery`.
|
||||
*/
|
||||
export function buildExplorerNavigationUrl(
|
||||
route: string,
|
||||
query: Query,
|
||||
searchParams: Record<string, unknown>,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
params.set(key, JSON.stringify(value));
|
||||
});
|
||||
return `${route}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function openSavedView(view: SavedviewtypesSavedViewDTO): void {
|
||||
const route = view.source ? explorerRouteForSourcePage(view.source) : null;
|
||||
if (!route) {
|
||||
|
||||
@@ -38,8 +38,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { isIngestionActive } from 'utils/app';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { isIngestionActive, isModifierKeyPressed } from 'utils/app';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import crackerUrl from '@/assets/Icons/cracker.svg';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
@@ -18,10 +18,10 @@ import {
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import K8sBaseDetails, {
|
||||
K8sDetailsFilters,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
|
||||
import { DefaultQueryRedirect } from 'container/InfraMonitoringK8sV2/Base/DefaultQueryRedirect';
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
|
||||
import StatusFilter from 'container/InfraMonitoringHostsV2/StatusFilter';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
InfraMonitoringEntity,
|
||||
METRIC_NAMESPACE_BY_ENTITY,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
getHostMetricsQueryPayload,
|
||||
@@ -58,6 +61,10 @@ import {
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const { redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isInitialized = useRef(false);
|
||||
|
||||
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
|
||||
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
|
||||
const { startUnixMilli, endUnixMilli } = useMemo(() => {
|
||||
@@ -69,6 +76,28 @@ function Hosts(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTime, getMinMaxTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current) {
|
||||
return;
|
||||
}
|
||||
isInitialized.current = true;
|
||||
|
||||
if (!compositeQuery) {
|
||||
const defaultQuery = initialQueriesMap[DataSource.METRICS];
|
||||
redirectWithQueryBuilderData({
|
||||
...defaultQuery,
|
||||
builder: {
|
||||
...defaultQuery.builder,
|
||||
queryData: defaultQuery.builder.queryData.map((query) => ({
|
||||
...query,
|
||||
filter: { expression: '' },
|
||||
filters: { items: [], op: 'AND' as const },
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [compositeQuery, redirectWithQueryBuilderData]);
|
||||
|
||||
const handleFilterVisibilityChange = (): void => {
|
||||
setShowFilters(!showFilters);
|
||||
};
|
||||
@@ -197,7 +226,6 @@ function Hosts(): JSX.Element {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DefaultQueryRedirect />
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
/**
|
||||
* Puts the default query in the URL when the page mounts without one.
|
||||
*
|
||||
* Render it before the list: React runs sibling effects in tree order, and this
|
||||
* navigation drops the nuqs writes still queued, such as the page size the list
|
||||
* writes on mount.
|
||||
*/
|
||||
export function DefaultQueryRedirect(): null {
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const { redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isInitialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current) {
|
||||
return;
|
||||
}
|
||||
isInitialized.current = true;
|
||||
|
||||
if (!compositeQuery) {
|
||||
const defaultQuery = initialQueriesMap[DataSource.METRICS];
|
||||
redirectWithQueryBuilderData({
|
||||
...defaultQuery,
|
||||
builder: {
|
||||
...defaultQuery.builder,
|
||||
queryData: defaultQuery.builder.queryData.map((query) => ({
|
||||
...query,
|
||||
filter: { expression: '' },
|
||||
filters: { items: [], op: 'AND' as const },
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [compositeQuery, redirectWithQueryBuilderData]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -62,6 +62,7 @@ function K8sHeader({
|
||||
const currentExpression =
|
||||
currentQuery.builder.queryData[0]?.filter?.expression || '';
|
||||
const finalExpression = newExpression ?? currentExpression;
|
||||
void setCurrentPage(1);
|
||||
|
||||
const updatedQuery = {
|
||||
...currentQuery,
|
||||
@@ -88,8 +89,6 @@ function K8sHeader({
|
||||
);
|
||||
|
||||
safeNavigate(`${location.pathname}?${newUrlQuery.toString()}`);
|
||||
// After the navigation: a navigation drops the nuqs writes still queued.
|
||||
void setCurrentPage(1);
|
||||
void invalidateQueries();
|
||||
|
||||
if (finalExpression?.trim()) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
QuickFilterCheckboxUseFieldApis,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { CategoryIcon } from './Base/categoryIcons';
|
||||
import { DefaultQueryRedirect } from './Base/DefaultQueryRedirect';
|
||||
import { K8sDynamicList } from './Base/K8sDynamicList';
|
||||
import EntityStatusFilter from './Base/components/EntityStatusFilter/EntityStatusFilter';
|
||||
import {
|
||||
@@ -66,7 +68,9 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [, setNodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
|
||||
const [, setContainerStatusFilter] = useInfraMonitoringContainerStatusFilter();
|
||||
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isInitialized = useRef(false);
|
||||
|
||||
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
|
||||
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
|
||||
@@ -93,6 +97,28 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
[getUseFieldApis, selectedCategory],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current) {
|
||||
return;
|
||||
}
|
||||
isInitialized.current = true;
|
||||
|
||||
if (!compositeQuery) {
|
||||
const defaultQuery = initialQueriesMap[DataSource.METRICS];
|
||||
redirectWithQueryBuilderData({
|
||||
...defaultQuery,
|
||||
builder: {
|
||||
...defaultQuery.builder,
|
||||
queryData: defaultQuery.builder.queryData.map((query) => ({
|
||||
...query,
|
||||
filter: { expression: '' },
|
||||
filters: { items: [], op: 'AND' as const },
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [compositeQuery, redirectWithQueryBuilderData]);
|
||||
|
||||
useEffect(() => {
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
@@ -195,7 +221,14 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
|
||||
const handleCategorySelect = (key: string): void => {
|
||||
if (key !== selectedCategory) {
|
||||
// Navigate first: a navigation drops the nuqs writes still queued.
|
||||
void setSelectedCategory(key as string);
|
||||
void setOrderBy(null);
|
||||
void setGroupBy(null);
|
||||
void setCurrentPage(null);
|
||||
setSelectedItemParams(null);
|
||||
void setPodStatusFilter(null);
|
||||
void setNodeReadinessFilter(null);
|
||||
void setContainerStatusFilter(null);
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
@@ -209,14 +242,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
],
|
||||
},
|
||||
});
|
||||
void setSelectedCategory(key as string);
|
||||
void setOrderBy(null);
|
||||
void setGroupBy(null);
|
||||
void setCurrentPage(null);
|
||||
setSelectedItemParams(null);
|
||||
void setPodStatusFilter(null);
|
||||
void setNodeReadinessFilter(null);
|
||||
void setContainerStatusFilter(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -245,7 +270,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<DefaultQueryRedirect />
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import { usePreferredPageSizeStore } from 'components/TanStackTableView/usePreferredPageSize.store';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import K8sHeader from '../Base/K8sHeader';
|
||||
import EntityDateTimeSelector from '../EntityDetailsUtils/EntityDateTimeSelector/EntityDateTimeSelector';
|
||||
import {
|
||||
InfraMonitoringEntity,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
K8sCategories,
|
||||
} from '../constants';
|
||||
import {
|
||||
useInfraMonitoringCategory,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from '../hooks';
|
||||
import InfraMonitoringK8s from '../InfraMonitoringK8s';
|
||||
|
||||
// The real hook: a navigation made while a nuqs write is queued drops that write,
|
||||
// which is what these tests guard. The extension slips past jest.config.ts's mock.
|
||||
jest.mock('hooks/useSafeNavigate', () =>
|
||||
jest.requireActual('../../../hooks/useSafeNavigate.ts'),
|
||||
);
|
||||
|
||||
jest.mock('components/QuickFilters/QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="quick-filters" />,
|
||||
}));
|
||||
|
||||
// Keeps the list's own URL write on mount: the page size.
|
||||
jest.mock('../Base/K8sDynamicList', () => {
|
||||
const { useTableParams } = jest.requireActual('components/TanStackTableView');
|
||||
function K8sDynamicList(): JSX.Element {
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'pageSize' },
|
||||
{ page: 1, limit: 10, storageKey: 'k8s-containers' },
|
||||
);
|
||||
return <div data-testid="k8s-dynamic-list" />;
|
||||
}
|
||||
return { __esModule: true, K8sDynamicList, default: K8sDynamicList };
|
||||
});
|
||||
|
||||
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onRun }: { onRun: (expression: string) => void }): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Run filter"
|
||||
data-testid="run-filter"
|
||||
onClick={(): void => onRun("k8s.pod.name = 'pod-1'")}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lib/navigation', () => ({
|
||||
getNavigationReferrer: (): string => 'direct',
|
||||
}));
|
||||
|
||||
const route = ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES;
|
||||
|
||||
function DrawerHarness(): JSX.Element {
|
||||
const [, setCategory] = useInfraMonitoringCategory();
|
||||
const [selectedItemParams, setSelectedItemParams] =
|
||||
useInfraMonitoringSelectedItemParams();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Select category"
|
||||
data-testid="select-category"
|
||||
onClick={(): void => {
|
||||
void setCategory(K8sCategories.PODS);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open row"
|
||||
data-testid="open-row"
|
||||
onClick={(): void =>
|
||||
setSelectedItemParams({
|
||||
selectedItem: 'pod-1',
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{selectedItemParams.selectedItem && (
|
||||
<div data-testid="drawer">
|
||||
<EntityDateTimeSelector
|
||||
eventEntity="k8s"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
view="overview"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getUrlParam(key: string): string | null {
|
||||
return new URLSearchParams(window.location.search).get(key);
|
||||
}
|
||||
|
||||
describe('K8s URL writes queued by nuqs', () => {
|
||||
afterEach(() => {
|
||||
usePreferredPageSizeStore
|
||||
.getState()
|
||||
.setPreferredPageSize('k8s-containers', null);
|
||||
});
|
||||
|
||||
it('opens the drawer on the first click after an earlier shallow write', async () => {
|
||||
render(<DrawerHarness />, undefined, {
|
||||
initialRoute: `${route}?relativeTime=15m`,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-category'));
|
||||
await waitFor(() =>
|
||||
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY)).toBe(
|
||||
K8sCategories.PODS,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('open-row'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM)).toBe(
|
||||
'pod-1',
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId('drawer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches category on the first click while a filter is applied', async () => {
|
||||
const query = initialQueriesMap[DataSource.METRICS];
|
||||
const filteredQuery = {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...query.builder.queryData[0],
|
||||
filter: { expression: "k8s.pod.name = 'pod-1'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const params = new URLSearchParams({
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY]: K8sCategories.PODS,
|
||||
compositeQuery: encodeURIComponent(JSON.stringify(filteredQuery)),
|
||||
});
|
||||
|
||||
render(<InfraMonitoringK8s />, undefined, {
|
||||
initialRoute: `${route}?${params.toString()}`,
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByTestId(`category-${K8sCategories.CONTAINERS}`),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const compositeQuery = JSON.parse(
|
||||
decodeURIComponent(getUrlParam('compositeQuery') ?? ''),
|
||||
);
|
||||
expect(compositeQuery.builder.queryData[0].filter.expression).toBe('');
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY)).toBe(
|
||||
K8sCategories.CONTAINERS,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('goes back to the first page when a filter runs', async () => {
|
||||
render(
|
||||
<K8sHeader
|
||||
entity={InfraMonitoringEntity.PODS}
|
||||
showAutoRefresh={false}
|
||||
cancelQuery={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: `${route}?relativeTime=30m&page=3` },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('run-filter'));
|
||||
|
||||
await waitFor(() => {
|
||||
const compositeQuery = JSON.parse(
|
||||
decodeURIComponent(getUrlParam('compositeQuery') ?? ''),
|
||||
);
|
||||
expect(compositeQuery.builder.queryData[0].filter.expression).toBe(
|
||||
"k8s.pod.name = 'pod-1'",
|
||||
);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE)).toBe('1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the page size the list writes while the default query loads', async () => {
|
||||
usePreferredPageSizeStore
|
||||
.getState()
|
||||
.setPreferredPageSize('k8s-containers', 25);
|
||||
|
||||
render(<InfraMonitoringK8s />, undefined, { initialRoute: route });
|
||||
|
||||
await waitFor(() => expect(getUrlParam('compositeQuery')).not.toBeNull());
|
||||
await waitFor(() =>
|
||||
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE_SIZE)).toBe('25'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,11 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.noFilters {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { CalendarClock } from '@signozhq/icons';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { SavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import { SAVED_VIEW_UPDATED_AT_FORMAT } from './constants';
|
||||
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
|
||||
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
|
||||
import SavedViewHoverCardDetails from './SavedViewHoverCardDetails';
|
||||
import { SavedViewHoverCardProps } from './types';
|
||||
|
||||
import styles from './SavedViewHoverCard.module.scss';
|
||||
@@ -18,23 +13,11 @@ function SavedViewHoverCard({
|
||||
onCardLeave,
|
||||
onClose,
|
||||
}: SavedViewHoverCardProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const view = target?.view;
|
||||
const querySummaries = useMemo(
|
||||
() => (view ? getSavedViewQuerySummaries(view) : []),
|
||||
[view],
|
||||
);
|
||||
const hasQueryNames = querySummaries.length > 1;
|
||||
const copyValue = querySummaries
|
||||
.filter(({ expression }) => expression.length > 0)
|
||||
.map(({ queryName, expression }) =>
|
||||
hasQueryNames ? `${queryName}: ${expression}` : expression,
|
||||
)
|
||||
.join('\n');
|
||||
const { allowed: canReadAll } = useAuthZ([SavedViewReadPermission]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={!!view}
|
||||
open={!!target}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
@@ -55,71 +38,17 @@ function SavedViewHoverCard({
|
||||
onOpenAutoFocus={(event): void => event.preventDefault()}
|
||||
onCloseAutoFocus={(event): void => event.preventDefault()}
|
||||
>
|
||||
{view && (
|
||||
{target && (
|
||||
<div
|
||||
className={styles.content}
|
||||
onMouseEnter={onCardEnter}
|
||||
onMouseLeave={onCardLeave}
|
||||
data-testid="saved-view-hover-card"
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.name}>
|
||||
{view.spec.displayName}
|
||||
</Typography.Text>
|
||||
{copyValue && (
|
||||
<CopyButton
|
||||
value={copyValue}
|
||||
ariaLabel="Copy query"
|
||||
testId="saved-view-hover-card-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.query} data-testid="saved-view-hover-card-query">
|
||||
{querySummaries.length === 0 ? (
|
||||
<Typography.Text className={styles.noFilters}>
|
||||
No filters
|
||||
</Typography.Text>
|
||||
) : (
|
||||
querySummaries.map(({ queryName, expression, metric }) => (
|
||||
<div key={queryName} className={styles.queryItem}>
|
||||
{hasQueryNames && (
|
||||
<Typography.Text className={styles.queryName}>
|
||||
{queryName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{metric && (
|
||||
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
|
||||
)}
|
||||
{expression && (
|
||||
<Typography.Text className={styles.expression}>
|
||||
<SavedViewHighlightedExpression expression={expression} />
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.updated}>
|
||||
<Typography.Text className={styles.sectionTitle}>
|
||||
Last updated
|
||||
</Typography.Text>
|
||||
{view.updatedBy && (
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{view.updatedBy}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{view.updatedAt && (
|
||||
<div className={styles.meta}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
view.updatedAt,
|
||||
SAVED_VIEW_UPDATED_AT_FORMAT,
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SavedViewHoverCardDetails
|
||||
viewId={target.viewId}
|
||||
canReadAll={canReadAll}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
|
||||
121
frontend/src/container/SavedViews/SavedViewHoverCardDetails.tsx
Normal file
121
frontend/src/container/SavedViews/SavedViewHoverCardDetails.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useMemo } from 'react';
|
||||
import { CalendarClock } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_LOAD_FAILED_NAME,
|
||||
SAVED_VIEW_UPDATED_AT_FORMAT,
|
||||
} from './constants';
|
||||
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
|
||||
import SavedViewHoverCardSkeleton from './SavedViewHoverCardSkeleton';
|
||||
import { SavedViewHoverCardDetailsProps } from './types';
|
||||
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
|
||||
|
||||
import styles from './SavedViewHoverCard.module.scss';
|
||||
|
||||
function SavedViewHoverCardDetails({
|
||||
viewId,
|
||||
}: SavedViewHoverCardDetailsProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const { data, isLoading, isError } = useGetSavedView({ id: viewId });
|
||||
const view = data?.data;
|
||||
const querySummaries = useMemo(
|
||||
() => (view ? getSavedViewQuerySummaries(view) : []),
|
||||
[view],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <SavedViewHoverCardSkeleton />;
|
||||
}
|
||||
|
||||
if (isError || !view) {
|
||||
return (
|
||||
<Typography.Text
|
||||
className={styles.error}
|
||||
data-testid="saved-view-hover-card-error"
|
||||
>
|
||||
{SAVED_VIEW_LOAD_FAILED_NAME}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
const hasQueryNames = querySummaries.length > 1;
|
||||
const copyValue = querySummaries
|
||||
.filter(({ expression }) => expression.length > 0)
|
||||
.map(({ queryName, expression }) =>
|
||||
hasQueryNames ? `${queryName}: ${expression}` : expression,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.name}>
|
||||
{view.spec.displayName}
|
||||
</Typography.Text>
|
||||
{copyValue && (
|
||||
<CopyButton
|
||||
value={copyValue}
|
||||
ariaLabel="Copy query"
|
||||
testId="saved-view-hover-card-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.query} data-testid="saved-view-hover-card-query">
|
||||
{querySummaries.length === 0 ? (
|
||||
<Typography.Text className={styles.noFilters}>No filters</Typography.Text>
|
||||
) : (
|
||||
querySummaries.map(({ queryName, expression, metric }) => (
|
||||
<div key={queryName} className={styles.queryItem}>
|
||||
{hasQueryNames && (
|
||||
<Typography.Text className={styles.queryName}>
|
||||
{queryName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{metric && (
|
||||
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
|
||||
)}
|
||||
{expression && (
|
||||
<Typography.Text className={styles.expression}>
|
||||
<SavedViewHighlightedExpression expression={expression} />
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.updated}>
|
||||
<Typography.Text className={styles.sectionTitle}>
|
||||
Last updated
|
||||
</Typography.Text>
|
||||
{view.updatedBy && (
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{view.updatedBy}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{view.updatedAt && (
|
||||
<div className={styles.meta}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
view.updatedAt,
|
||||
SAVED_VIEW_UPDATED_AT_FORMAT,
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withAuthZContent(SavedViewHoverCardDetails, {
|
||||
checks: ({ viewId, canReadAll }) =>
|
||||
canReadAll ? [] : [buildSavedViewReadPermission(viewId)],
|
||||
fallbackOnLoading: <SavedViewHoverCardSkeleton />,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
|
||||
function SavedViewHoverCardSkeleton(): JSX.Element {
|
||||
return (
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{ rows: 3, width: ['100%', '80%', '60%'] }}
|
||||
testId="saved-view-hover-card-loading"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewHoverCardSkeleton;
|
||||
@@ -54,9 +54,8 @@
|
||||
&.isActive {
|
||||
background-color: var(--l3-background);
|
||||
|
||||
.rowName {
|
||||
--button-variant-ghost-color: var(--l1-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
.rowLink {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,18 +75,32 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
--button-height: 100%;
|
||||
--button-padding: 0 var(--spacing-6);
|
||||
--button-justify-content: flex-start;
|
||||
--button-font-size: var(--periscope-font-size-base);
|
||||
--button-variant-ghost-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
|
||||
.rowLink {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0 var(--spacing-6);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--l2-foreground);
|
||||
text-decoration: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--button-focus-ring);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--l2-foreground-hover);
|
||||
}
|
||||
}
|
||||
|
||||
// nowrap fills the row before the ellipsis; line clamping alone stops at the last whole word.
|
||||
.rowLinkLabel {
|
||||
min-width: 0;
|
||||
letter-spacing: -0.005em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@@ -65,11 +65,11 @@ function SavedViewsList({
|
||||
|
||||
const rowHover = useMemo<SavedViewsListRowHoverProps>(
|
||||
() => ({
|
||||
onRowEnter: (view, row): void => {
|
||||
onRowEnter: (viewId, row): void => {
|
||||
const listTop = listRef.current?.getBoundingClientRect().top ?? 0;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
onHoverCardRowEnter({
|
||||
view,
|
||||
viewId,
|
||||
anchorTop: rowRect.top - listTop,
|
||||
anchorHeight: rowRect.height,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import cx from 'classnames';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import SavedViewsRowButton from './SavedViewsRowButton';
|
||||
import SavedViewsRowLink from './SavedViewsRowLink';
|
||||
import SavedViewsRowMenu from './SavedViewsRowMenu';
|
||||
import { SavedViewsListRowProps } from './types';
|
||||
|
||||
@@ -18,42 +16,17 @@ function SavedViewsListRow({
|
||||
onAction,
|
||||
hover,
|
||||
}: SavedViewsListRowProps): JSX.Element {
|
||||
const name = view.spec.displayName;
|
||||
const readPermission = buildSavedViewReadPermission(view.id);
|
||||
// Same cached check as the button; gates the hover card.
|
||||
const { isLoading, deniedPermissions } = useAuthZ([readPermission]);
|
||||
const canRead = !isLoading && deniedPermissions.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, { [styles.isActive]: isActive })}
|
||||
data-testid="saved-views-row"
|
||||
data-view-id={view.id}
|
||||
onMouseEnter={(event): void => {
|
||||
if (canRead) {
|
||||
hover.onRowEnter(view, event.currentTarget);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(event): void =>
|
||||
hover.onRowEnter(view.id, event.currentTarget)
|
||||
}
|
||||
onMouseLeave={hover.onRowLeave}
|
||||
>
|
||||
<div className={styles.rowName}>
|
||||
<AuthZButton
|
||||
checks={[readPermission]}
|
||||
size="md"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
width="100%"
|
||||
textOverflow="ellipsis"
|
||||
onClick={(): void => {
|
||||
if (!isActive) {
|
||||
onSelect(view);
|
||||
}
|
||||
}}
|
||||
testId="saved-views-row-name"
|
||||
>
|
||||
{name}
|
||||
</AuthZButton>
|
||||
</div>
|
||||
<SavedViewsRowLink view={view} isActive={isActive} onSelect={onSelect} />
|
||||
{isActive && (
|
||||
<SavedViewsRowButton
|
||||
icon={<Undo2 size={14} />}
|
||||
|
||||
@@ -5,7 +5,11 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import { SavedViewListPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import {
|
||||
SavedViewListPermission,
|
||||
SavedViewReadPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SavedViewsList from './SavedViewsList';
|
||||
@@ -21,6 +25,8 @@ function SavedViewsPanel({
|
||||
onClose?: () => void;
|
||||
}): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
// Batched with the list check, so the hover card reads it from cache.
|
||||
useAuthZ([SavedViewReadPermission]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
57
frontend/src/container/SavedViews/SavedViewsRowLink.tsx
Normal file
57
frontend/src/container/SavedViews/SavedViewsRowLink.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { MouseEvent, useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
|
||||
import { SavedViewsRowLinkProps } from './types';
|
||||
import { getSavedViewPath } from './utils/getSavedViewPath';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsRowLink({
|
||||
view,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: SavedViewsRowLinkProps): JSX.Element {
|
||||
const { pathname } = useAppLocation();
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const path = useMemo(
|
||||
() => getSavedViewPath(pathname, view, timeSearch),
|
||||
[pathname, view, timeSearch],
|
||||
);
|
||||
|
||||
// Opening in a new tab or window is left to the browser.
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {
|
||||
if (isModifierKeyPressed(event) || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!isActive) {
|
||||
onSelect(view);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
to={path}
|
||||
className={styles.rowLink}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={handleClick}
|
||||
data-testid="saved-views-row-name"
|
||||
>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="base"
|
||||
weight="normal"
|
||||
truncate={1}
|
||||
className={styles.rowLinkLabel}
|
||||
>
|
||||
{view.spec.displayName}
|
||||
</Typography.Text>
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsRowLink;
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS,
|
||||
} from '../constants';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
@@ -139,9 +141,11 @@ function advance(ms: number): void {
|
||||
});
|
||||
}
|
||||
|
||||
function openCard(name: string): void {
|
||||
// Resolves once the card has fetched its view.
|
||||
async function openCard(name: string): Promise<HTMLElement> {
|
||||
fireEvent.mouseEnter(row(name));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
return screen.findByTestId('saved-view-hover-card-query');
|
||||
}
|
||||
|
||||
describe('SavedViewHoverCard', () => {
|
||||
@@ -168,7 +172,8 @@ describe('SavedViewHoverCard', () => {
|
||||
expect(card()).toBeNull();
|
||||
|
||||
advance(1);
|
||||
expect(card()).toHaveTextContent('Errors');
|
||||
expect(card()).toBeInTheDocument();
|
||||
await waitFor(() => expect(card()).toHaveTextContent('Errors'));
|
||||
});
|
||||
|
||||
it('does not open when the pointer leaves before the delay', async () => {
|
||||
@@ -184,7 +189,7 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('stays open while the pointer moves into the card', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
fireEvent.mouseEnter(card() as HTMLElement);
|
||||
@@ -198,7 +203,7 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('closes on leaving the row and reopens for the next row after the delay', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
fireEvent.mouseEnter(row('Slow spans'));
|
||||
@@ -206,12 +211,12 @@ describe('SavedViewHoverCard', () => {
|
||||
expect(card()).toBeNull();
|
||||
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
expect(card()).toHaveTextContent('Slow spans');
|
||||
await waitFor(() => expect(card()).toHaveTextContent('Slow spans'));
|
||||
});
|
||||
|
||||
it('closes when the row menu is pressed', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
|
||||
|
||||
@@ -222,19 +227,17 @@ describe('SavedViewHoverCard', () => {
|
||||
describe('content', () => {
|
||||
it('shows the filter, the updater and the update time', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
const query = await openCard('Errors');
|
||||
|
||||
const content = card() as HTMLElement;
|
||||
expect(
|
||||
within(content).getByTestId('saved-view-hover-card-query'),
|
||||
).toHaveTextContent('has_error = true');
|
||||
expect(query).toHaveTextContent('has_error = true');
|
||||
expect(content).toHaveTextContent('editor@signoz.io');
|
||||
expect(content).toHaveTextContent('Sep 28, 2026');
|
||||
});
|
||||
|
||||
it('copies the filter expression', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
|
||||
|
||||
@@ -243,9 +246,8 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('names each query when there are several, and copies them per line', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors and slow');
|
||||
const query = await openCard('Errors and slow');
|
||||
|
||||
const query = screen.getByTestId('saved-view-hover-card-query');
|
||||
expect(query).toHaveTextContent('A');
|
||||
expect(query).toHaveTextContent('B');
|
||||
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
|
||||
@@ -257,21 +259,74 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('says there are no filters, with nothing to copy', async () => {
|
||||
await renderPanel();
|
||||
openCard('Everything');
|
||||
const query = await openCard('Everything');
|
||||
|
||||
expect(screen.getByTestId('saved-view-hover-card-query')).toHaveTextContent(
|
||||
'No filters',
|
||||
);
|
||||
expect(query).toHaveTextContent('No filters');
|
||||
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the metric of a metrics query', async () => {
|
||||
await renderPanel(SavedviewtypesSourceDTO.metrics, DataSource.METRICS);
|
||||
openCard('Request rate');
|
||||
const query = await openCard('Request rate');
|
||||
|
||||
const query = screen.getByTestId('saved-view-hover-card-query');
|
||||
expect(query).toHaveTextContent('app.requests · rate · sum');
|
||||
expect(query).not.toHaveTextContent('No filters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetching', () => {
|
||||
it('shows the query from the view fetch, not from the list', async () => {
|
||||
server.use(
|
||||
rest.get(API, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: [{ ...errors, spec: { ...errors.spec, queries: [] } }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
const query = await openCard('Errors');
|
||||
|
||||
expect(query).toHaveTextContent('has_error = true');
|
||||
});
|
||||
|
||||
it('fetches the view again on every open', async () => {
|
||||
let fetches = 0;
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) => {
|
||||
fetches += 1;
|
||||
return res(ctx.json({ status: 'success', data: errors }));
|
||||
}),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
await openCard('Errors');
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS);
|
||||
expect(card()).toBeNull();
|
||||
await openCard('Errors');
|
||||
|
||||
await waitFor(() => expect(fetches).toBe(2));
|
||||
});
|
||||
|
||||
it('says the view failed to load when its fetch fails', async () => {
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('saved-view-hover-card-error'),
|
||||
).resolves.toHaveTextContent('View failed to load');
|
||||
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AuthtypesTransactionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
@@ -9,34 +13,42 @@ import {
|
||||
buildSavedViewUpdatePermission,
|
||||
SavedViewCreatePermission,
|
||||
SavedViewListPermission,
|
||||
SavedViewReadPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS } from '../constants';
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
const HOVER_CARD_TIMEOUT = { timeout: SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS * 3 };
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
|
||||
|
||||
function renderPanel(url = explorerUrl(PATH, {})): void {
|
||||
renderWithExplorerProviders(
|
||||
function renderPanel(
|
||||
url = explorerUrl(PATH, {}),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
@@ -68,6 +80,37 @@ async function openRowMenu(name: string): Promise<void> {
|
||||
fireEvent.click(within(rowNamed(name)).getByTestId('saved-views-row-menu'));
|
||||
}
|
||||
|
||||
// Records every check, answered by `isGranted`.
|
||||
function recordChecks(
|
||||
isGranted: (transaction: AuthtypesTransactionDTO) => boolean = (): boolean =>
|
||||
true,
|
||||
): AuthtypesTransactionDTO[] {
|
||||
const checks: AuthtypesTransactionDTO[] = [];
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
checks.push(...payload);
|
||||
return res(ctx.json(authzMockResponse(payload, payload.map(isGranted))));
|
||||
}),
|
||||
);
|
||||
return checks;
|
||||
}
|
||||
|
||||
const readSelectors = (checks: AuthtypesTransactionDTO[]): string[] =>
|
||||
checks
|
||||
.filter((transaction) => transaction.relation === 'read')
|
||||
.map((transaction) => transaction.object.selector);
|
||||
|
||||
async function hoverRow(name: string): Promise<HTMLElement> {
|
||||
await screen.findAllByTestId('saved-views-row');
|
||||
fireEvent.mouseEnter(rowNamed(name));
|
||||
return screen.findByTestId(
|
||||
'saved-view-hover-card',
|
||||
undefined,
|
||||
HOVER_CARD_TIMEOUT,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolves once the row's check is back, so its state is final.
|
||||
async function settledRow(testId: string): Promise<HTMLElement> {
|
||||
await waitFor(
|
||||
@@ -98,21 +141,51 @@ describe('SavedViews - AuthZ', () => {
|
||||
expect(screen.queryByTestId('saved-views-search')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables only the views it cannot read', async () => {
|
||||
it('runs no read check per row', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
const checks = recordChecks();
|
||||
renderPanel();
|
||||
|
||||
// The menu checks go out after any check the rows made on mount.
|
||||
await openRowMenu('Errors');
|
||||
await settledRow('saved-views-row-menu-item-delete');
|
||||
expect(checks.map((transaction) => transaction.relation)).toContain('delete');
|
||||
expect(readSelectors(checks)).toStrictEqual(['*']);
|
||||
});
|
||||
|
||||
it('opens a view it cannot read, then the header says no access', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
const { history } = renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
onToggleViews={jest.fn()}
|
||||
/>
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</>,
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
await screen.findAllByTestId('saved-views-row');
|
||||
const nameOf = (view: string): HTMLElement =>
|
||||
within(rowNamed(view)).getByTestId('saved-views-row-name');
|
||||
const name = within(rowNamed('Errors')).getByTestId('saved-views-row-name');
|
||||
expectUsable(name);
|
||||
await userEvent.click(name);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Errors')).toHaveAttribute('aria-disabled', 'true'),
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(errors.id),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-busy'),
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'No view access',
|
||||
),
|
||||
);
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('keeps the row menu on a view it cannot read', async () => {
|
||||
@@ -133,6 +206,58 @@ describe('SavedViews - AuthZ', () => {
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('shows the denied callout in the hover card of a view it cannot read, without fetching it', async () => {
|
||||
let fetches = 0;
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(
|
||||
setupAuthzDeny(
|
||||
SavedViewReadPermission,
|
||||
buildSavedViewReadPermission(errors.id),
|
||||
),
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) => {
|
||||
fetches += 1;
|
||||
return res(ctx.json({ status: 'success', data: errors }));
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
const card = await hoverRow('Errors');
|
||||
await expect(
|
||||
within(card).findByText(/not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(within(card).queryByTestId('saved-view-hover-card-query')).toBeNull();
|
||||
expect(fetches).toBe(0);
|
||||
});
|
||||
|
||||
it('skips the read check of the hovered view with read on every view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const checks = recordChecks();
|
||||
renderPanel();
|
||||
|
||||
const card = await hoverRow('Errors');
|
||||
|
||||
await expect(
|
||||
within(card).findByTestId('saved-view-hover-card-query'),
|
||||
).resolves.toHaveTextContent('has_error = true');
|
||||
expect(readSelectors(checks)).toStrictEqual(['*']);
|
||||
});
|
||||
|
||||
it('checks read on the hovered view without read on every view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const checks = recordChecks(
|
||||
(transaction) =>
|
||||
transaction.relation !== 'read' || transaction.object.selector !== '*',
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
const card = await hoverRow('Errors');
|
||||
|
||||
await expect(
|
||||
within(card).findByTestId('saved-view-hover-card-query'),
|
||||
).resolves.toHaveTextContent('has_error = true');
|
||||
expect(readSelectors(checks)).toStrictEqual(['*', errors.id]);
|
||||
});
|
||||
|
||||
it('disables creating without create', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
server.use(setupAuthzDeny(SavedViewCreatePermission));
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
fireEvent,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
withGlobalTime,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
@@ -53,6 +59,9 @@ const row = (name: string): HTMLElement =>
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
const rowName = (name: string): HTMLElement =>
|
||||
within(row(name)).getByTestId('saved-views-row-name');
|
||||
|
||||
describe('SavedViewsPanel list', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -85,9 +94,8 @@ describe('SavedViewsPanel list', () => {
|
||||
const { history } = renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
|
||||
await waitFor(() => expectUsable(name));
|
||||
await userEvent.click(name);
|
||||
await waitFor(() => expectUsable(rowName('Slow spans')));
|
||||
await userEvent.click(rowName('Slow spans'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
@@ -96,6 +104,60 @@ describe('SavedViewsPanel list', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('links each row to its view in the current time range', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
renderWithExplorerProviders(
|
||||
withGlobalTime(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
{ selectedTime: '1h' },
|
||||
),
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const url = new URL(
|
||||
rowName('Slow spans').getAttribute('href') as string,
|
||||
window.location.origin,
|
||||
);
|
||||
|
||||
expect(url.pathname).toBe(PATH);
|
||||
expect(url.searchParams.get(QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(alsoMine.id),
|
||||
);
|
||||
expect(url.searchParams.get(QueryParams.panelTypes)).toBe(
|
||||
JSON.stringify(alsoMine.spec.panelType),
|
||||
);
|
||||
expect(url.searchParams.has(QueryParams.compositeQuery)).toBe(true);
|
||||
expect(url.searchParams.get(QueryParams.relativeTime)).toBe('1h');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Ctrl', { ctrlKey: true }],
|
||||
['Cmd', { metaKey: true }],
|
||||
['Shift', { shiftKey: true }],
|
||||
])('leaves a %s click on a row to the browser', async (_key, modifiers) => {
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
const { history } = renderPanel();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const before = history.location.search;
|
||||
|
||||
let defaultPrevented: boolean | undefined;
|
||||
const recordClick = (event: MouseEvent): void => {
|
||||
defaultPrevented = event.defaultPrevented;
|
||||
// jsdom cannot open a new tab.
|
||||
event.preventDefault();
|
||||
};
|
||||
document.addEventListener('click', recordClick);
|
||||
fireEvent.click(rowName('Slow spans'), modifiers);
|
||||
document.removeEventListener('click', recordClick);
|
||||
|
||||
expect(defaultPrevented).toBe(false);
|
||||
expect(history.location.search).toBe(before);
|
||||
});
|
||||
|
||||
it('marks the open view and clears it from its row', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
const { history } = renderPanel(viewUrl(PATH, mine));
|
||||
@@ -120,9 +182,8 @@ describe('SavedViewsPanel list', () => {
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const before = history.location.search;
|
||||
|
||||
await userEvent.click(
|
||||
within(row('Errors')).getByTestId('saved-views-row-name'),
|
||||
);
|
||||
expect(rowName('Errors')).toHaveAttribute('aria-current', 'page');
|
||||
await userEvent.click(rowName('Errors'));
|
||||
|
||||
expect(history.location.search).toBe(before);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
withGlobalTime,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
@@ -32,6 +34,8 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
const START_MS = 1700000000000;
|
||||
const END_MS = 1700003600000;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({
|
||||
@@ -119,9 +123,22 @@ describe('SavedViewsPanel row menu', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('copies a link that opens the view with its query and tab', async () => {
|
||||
it('copies a link that opens the view with its query, tab and time range', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
renderWithExplorerProviders(
|
||||
withGlobalTime(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
{
|
||||
selectedTime: 'custom',
|
||||
minTime: START_MS * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: END_MS * NANO_SECOND_MULTIPLIER,
|
||||
},
|
||||
),
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Copy link');
|
||||
|
||||
@@ -137,6 +154,9 @@ describe('SavedViewsPanel row menu', () => {
|
||||
expect(
|
||||
decodeURIComponent(link.searchParams.get(QueryParams.compositeQuery) ?? ''),
|
||||
).toContain('duration_nano > 1000');
|
||||
expect(link.searchParams.get(QueryParams.startTime)).toBe(String(START_MS));
|
||||
expect(link.searchParams.get(QueryParams.endTime)).toBe(String(END_MS));
|
||||
expect(link.searchParams.has(QueryParams.relativeTime)).toBe(false);
|
||||
expect(toast.success).toHaveBeenCalledWith('Link copied', expect.anything());
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { TestRouter } from 'tests/router';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
@@ -160,6 +161,23 @@ export function viewUrl(
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
export function withGlobalTime(
|
||||
ui: ReactElement,
|
||||
globalTime: Partial<GlobalReducer>,
|
||||
): ReactElement {
|
||||
const state = store.getState();
|
||||
return (
|
||||
<Provider
|
||||
store={mockStore({
|
||||
...state,
|
||||
globalTime: { ...state.globalTime, ...globalTime },
|
||||
})}
|
||||
>
|
||||
{ui}
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// What the explorer page does around the header: writes its default query to a
|
||||
// bare url, and sets the provider's tab from the url (the provider alone seeds
|
||||
// it from the raw, still JSON-quoted param).
|
||||
|
||||
@@ -12,11 +12,10 @@ import {
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { buildExplorerNavigationUrl } from 'container/AIAssistant/components/ActionsSection/utils/openSavedView';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_SCHEMA_VERSION,
|
||||
@@ -24,7 +23,7 @@ import {
|
||||
} from '../constants';
|
||||
import { SavedViewEventKeys, SavedViewEvents } from '../events';
|
||||
import { UseSavedViewRowActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { getSavedViewPath } from '../utils/getSavedViewPath';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
|
||||
|
||||
@@ -43,6 +42,7 @@ export function useSavedViewRowActions(
|
||||
): UseSavedViewRowActionsResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { pathname } = useAppLocation();
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
|
||||
@@ -52,17 +52,15 @@ export function useSavedViewRowActions(
|
||||
|
||||
const copyViewLink = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
|
||||
[QueryParams.panelTypes]: view.spec.panelType,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
copyToClipboard(getAbsoluteUrl(path));
|
||||
copyToClipboard(
|
||||
getAbsoluteUrl(getSavedViewPath(pathname, view, timeSearch)),
|
||||
);
|
||||
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.LinkCopied, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[pathname, copyToClipboard, logSavedViewEvent],
|
||||
[pathname, timeSearch, copyToClipboard, logSavedViewEvent],
|
||||
);
|
||||
|
||||
const copyViewId = useCallback(
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
savedViewsListResponse,
|
||||
type SavedViewSource,
|
||||
unreadableViewId,
|
||||
withLongName,
|
||||
} from './__story_mockdata__/savedViews';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
@@ -73,7 +74,13 @@ const controls = {
|
||||
unreadableView: toggleControl('Unreadable view', {
|
||||
group: VIEW,
|
||||
description:
|
||||
'Denies `read` on the first view created by others alone, on top of the Access controls: its row greys out, and with Open view on `created-by-others` the header shows No view access.',
|
||||
'Denies `read` on the first view created by others, and so on `saved-view:*`, on top of the Access controls: its row still opens it, and the header then shows No view access, as with Open view on `created-by-others`. Its hover card shows the permission callout in place of the query.',
|
||||
value: false,
|
||||
}),
|
||||
viewPending: toggleControl('View fetch pending', {
|
||||
group: VIEW,
|
||||
description:
|
||||
'Holds every `GET /saved_views/:id` open, so a hover card stays on its loading skeleton.',
|
||||
value: false,
|
||||
}),
|
||||
createdByMe: countControl('Created by me', {
|
||||
@@ -87,6 +94,12 @@ const controls = {
|
||||
value: 4,
|
||||
max: SAVED_VIEWS_MAX,
|
||||
}),
|
||||
longNames: toggleControl('Long names', {
|
||||
group: LIST,
|
||||
description:
|
||||
'Lengthens every view name past the sidebar width, so rows and the open view name truncate.',
|
||||
value: false,
|
||||
}),
|
||||
};
|
||||
|
||||
type SavedViewsValues = MockControlValues<typeof controls>;
|
||||
@@ -99,7 +112,7 @@ const viewDenied = (
|
||||
): boolean =>
|
||||
transaction.relation === 'read' &&
|
||||
transaction.object.resource.kind === 'saved-view' &&
|
||||
transaction.object.selector === deniedId;
|
||||
[deniedId, '*'].includes(transaction.object.selector);
|
||||
|
||||
const definition = defineStoryMocks({
|
||||
controls,
|
||||
@@ -107,7 +120,14 @@ const definition = defineStoryMocks({
|
||||
values: SavedViewsValues & AuthzArgs,
|
||||
response,
|
||||
): RequestHandler[] => {
|
||||
const { source, openView, createdByMe, createdByOthers } = values;
|
||||
const {
|
||||
source,
|
||||
openView,
|
||||
viewPending,
|
||||
createdByMe,
|
||||
createdByOthers,
|
||||
longNames,
|
||||
} = values;
|
||||
|
||||
// What the panel writes stays in this handler set until a control changes.
|
||||
const created: SavedviewtypesSavedViewDTO[] = [];
|
||||
@@ -118,8 +138,14 @@ const definition = defineStoryMocks({
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedviewtypesSavedViewDTO => ({ ...view, ...updated.get(view.id) });
|
||||
|
||||
const named = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedviewtypesSavedViewDTO => (longNames ? withLongName(view) : view);
|
||||
|
||||
const findView = (id: string): SavedviewtypesSavedViewDTO | undefined => {
|
||||
const view = created.find((item) => item.id === id) ?? savedViewById(id);
|
||||
const stored = savedViewById(id);
|
||||
const view =
|
||||
created.find((item) => item.id === id) ?? (stored && named(stored));
|
||||
|
||||
return view && !deleted.has(id) ? current(view) : undefined;
|
||||
};
|
||||
@@ -143,7 +169,7 @@ const definition = defineStoryMocks({
|
||||
|
||||
return {
|
||||
...list,
|
||||
data: [...(list.data ?? []), ...created]
|
||||
data: [...(list.data ?? []).map(named), ...created]
|
||||
.filter((view) => !deleted.has(view.id))
|
||||
.map(current),
|
||||
};
|
||||
@@ -151,6 +177,10 @@ const definition = defineStoryMocks({
|
||||
),
|
||||
|
||||
rest.get(`${API}/:id`, (req, res, ctx) => {
|
||||
if (viewPending) {
|
||||
return res(ctx.delay('infinite'));
|
||||
}
|
||||
|
||||
if (openError) {
|
||||
return res(ctx.status(openError.status), ctx.json(openError.body));
|
||||
}
|
||||
@@ -274,7 +304,9 @@ const isPending = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('data-loading');
|
||||
|
||||
const isDisabled = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('disabled') || element.hasAttribute('data-disabled');
|
||||
element.hasAttribute('disabled') ||
|
||||
element.hasAttribute('data-disabled') ||
|
||||
element.getAttribute('aria-disabled') === 'true';
|
||||
|
||||
export const expectDisabled = (
|
||||
find: FindElement,
|
||||
@@ -302,6 +334,30 @@ export const rowName =
|
||||
(): HTMLElement =>
|
||||
within(row).getByTestId('saved-views-row-name');
|
||||
|
||||
const hoverRow = async (row: HTMLElement): Promise<HTMLElement> => {
|
||||
await userEvent.hover(row);
|
||||
|
||||
return screen.findByTestId('saved-view-hover-card', undefined, UNTIL_LOADED);
|
||||
};
|
||||
|
||||
const searchViews = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
const search = await within(canvasElement).findByTestId(
|
||||
'saved-views-search',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
|
||||
await userEvent.type(search, text);
|
||||
};
|
||||
|
||||
const clickWhenEnabled = async (find: FindElement): Promise<void> => {
|
||||
await expectDisabled(find, false);
|
||||
await userEvent.click(find());
|
||||
};
|
||||
|
||||
export const openRowMenu = async (row: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
|
||||
await screen.findByRole('menu', undefined, UNTIL_LOADED);
|
||||
@@ -327,3 +383,115 @@ export const openSaveChangesMenu = async (
|
||||
|
||||
export const saveChangesItem = (key: 'save-as-new' | 'update'): FindElement =>
|
||||
byTestId(`saved-views-save-changes-item-${key}`);
|
||||
|
||||
type PlayContext = {
|
||||
canvasElement: HTMLElement;
|
||||
args: { source: SavedViewSource };
|
||||
};
|
||||
|
||||
export const hoverFirstRow = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
const card = await hoverRow(row);
|
||||
|
||||
await within(card).findByTestId(
|
||||
'saved-view-hover-card-query',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
};
|
||||
|
||||
export const hoverFirstRowLoading = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
const card = await hoverRow(row);
|
||||
|
||||
await within(card).findByTestId(
|
||||
'saved-view-hover-card-loading',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
};
|
||||
|
||||
export const searchWithNoResults = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await searchViews(canvasElement, 'nothing like this');
|
||||
await screen.findByTestId('saved-views-list-no-results');
|
||||
};
|
||||
|
||||
export const openCreateModal = async (): Promise<void> => {
|
||||
await clickWhenEnabled(byTestId('saved-views-create'));
|
||||
await screen.findByTestId('save-view-modal');
|
||||
};
|
||||
|
||||
export const openEditDetailsModal = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
await clickWhenEnabled(rowMenuItem('editDetails'));
|
||||
await screen.findByTestId('save-view-modal');
|
||||
};
|
||||
|
||||
export const openSaveAsNewModal = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
await clickWhenEnabled(saveChangesItem('save-as-new'));
|
||||
await screen.findByTestId('save-view-modal');
|
||||
};
|
||||
|
||||
export const openDeleteDialog = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
await clickWhenEnabled(rowMenuItem('delete'));
|
||||
await screen.findByTestId('delete-saved-view-confirm');
|
||||
};
|
||||
|
||||
const findUnreadableRow = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<HTMLElement> => {
|
||||
const rows = await findRows(canvasElement);
|
||||
const deniedId = unreadableViewId(args.source);
|
||||
|
||||
return rows.find(
|
||||
(row) => row.getAttribute('data-view-id') === deniedId,
|
||||
) as HTMLElement;
|
||||
};
|
||||
|
||||
export const hoverUnreadableRow = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
const card = await hoverRow(await findUnreadableRow(context));
|
||||
|
||||
await expect(
|
||||
await within(card).findByText(/not authorized/i, undefined, UNTIL_LOADED),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
within(card).queryByTestId('saved-view-hover-card-query'),
|
||||
).toBeNull();
|
||||
};
|
||||
|
||||
export const openUnreadableView = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
const { canvasElement } = context;
|
||||
const denied = await findUnreadableRow(context);
|
||||
|
||||
await userEvent.click(rowName(denied)());
|
||||
await expect(
|
||||
await within(canvasElement).findByText(
|
||||
'No view access',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
),
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
@@ -6,10 +6,17 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import {
|
||||
findRows,
|
||||
hoverFirstRow,
|
||||
hoverFirstRowLoading,
|
||||
openCreateModal,
|
||||
openDeleteDialog,
|
||||
openEditDetailsModal,
|
||||
openRowMenu,
|
||||
openSaveAsNewModal,
|
||||
openSaveChangesMenu,
|
||||
SavedViewsSidebar,
|
||||
savedViewsMocks,
|
||||
searchWithNoResults,
|
||||
sidebarCanvas,
|
||||
} from './SavedViews.stories.mocks';
|
||||
|
||||
@@ -78,3 +85,48 @@ export const SaveChangesMenu: Story = {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Views whose names run past the sidebar, with one of them open: each row and
|
||||
* the open view name truncate.
|
||||
*/
|
||||
export const LongNames: Story = {
|
||||
args: { longNames: true, openView: 'created-by-me' },
|
||||
};
|
||||
|
||||
/** The hover card of the first row: its query, who updated it and when. */
|
||||
export const HoverCard: Story = {
|
||||
play: hoverFirstRow,
|
||||
};
|
||||
|
||||
/** The hover card while its view is still loading: a skeleton in place of the query. */
|
||||
export const HoverCardLoading: Story = {
|
||||
args: { viewPending: true },
|
||||
play: hoverFirstRowLoading,
|
||||
};
|
||||
|
||||
/** A search that matches no view: the list says so in place of the rows. */
|
||||
export const SearchNoResults: Story = {
|
||||
play: searchWithNoResults,
|
||||
};
|
||||
|
||||
/** The + in the header opens Save view for the current query. */
|
||||
export const CreateModal: Story = {
|
||||
play: openCreateModal,
|
||||
};
|
||||
|
||||
/** Edit details from a row's menu: the modal starts on the view's name. */
|
||||
export const EditDetailsModal: Story = {
|
||||
play: openEditDetailsModal,
|
||||
};
|
||||
|
||||
/** Save as new view from the ✓ menu on unsaved changes. */
|
||||
export const SaveAsNewModal: Story = {
|
||||
args: { openView: 'unsaved-changes' },
|
||||
play: openSaveAsNewModal,
|
||||
};
|
||||
|
||||
/** Delete from a row's menu asks to confirm before the view goes. */
|
||||
export const DeleteDialog: Story = {
|
||||
play: openDeleteDialog,
|
||||
};
|
||||
|
||||
@@ -209,6 +209,18 @@ export const createdSavedView = (
|
||||
spec,
|
||||
});
|
||||
|
||||
const LONG_NAME_SUFFIX = ', every environment and region, last 30 days';
|
||||
|
||||
export const withLongName = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedviewtypesSavedViewDTO => ({
|
||||
...view,
|
||||
spec: {
|
||||
...view.spec,
|
||||
displayName: `${view.spec.displayName}${LONG_NAME_SUFFIX}`,
|
||||
},
|
||||
});
|
||||
|
||||
export const savedViewResponse = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): GetSavedView200 => ({ status: 'success', data: view });
|
||||
|
||||
@@ -4,14 +4,15 @@ import { expect, waitFor, within } from 'storybook/test';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { unreadableViewId } from '../__story_mockdata__/savedViews';
|
||||
import {
|
||||
byTestId,
|
||||
expectDisabled,
|
||||
expectPending,
|
||||
findRows,
|
||||
hoverUnreadableRow,
|
||||
openRowMenu,
|
||||
openSaveChangesMenu,
|
||||
openUnreadableView,
|
||||
rowMenuItem,
|
||||
rowName,
|
||||
saveChangesItem,
|
||||
@@ -99,21 +100,18 @@ export const NoCreateSaveMenu: Story = {
|
||||
};
|
||||
|
||||
/**
|
||||
* `read` denied on one view made by someone else: its name is disabled and its
|
||||
* hover card never opens, while every other view still opens.
|
||||
* `read` denied on one view made by someone else: the list checks no `read`, so
|
||||
* its row opens it like any other, and the header then says No view access.
|
||||
*/
|
||||
export const NoRead: Story = {
|
||||
args: { unreadableView: true },
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const rows = await findRows(canvasElement);
|
||||
const deniedId = unreadableViewId(args.source);
|
||||
play: openUnreadableView,
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
rows.map((row) =>
|
||||
expectDisabled(rowName(row), row.getAttribute('data-view-id') === deniedId),
|
||||
),
|
||||
);
|
||||
},
|
||||
/** The hover card of the unreadable view: the permission callout in place of its query. */
|
||||
export const NoReadHoverCard: Story = {
|
||||
args: { unreadableView: true },
|
||||
play: hoverUnreadableRow,
|
||||
};
|
||||
|
||||
/** The unreadable view open from its link: the header names it No view access. */
|
||||
|
||||
@@ -128,6 +128,11 @@ export interface SavedViewsListRowProps {
|
||||
hover: SavedViewsListRowHoverProps;
|
||||
}
|
||||
|
||||
export type SavedViewsRowLinkProps = Pick<
|
||||
SavedViewsListRowProps,
|
||||
'view' | 'isActive' | 'onSelect'
|
||||
>;
|
||||
|
||||
export type SavedViewRowActionKey =
|
||||
| 'copyLink'
|
||||
| 'copyId'
|
||||
@@ -181,7 +186,7 @@ export interface UseSavedViewRowActionsResult {
|
||||
}
|
||||
|
||||
export interface SavedViewHoverCardTarget {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
viewId: string;
|
||||
anchorTop: number;
|
||||
anchorHeight: number;
|
||||
}
|
||||
@@ -202,8 +207,14 @@ export interface SavedViewHoverCardProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewHoverCardDetailsProps {
|
||||
viewId: string;
|
||||
/** `read` is granted on every view, so the view needs no check of its own. */
|
||||
canReadAll: boolean;
|
||||
}
|
||||
|
||||
export interface SavedViewsListRowHoverProps {
|
||||
onRowEnter: (view: SavedviewtypesSavedViewDTO, row: HTMLElement) => void;
|
||||
onRowEnter: (viewId: string, row: HTMLElement) => void;
|
||||
onRowLeave: () => void;
|
||||
onMenuOpen: () => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { makeView } from '../../__tests__/savedViewsTestUtils';
|
||||
import { getSavedViewPath } from '../getSavedViewPath';
|
||||
|
||||
const view = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
|
||||
function paramsOf(path: string): URLSearchParams {
|
||||
return new URL(path, window.location.origin).searchParams;
|
||||
}
|
||||
|
||||
describe('getSavedViewPath', () => {
|
||||
it('opens the view on the given explorer in the given time range', () => {
|
||||
const path = getSavedViewPath(
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
view,
|
||||
'startTime=1700000000000&endTime=1700003600000',
|
||||
);
|
||||
const params = paramsOf(path);
|
||||
|
||||
expect(path.startsWith(`${ROUTES.TRACES_EXPLORER}?`)).toBe(true);
|
||||
expect(params.get(QueryParams.viewKey)).toBe(JSON.stringify(view.id));
|
||||
expect(params.get(QueryParams.startTime)).toBe('1700000000000');
|
||||
expect(params.get(QueryParams.endTime)).toBe('1700003600000');
|
||||
});
|
||||
|
||||
it('adds no time range when there is none', () => {
|
||||
const path = getSavedViewPath(ROUTES.TRACES_EXPLORER, view, '');
|
||||
|
||||
expect(path.endsWith('&')).toBe(false);
|
||||
expect(paramsOf(path).has(QueryParams.relativeTime)).toBe(false);
|
||||
});
|
||||
});
|
||||
17
frontend/src/container/SavedViews/utils/getSavedViewPath.ts
Normal file
17
frontend/src/container/SavedViews/utils/getSavedViewPath.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
|
||||
|
||||
import { getSavedViewQuery } from './getSavedViewQuery';
|
||||
|
||||
export function getSavedViewPath(
|
||||
pathname: string,
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
timeSearch: string,
|
||||
): string {
|
||||
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
|
||||
[QueryParams.panelTypes]: view.spec.panelType,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
return timeSearch ? `${path}&${timeSearch}` : path;
|
||||
}
|
||||
@@ -1,46 +1,28 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { getCurrentLocation, navigate } from 'lib/router/navigation';
|
||||
import { TestRouter } from 'tests/router';
|
||||
|
||||
import { useNavigationBlocker } from '../useNavigationBlocker';
|
||||
|
||||
type BlockerHook = RenderHookResult<
|
||||
ReturnType<typeof useNavigationBlocker>,
|
||||
{ shouldBlock: boolean }
|
||||
>;
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return <TestRouter initialRoute="/start">{children}</TestRouter>;
|
||||
}
|
||||
|
||||
/** Lets router updates, and the `popstate` a POP arrives on, reach React. */
|
||||
async function settle(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
function settle(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 60);
|
||||
});
|
||||
}
|
||||
|
||||
/** Mounts the blocker, disarmed, at /c with /start, /a and /b behind it. */
|
||||
async function renderAtC(): Promise<BlockerHook> {
|
||||
const hook = renderHook(
|
||||
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
|
||||
{ wrapper, initialProps: { shouldBlock: false } },
|
||||
);
|
||||
navigate('/a');
|
||||
navigate('/b');
|
||||
navigate('/c');
|
||||
await settle();
|
||||
return hook;
|
||||
}
|
||||
|
||||
describe('useNavigationBlocker against the real history', () => {
|
||||
beforeEach(async () => {
|
||||
navigate('/start');
|
||||
navigate('/a');
|
||||
navigate('/b');
|
||||
navigate('/c');
|
||||
await settle();
|
||||
});
|
||||
|
||||
it('lets a bypassed browser Back through without showing the modal', async () => {
|
||||
const { result, rerender } = await renderAtC();
|
||||
rerender({ shouldBlock: true });
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
act(() => result.current.allowNextNavigation());
|
||||
window.history.back();
|
||||
act(() => window.history.back());
|
||||
await settle();
|
||||
|
||||
expect(getCurrentLocation().pathname).toBe('/b');
|
||||
@@ -48,26 +30,23 @@ describe('useNavigationBlocker against the real history', () => {
|
||||
});
|
||||
|
||||
it('keeps blocking after a bypassed navigation', async () => {
|
||||
const { result, rerender } = await renderAtC();
|
||||
rerender({ shouldBlock: true });
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
act(() => result.current.allowNextNavigation());
|
||||
window.history.back();
|
||||
await settle();
|
||||
navigate('/elsewhere');
|
||||
act(() => window.history.back());
|
||||
await settle();
|
||||
act(() => navigate('/elsewhere'));
|
||||
|
||||
expect(getCurrentLocation().pathname).toBe('/b');
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
});
|
||||
|
||||
it('replays a blocked Forward on confirm', async () => {
|
||||
const { result, rerender } = await renderAtC();
|
||||
window.history.go(-2);
|
||||
act(() => window.history.go(-2));
|
||||
await settle();
|
||||
rerender({ shouldBlock: true });
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
window.history.forward();
|
||||
act(() => window.history.forward());
|
||||
await settle();
|
||||
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
@@ -80,16 +59,12 @@ describe('useNavigationBlocker against the real history', () => {
|
||||
});
|
||||
|
||||
it('replays a blocked multi-entry jump on confirm', async () => {
|
||||
const { result, rerender } = await renderAtC();
|
||||
window.history.go(-2);
|
||||
act(() => window.history.go(-2));
|
||||
await settle();
|
||||
rerender({ shouldBlock: true });
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
window.history.go(2);
|
||||
act(() => window.history.go(2));
|
||||
await settle();
|
||||
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
|
||||
act(() => result.current.confirmNavigation());
|
||||
await settle();
|
||||
|
||||
|
||||
383
frontend/src/hooks/__tests__/useNavigationBlocker.test.ts
Normal file
383
frontend/src/hooks/__tests__/useNavigationBlocker.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useNavigationBlocker } from '../useNavigationBlocker';
|
||||
|
||||
const mockUnblock = jest.fn();
|
||||
const mockBlock = jest.fn().mockReturnValue(mockUnblock);
|
||||
const mockRetry = jest.fn();
|
||||
|
||||
jest.mock('lib/router/navigation', () => ({
|
||||
blockNavigation: (blocker: unknown): unknown => mockBlock(blocker),
|
||||
}));
|
||||
|
||||
describe('useNavigationBlocker', () => {
|
||||
const mockLocation = {
|
||||
pathname: '/new-route',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'test-key',
|
||||
};
|
||||
|
||||
/**
|
||||
* `blockNavigation` hands the blocker one transition object and cancels the
|
||||
* navigation on its own; `retry()` is the only way through, which is why
|
||||
* these cases assert on `retry` rather than on a returned `false`.
|
||||
*/
|
||||
function transition(action: string): {
|
||||
location: typeof mockLocation;
|
||||
action: string;
|
||||
retry: jest.Mock;
|
||||
} {
|
||||
return { location: mockLocation, action, retry: mockRetry };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockBlock.mockReturnValue(mockUnblock);
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('returns isBlocked false when shouldBlock is false', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(false));
|
||||
|
||||
expect(result.current.isBlocked).toBe(false);
|
||||
expect(result.current.blockedNavigationDetails).toBeNull();
|
||||
});
|
||||
|
||||
it('returns isBlocked false when shouldBlock is true but no navigation attempted', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
expect(result.current.isBlocked).toBe(false);
|
||||
expect(result.current.blockedNavigationDetails).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('blockNavigation behavior', () => {
|
||||
it('registers a blocker when shouldBlock is true', () => {
|
||||
renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
expect(mockBlock).toHaveBeenCalledTimes(1);
|
||||
expect(mockBlock).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
it('does not register a blocker when shouldBlock is false', () => {
|
||||
renderHook(() => useNavigationBlocker(false));
|
||||
|
||||
expect(mockBlock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unblocks when shouldBlock changes from true to false', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
|
||||
{ initialProps: { shouldBlock: true } },
|
||||
);
|
||||
|
||||
expect(mockBlock).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ shouldBlock: false });
|
||||
|
||||
expect(mockUnblock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('unblocks on unmount when blocking', () => {
|
||||
const { unmount } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockUnblock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call unblock on unmount when not blocking', () => {
|
||||
const { unmount } = renderHook(() => useNavigationBlocker(false));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockUnblock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation blocking', () => {
|
||||
it('blocks PUSH navigation and sets blockedNavigationDetails', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
const blockCallback = mockBlock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
blockCallback(transition('PUSH'));
|
||||
});
|
||||
|
||||
expect(mockRetry).not.toHaveBeenCalled();
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
expect(result.current.blockedNavigationDetails).toStrictEqual({
|
||||
location: mockLocation,
|
||||
action: 'PUSH',
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks REPLACE navigation and sets blockedNavigationDetails', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
const blockCallback = mockBlock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
blockCallback(transition('REPLACE'));
|
||||
});
|
||||
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
expect(result.current.blockedNavigationDetails?.action).toBe('REPLACE');
|
||||
});
|
||||
|
||||
it('blocks POP navigation and sets blockedNavigationDetails', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
const blockCallback = mockBlock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
blockCallback(transition('POP'));
|
||||
});
|
||||
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
expect(result.current.blockedNavigationDetails?.action).toBe('POP');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmNavigation', () => {
|
||||
it('does nothing when no blockedNavigationDetails', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
act(() => {
|
||||
result.current.confirmNavigation();
|
||||
});
|
||||
|
||||
expect(mockRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unblocks before retrying, or the blocker would catch the retry too', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
const blockCallback = mockBlock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
blockCallback(transition('PUSH'));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.confirmNavigation();
|
||||
});
|
||||
|
||||
expect(mockUnblock).toHaveBeenCalled();
|
||||
expect(mockUnblock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockRetry.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(result.current.isBlocked).toBe(false);
|
||||
expect(result.current.blockedNavigationDetails).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['PUSH', 'REPLACE', 'POP'])(
|
||||
'retries the blocked %s, which replays it with its own action',
|
||||
(action) => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
const blockCallback = mockBlock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
blockCallback(transition(action));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.confirmNavigation();
|
||||
});
|
||||
|
||||
expect(mockRetry).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('cancelNavigation', () => {
|
||||
it('clears blockedNavigationDetails without performing navigation', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
const blockCallback = mockBlock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
blockCallback(transition('PUSH'));
|
||||
});
|
||||
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.cancelNavigation();
|
||||
});
|
||||
|
||||
expect(result.current.isBlocked).toBe(false);
|
||||
expect(result.current.blockedNavigationDetails).toBeNull();
|
||||
expect(mockRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowNextNavigation', () => {
|
||||
it('bypasses blocking for next navigation only', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
act(() => {
|
||||
result.current.allowNextNavigation();
|
||||
});
|
||||
|
||||
const blockCallback = mockBlock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
blockCallback(transition('PUSH'));
|
||||
});
|
||||
|
||||
expect(mockRetry).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.isBlocked).toBe(false);
|
||||
expect(mockBlock).toHaveBeenCalledTimes(1);
|
||||
expect(mockUnblock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops a pending prompt when a bypassed navigation goes through', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
const blockCallback = mockBlock.mock.calls[0][0];
|
||||
const stale = jest.fn();
|
||||
|
||||
act(() => {
|
||||
blockCallback({ ...transition('PUSH'), retry: stale });
|
||||
});
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.allowNextNavigation();
|
||||
});
|
||||
act(() => {
|
||||
blockCallback(transition('PUSH'));
|
||||
});
|
||||
act(() => {
|
||||
result.current.confirmNavigation();
|
||||
});
|
||||
|
||||
expect(result.current.isBlocked).toBe(false);
|
||||
expect(stale).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resumes blocking after bypassed navigation', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
const blockCallback = mockBlock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
result.current.allowNextNavigation();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
blockCallback(transition('PUSH'));
|
||||
});
|
||||
|
||||
mockRetry.mockClear();
|
||||
act(() => {
|
||||
blockCallback(transition('PUSH'));
|
||||
});
|
||||
|
||||
expect(mockRetry).not.toHaveBeenCalled();
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
});
|
||||
|
||||
it('resets bypass flag when shouldBlock changes to false before navigation', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
|
||||
{ initialProps: { shouldBlock: true } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.allowNextNavigation();
|
||||
});
|
||||
|
||||
rerender({ shouldBlock: false });
|
||||
rerender({ shouldBlock: true });
|
||||
|
||||
const blockCallback = mockBlock.mock.calls[1][0];
|
||||
|
||||
act(() => {
|
||||
blockCallback(transition('PUSH'));
|
||||
});
|
||||
|
||||
expect(mockRetry).not.toHaveBeenCalled();
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('beforeunload event', () => {
|
||||
let addEventListenerSpy: jest.SpyInstance;
|
||||
let removeEventListenerSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
addEventListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
addEventListenerSpy.mockRestore();
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('adds beforeunload listener when shouldBlock is true', () => {
|
||||
renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'beforeunload',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add beforeunload listener when shouldBlock is false', () => {
|
||||
renderHook(() => useNavigationBlocker(false));
|
||||
|
||||
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
|
||||
'beforeunload',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes beforeunload listener on unmount', () => {
|
||||
const { unmount } = renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'beforeunload',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes beforeunload listener when shouldBlock changes to false', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
|
||||
{ initialProps: { shouldBlock: true } },
|
||||
);
|
||||
|
||||
rerender({ shouldBlock: false });
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'beforeunload',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('beforeunload handler calls preventDefault', () => {
|
||||
renderHook(() => useNavigationBlocker(true));
|
||||
|
||||
const beforeUnloadHandler = addEventListenerSpy.mock.calls.find(
|
||||
(call) => call[0] === 'beforeunload',
|
||||
)?.[1];
|
||||
|
||||
const mockEvent = {
|
||||
preventDefault: jest.fn(),
|
||||
};
|
||||
|
||||
const result = beforeUnloadHandler(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,317 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { back, getCurrentLocation, navigate } from 'lib/router/navigation';
|
||||
import { TestRouter } from 'tests/router';
|
||||
|
||||
import { useNavigationBlocker } from '../useNavigationBlocker';
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return <TestRouter initialRoute="/start">{children}</TestRouter>;
|
||||
}
|
||||
|
||||
/** Lets router updates, and the `popstate` a POP arrives on, reach React. */
|
||||
async function settle(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe('useNavigationBlocker', () => {
|
||||
describe('initial state', () => {
|
||||
it('returns isBlocked false when shouldBlock is false', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(false), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.isBlocked).toBe(false);
|
||||
expect(result.current.blockedNavigationDetails).toBeNull();
|
||||
});
|
||||
|
||||
it('returns isBlocked false when shouldBlock is true but no navigation attempted', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.isBlocked).toBe(false);
|
||||
expect(result.current.blockedNavigationDetails).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation blocking', () => {
|
||||
it('lets navigation through when shouldBlock is false', async () => {
|
||||
renderHook(() => useNavigationBlocker(false), { wrapper });
|
||||
|
||||
navigate('/next');
|
||||
await settle();
|
||||
|
||||
expect(getCurrentLocation().pathname).toBe('/next');
|
||||
});
|
||||
|
||||
it('blocks PUSH navigation and sets blockedNavigationDetails', async () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
navigate('/next');
|
||||
await settle();
|
||||
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
expect(result.current.blockedNavigationDetails).toMatchObject({
|
||||
location: { pathname: '/next' },
|
||||
action: 'PUSH',
|
||||
});
|
||||
expect(getCurrentLocation().pathname).toBe('/start');
|
||||
});
|
||||
|
||||
it('blocks REPLACE navigation and sets blockedNavigationDetails', async () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
navigate('/next', { replace: true });
|
||||
await settle();
|
||||
|
||||
expect(result.current.blockedNavigationDetails?.action).toBe('REPLACE');
|
||||
});
|
||||
|
||||
it('blocks POP navigation and sets blockedNavigationDetails', async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
|
||||
{ wrapper, initialProps: { shouldBlock: false } },
|
||||
);
|
||||
navigate('/next');
|
||||
await settle();
|
||||
rerender({ shouldBlock: true });
|
||||
await settle();
|
||||
|
||||
back();
|
||||
await waitFor(() => expect(result.current.isBlocked).toBe(true));
|
||||
|
||||
expect(result.current.blockedNavigationDetails).toMatchObject({
|
||||
location: { pathname: '/start' },
|
||||
action: 'POP',
|
||||
});
|
||||
await waitFor(() => expect(getCurrentLocation().pathname).toBe('/next'));
|
||||
});
|
||||
|
||||
it('stops blocking when shouldBlock changes from true to false', async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
|
||||
{ wrapper, initialProps: { shouldBlock: true } },
|
||||
);
|
||||
|
||||
rerender({ shouldBlock: false });
|
||||
navigate('/next');
|
||||
await settle();
|
||||
|
||||
expect(getCurrentLocation().pathname).toBe('/next');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmNavigation', () => {
|
||||
it('does nothing when nothing is blocked', () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => result.current.confirmNavigation());
|
||||
|
||||
expect(getCurrentLocation().pathname).toBe('/start');
|
||||
});
|
||||
|
||||
it('completes the blocked navigation', async () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true), {
|
||||
wrapper,
|
||||
});
|
||||
navigate('/next', { state: { from: 'test' } });
|
||||
await settle();
|
||||
|
||||
act(() => result.current.confirmNavigation());
|
||||
await settle();
|
||||
|
||||
expect(getCurrentLocation()).toMatchObject({
|
||||
pathname: '/next',
|
||||
state: { from: 'test' },
|
||||
});
|
||||
expect(result.current.isBlocked).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a blocked REPLACE a replace', async () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true), {
|
||||
wrapper,
|
||||
});
|
||||
const before = window.history.length;
|
||||
navigate('/next', { replace: true });
|
||||
await settle();
|
||||
|
||||
act(() => result.current.confirmNavigation());
|
||||
await settle();
|
||||
|
||||
expect(getCurrentLocation().pathname).toBe('/next');
|
||||
expect(window.history).toHaveLength(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelNavigation', () => {
|
||||
it('clears blockedNavigationDetails without performing navigation', async () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true), {
|
||||
wrapper,
|
||||
});
|
||||
navigate('/next');
|
||||
await settle();
|
||||
|
||||
act(() => result.current.cancelNavigation());
|
||||
await settle();
|
||||
|
||||
expect(result.current.isBlocked).toBe(false);
|
||||
expect(result.current.blockedNavigationDetails).toBeNull();
|
||||
expect(getCurrentLocation().pathname).toBe('/start');
|
||||
});
|
||||
|
||||
it('keeps blocking after a cancel', async () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true), {
|
||||
wrapper,
|
||||
});
|
||||
navigate('/next');
|
||||
await settle();
|
||||
act(() => result.current.cancelNavigation());
|
||||
await settle();
|
||||
|
||||
navigate('/next');
|
||||
await settle();
|
||||
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowNextNavigation', () => {
|
||||
it('bypasses blocking for next navigation only', async () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => result.current.allowNextNavigation());
|
||||
navigate('/next');
|
||||
await settle();
|
||||
|
||||
expect(getCurrentLocation().pathname).toBe('/next');
|
||||
expect(result.current.isBlocked).toBe(false);
|
||||
|
||||
navigate('/after');
|
||||
await settle();
|
||||
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
expect(getCurrentLocation().pathname).toBe('/next');
|
||||
});
|
||||
|
||||
it('drops a pending prompt when a bypassed navigation goes through', async () => {
|
||||
const { result } = renderHook(() => useNavigationBlocker(true), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
navigate('/next');
|
||||
await settle();
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
|
||||
act(() => result.current.allowNextNavigation());
|
||||
navigate('/other');
|
||||
await settle();
|
||||
|
||||
expect(getCurrentLocation().pathname).toBe('/other');
|
||||
expect(result.current.isBlocked).toBe(false);
|
||||
});
|
||||
|
||||
it('resets bypass flag when shouldBlock changes to false before navigation', async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
|
||||
{ wrapper, initialProps: { shouldBlock: true } },
|
||||
);
|
||||
|
||||
act(() => result.current.allowNextNavigation());
|
||||
rerender({ shouldBlock: false });
|
||||
rerender({ shouldBlock: true });
|
||||
|
||||
navigate('/next');
|
||||
await settle();
|
||||
|
||||
expect(result.current.isBlocked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('beforeunload event', () => {
|
||||
let addEventListenerSpy: jest.SpyInstance;
|
||||
let removeEventListenerSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
addEventListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
addEventListenerSpy.mockRestore();
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('adds beforeunload listener when shouldBlock is true', () => {
|
||||
renderHook(() => useNavigationBlocker(true), { wrapper });
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'beforeunload',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add beforeunload listener when shouldBlock is false', () => {
|
||||
renderHook(() => useNavigationBlocker(false), { wrapper });
|
||||
|
||||
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
|
||||
'beforeunload',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes beforeunload listener on unmount', () => {
|
||||
const { unmount } = renderHook(() => useNavigationBlocker(true), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'beforeunload',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes beforeunload listener when shouldBlock changes to false', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
|
||||
{ wrapper, initialProps: { shouldBlock: true } },
|
||||
);
|
||||
|
||||
rerender({ shouldBlock: false });
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'beforeunload',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('beforeunload handler calls preventDefault', () => {
|
||||
renderHook(() => useNavigationBlocker(true), { wrapper });
|
||||
|
||||
const beforeUnloadHandler = addEventListenerSpy.mock.calls.find(
|
||||
(call) => call[0] === 'beforeunload',
|
||||
)?.[1];
|
||||
|
||||
const mockEvent = {
|
||||
preventDefault: jest.fn(),
|
||||
};
|
||||
|
||||
const result = beforeUnloadHandler(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { TestRouter } from 'tests/router';
|
||||
|
||||
// jest.config.ts maps every `useSafeNavigate` import to a mock; the extension
|
||||
// slips past that mapping.
|
||||
const { useSafeNavigate } = jest.requireActual<
|
||||
typeof import('hooks/useSafeNavigate')
|
||||
>('../useSafeNavigate.ts');
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return <TestRouter initialRoute="/page?a=1">{children}</TestRouter>;
|
||||
}
|
||||
|
||||
describe('useSafeNavigate same-URL check', () => {
|
||||
let pushState: jest.SpyInstance;
|
||||
let replaceState: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
pushState = jest.spyOn(window.history, 'pushState');
|
||||
replaceState = jest.spyOn(window.history, 'replaceState');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function render(): ReturnType<typeof useSafeNavigate>['safeNavigate'] {
|
||||
const { result } = renderHook(() => useSafeNavigate(), { wrapper });
|
||||
pushState.mockClear();
|
||||
replaceState.mockClear();
|
||||
return result.current.safeNavigate;
|
||||
}
|
||||
|
||||
it('navigates to a different URL', () => {
|
||||
const safeNavigate = render();
|
||||
|
||||
safeNavigate('/page?a=2');
|
||||
|
||||
expect(pushState).toHaveBeenCalledTimes(1);
|
||||
expect(window.location.search).toBe('?a=2');
|
||||
});
|
||||
|
||||
it('skips the URL the router is on', () => {
|
||||
const safeNavigate = render();
|
||||
|
||||
safeNavigate('/page?a=1');
|
||||
|
||||
expect(pushState).not.toHaveBeenCalled();
|
||||
expect(replaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the URL a shallow write published ahead of the router', () => {
|
||||
const safeNavigate = render();
|
||||
// A nuqs write: it reaches the address bar but not the router.
|
||||
window.history.replaceState(window.history.state, '', '/page?a=1&b=1');
|
||||
replaceState.mockClear();
|
||||
|
||||
safeNavigate('/page?a=1&b=1');
|
||||
|
||||
expect(pushState).not.toHaveBeenCalled();
|
||||
expect(replaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { useTimeSearchParams } from '../useTimeSearchParams';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: '30m',
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useGetCompositeQueryParam } from './useGetCompositeQueryParam';
|
||||
@@ -18,19 +17,10 @@ export const useShareBuilderUrl = ({
|
||||
}: UseShareBuilderUrlParams): void => {
|
||||
const { resetQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useAppLocation();
|
||||
// The page that owns this query. A caller can keep rendering for a beat after
|
||||
// a sibling pushes the next route (tab switches do this), and publishing then
|
||||
// writes this page's query onto the URL of the page being entered.
|
||||
const ownerPathname = useRef(pathname);
|
||||
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname !== ownerPathname.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!compositeQuery || forceReset) {
|
||||
resetQuery(defaultValue);
|
||||
redirectWithQueryBuilderData(defaultValue);
|
||||
@@ -42,6 +32,5 @@ export const useShareBuilderUrl = ({
|
||||
compositeQuery,
|
||||
resetQuery,
|
||||
forceReset,
|
||||
pathname,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { blockNavigation } from 'lib/router/navigation';
|
||||
import type {
|
||||
AppLocation,
|
||||
BlockerFunction,
|
||||
BlockedTransition,
|
||||
NavigationAction,
|
||||
} from 'lib/router/types';
|
||||
import { useAppBlocker } from 'lib/router/useAppBlocker';
|
||||
|
||||
interface BlockedNavigationDetails {
|
||||
location: AppLocation;
|
||||
@@ -48,31 +48,45 @@ interface UseNavigationBlockerResult {
|
||||
export function useNavigationBlocker(
|
||||
shouldBlock: boolean,
|
||||
): UseNavigationBlockerResult {
|
||||
const [blockedNavigation, setBlockedNavigation] =
|
||||
useState<BlockedNavigationDetails | null>(null);
|
||||
const unblockRef = useRef<(() => void) | null>(null);
|
||||
const retryRef = useRef<(() => void) | null>(null);
|
||||
const bypassNextRef = useRef(false);
|
||||
const blockedActionRef = useRef<NavigationAction>('PUSH');
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldBlock) {
|
||||
bypassNextRef.current = false;
|
||||
}
|
||||
}, [shouldBlock]);
|
||||
|
||||
const blockerFunction = useCallback<BlockerFunction>(
|
||||
({ action }) => {
|
||||
if (!shouldBlock) {
|
||||
return false;
|
||||
if (unblockRef.current) {
|
||||
unblockRef.current();
|
||||
unblockRef.current = null;
|
||||
}
|
||||
bypassNextRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
unblockRef.current = blockNavigation((transition: BlockedTransition) => {
|
||||
if (bypassNextRef.current) {
|
||||
bypassNextRef.current = false;
|
||||
return false;
|
||||
retryRef.current = null;
|
||||
setBlockedNavigation(null);
|
||||
transition.retry();
|
||||
return;
|
||||
}
|
||||
blockedActionRef.current = action;
|
||||
return true;
|
||||
},
|
||||
[shouldBlock],
|
||||
);
|
||||
retryRef.current = transition.retry;
|
||||
setBlockedNavigation({
|
||||
location: transition.location,
|
||||
action: transition.action,
|
||||
});
|
||||
});
|
||||
|
||||
const blocker = useAppBlocker(blockerFunction);
|
||||
return (): void => {
|
||||
if (unblockRef.current) {
|
||||
unblockRef.current();
|
||||
unblockRef.current = null;
|
||||
}
|
||||
bypassNextRef.current = false;
|
||||
};
|
||||
}, [shouldBlock]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldBlock) {
|
||||
@@ -90,30 +104,37 @@ export function useNavigationBlocker(
|
||||
};
|
||||
}, [shouldBlock]);
|
||||
|
||||
// The router replays a proceeded navigation itself, so a blocked REPLACE
|
||||
// stays a REPLACE and a blocked POP still walks the history stack.
|
||||
const confirmNavigation = useCallback((): void => {
|
||||
if (blocker.state === 'blocked') {
|
||||
blocker.proceed();
|
||||
if (!blockedNavigation) {
|
||||
return;
|
||||
}
|
||||
}, [blocker]);
|
||||
|
||||
if (unblockRef.current) {
|
||||
unblockRef.current();
|
||||
unblockRef.current = null;
|
||||
}
|
||||
|
||||
setBlockedNavigation(null);
|
||||
|
||||
// The transition replays itself, so a blocked REPLACE stays a REPLACE and
|
||||
// a blocked POP still walks the history stack.
|
||||
const retry = retryRef.current;
|
||||
retryRef.current = null;
|
||||
retry?.();
|
||||
}, [blockedNavigation]);
|
||||
|
||||
const cancelNavigation = useCallback((): void => {
|
||||
if (blocker.state === 'blocked') {
|
||||
blocker.reset();
|
||||
}
|
||||
}, [blocker]);
|
||||
retryRef.current = null;
|
||||
setBlockedNavigation(null);
|
||||
}, []);
|
||||
|
||||
const allowNextNavigation = useCallback((): void => {
|
||||
bypassNextRef.current = true;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isBlocked: blocker.state === 'blocked',
|
||||
blockedNavigationDetails:
|
||||
blocker.state === 'blocked'
|
||||
? { location: blocker.location, action: blockedActionRef.current }
|
||||
: null,
|
||||
isBlocked: blockedNavigation !== null,
|
||||
blockedNavigationDetails: blockedNavigation,
|
||||
confirmNavigation,
|
||||
cancelNavigation,
|
||||
allowNextNavigation,
|
||||
|
||||
@@ -553,9 +553,8 @@ describe('ResourceProvider', () => {
|
||||
];
|
||||
const encoded = encode(JSON.stringify(seeded));
|
||||
|
||||
// The effect that re-hydrates reads the URL through lib/router/navigation,
|
||||
// which drives the same router the tree renders in, so one navigation
|
||||
// drives both.
|
||||
// The effect that re-hydrates reads the URL through lib/history, the same
|
||||
// history the router runs on, so one navigation drives both.
|
||||
act(() => {
|
||||
navigate(`/?resourceAttribute=${encoded}`);
|
||||
});
|
||||
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
IOption,
|
||||
IResourceAttribute,
|
||||
IResourceAttributeProps,
|
||||
OperatorValues,
|
||||
Tags,
|
||||
} from 'hooks/useResourceAttribute/types';
|
||||
import { decode } from 'js-base64';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { OperatorValues, Tags } from 'hooks/useResourceAttribute/types';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { whilelistedKeys } from './config';
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
state?: any;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
@@ -109,12 +108,6 @@ export const useSafeNavigate = (
|
||||
// oxlint-disable-next-line signoz/no-raw-absolute-path
|
||||
const base = window.location.origin;
|
||||
const currentUrl = new URL(`${location.pathname}${location.search}`, base);
|
||||
// nuqs writes shallowly, so the address bar can be ahead of the router.
|
||||
// Navigating to it anyway makes nuqs drop its queued writes.
|
||||
const publishedUrl = new URL(
|
||||
`${location.pathname}?${getUnstableCurrentSearchParams().toString()}`,
|
||||
base,
|
||||
);
|
||||
|
||||
let targetUrl: URL;
|
||||
|
||||
@@ -138,9 +131,7 @@ export const useSafeNavigate = (
|
||||
return;
|
||||
}
|
||||
|
||||
const urlsAreSame =
|
||||
areUrlsEffectivelySame(currentUrl, targetUrl) ||
|
||||
areUrlsEffectivelySame(publishedUrl, targetUrl);
|
||||
const urlsAreSame = areUrlsEffectivelySame(currentUrl, targetUrl);
|
||||
const isDefaultParamsNavigation = isDefaultNavigation(currentUrl, targetUrl);
|
||||
|
||||
if (preventSameUrlNavigation && urlsAreSame) {
|
||||
@@ -153,9 +144,9 @@ export const useSafeNavigate = (
|
||||
};
|
||||
|
||||
if (typeof to === 'string') {
|
||||
void navigate(to, navigationOptions);
|
||||
navigate(to, navigationOptions);
|
||||
} else {
|
||||
void navigate(
|
||||
navigate(
|
||||
{
|
||||
pathname: to.pathname || location.pathname,
|
||||
search: to.search,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { timeParamsFromGlobalTime } from '../utils/timeUrlParams';
|
||||
import { timeParamsFromGlobalTime } from 'utils/timeUrlParams';
|
||||
|
||||
/** Active time window as a query string (no leading `?`), or `''` when unset. */
|
||||
export function useTimeSearchParams(): string {
|
||||
@@ -2,11 +2,10 @@ import { ReactNode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient } from 'react-query';
|
||||
import AppProviders from 'app/AppProviders';
|
||||
import { AppRouterProvider, createAppRouter } from 'app/AppRouter';
|
||||
import AppRoutes from 'AppRoutes';
|
||||
import { AxiosError } from 'axios';
|
||||
import { configureOverlayScrollbars } from 'lib/configureOverlayScrollbars';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import store from 'store';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -56,7 +55,8 @@ const container = document.getElementById('root');
|
||||
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
const router = createAppRouter(
|
||||
|
||||
root.render(
|
||||
<AppProviders
|
||||
store={store}
|
||||
queryClient={queryClient}
|
||||
@@ -66,6 +66,4 @@ if (container) {
|
||||
<AppRoutes />
|
||||
</AppProviders>,
|
||||
);
|
||||
|
||||
root.render(<AppRouterProvider router={router} />);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const SavedViewCreatePermission = buildPermission(
|
||||
'create',
|
||||
'saved-view:*',
|
||||
);
|
||||
export const SavedViewReadPermission = buildPermission('read', 'saved-view:*');
|
||||
|
||||
export const buildSavedViewReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `saved-view:${id}`);
|
||||
|
||||
15
frontend/src/lib/history.ts
Normal file
15
frontend/src/lib/history.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { getBasePath } from 'utils/basePath';
|
||||
|
||||
const history = createBrowserHistory({ basename: getBasePath() });
|
||||
|
||||
let inAppPushCount = 0;
|
||||
history.listen((_, action) => {
|
||||
if (action === 'PUSH') {
|
||||
inAppPushCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
export const hasInAppHistory = (): boolean => inAppPushCount > 0;
|
||||
|
||||
export default history;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { forwardRef } from 'react';
|
||||
import type { AnchorHTMLAttributes, ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { To } from './types';
|
||||
import { useAppLocation } from './useAppLocation';
|
||||
import { withState } from './utils';
|
||||
|
||||
export interface AppLinkProps extends Omit<
|
||||
AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
@@ -16,8 +18,15 @@ export interface AppLinkProps extends Omit<
|
||||
|
||||
export const AppLink = forwardRef<HTMLAnchorElement, AppLinkProps>(
|
||||
function AppLink({ to, replace, state, children, ...rest }, ref) {
|
||||
const location = useAppLocation();
|
||||
|
||||
return (
|
||||
<Link ref={ref} to={to} replace={replace} state={state} {...rest}>
|
||||
<Link
|
||||
ref={ref}
|
||||
to={withState(to, state, location)}
|
||||
replace={replace}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { createContext, type ReactNode, useContext } from 'react';
|
||||
import {
|
||||
type DataRouter,
|
||||
type RouteObject,
|
||||
RouterProvider,
|
||||
} from 'react-router';
|
||||
|
||||
import { RethrowRouteError } from './RethrowRouteError';
|
||||
|
||||
const HostedTree = createContext<ReactNode>(null);
|
||||
|
||||
function HostedTreeOutlet(): JSX.Element {
|
||||
return <>{useContext(HostedTree)}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The route table for the test and story mounts: the tree `HostRouter` was last
|
||||
* rendered with, at any URL, or only at `patterns` so it can read their params
|
||||
* the way a page does. Render errors propagate out of the router.
|
||||
*/
|
||||
export function createHostRoutes(patterns?: string[]): RouteObject[] {
|
||||
const children: RouteObject[] = patterns
|
||||
? [
|
||||
...patterns.map((path) => ({ path, element: <HostedTreeOutlet /> })),
|
||||
{ path: '*', element: null },
|
||||
]
|
||||
: [{ path: '*', element: <HostedTreeOutlet /> }];
|
||||
|
||||
return [{ errorElement: <RethrowRouteError />, children }];
|
||||
}
|
||||
|
||||
export const hostRoutes: RouteObject[] = createHostRoutes();
|
||||
|
||||
interface HostRouterProps {
|
||||
router: DataRouter;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A data router's routes are fixed when it is created, so the tree reaches the
|
||||
* route element through context instead, and a re-render with new children
|
||||
* shows them.
|
||||
*/
|
||||
export function HostRouter({ router, children }: HostRouterProps): JSX.Element {
|
||||
return (
|
||||
<HostedTree.Provider value={children}>
|
||||
<RouterProvider router={router} useTransitions={false} />
|
||||
</HostedTree.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Navigate } from 'react-router';
|
||||
import { Redirect as RouterRedirect } from 'react-router-dom';
|
||||
|
||||
import type { To } from './types';
|
||||
import { withState } from './utils';
|
||||
|
||||
export interface RedirectProps {
|
||||
to: To;
|
||||
/**
|
||||
* Defaults to `true`, matching v5's `<Redirect>`. v7's `<Navigate>` pushes
|
||||
* Defaults to `true`, matching v5's `<Redirect>`. v6's `<Navigate>` pushes
|
||||
* unless told otherwise, which is invisible until someone presses Back.
|
||||
*/
|
||||
replace?: boolean;
|
||||
@@ -17,7 +18,7 @@ export function Redirect({
|
||||
replace = true,
|
||||
state,
|
||||
}: RedirectProps): JSX.Element {
|
||||
return <Navigate to={to} replace={replace} state={state} />;
|
||||
return <RouterRedirect to={withState(to, state)} push={!replace} />;
|
||||
}
|
||||
|
||||
Redirect.defaultProps = {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useRouteError } from 'react-router';
|
||||
|
||||
/**
|
||||
* Rethrows past the router, so a render error reaches the next error boundary
|
||||
* outside it (or fails a test) instead of rendering the router's default error
|
||||
* page.
|
||||
*/
|
||||
export function RethrowRouteError(): never {
|
||||
throw useRouteError();
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { createRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { cleanup, fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import { AppLink, type AppLinkProps } from '../AppLink';
|
||||
import { useAppLocation } from '../useAppLocation';
|
||||
import { useAppNavigationType } from '../useAppNavigationType';
|
||||
|
||||
function LocationProbe(): JSX.Element {
|
||||
const location = useAppLocation();
|
||||
const navigationType = useAppNavigationType();
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -16,7 +16,7 @@ function LocationProbe(): JSX.Element {
|
||||
<span data-testid="search">{location.search}</span>
|
||||
<span data-testid="hash">{location.hash}</span>
|
||||
<span data-testid="state">{JSON.stringify(location.state ?? null)}</span>
|
||||
<span data-testid="action">{navigationType}</span>
|
||||
<span data-testid="length">{history.length}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -146,23 +146,17 @@ describe('AppLink', () => {
|
||||
expect(screen.getByTestId('state')).toHaveTextContent('{"from":"test"}');
|
||||
});
|
||||
|
||||
it('pushes by default', () => {
|
||||
renderLink({ to: '/logs', 'data-testid': 'link' } as AppLinkProps);
|
||||
fireEvent.click(screen.getByTestId('link'));
|
||||
|
||||
expect(screen.getByTestId('action')).toHaveTextContent('PUSH');
|
||||
});
|
||||
|
||||
it('replaces the entry when replace is set', () => {
|
||||
renderLink({
|
||||
to: '/logs',
|
||||
replace: true,
|
||||
'data-testid': 'link',
|
||||
} as AppLinkProps);
|
||||
const before = screen.getByTestId('length').textContent;
|
||||
fireEvent.click(screen.getByTestId('link'));
|
||||
|
||||
expect(screen.getByTestId('pathname')).toHaveTextContent('/logs');
|
||||
expect(screen.getByTestId('action')).toHaveTextContent('REPLACE');
|
||||
expect(screen.getByTestId('length').textContent).toBe(before);
|
||||
});
|
||||
|
||||
it('forwards a ref to the anchor', () => {
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { Redirect, type RedirectProps } from '../Redirect';
|
||||
import { useAppLocation } from '../useAppLocation';
|
||||
import { useAppNavigationType } from '../useAppNavigationType';
|
||||
|
||||
function LocationProbe(): JSX.Element {
|
||||
const location = useAppLocation();
|
||||
const navigationType = useAppNavigationType();
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="pathname">{location.pathname}</span>
|
||||
<span data-testid="search">{location.search}</span>
|
||||
<span data-testid="state">{JSON.stringify(location.state ?? null)}</span>
|
||||
<span data-testid="action">{navigationType}</span>
|
||||
<span data-testid="action">{history.action}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import ROUTES from 'constants/routes';
|
||||
import { buildRoutePath } from '../buildRoutePath';
|
||||
|
||||
/**
|
||||
* The encoding contract. v7's `generatePath` percent-encodes every param with
|
||||
* `encodeURIComponent`, so `buildRoutePath` no longer encodes anything itself
|
||||
* and these assertions are what pins the library's behaviour.
|
||||
* These assertions pin every URL `buildRoutePath` produces, so a router
|
||||
* upgrade cannot change one unnoticed.
|
||||
*/
|
||||
describe('buildRoutePath', () => {
|
||||
it('fills a single param', () => {
|
||||
@@ -46,15 +45,9 @@ describe('buildRoutePath', () => {
|
||||
expect(buildRoutePath(ROUTES.TRACE_DETAIL, { id: value })).toBe(expected);
|
||||
});
|
||||
|
||||
it('percent-encodes a colon', () => {
|
||||
it('leaves a colon alone', () => {
|
||||
expect(buildRoutePath(ROUTES.TRACE_DETAIL, { id: 'a:b' })).toBe(
|
||||
'/trace/a%3Ab',
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes a raw value rather than passing an encoded one through', () => {
|
||||
expect(buildRoutePath(ROUTES.TRACE_DETAIL, { id: 'a%2Fb' })).toBe(
|
||||
'/trace/a%252Fb',
|
||||
'/trace/a:b',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { BlockedTransition } from '../types';
|
||||
|
||||
/**
|
||||
* `utils/basePath` reads the base path at import time, so every case loads the
|
||||
* module, and the test router it drives, in isolation with a fresh DOM.
|
||||
* `lib/history` is a module-level singleton that reads the base path at import
|
||||
* time, so every case loads it in isolation with a fresh DOM.
|
||||
*/
|
||||
type NavigationModule = typeof import('../navigation');
|
||||
type TestRouterModule = typeof import('tests/router-utils');
|
||||
|
||||
let release: (() => void) | undefined;
|
||||
|
||||
function loadNavigation(baseHref?: string): NavigationModule {
|
||||
if (baseHref !== undefined) {
|
||||
@@ -15,25 +14,16 @@ function loadNavigation(baseHref?: string): NavigationModule {
|
||||
}
|
||||
|
||||
let mod!: NavigationModule;
|
||||
let testRouter!: TestRouterModule;
|
||||
jest.isolateModules(() => {
|
||||
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
|
||||
mod = require('../navigation');
|
||||
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
|
||||
testRouter = require('tests/router-utils');
|
||||
});
|
||||
({ release } = testRouter.attachTestRouter());
|
||||
return mod;
|
||||
}
|
||||
|
||||
function nextUpdate(
|
||||
nav: NavigationModule,
|
||||
): Promise<{ location: { pathname: string }; action: string }> {
|
||||
function settle(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const unsubscribe = nav.subscribe((update) => {
|
||||
unsubscribe();
|
||||
resolve(update);
|
||||
});
|
||||
setTimeout(resolve, 60);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,8 +32,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
release?.();
|
||||
release = undefined;
|
||||
for (const element of document.head.querySelectorAll('base')) {
|
||||
element.remove();
|
||||
}
|
||||
@@ -56,13 +44,6 @@ describe('navigate', () => {
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/logs');
|
||||
});
|
||||
|
||||
it('throws when no router is attached', () => {
|
||||
const nav = loadNavigation();
|
||||
release?.();
|
||||
release = undefined;
|
||||
expect(() => nav.navigate('/logs')).toThrow('no router is attached');
|
||||
});
|
||||
|
||||
it('carries search and hash from a string target', () => {
|
||||
const nav = loadNavigation();
|
||||
nav.navigate('/logs?a=1#top');
|
||||
@@ -131,79 +112,23 @@ describe('basename ownership', () => {
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/logs');
|
||||
});
|
||||
|
||||
it('round-trips getCurrentLocation through navigate', () => {
|
||||
it('does not double-prefix a path that already carries the base path', () => {
|
||||
const nav = loadNavigation('/signoz/');
|
||||
nav.navigate('/logs');
|
||||
nav.navigate(nav.getCurrentLocation().pathname);
|
||||
expect(window.location.pathname).toBe('/signoz/logs');
|
||||
});
|
||||
|
||||
it('prefixes a path whose first segment equals the base path', () => {
|
||||
const nav = loadNavigation('/logs/');
|
||||
nav.navigate('/logs/logs-explorer');
|
||||
expect(window.location.pathname).toBe('/logs/logs/logs-explorer');
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/logs/logs-explorer');
|
||||
});
|
||||
|
||||
it('prefixes the pathname of an object target', () => {
|
||||
const nav = loadNavigation('/signoz/');
|
||||
nav.navigate({ pathname: '/logs', search: '?a=1' });
|
||||
expect(window.location.pathname).toBe('/signoz/logs');
|
||||
expect(window.location.search).toBe('?a=1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribe', () => {
|
||||
it('reports a single update object, not two arguments', async () => {
|
||||
const nav = loadNavigation();
|
||||
const listener = jest.fn();
|
||||
const unsubscribe = nav.subscribe(listener);
|
||||
|
||||
nav.navigate('/logs');
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener.mock.calls[0]).toHaveLength(1);
|
||||
expect(listener.mock.calls[0][0]).toMatchObject({
|
||||
location: { pathname: '/logs' },
|
||||
action: 'PUSH',
|
||||
});
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('reports REPLACE', () => {
|
||||
const nav = loadNavigation();
|
||||
const listener = jest.fn();
|
||||
const unsubscribe = nav.subscribe(listener);
|
||||
|
||||
nav.navigate('/logs', { replace: true });
|
||||
|
||||
expect(listener.mock.calls[0][0].action).toBe('REPLACE');
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('stops reporting after unsubscribe', () => {
|
||||
const nav = loadNavigation();
|
||||
const listener = jest.fn();
|
||||
nav.subscribe(listener)();
|
||||
|
||||
nav.navigate('/logs');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('back', () => {
|
||||
it('reports POP and restores the previous location', async () => {
|
||||
it('restores the previous location', async () => {
|
||||
const nav = loadNavigation();
|
||||
nav.navigate('/logs');
|
||||
nav.navigate('/traces');
|
||||
|
||||
const popped = nextUpdate(nav);
|
||||
nav.back();
|
||||
const update = await popped;
|
||||
await settle();
|
||||
|
||||
expect(update.action).toBe('POP');
|
||||
expect(update.location.pathname).toBe('/logs');
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/logs');
|
||||
});
|
||||
});
|
||||
@@ -226,3 +151,261 @@ describe('hasInAppHistory', () => {
|
||||
expect(nav.hasInAppHistory()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blockNavigation', () => {
|
||||
it('hands the blocker a transition and cancels the navigation', () => {
|
||||
const nav = loadNavigation();
|
||||
const seen: BlockedTransition[] = [];
|
||||
const unblock = nav.blockNavigation((tx) => seen.push(tx));
|
||||
|
||||
nav.navigate('/logs');
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].action).toBe('PUSH');
|
||||
expect(seen[0].location.pathname).toBe('/logs');
|
||||
expect(typeof seen[0].retry).toBe('function');
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/');
|
||||
unblock();
|
||||
});
|
||||
|
||||
it('completes the navigation when retry runs after unblocking', () => {
|
||||
const nav = loadNavigation();
|
||||
const seen: BlockedTransition[] = [];
|
||||
const unblock = nav.blockNavigation((tx) => seen.push(tx));
|
||||
|
||||
nav.navigate('/logs');
|
||||
unblock();
|
||||
seen[0].retry();
|
||||
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/logs');
|
||||
});
|
||||
|
||||
it('blocks again when retry runs while still blocked, as history@5 does', () => {
|
||||
const nav = loadNavigation();
|
||||
const seen: BlockedTransition[] = [];
|
||||
const unblock = nav.blockNavigation((tx) => seen.push(tx));
|
||||
|
||||
nav.navigate('/logs');
|
||||
seen[0].retry();
|
||||
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/');
|
||||
unblock();
|
||||
});
|
||||
|
||||
it('lets a POP through when retry runs inside the blocker', async () => {
|
||||
const nav = loadNavigation();
|
||||
nav.navigate('/logs');
|
||||
nav.navigate('/traces');
|
||||
|
||||
const seen: BlockedTransition[] = [];
|
||||
let unblock = (): void => {};
|
||||
unblock = nav.blockNavigation((tx) => {
|
||||
seen.push(tx);
|
||||
unblock();
|
||||
tx.retry();
|
||||
});
|
||||
nav.back();
|
||||
await settle();
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].action).toBe('POP');
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/logs');
|
||||
});
|
||||
|
||||
it('replays a blocked Forward by one entry', async () => {
|
||||
const nav = loadNavigation();
|
||||
nav.navigate('/logs');
|
||||
nav.navigate('/traces');
|
||||
nav.back();
|
||||
await settle();
|
||||
|
||||
const seen: BlockedTransition[] = [];
|
||||
const unblock = nav.blockNavigation((tx) => seen.push(tx));
|
||||
window.history.forward();
|
||||
await settle();
|
||||
unblock();
|
||||
seen[0].retry();
|
||||
await settle();
|
||||
|
||||
expect(seen[0].action).toBe('POP');
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/traces');
|
||||
});
|
||||
|
||||
it('replays a blocked multi-entry jump by its full distance', async () => {
|
||||
const nav = loadNavigation();
|
||||
nav.navigate('/a');
|
||||
nav.navigate('/b');
|
||||
nav.navigate('/c');
|
||||
window.history.go(-2);
|
||||
await settle();
|
||||
|
||||
const seen: BlockedTransition[] = [];
|
||||
const unblock = nav.blockNavigation((tx) => seen.push(tx));
|
||||
window.history.go(2);
|
||||
await settle();
|
||||
unblock();
|
||||
seen[0].retry();
|
||||
await settle();
|
||||
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/c');
|
||||
});
|
||||
|
||||
it('replays a blocked Back to an entry from before a reload', async () => {
|
||||
const earlier = loadNavigation();
|
||||
earlier.navigate('/a');
|
||||
earlier.navigate('/b');
|
||||
earlier.navigate('/c');
|
||||
|
||||
const nav = loadNavigation();
|
||||
const seen: BlockedTransition[] = [];
|
||||
const unblock = nav.blockNavigation((tx) => seen.push(tx));
|
||||
window.history.back();
|
||||
await settle();
|
||||
unblock();
|
||||
seen[0].retry();
|
||||
await settle();
|
||||
|
||||
expect(seen[0].action).toBe('POP');
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/b');
|
||||
expect(window.location.pathname).toBe('/b');
|
||||
});
|
||||
|
||||
it('replays a blocked Forward after leaving through an entry from before a reload', async () => {
|
||||
const earlier = loadNavigation();
|
||||
earlier.navigate('/a');
|
||||
earlier.navigate('/b');
|
||||
|
||||
const nav = loadNavigation();
|
||||
nav.navigate('/c');
|
||||
window.history.go(-2);
|
||||
await settle();
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/a');
|
||||
|
||||
const seen: BlockedTransition[] = [];
|
||||
const unblock = nav.blockNavigation((tx) => seen.push(tx));
|
||||
window.history.forward();
|
||||
await settle();
|
||||
unblock();
|
||||
seen[0].retry();
|
||||
await settle();
|
||||
|
||||
expect(seen[0].action).toBe('POP');
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/b');
|
||||
expect(window.location.pathname).toBe('/b');
|
||||
});
|
||||
|
||||
it('replays a blocked Forward that skips from an entry from before a reload past the first known one', async () => {
|
||||
const earlier = loadNavigation();
|
||||
earlier.navigate('/a');
|
||||
earlier.navigate('/b');
|
||||
|
||||
const nav = loadNavigation();
|
||||
nav.navigate('/c');
|
||||
window.history.go(-2);
|
||||
await settle();
|
||||
|
||||
const seen: BlockedTransition[] = [];
|
||||
const unblock = nav.blockNavigation((tx) => seen.push(tx));
|
||||
window.history.go(2);
|
||||
await settle();
|
||||
unblock();
|
||||
seen[0].retry();
|
||||
await settle();
|
||||
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/c');
|
||||
expect(window.location.pathname).toBe('/c');
|
||||
window.history.back();
|
||||
await settle();
|
||||
expect(window.location.pathname).toBe('/b');
|
||||
});
|
||||
|
||||
it('replays a blocked Back that skips from a known entry to one from before a reload', async () => {
|
||||
const earlier = loadNavigation();
|
||||
earlier.navigate('/a');
|
||||
earlier.navigate('/b');
|
||||
|
||||
const nav = loadNavigation();
|
||||
nav.navigate('/c');
|
||||
|
||||
const seen: BlockedTransition[] = [];
|
||||
const unblock = nav.blockNavigation((tx) => seen.push(tx));
|
||||
window.history.go(-2);
|
||||
await settle();
|
||||
unblock();
|
||||
seen[0].retry();
|
||||
await settle();
|
||||
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/a');
|
||||
expect(window.location.pathname).toBe('/a');
|
||||
window.history.forward();
|
||||
await settle();
|
||||
expect(window.location.pathname).toBe('/b');
|
||||
});
|
||||
|
||||
it('catches the router up when history@4 does not revert a blocked POP', async () => {
|
||||
const nav = loadNavigation();
|
||||
nav.navigate('/p');
|
||||
// nuqs pushes like this, so both entries carry the same history@4 key.
|
||||
window.history.pushState(window.history.state, '', '/p?x=1');
|
||||
window.history.back();
|
||||
await settle();
|
||||
|
||||
const seen: BlockedTransition[] = [];
|
||||
const unblock = nav.blockNavigation((tx) => seen.push(tx));
|
||||
window.history.forward();
|
||||
await settle();
|
||||
unblock();
|
||||
seen[0].retry();
|
||||
|
||||
expect(nav.getCurrentLocation().search).toBe('?x=1');
|
||||
expect(window.location.search).toBe('?x=1');
|
||||
});
|
||||
|
||||
it('keeps the blocker armed when retry runs inside it', () => {
|
||||
const nav = loadNavigation();
|
||||
let allowNext = true;
|
||||
const seen: BlockedTransition[] = [];
|
||||
const unblock = nav.blockNavigation((tx) => {
|
||||
seen.push(tx);
|
||||
if (allowNext) {
|
||||
allowNext = false;
|
||||
tx.retry();
|
||||
}
|
||||
});
|
||||
|
||||
nav.navigate('/logs');
|
||||
nav.navigate('/traces');
|
||||
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/logs');
|
||||
expect(seen).toHaveLength(2);
|
||||
unblock();
|
||||
});
|
||||
|
||||
it('preserves the replace action through retry', () => {
|
||||
const nav = loadNavigation();
|
||||
nav.navigate('/logs');
|
||||
const before = window.history.length;
|
||||
|
||||
const seen: BlockedTransition[] = [];
|
||||
const unblock = nav.blockNavigation((tx) => seen.push(tx));
|
||||
nav.navigate('/traces', { replace: true });
|
||||
unblock();
|
||||
seen[0].retry();
|
||||
|
||||
expect(seen[0].action).toBe('REPLACE');
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/traces');
|
||||
expect(window.history).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('stops blocking after unblock', () => {
|
||||
const nav = loadNavigation();
|
||||
const listener = jest.fn();
|
||||
nav.blockNavigation(listener)();
|
||||
|
||||
nav.navigate('/logs');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(nav.getCurrentLocation().pathname).toBe('/logs');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { TestRouter } from 'tests/router';
|
||||
import { act, renderHook } from 'tests/test-utils';
|
||||
import { renderHook } from 'tests/test-utils';
|
||||
|
||||
import { navigate } from '../navigation';
|
||||
import { useAppLocation } from '../useAppLocation';
|
||||
@@ -25,10 +25,10 @@ describe('useAppLocation', () => {
|
||||
});
|
||||
|
||||
it('returns the entry state', () => {
|
||||
navigate('/logs', { replace: true, state: { from: 'test' } });
|
||||
const { result } = renderHook(() => useAppLocation<{ from: string }>(), {
|
||||
wrapper: TestRouter,
|
||||
});
|
||||
act(() => navigate('/logs', { replace: true, state: { from: 'test' } }));
|
||||
|
||||
expect(result.current.state.from).toBe('test');
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import type { NavigateOptions, To } from '../types';
|
||||
import { useAppLocation } from '../useAppLocation';
|
||||
import { useAppNavigate } from '../useAppNavigate';
|
||||
import { useAppNavigationType } from '../useAppNavigationType';
|
||||
|
||||
function Probe({
|
||||
to,
|
||||
@@ -14,7 +14,7 @@ function Probe({
|
||||
}): JSX.Element {
|
||||
const navigate = useAppNavigate();
|
||||
const location = useAppLocation();
|
||||
const navigationType = useAppNavigationType();
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -27,7 +27,7 @@ function Probe({
|
||||
<span data-testid="pathname">{location.pathname}</span>
|
||||
<span data-testid="search">{location.search}</span>
|
||||
<span data-testid="state">{JSON.stringify(location.state ?? null)}</span>
|
||||
<span data-testid="action">{navigationType}</span>
|
||||
<span data-testid="length">{history.length}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,16 +62,20 @@ describe('useAppNavigate', () => {
|
||||
|
||||
it('adds a history entry by default', () => {
|
||||
renderProbe('/logs');
|
||||
const before = screen.getByTestId('length').textContent;
|
||||
fireEvent.click(screen.getByTestId('navigate'));
|
||||
|
||||
expect(screen.getByTestId('action')).toHaveTextContent('PUSH');
|
||||
expect(Number(screen.getByTestId('length').textContent)).toBe(
|
||||
Number(before) + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add a history entry when replacing', () => {
|
||||
renderProbe('/logs', { replace: true });
|
||||
const before = screen.getByTestId('length').textContent;
|
||||
fireEvent.click(screen.getByTestId('navigate'));
|
||||
|
||||
expect(screen.getByTestId('pathname')).toHaveTextContent('/logs');
|
||||
expect(screen.getByTestId('action')).toHaveTextContent('REPLACE');
|
||||
expect(screen.getByTestId('length').textContent).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
146
frontend/src/lib/router/__tests__/utils.test.ts
Normal file
146
frontend/src/lib/router/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { History } from 'history';
|
||||
|
||||
import { applyNavigate, retryTransition, withState } from '../utils';
|
||||
|
||||
function fakeHistory(): {
|
||||
history: History;
|
||||
push: jest.Mock;
|
||||
replace: jest.Mock;
|
||||
go: jest.Mock;
|
||||
} {
|
||||
const push = jest.fn();
|
||||
const replace = jest.fn();
|
||||
const go = jest.fn();
|
||||
return {
|
||||
history: { push, replace, go } as unknown as History,
|
||||
push,
|
||||
replace,
|
||||
go,
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyNavigate', () => {
|
||||
it('pushes a string target with no second argument', () => {
|
||||
const { history, push } = fakeHistory();
|
||||
|
||||
applyNavigate(history, '/logs');
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/logs');
|
||||
});
|
||||
|
||||
it('pushes an object target with no state key', () => {
|
||||
const { history, push } = fakeHistory();
|
||||
|
||||
applyNavigate(history, { pathname: '/logs', search: '?a=1' });
|
||||
|
||||
expect(push).toHaveBeenCalledWith({ pathname: '/logs', search: '?a=1' });
|
||||
});
|
||||
|
||||
it('forwards state when there is state to forward', () => {
|
||||
const { history, push } = fakeHistory();
|
||||
|
||||
applyNavigate(history, '/logs', { state: { from: '/home' } });
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/logs', { from: '/home' });
|
||||
});
|
||||
|
||||
it('attaches state to an object target', () => {
|
||||
const { history, push } = fakeHistory();
|
||||
|
||||
applyNavigate(history, { pathname: '/logs' }, { state: { from: '/home' } });
|
||||
|
||||
expect(push).toHaveBeenCalledWith({ pathname: '/logs' }, { from: '/home' });
|
||||
});
|
||||
|
||||
it('replaces instead of pushing', () => {
|
||||
const { history, push, replace } = fakeHistory();
|
||||
|
||||
applyNavigate(history, '/logs', { replace: true });
|
||||
|
||||
expect(replace).toHaveBeenCalledWith('/logs');
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces with state', () => {
|
||||
const { history, replace } = fakeHistory();
|
||||
|
||||
applyNavigate(history, '/logs', { replace: true, state: { a: 1 } });
|
||||
|
||||
expect(replace).toHaveBeenCalledWith('/logs', { a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryTransition', () => {
|
||||
const location = { pathname: '/logs', search: '', hash: '', state: undefined };
|
||||
|
||||
it('walks a blocked POP by its distance', () => {
|
||||
const { history, go } = fakeHistory();
|
||||
|
||||
retryTransition(history, location, 'POP', 2);
|
||||
|
||||
expect(go).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('does not reload when a blocked POP has no distance', () => {
|
||||
const { history, go } = fakeHistory();
|
||||
|
||||
retryTransition(history, location, 'POP', 0);
|
||||
|
||||
expect(go).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('settles on the carried location when a blocked POP has an unknown distance', () => {
|
||||
const { history, go, replace } = fakeHistory();
|
||||
|
||||
retryTransition(history, location, 'POP', null);
|
||||
|
||||
expect(go).not.toHaveBeenCalled();
|
||||
expect(replace).toHaveBeenCalledWith(location);
|
||||
});
|
||||
|
||||
it('replays PUSH and REPLACE from the carried location', () => {
|
||||
const { history, push, replace } = fakeHistory();
|
||||
|
||||
retryTransition(history, location, 'PUSH', 0);
|
||||
retryTransition(history, location, 'REPLACE', 0);
|
||||
|
||||
expect(push).toHaveBeenCalledWith(location);
|
||||
expect(replace).toHaveBeenCalledWith(location);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withState', () => {
|
||||
it('returns the target untouched when there is no state', () => {
|
||||
expect(withState('/logs?a=1', undefined)).toBe('/logs?a=1');
|
||||
});
|
||||
|
||||
it('splits a string target before attaching state', () => {
|
||||
expect(withState('/logs?a=1#top', { from: '/home' })).toStrictEqual({
|
||||
pathname: '/logs',
|
||||
search: '?a=1',
|
||||
hash: '#top',
|
||||
state: { from: '/home' },
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a search-only string against the current location', () => {
|
||||
expect(
|
||||
withState(
|
||||
'?tab=2',
|
||||
{ from: '/home' },
|
||||
{ pathname: '/x/y', search: '?q=1', hash: '', state: null },
|
||||
),
|
||||
).toMatchObject({
|
||||
pathname: '/x/y',
|
||||
search: '?tab=2',
|
||||
state: { from: '/home' },
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches state to an object target', () => {
|
||||
expect(withState({ pathname: '/logs' }, { from: '/home' })).toStrictEqual({
|
||||
pathname: '/logs',
|
||||
state: { from: '/home' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,16 @@
|
||||
import { generatePath } from 'react-router';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Fills `:param` segments in a route pattern.
|
||||
*
|
||||
* Param values are passed raw and come back percent-encoded. `generatePath`
|
||||
* runs each one through `encodeURIComponent`, so a value can never introduce
|
||||
* extra path segments or a query string. A value that is already encoded gets
|
||||
* encoded twice: read params back with `matchRoute` / `useAppParams`, which
|
||||
* un-escape only `%2F`, and decode before passing one here.
|
||||
*
|
||||
* v7's `generatePath` types params as `string | null`, so non-strings are
|
||||
* stringified here. `undefined` values are dropped, which leaves an optional
|
||||
* segment out and makes a required one throw.
|
||||
* Param values are URI-encoded. `/`, `?` and `#` come back percent-encoded, so
|
||||
* a value can never introduce extra path segments or a query string. v5's
|
||||
* `generatePath` does this via path-to-regexp; v7's runs `encodeURIComponent`,
|
||||
* which also encodes `:`.
|
||||
*/
|
||||
export function buildRoutePath(
|
||||
pattern: string,
|
||||
params?: Record<string, string | number | boolean | undefined>,
|
||||
): string {
|
||||
if (params === undefined) {
|
||||
return generatePath(pattern);
|
||||
}
|
||||
|
||||
const stringified = Object.fromEntries(
|
||||
Object.entries(params)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => [key, String(value)]),
|
||||
);
|
||||
|
||||
return generatePath(pattern, stringified);
|
||||
return generatePath(pattern, params);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { matchPath } from 'react-router';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
import type { AppParams, RouteMatch } from './types';
|
||||
|
||||
interface MatchRouteOptions {
|
||||
/**
|
||||
* Defaults to `false`, matching v5's string form. v7's `end` defaults to
|
||||
* Defaults to `false`, matching v5's string form. v6's `end` defaults to
|
||||
* `true`, so callers that pass no options rely on this default and would
|
||||
* silently flip to exact matching without it.
|
||||
*/
|
||||
@@ -18,22 +18,29 @@ export function matchRoute<Key extends string = string>(
|
||||
): RouteMatch<Key> | null {
|
||||
const end = options.exact ?? false;
|
||||
|
||||
// v7 un-escapes `%2F` in param values itself (and nothing else), which is
|
||||
// what keeps `buildRoutePath` -> `matchRoute` a round trip for a value
|
||||
// containing a slash.
|
||||
const match = matchPath(
|
||||
{ path: pattern, end, caseSensitive: false },
|
||||
pathname,
|
||||
);
|
||||
const match = matchPath<Record<string, string | undefined>>(pathname, {
|
||||
path: pattern,
|
||||
exact: end,
|
||||
});
|
||||
|
||||
if (match === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// v6.30 un-escapes `%2F` in param values (and nothing else); v5 hands them
|
||||
// back raw. Doing it here keeps `buildRoutePath` -> `matchRoute` a round trip
|
||||
// for values containing a slash, on both versions.
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(match.params).map(([key, value]) => [
|
||||
key,
|
||||
value?.replace(/%2F/g, '/'),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
params: match.params as AppParams<Key>,
|
||||
pathname: match.pathname,
|
||||
pathnameBase: match.pathnameBase,
|
||||
params: params as AppParams<Key>,
|
||||
pathname: match.url,
|
||||
pathnameBase: match.url,
|
||||
pattern: { path: pattern, caseSensitive: false, end },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,114 +1,85 @@
|
||||
import type { DataRouter } from 'react-router';
|
||||
import { stripBasePath } from 'utils/basePath';
|
||||
import history from 'lib/history';
|
||||
|
||||
import type {
|
||||
AppLocation,
|
||||
NavigateOptions,
|
||||
NavigationAction,
|
||||
NavigationBlocker,
|
||||
To,
|
||||
} from './types';
|
||||
import { toAppLocation } from './utils';
|
||||
import { applyNavigate, retryTransition, toAppLocation } from './utils';
|
||||
|
||||
/**
|
||||
* Imperative navigation, usable from any code. `useAppNavigate` navigates on
|
||||
* the nearest router instead. Unlike `useSafeNavigate` it does not suppress
|
||||
* same-URL navigation and has no new-tab branch, because it cannot see the
|
||||
* current render.
|
||||
*
|
||||
* It drives the data router attached last: the app's, or the one a test or a
|
||||
* story mounts. Callers pass and receive basename-free app paths. The router
|
||||
* prepends the base path on write, and its `state.location` is the full browser
|
||||
* path, so reads strip it here.
|
||||
*
|
||||
* A navigation commits synchronously only while no matched route has a loader;
|
||||
* once one does, `router.navigate` settles after the loader.
|
||||
*/
|
||||
type Listener = (update: {
|
||||
location: AppLocation;
|
||||
action: NavigationAction;
|
||||
}) => void;
|
||||
let entryKeys: Array<string | undefined> = [history.location.key];
|
||||
let currentKey = history.location.key;
|
||||
|
||||
const attached: DataRouter[] = [];
|
||||
const listeners = new Set<Listener>();
|
||||
let inAppPushCount = 0;
|
||||
|
||||
function activeRouter(): DataRouter {
|
||||
const router = attached[attached.length - 1];
|
||||
if (!router) {
|
||||
throw new Error(
|
||||
'lib/router/navigation: no router is attached. Mount the app router, a TestRouter or a story first.',
|
||||
);
|
||||
// history@4 reports a blocked POP's target but not how far away it is, and the
|
||||
// POP is already reverted by the time `retry` runs. Mirroring its entry keys
|
||||
// gives the distance back. Like history@4, an entry it never saw (one from
|
||||
// before a reload) counts as the first one, so a POP whose ends both land on
|
||||
// index 0 is not reverted.
|
||||
history.listen((location, action) => {
|
||||
const index = entryKeys.indexOf(currentKey);
|
||||
currentKey = location.key;
|
||||
if (action === 'PUSH') {
|
||||
entryKeys = [...entryKeys.slice(0, index + 1), location.key];
|
||||
} else if (action === 'REPLACE' && index !== -1) {
|
||||
entryKeys[index] = location.key;
|
||||
}
|
||||
return router;
|
||||
}
|
||||
});
|
||||
|
||||
function toAppPath<S>(location: AppLocation<S>): AppLocation<S> {
|
||||
return { ...location, pathname: stripBasePath(location.pathname) };
|
||||
function entryIndex(key: string | undefined): number {
|
||||
return Math.max(0, entryKeys.indexOf(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes `router` the one this module drives until the returned detach runs.
|
||||
* Attachments stack, so detaching a router a test mounted hands control back
|
||||
* to the one below it.
|
||||
* Imperative navigation on the app's history singleton, usable from any code.
|
||||
* `useAppNavigate` navigates on the nearest router instead. Unlike
|
||||
* `useSafeNavigate` it does not suppress same-URL navigation and has no new-tab
|
||||
* branch, because it cannot see the current render.
|
||||
*
|
||||
* Callers pass and receive basename-free app paths.
|
||||
*/
|
||||
export function attachRouter(router: DataRouter): () => void {
|
||||
attached.push(router);
|
||||
|
||||
let { location } = router.state;
|
||||
const unsubscribe = router.subscribe((state) => {
|
||||
// The router also publishes navigation, fetcher and blocker state; only a
|
||||
// committed location is a navigation.
|
||||
if (state.location === location) {
|
||||
return;
|
||||
}
|
||||
location = state.location;
|
||||
|
||||
if (attached[attached.length - 1] !== router) {
|
||||
return;
|
||||
}
|
||||
if (state.historyAction === 'PUSH') {
|
||||
inAppPushCount += 1;
|
||||
}
|
||||
|
||||
const update = {
|
||||
location: toAppPath(toAppLocation(state.location)),
|
||||
action: state.historyAction as NavigationAction,
|
||||
};
|
||||
listeners.forEach((listener) => listener(update));
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
unsubscribe();
|
||||
const index = attached.lastIndexOf(router);
|
||||
if (index !== -1) {
|
||||
attached.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function navigate(to: To, options?: NavigateOptions): void {
|
||||
void activeRouter().navigate(to, {
|
||||
replace: options?.replace,
|
||||
state: options?.state,
|
||||
});
|
||||
applyNavigate(history, to, options);
|
||||
}
|
||||
|
||||
export function back(): void {
|
||||
void activeRouter().navigate(-1);
|
||||
history.goBack();
|
||||
}
|
||||
|
||||
export function getCurrentLocation(): AppLocation {
|
||||
return toAppPath(toAppLocation(activeRouter().state.location));
|
||||
return toAppLocation(history.location);
|
||||
}
|
||||
|
||||
export function subscribe(listener: Listener): () => void {
|
||||
// Wrapped so that subscribing the same function twice yields two
|
||||
// subscriptions, as `history.listen` did.
|
||||
const entry: Listener = (update) => listener(update);
|
||||
listeners.add(entry);
|
||||
return (): void => {
|
||||
listeners.delete(entry);
|
||||
};
|
||||
export function blockNavigation(blocker: NavigationBlocker): () => void {
|
||||
return history.block((location, action) => {
|
||||
const appLocation = toAppLocation(location);
|
||||
const navigationAction = action as NavigationAction;
|
||||
const delta = entryIndex(location.key) - entryIndex(currentKey);
|
||||
const popDelta = delta === 0 ? null : delta;
|
||||
let deciding = true;
|
||||
let allowed = false;
|
||||
|
||||
blocker({
|
||||
location: appLocation,
|
||||
action: navigationAction,
|
||||
retry: () => {
|
||||
if (deciding) {
|
||||
allowed = true;
|
||||
return;
|
||||
}
|
||||
retryTransition(history, appLocation, navigationAction, popDelta);
|
||||
},
|
||||
});
|
||||
deciding = false;
|
||||
|
||||
// history@4 cancels the transition when the prompt returns `false`;
|
||||
// history@5 cancels whenever a blocker is registered and hands control to
|
||||
// `retry()`. A `retry()` inside the blocker means "let it through", and
|
||||
// history@4 does that by not cancelling: re-issuing a POP would walk the
|
||||
// stack twice.
|
||||
return allowed ? undefined : false;
|
||||
});
|
||||
}
|
||||
|
||||
export const hasInAppHistory = (): boolean => inAppPushCount > 0;
|
||||
export { hasInAppHistory } from 'lib/history';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Facade contracts for the router. Every shape here is defined the way
|
||||
* react-router v7 behaves.
|
||||
* react-router v6+ / history@5 behaves; the v5-backed implementations adapt up
|
||||
* to it.
|
||||
*/
|
||||
|
||||
export type NavigationAction = 'PUSH' | 'REPLACE' | 'POP';
|
||||
@@ -10,7 +11,7 @@ export interface AppLocation<S = unknown> {
|
||||
search: string;
|
||||
hash: string;
|
||||
state: S;
|
||||
/** Always set by v7; optional for callers that build a location by hand. */
|
||||
/** history@4 leaves this undefined on the initial entry; v6 always sets it. */
|
||||
key?: string;
|
||||
}
|
||||
|
||||
@@ -23,7 +24,7 @@ export type AppParams<Key extends string = string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Mirrors v7's `useParams` return type so both call shapes keep working:
|
||||
* Mirrors v6's `useParams` return type so both call shapes keep working:
|
||||
* a union of param names (`useAppParams<'dashboardId'>()`) or a record
|
||||
* (`useAppParams<{ dashboardId: string }>()`).
|
||||
*/
|
||||
@@ -57,25 +58,15 @@ export interface NavigateOptions {
|
||||
|
||||
export type NavigateFn = (to: To, options?: NavigateOptions) => void;
|
||||
|
||||
/** The navigation a blocker function is asked about. */
|
||||
export interface BlockerArgs {
|
||||
currentLocation: AppLocation;
|
||||
nextLocation: AppLocation;
|
||||
export interface BlockedTransition {
|
||||
location: AppLocation;
|
||||
action: NavigationAction;
|
||||
/**
|
||||
* Called inside the blocker, lets this navigation through and leaves the
|
||||
* blocker registered. Called later, replays the navigation, so unblock first
|
||||
* or the blocker catches the replay too.
|
||||
*/
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
export type BlockerFunction = (args: BlockerArgs) => boolean;
|
||||
|
||||
/**
|
||||
* A blocked navigation waits on `proceed` or `reset`. Locations are
|
||||
* basename-free, like every other location the facade hands out.
|
||||
*/
|
||||
export type AppBlocker =
|
||||
| { state: 'unblocked'; location: undefined }
|
||||
| {
|
||||
state: 'blocked';
|
||||
location: AppLocation;
|
||||
proceed: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
| { state: 'proceeding'; location: AppLocation };
|
||||
export type NavigationBlocker = (transition: BlockedTransition) => void;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
type BlockerFunction as RouterBlockerFunction,
|
||||
useBlocker,
|
||||
} from 'react-router';
|
||||
import { stripBasePath } from 'utils/basePath';
|
||||
|
||||
import type {
|
||||
AppBlocker,
|
||||
AppLocation,
|
||||
BlockerFunction,
|
||||
NavigationAction,
|
||||
} from './types';
|
||||
import { toAppLocation } from './utils';
|
||||
|
||||
function toAppPath(location: AppLocation): AppLocation {
|
||||
return {
|
||||
...toAppLocation(location),
|
||||
pathname: stripBasePath(location.pathname),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds in-app navigations while `shouldBlock` returns true: links, `navigate`
|
||||
* calls and the browser's back and forward buttons. A page unload is not an
|
||||
* in-app navigation and needs its own `beforeunload` handler.
|
||||
*
|
||||
* The blocker registers in an effect, so it does not cover a navigation made
|
||||
* during the render that mounts it. A router honours one blocker at a time.
|
||||
*/
|
||||
export function useAppBlocker(shouldBlock: BlockerFunction): AppBlocker {
|
||||
// The router strips the base path from these before calling in.
|
||||
const blockerFunction = useCallback<RouterBlockerFunction>(
|
||||
({ currentLocation, nextLocation, historyAction }) =>
|
||||
shouldBlock({
|
||||
currentLocation: toAppLocation(currentLocation),
|
||||
nextLocation: toAppLocation(nextLocation),
|
||||
action: historyAction as NavigationAction,
|
||||
}),
|
||||
[shouldBlock],
|
||||
);
|
||||
|
||||
const blocker = useBlocker(blockerFunction);
|
||||
|
||||
switch (blocker.state) {
|
||||
case 'blocked':
|
||||
return {
|
||||
state: 'blocked',
|
||||
location: toAppPath(blocker.location),
|
||||
proceed: blocker.proceed,
|
||||
reset: blocker.reset,
|
||||
};
|
||||
case 'proceeding':
|
||||
return { state: 'proceeding', location: toAppPath(blocker.location) };
|
||||
default:
|
||||
return { state: 'unblocked', location: undefined };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLocation } from 'react-router';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import type { AppLocation } from './types';
|
||||
|
||||
export function useAppLocation<S = unknown>(): AppLocation<S> {
|
||||
return useLocation() as AppLocation<S>;
|
||||
return useLocation<S>();
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import type { NavigateFn, NavigateOptions, To } from './types';
|
||||
import { applyNavigate } from './utils';
|
||||
|
||||
/**
|
||||
* Hook-context imperative navigation, for the call sites that only push or
|
||||
* replace. Prefer `useSafeNavigate` when the call site wants same-URL
|
||||
* Hook-context imperative navigation, for `useHistory()` call sites that only
|
||||
* push or replace. Prefer `useSafeNavigate` when the call site wants same-URL
|
||||
* suppression or new-tab handling.
|
||||
*/
|
||||
export function useAppNavigate(): NavigateFn {
|
||||
const navigate = useNavigate();
|
||||
const history = useHistory();
|
||||
|
||||
return useCallback(
|
||||
(to: To, options?: NavigateOptions): void => {
|
||||
navigate(to, { replace: options?.replace, state: options?.state });
|
||||
applyNavigate(history, to, options);
|
||||
},
|
||||
[navigate],
|
||||
[history],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useNavigationType } from 'react-router';
|
||||
import { useNavigationType } from 'react-router-dom-v5-compat';
|
||||
|
||||
import type { NavigationAction } from './types';
|
||||
|
||||
/**
|
||||
* The action that produced the current location (`POP` for browser back and
|
||||
* forward).
|
||||
* forward). v6 only; v5 exposes this as `history.action`.
|
||||
*/
|
||||
export function useAppNavigationType(): NavigationAction {
|
||||
return useNavigationType();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import type { AppParamsResult } from './types';
|
||||
|
||||
/**
|
||||
* Params are typed `string | undefined`, which is what v7 returns.
|
||||
* Types every param `string | undefined`, as v6 does. v5 types them as required
|
||||
* strings.
|
||||
*/
|
||||
export function useAppParams<
|
||||
ParamsOrKey extends string | Record<string, string | undefined> = string,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
/**
|
||||
* Its setter navigates through the router.
|
||||
* v6's `useSearchParams`, which has no v5 equivalent. Its setter navigates
|
||||
* through the router.
|
||||
*/
|
||||
export function useAppSearchParams(): ReturnType<typeof useSearchParams> {
|
||||
return useSearchParams();
|
||||
|
||||
@@ -1,13 +1,98 @@
|
||||
import type { AppLocation } from './types';
|
||||
import type { History, Location, LocationDescriptor } from 'history';
|
||||
import { parsePath } from 'history';
|
||||
|
||||
import type {
|
||||
AppLocation,
|
||||
NavigateOptions,
|
||||
NavigationAction,
|
||||
To,
|
||||
} from './types';
|
||||
|
||||
export function toAppLocation<S = unknown>(
|
||||
location: AppLocation<unknown>,
|
||||
location: Location<S>,
|
||||
): AppLocation<S> {
|
||||
return {
|
||||
pathname: location.pathname,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
state: location.state as S,
|
||||
state: location.state,
|
||||
key: location.key,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v5 carries state inside `to`. The string form is parsed first, or search and
|
||||
* hash would land in `pathname`. Pass `currentLocation` where the target
|
||||
* renders an href, so a search-only or hash-only string keeps the current
|
||||
* pathname as it does without state; `history.push` resolves it on its own.
|
||||
* Not `createLocation`: its `decodeURI` runs again when the link is followed.
|
||||
*/
|
||||
export function withState(
|
||||
to: To,
|
||||
state: unknown,
|
||||
currentLocation?: AppLocation,
|
||||
): LocationDescriptor {
|
||||
if (state === undefined) {
|
||||
return to;
|
||||
}
|
||||
if (typeof to === 'string') {
|
||||
const parsed = parsePath(to);
|
||||
return {
|
||||
...parsed,
|
||||
pathname: parsed.pathname || currentLocation?.pathname || '',
|
||||
state,
|
||||
};
|
||||
}
|
||||
return { ...to, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* `state` always travels as the second argument, which history@4 and history@5
|
||||
* both honour for string and object targets. It is omitted rather than passed
|
||||
* as `undefined`, so a stateless `navigate(path)` reaches the history as the
|
||||
* bare `push(path)` the call sites used before the facade.
|
||||
*/
|
||||
export function applyNavigate(
|
||||
history: History,
|
||||
to: To,
|
||||
options?: NavigateOptions,
|
||||
): void {
|
||||
const { replace = false, state } = options ?? {};
|
||||
|
||||
const args: Parameters<History['push']> =
|
||||
state === undefined ? [to] : [to, state];
|
||||
|
||||
if (replace) {
|
||||
history.replace(...args);
|
||||
} else {
|
||||
history.push(...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a transition that a blocker cancelled. history@4 discards the
|
||||
* transition on cancel, so it has to be re-issued from the location it carried.
|
||||
* `popDelta` walks the browser from where history@4 reverted the blocked POP
|
||||
* back to its target. It is `null` when history@4 did not revert, so the
|
||||
* browser is already on the target and only the router has to catch up.
|
||||
*/
|
||||
export function retryTransition(
|
||||
history: History,
|
||||
location: AppLocation,
|
||||
action: NavigationAction,
|
||||
popDelta: number | null,
|
||||
): void {
|
||||
if (action === 'POP') {
|
||||
if (popDelta === null) {
|
||||
history.replace(location);
|
||||
} else if (popDelta !== 0) {
|
||||
history.go(popDelta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === 'REPLACE') {
|
||||
history.replace(location);
|
||||
return;
|
||||
}
|
||||
history.push(location);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { getPanelBuilderQuery } from '../Panels/utils/getPanelBuilderQuery';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { useTimeSearchParams } from './useTimeSearchParams';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardDetailEvents } from '../../constants/events';
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
parseNewPanelTarget,
|
||||
} from '../DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
|
||||
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
import { createDefaultPanel } from '../DashboardContainer/patchOps';
|
||||
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
|
||||
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/hooks/useSeedVariableSelection';
|
||||
|
||||
@@ -352,6 +352,7 @@ rather than a story that fails at render:
|
||||
|
||||
| Module | Replacement | Why |
|
||||
| --------------------- | ---------------------------------- | ------------------------------------------ |
|
||||
| `lib/history` | `navigation/history.alias.ts` | keeps a story on its page, see below |
|
||||
| `store` | `mocks/store.mock.ts` | the singleton answers from the story's store |
|
||||
| `api/common/logEvent` | `mocks/logEvent.mock.ts` | analytics never leave the iframe |
|
||||
| `constants/env` | `mocks/env.mock.ts` | pins the API origin the handlers answer on |
|
||||
@@ -414,17 +415,17 @@ story turns the control that lengthens it rather than a story-only prop.
|
||||
## Navigation
|
||||
|
||||
A story renders one page, so leaving that page would unmount it.
|
||||
`navigation/pageScope.ts` holds the rule, `navigation/containment.ts` creates the
|
||||
story's memory data router and guards its `navigate`, which every navigation in
|
||||
the app reaches, and the two tell a navigation apart by pathname:
|
||||
`navigation/pageScope.ts` holds the rule, `navigation/containment.ts` is what the
|
||||
app sees in place of `lib/history`, and the two tell a navigation apart by
|
||||
pathname:
|
||||
|
||||
- **Same page**: a query-param or hash change, which is how tabs, filters,
|
||||
pagination and time ranges are driven. It is applied, and the page re-renders
|
||||
the way it does in the app. Anchors are covered too: an in-page `<a href="?tab=x">`
|
||||
or `<Link to="/home?tab=x">` is intercepted and pushed onto the story's router
|
||||
or `<Link to="/home?tab=x">` is intercepted and pushed onto the story's history
|
||||
rather than followed, which would navigate the iframe out of the story.
|
||||
- **Another page**: a different pathname, an off-site href, `window.open` (what
|
||||
`useSafeNavigate({ newTab })` calls) or a relative `navigate(-1)`, which carries
|
||||
`useSafeNavigate({ newTab })` calls) or a relative `go`/`goBack`, which carries
|
||||
no target to compare against. It is swallowed and reported to
|
||||
`NavigationBlockedOverlay`, which lists what was attempted. Nothing is silently
|
||||
dropped.
|
||||
|
||||
@@ -1,46 +1,65 @@
|
||||
import { createMemoryRouter, type DataRouter, parsePath } from 'react-router';
|
||||
import { hostRoutes } from 'lib/router/HostRouter';
|
||||
import { fn } from 'storybook/test';
|
||||
import {
|
||||
History,
|
||||
LocationDescriptor,
|
||||
LocationDescriptorObject,
|
||||
parsePath,
|
||||
} from 'history';
|
||||
import { fn, type Mock } from 'storybook/test';
|
||||
|
||||
import { recordBlockedNavigation } from './blockedNavigationStore';
|
||||
import { navigateWithinPage, setStoryRouter, toHref } from './pageScope';
|
||||
import { navigateWithinPage, storyHistory, toHref } from './pageScope';
|
||||
|
||||
type Navigate = DataRouter['navigate'];
|
||||
const guardedNavigate = (
|
||||
via: 'push' | 'replace',
|
||||
): Mock<(to: LocationDescriptor, state?: unknown) => void> =>
|
||||
fn((to: LocationDescriptor, state?: unknown): void => {
|
||||
const target: LocationDescriptorObject =
|
||||
typeof to === 'string' ? { ...parsePath(to), state } : { state, ...to };
|
||||
|
||||
if (navigateWithinPage(target, { replace: via === 'replace' })) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordBlockedNavigation(via, toHref(to));
|
||||
}).mockName(`history.${via}`);
|
||||
|
||||
const blockedRelativeNavigate = (via: string): Mock<(delta?: number) => void> =>
|
||||
fn((delta?: number): void => {
|
||||
recordBlockedNavigation(via, delta === undefined ? via : `${via}(${delta})`);
|
||||
}).mockName(`history.${via}`);
|
||||
|
||||
const overriddenMethods = {
|
||||
push: guardedNavigate('push'),
|
||||
replace: guardedNavigate('replace'),
|
||||
go: blockedRelativeNavigate('go'),
|
||||
goBack: blockedRelativeNavigate('goBack'),
|
||||
goForward: blockedRelativeNavigate('goForward'),
|
||||
} as const;
|
||||
|
||||
type OverriddenMethod = keyof typeof overriddenMethods;
|
||||
|
||||
const isOverriddenMethod = (prop: string | symbol): prop is OverriddenMethod =>
|
||||
typeof prop === 'string' && prop in overriddenMethods;
|
||||
|
||||
/**
|
||||
* Every navigation the app makes reaches `router.navigate`: `useNavigate`,
|
||||
* `<Link>`, `useSearchParams` and `lib/router/navigation` all call it. One that
|
||||
* stays on the page goes through `pageScope`; whatever would leave the page is
|
||||
* swallowed and reported to `blockedNavigationStore`.
|
||||
* What the app sees in place of `lib/history`. Reads (`location`, `action`,
|
||||
* `listen`) are proxied to the story's memory history so react-router renders
|
||||
* normally; navigation goes through `pageScope`, and whatever would leave the
|
||||
* page is swallowed and reported to `blockedNavigationStore`.
|
||||
* `react-router-dom-v5-compat` drives its `useNavigate` through this same
|
||||
* object, so `useSafeNavigate` is covered too.
|
||||
*/
|
||||
const guardedNavigate = fn(((to, options) => {
|
||||
if (typeof to === 'number') {
|
||||
recordBlockedNavigation('go', `go(${to})`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
export const containedHistory: History = new Proxy(storyHistory, {
|
||||
get(target, prop, receiver) {
|
||||
if (isOverriddenMethod(prop)) {
|
||||
return overriddenMethods[prop];
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
const target = typeof to === 'string' ? parsePath(to) : (to ?? {});
|
||||
const replace = options?.replace ?? false;
|
||||
|
||||
if (!navigateWithinPage(target, { replace, state: options?.state })) {
|
||||
recordBlockedNavigation(replace ? 'replace' : 'push', toHref(target));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}) as Navigate).mockName('router.navigate');
|
||||
|
||||
/** A router for one story, placed at its route before anything renders. */
|
||||
export const createStoryRouter = (
|
||||
route: string,
|
||||
routeState?: unknown,
|
||||
): DataRouter => {
|
||||
const router = createMemoryRouter(hostRoutes, {
|
||||
initialEntries: [{ ...parsePath(route), state: routeState }],
|
||||
});
|
||||
setStoryRouter(router);
|
||||
router.navigate = guardedNavigate as Navigate;
|
||||
return router;
|
||||
};
|
||||
export const hasInAppHistory = (): boolean => false;
|
||||
|
||||
export const resetStoryHistory = (): void => {
|
||||
guardedNavigate.mockClear();
|
||||
Object.values(overriddenMethods).forEach((method) => method.mockClear());
|
||||
};
|
||||
|
||||
20
frontend/src/storybook/navigation/history.alias.ts
Normal file
20
frontend/src/storybook/navigation/history.alias.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
containedHistory,
|
||||
hasInAppHistory as containedHasInAppHistory,
|
||||
} from './containment';
|
||||
|
||||
/**
|
||||
* Replaces `lib/history` in Storybook (aliased in `.storybook/main.ts`). The
|
||||
* containment rule lives in `containment.ts`; this file only has to keep the
|
||||
* module's shape, and the annotation is what checks it against the real one.
|
||||
* An export added to `lib/history` fails to compile here instead of failing at
|
||||
* render in whichever component imports it.
|
||||
*/
|
||||
const libHistory: typeof import('lib/history') = {
|
||||
default: containedHistory,
|
||||
hasInAppHistory: containedHasInAppHistory,
|
||||
};
|
||||
|
||||
export default libHistory.default;
|
||||
|
||||
export const { hasInAppHistory } = libHistory;
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createPath, type DataRouter, parsePath } from 'react-router';
|
||||
import type { To } from 'lib/router/types';
|
||||
import {
|
||||
createMemoryHistory,
|
||||
LocationDescriptor,
|
||||
MemoryHistory,
|
||||
parsePath,
|
||||
} from 'history';
|
||||
|
||||
/**
|
||||
* Query keys the Storybook preview owns. They live in the iframe's URL, which
|
||||
@@ -15,18 +19,16 @@ const PREVIEW_PARAMS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* The story's own router. A story renders one page, so this never leaves it:
|
||||
* `pageScope` decides what counts as staying, and `containment.ts` creates the
|
||||
* router and guards its `navigate`. `navigateStoryRouter` is the unguarded one.
|
||||
* The story's own history. A story renders one page, so this never leaves it:
|
||||
* `pageScope` decides what counts as staying, and `containment.ts` is what the
|
||||
* app sees in place of `lib/history`.
|
||||
*/
|
||||
let storyRouter: DataRouter | undefined;
|
||||
let navigateStoryRouter: DataRouter['navigate'] | undefined;
|
||||
export const storyHistory: MemoryHistory = createMemoryHistory({
|
||||
initialEntries: ['/'],
|
||||
});
|
||||
|
||||
const currentPathname = (): string =>
|
||||
storyRouter?.state.location.pathname ?? '/';
|
||||
|
||||
export const toHref = (to: To): string =>
|
||||
typeof to === 'string' ? to : createPath(to);
|
||||
export const toHref = (to: LocationDescriptor): string =>
|
||||
typeof to === 'string' ? to : storyHistory.createHref(to);
|
||||
|
||||
const withoutPreviewParams = (search: string): URLSearchParams => {
|
||||
const params = new URLSearchParams(search);
|
||||
@@ -54,11 +56,9 @@ const mirrorSearchToBrowser = (): void => {
|
||||
}
|
||||
});
|
||||
|
||||
withoutPreviewParams(storyRouter?.state.location.search ?? '').forEach(
|
||||
(value, key) => {
|
||||
mirrored.set(key, value);
|
||||
},
|
||||
);
|
||||
withoutPreviewParams(storyHistory.location.search).forEach((value, key) => {
|
||||
mirrored.set(key, value);
|
||||
});
|
||||
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
@@ -67,23 +67,7 @@ const mirrorSearchToBrowser = (): void => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes `router` the story's router. `navigate` is captured before
|
||||
* `containment.ts` replaces it, so in-page moves skip the guard.
|
||||
*/
|
||||
export const setStoryRouter = (router: DataRouter): void => {
|
||||
storyRouter = router;
|
||||
navigateStoryRouter = router.navigate;
|
||||
|
||||
let { location } = router.state;
|
||||
router.subscribe((state) => {
|
||||
if (state.location !== location) {
|
||||
({ location } = state);
|
||||
mirrorSearchToBrowser();
|
||||
}
|
||||
});
|
||||
mirrorSearchToBrowser();
|
||||
};
|
||||
storyHistory.listen(mirrorSearchToBrowser);
|
||||
|
||||
/** `/home/` and `/home` are the same page as far as a story is concerned. */
|
||||
const normalizePathname = (pathname: string): string =>
|
||||
@@ -93,7 +77,8 @@ const normalizePathname = (pathname: string): string =>
|
||||
|
||||
export const isSamePagePathname = (pathname: string | undefined): boolean =>
|
||||
!pathname ||
|
||||
normalizePathname(pathname) === normalizePathname(currentPathname());
|
||||
normalizePathname(pathname) ===
|
||||
normalizePathname(storyHistory.location.pathname);
|
||||
|
||||
/**
|
||||
* Applies a navigation that stays on the story's page: a query-param or hash
|
||||
@@ -101,8 +86,8 @@ export const isSamePagePathname = (pathname: string | undefined): boolean =>
|
||||
* when the target is another page, leaving the caller to report it as blocked.
|
||||
*/
|
||||
export const navigateWithinPage = (
|
||||
to: To,
|
||||
{ replace = false, state }: { replace?: boolean; state?: unknown } = {},
|
||||
to: LocationDescriptor,
|
||||
{ replace = false }: { replace?: boolean } = {},
|
||||
): boolean => {
|
||||
const target = typeof to === 'string' ? parsePath(to) : to;
|
||||
|
||||
@@ -113,21 +98,19 @@ export const navigateWithinPage = (
|
||||
// The preview's params ride along in whatever the app read out of
|
||||
// `window.location.search`, so they are dropped on the way back in and the
|
||||
// story's history stays the page's own URL.
|
||||
void navigateStoryRouter?.(
|
||||
{
|
||||
...target,
|
||||
search: `?${withoutPreviewParams(target.search ?? '')}`,
|
||||
pathname: currentPathname(),
|
||||
},
|
||||
{ replace, state },
|
||||
);
|
||||
storyHistory[replace ? 'replace' : 'push']({
|
||||
...target,
|
||||
search: `?${withoutPreviewParams(target.search ?? '')}`,
|
||||
pathname: storyHistory.location.pathname,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Moves the story to another route without going through the block. */
|
||||
export const setStoryLocation = (route: string, state?: unknown): void => {
|
||||
void navigateStoryRouter?.(route, { replace: true, state });
|
||||
/** Places the story at a route without going through the block. */
|
||||
export const setStoryLocation = (to: LocationDescriptor): void => {
|
||||
storyHistory.replace(to);
|
||||
mirrorSearchToBrowser();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -138,7 +121,7 @@ export const isBlockableHref = (href: string): boolean =>
|
||||
href.length > 0 && !href.startsWith('#') && !href.startsWith('javascript:');
|
||||
|
||||
/**
|
||||
* An anchor href as a location the story's router understands, or `undefined`
|
||||
* An anchor href as a location the story's history understands, or `undefined`
|
||||
* when it leads off the page. Relative hrefs (`?tab=logs`) carry no pathname and
|
||||
* stay on the page; app links do, and are resolved against the iframe so an
|
||||
* off-site href fails the host check before its path is compared.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ReactNode, useEffect, useMemo } from 'react';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import AppPageProviders from '@/app/AppPageProviders';
|
||||
import AppProviders from '@/app/AppProviders';
|
||||
import AppShell from '@/app/AppShell';
|
||||
@@ -6,7 +8,7 @@ import type { AppLayer } from '@/app/types';
|
||||
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import { Toaster } from '@signozhq/ui/sonner';
|
||||
import AppLayout from 'container/AppLayout';
|
||||
import { HostRouter } from 'lib/router/HostRouter';
|
||||
import history from 'lib/history';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { AppContext, useAppContext } from 'providers/App/App';
|
||||
|
||||
@@ -39,6 +41,12 @@ function StoryContextProbe(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storyRouter: AppLayer = (children) => (
|
||||
<Router history={history}>
|
||||
<CompatRouter>{children}</CompatRouter>
|
||||
</Router>
|
||||
);
|
||||
|
||||
const appLayout: AppLayer = (children) => <AppLayout>{children}</AppLayout>;
|
||||
|
||||
const bareLayout: AppLayer = (children) => (
|
||||
@@ -58,7 +66,7 @@ function StorybookProviders({
|
||||
routeState,
|
||||
reduxState,
|
||||
}: StorybookProvidersProps): JSX.Element {
|
||||
const { router, searchParams } = useStoryRoute(route, routeState);
|
||||
const searchParams = useStoryRoute(route, routeState);
|
||||
const queryClient = useMemo(createStorybookQueryClient, []);
|
||||
const store = useMemo(() => {
|
||||
const created = createStorybookStore(reduxState);
|
||||
@@ -75,37 +83,36 @@ function StorybookProviders({
|
||||
useEffect(interceptExternalNavigation, []);
|
||||
|
||||
return (
|
||||
<HostRouter router={router}>
|
||||
<AppProviders
|
||||
store={store}
|
||||
queryClient={queryClient}
|
||||
appContext={(scoped): ReactNode => (
|
||||
<AppContext.Provider value={appContextValue}>{scoped}</AppContext.Provider>
|
||||
)}
|
||||
searchParams={(scoped): ReactNode => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
{scoped}
|
||||
</NuqsTestingAdapter>
|
||||
)}
|
||||
<AppProviders
|
||||
store={store}
|
||||
queryClient={queryClient}
|
||||
appContext={(scoped): ReactNode => (
|
||||
<AppContext.Provider value={appContextValue}>{scoped}</AppContext.Provider>
|
||||
)}
|
||||
searchParams={(scoped): ReactNode => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
{scoped}
|
||||
</NuqsTestingAdapter>
|
||||
)}
|
||||
>
|
||||
<AppShell
|
||||
router={storyRouter}
|
||||
overlays={
|
||||
<>
|
||||
<StoryContextProbe />
|
||||
<CmdKPalette userRole={role} />
|
||||
<NavigationBlockedOverlay />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AppShell
|
||||
overlays={
|
||||
<>
|
||||
<StoryContextProbe />
|
||||
<CmdKPalette userRole={role} />
|
||||
<NavigationBlockedOverlay />
|
||||
</>
|
||||
}
|
||||
<AppPageProviders
|
||||
layout={layout === 'app' ? appLayout : bareLayout}
|
||||
queryBuilder={queryBuilder}
|
||||
>
|
||||
<AppPageProviders
|
||||
layout={layout === 'app' ? appLayout : bareLayout}
|
||||
queryBuilder={queryBuilder}
|
||||
>
|
||||
{children}
|
||||
</AppPageProviders>
|
||||
</AppShell>
|
||||
</AppProviders>
|
||||
</HostRouter>
|
||||
{children}
|
||||
</AppPageProviders>
|
||||
</AppShell>
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +1,18 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { attachRouter } from 'lib/router/navigation';
|
||||
import { useMemo } from 'react';
|
||||
import { parsePath } from 'history';
|
||||
|
||||
import { createStoryRouter } from '../navigation/containment';
|
||||
import { setStoryLocation } from '../navigation/pageScope';
|
||||
|
||||
type StoryRouter = ReturnType<typeof createStoryRouter>;
|
||||
|
||||
/**
|
||||
* Creates the story's router at its route, so the page's first render already
|
||||
* reads it, and attaches it to `lib/router/navigation`. A later route (a control
|
||||
* that changes it) moves the same router. Also hands back the search params for
|
||||
* the nuqs testing adapter.
|
||||
* Places the story's history at its route before the router mounts and hands
|
||||
* back the search params for the nuqs testing adapter.
|
||||
*/
|
||||
export const useStoryRoute = (
|
||||
route: string,
|
||||
routeState?: unknown,
|
||||
): { router: StoryRouter; searchParams: URLSearchParams } => {
|
||||
// Attached while rendering: children run their effects first, and one that
|
||||
// navigates must reach this router.
|
||||
const [mounted] = useState(() => {
|
||||
const router = createStoryRouter(route, routeState);
|
||||
return { router, detach: attachRouter(router) };
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
mounted.detach();
|
||||
mounted.router.dispose();
|
||||
},
|
||||
[mounted],
|
||||
);
|
||||
|
||||
const placed = useRef({ route, routeState });
|
||||
useEffect(() => {
|
||||
if (
|
||||
placed.current.route !== route ||
|
||||
placed.current.routeState !== routeState
|
||||
) {
|
||||
placed.current = { route, routeState };
|
||||
setStoryLocation(route, routeState);
|
||||
}
|
||||
}, [route, routeState]);
|
||||
|
||||
const searchParams = useMemo(() => {
|
||||
): URLSearchParams =>
|
||||
useMemo(() => {
|
||||
setStoryLocation({ ...parsePath(route), state: routeState });
|
||||
const [, search = ''] = route.split('?');
|
||||
return new URLSearchParams(search);
|
||||
}, [route]);
|
||||
|
||||
return { router: mounted.router, searchParams };
|
||||
};
|
||||
}, [route, routeState]);
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { Route } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* A page that reads its params out of the pathname renders under its own route
|
||||
* rather than being mounted bare. `<Route>` only resolves inside `<Routes>`, so
|
||||
* the pattern a story declares is mounted here instead of in each meta.
|
||||
* rather than being mounted bare. Stories declare the pattern here instead of
|
||||
* mounting a router `<Route>` themselves.
|
||||
*/
|
||||
export const renderAtRoute = (
|
||||
path: string | string[],
|
||||
Component: ComponentType,
|
||||
): (() => JSX.Element) => {
|
||||
function StoryAtRoute(): JSX.Element {
|
||||
return (
|
||||
<Routes>
|
||||
{(Array.isArray(path) ? path : [path]).map((pattern) => (
|
||||
<Route key={pattern} path={pattern} element={<Component />} />
|
||||
))}
|
||||
</Routes>
|
||||
);
|
||||
return <Route exact path={path} component={Component} />;
|
||||
}
|
||||
|
||||
return StoryAtRoute;
|
||||
|
||||
@@ -97,10 +97,7 @@ render(<Page />, undefined, {
|
||||
expect(getCurrentLocation().pathname).toBe(ROUTES.HOME);
|
||||
```
|
||||
|
||||
Module-level `navigate()` and `getCurrentLocation()` drive the mounted router and throw
|
||||
when none is. Render first, or attach one with `attachTestRouter` from `tests/router-utils`.
|
||||
|
||||
Never mock the router packages (`react-router`, `react-router-dom`);
|
||||
Never mock the router packages (`react-router`, `react-router-dom`) or `lib/history`;
|
||||
`signoz/no-direct-react-router-import` rejects it. When a test cannot render at a route,
|
||||
or asserts the call itself, spy on the `lib/router` facade with `tests/router-mocks`:
|
||||
```ts
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Spies on the `lib/router` facade, for tests that cannot render at a route or
|
||||
// assert the navigation call itself. Tests never mock the router packages: they
|
||||
// change at a router upgrade, the facade does not.
|
||||
// assert the navigation call itself. Tests never mock the router packages or
|
||||
// `lib/history`: those change at a router upgrade, the facade does not.
|
||||
// Spies last for the rest of the file unless restored.
|
||||
|
||||
import * as navigation from 'lib/router/navigation';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user