diff --git a/.config/.cprc.json b/.config/.cprc.json index c71181caa..790c93480 100644 --- a/.config/.cprc.json +++ b/.config/.cprc.json @@ -1,3 +1,4 @@ { - "version": "5.25.8" + "version": "6.7.8", + "features": {} } diff --git a/.config/bundler/externals.ts b/.config/bundler/externals.ts new file mode 100644 index 000000000..3a287d585 --- /dev/null +++ b/.config/bundler/externals.ts @@ -0,0 +1,43 @@ +import type { Configuration, ExternalItemFunctionData } from 'webpack'; + +type ExternalsType = Configuration['externals']; + +export const externals: ExternalsType = [ + // Required for dynamic publicPath resolution + { 'amd-module': 'module' }, + 'lodash', + 'jquery', + 'moment', + 'slate', + 'emotion', + '@emotion/react', + '@emotion/css', + 'prismjs', + 'slate-plain-serializer', + '@grafana/slate-react', + 'react', + 'react-dom', + 'react-redux', + 'redux', + 'rxjs', + 'i18next', + 'react-router', + 'd3', + 'angular', + /^@grafana\/ui/i, + /^@grafana\/runtime/i, + /^@grafana\/data/i, + + // Mark legacy SDK imports as external if their name starts with the "grafana/" prefix + ({ request }: ExternalItemFunctionData, callback: (error?: Error, result?: string) => void) => { + const prefix = 'grafana/'; + const hasPrefix = (request: string) => request.indexOf(prefix) === 0; + const stripPrefix = (request: string) => request.slice(prefix.length); + + if (request && hasPrefix(request)) { + return callback(undefined, stripPrefix(request)); + } + + callback(); + }, +]; diff --git a/.config/types/setupTests.d.ts b/.config/types/setupTests.d.ts new file mode 100644 index 000000000..7b0828bfa --- /dev/null +++ b/.config/types/setupTests.d.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/.config/webpack/webpack.config.ts b/.config/webpack/webpack.config.ts index 5aca63a65..793115b59 100644 --- a/.config/webpack/webpack.config.ts +++ b/.config/webpack/webpack.config.ts @@ -19,9 +19,11 @@ import VirtualModulesPlugin from 'webpack-virtual-modules'; import { BuildModeWebpackPlugin } from './BuildModeWebpackPlugin.ts'; import { DIST_DIR, SOURCE_DIR } from './constants.ts'; import { getCPConfigVersion, getEntries, getPackageJson, getPluginJson, hasReadme, isWSL } from './utils.ts'; +import { externals } from '../bundler/externals.ts'; const pluginJson = getPluginJson(); const cpVersion = getCPConfigVersion(); +const pluginVersion = getPackageJson().version; const virtualPublicPath = new VirtualModulesPlugin({ 'node_modules/grafana-public-path.js': ` @@ -54,45 +56,7 @@ const config = async (env: Env): Promise => { entry: await getEntries(), - externals: [ - // Required for dynamic publicPath resolution - { 'amd-module': 'module' }, - 'lodash', - 'jquery', - 'moment', - 'slate', - 'emotion', - '@emotion/react', - '@emotion/css', - 'prismjs', - 'slate-plain-serializer', - '@grafana/slate-react', - 'react', - 'react-dom', - 'react-redux', - 'redux', - 'rxjs', - 'react-router', - 'react-router-dom', - 'd3', - 'angular', - /^@grafana\/ui/i, - /^@grafana\/runtime/i, - /^@grafana\/data/i, - - // Mark legacy SDK imports as external if their name starts with the "grafana/" prefix - ({ request }, callback) => { - const prefix = 'grafana/'; - const hasPrefix = (request: string) => request.indexOf(prefix) === 0; - const stripPrefix = (request: string) => request.substr(prefix.length); - - if (request && hasPrefix(request)) { - return callback(undefined, stripPrefix(request)); - } - - callback(); - }, - ], + externals, // Support WebAssembly according to latest spec - makes WebAssembly module async experiments: { @@ -196,7 +160,8 @@ const config = async (env: Env): Promise => { virtualPublicPath, // Insert create plugin version information into the bundle new webpack.BannerPlugin({ - banner: '/* [create-plugin] version: ' + cpVersion + ' */', + banner: `/* [create-plugin] version: ${cpVersion} */ + /* [create-plugin] plugin: ${pluginJson.id}@${pluginVersion} */`, raw: true, entryOnly: true, }), @@ -222,11 +187,12 @@ const config = async (env: Env): Promise => { new ReplaceInFileWebpackPlugin([ { dir: DIST_DIR, - files: ['plugin.json', 'README.md'], + test: [/(^|\/)plugin\.json$/, /(^|\/)README\.md$/], + rules: [ { search: /\%VERSION\%/g, - replace: getPackageJson().version, + replace: pluginVersion, }, { search: /\%TODAY\%/g, diff --git a/.cprc.json b/.cprc.json index e5c595f74..7245cabdd 100644 --- a/.cprc.json +++ b/.cprc.json @@ -1,7 +1,7 @@ { "features": { "bundleGrafanaUI": false, - "useReactRouterV6": false, + "useReactRouterV6": true, "useExperimentalRspack": false } } diff --git a/package.json b/package.json index 47615a70c..7150879cf 100644 --- a/package.json +++ b/package.json @@ -46,14 +46,15 @@ "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", "@types/eslint": "9.6.1", + "@types/history": "4.7.11", "@types/is-base64": "1.1.3", "@types/jest": "30.0.0", "@types/k6": "1.5.0", "@types/lodash": "4.17.21", "@types/node": "24.10.1", "@types/prismjs": "1.26.5", + "@types/react": "18.3.0", "@types/react-dom": "19.2.3", - "@types/react-router-dom": "5.3.3", "@types/valid-url": "1.0.7", "@typescript-eslint/eslint-plugin": "8.46.3", "@typescript-eslint/parser": "8.46.3", @@ -108,13 +109,14 @@ "dependencies": { "@emotion/css": "11.13.5", "@grafana/alerting": "12.3.0", - "@grafana/data": "^12.1.0", + "@grafana/data": "^12.2.0", "@grafana/faro-web-sdk": "1.19.0", - "@grafana/runtime": "^12.1.0", + "@grafana/i18n": "^12.2.0", + "@grafana/runtime": "^12.2.0", "@grafana/scenes": "6.52.10", "@grafana/scenes-react": "6.52.10", - "@grafana/schema": "^12.1.0", - "@grafana/ui": "^12.1.0", + "@grafana/schema": "^12.2.0", + "@grafana/ui": "^12.2.0", "@hookform/resolvers": "5.2.2", "@tanstack/react-query": "5.90.11", "@tanstack/react-query-devtools": "5.91.1", @@ -131,15 +133,14 @@ "prismjs": "1.30.0", "punycode": "^2.1.1", "rc-slider": "11.1.9", - "react": "18.2.0", + "react": "18.3.0", "react-async-hook": "4.0.0", "react-data-table-component": "^7.5.4", - "react-dom": "18.2.0", + "react-dom": "18.3.0", "react-error-boundary": "6.0.0", "react-hook-form": "7.66.0", "react-popper": "^2.2.5", - "react-router-dom": "^5.2.0", - "react-router-dom-v5-compat": "^6.27.0", + "react-router-dom": "6.27.0", "rxjs": "7.8.2", "use-konami": "^1.0.1", "usehooks-ts": "3.1.1", diff --git a/src/components/Checkster/contexts/AppContainerContext.ts b/src/components/Checkster/contexts/AppContainerContext.ts index 9ef77659b..674528b29 100644 --- a/src/components/Checkster/contexts/AppContainerContext.ts +++ b/src/components/Checkster/contexts/AppContainerContext.ts @@ -1,7 +1,7 @@ import { createContext, CSSProperties, RefObject, useContext } from 'react'; interface SplitterComponentProps { - ref: RefObject; + ref: RefObject; className: string; style?: CSSProperties; } diff --git a/src/components/Checkster/utils/form.ts b/src/components/Checkster/utils/form.ts index 1edc951bc..45e6fa657 100644 --- a/src/components/Checkster/utils/form.ts +++ b/src/components/Checkster/utils/form.ts @@ -65,7 +65,7 @@ export function getFieldErrorProps { - // History needs to be reset manually between tests as it uses Grafana's global locationService - locationService.replace('/'); -}); - afterEach(() => { - // History needs to be reset manually between tests as it uses Grafana's global locationService locationService.replace('/'); }); function Wrapper({ children }: PropsWithChildren<{}>) { - const history = locationService.getHistory(); - // History will not automatically be reset between tests + const { history, location } = useLocationServiceHistory('/'); return ( - - - - {children}} /> - } /> - - + + + {children}} /> + } /> + ); } @@ -50,13 +41,31 @@ describe('ConfirmLeavingPage', () => { }); describe('router navigation', () => { - it.each([ - ['Stay on page', TEST_IDS.INITIAL_PAGE], - ['Leave page', TEST_IDS.OTHER_PAGE], - ])('should render a modal when navigating away (%s)', async (buttonText, expectedTestId) => { + it('should render a modal when navigating away and stay on page when clicking "Stay on page"', async () => { + render( + <> + + Leave page + + + , + { wrapper: Wrapper } + ); + + expect(await screen.findByTestId(TEST_IDS.INITIAL_PAGE)).toBeInTheDocument(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const user = userEventLib.setup(); + const link = screen.getByTestId(TEST_IDS.LEAVE_PAGE_LINK); + await user.click(link); + expect(await screen.findByTestId(DataTestIds.ConfirmUnsavedModalHeading)).toBeInTheDocument(); + await user.click(screen.getByText('Stay on page', { selector: 'button > span' })); + expect(await screen.findByTestId(TEST_IDS.INITIAL_PAGE)).toBeInTheDocument(); + }); + + it('should close modal and allow navigation when clicking "Leave page"', async () => { render( <> - + Leave page @@ -70,8 +79,8 @@ describe('ConfirmLeavingPage', () => { const link = screen.getByTestId(TEST_IDS.LEAVE_PAGE_LINK); await user.click(link); expect(await screen.findByTestId(DataTestIds.ConfirmUnsavedModalHeading)).toBeInTheDocument(); - await user.click(screen.getByText(buttonText, { selector: 'button > span' })); - expect(await screen.findByTestId(expectedTestId)).toBeInTheDocument(); + await user.click(screen.getByText('Leave page', { selector: 'button > span' })); + expect(screen.queryByTestId(DataTestIds.ConfirmUnsavedModalHeading)).not.toBeInTheDocument(); }); }); @@ -79,7 +88,7 @@ describe('ConfirmLeavingPage', () => { it('should trigger confirm on beforeunload', async () => { render( <> - + Leave page diff --git a/src/components/ConfirmLeavingPage/ConfirmLeavingPage.tsx b/src/components/ConfirmLeavingPage/ConfirmLeavingPage.tsx index 884dd389c..a1aa38782 100644 --- a/src/components/ConfirmLeavingPage/ConfirmLeavingPage.tsx +++ b/src/components/ConfirmLeavingPage/ConfirmLeavingPage.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom-v5-compat'; +import { useLocation, useNavigate } from 'react-router-dom'; import { locationService } from '@grafana/runtime'; -import { Location, TransitionPromptHook } from 'history'; +import { Location } from 'history'; import { useConfirmBeforeUnload } from 'hooks/useConfirmBeforeUnload'; @@ -20,7 +20,6 @@ interface ConfirmLeavingPageProps { * * @see {useConfirmBeforeUnload} * @param {boolean} enabled Whether or not to actively block transitions - * @constructor */ export function ConfirmLeavingPage({ enabled }: ConfirmLeavingPageProps) { const [showModal, setShowModal] = useState(false); @@ -32,12 +31,11 @@ export function ConfirmLeavingPage({ enabled }: ConfirmLeavingPageProps) { const location = useLocation(); - const blockHandler: TransitionPromptHook = useCallback( + const blockHandler = useCallback( (nextLocation: Location) => { const path = location.pathname; const nextPath = nextLocation.pathname; - // Check all the reasons to allow navigation if (!enabled || path === nextPath || changesDiscarded) { return; } @@ -51,10 +49,7 @@ export function ConfirmLeavingPage({ enabled }: ConfirmLeavingPageProps) { useEffect(() => { const unblock = history.block(blockHandler); - - return () => { - unblock(); - }; + return () => unblock(); }, [blockHandler, blockedLocation, history]); useEffect(() => { diff --git a/src/components/SceneRedirecter.test.tsx b/src/components/SceneRedirecter.test.tsx index 4af092dc2..59e365306 100644 --- a/src/components/SceneRedirecter.test.tsx +++ b/src/components/SceneRedirecter.test.tsx @@ -19,7 +19,7 @@ jest.mock('components/RunbookRedirectAlert.utils', () => ({ doRunbookRedirect: jest.fn(), })); -jest.mock('react-router-dom-v5-compat', () => ({ +jest.mock('react-router-dom', () => ({ Navigate: ({ to, replace }: { to: string; replace: boolean }) => (
Navigate to {to} diff --git a/src/components/SceneRedirecter.tsx b/src/components/SceneRedirecter.tsx index 36acf92fa..d5da0b21e 100644 --- a/src/components/SceneRedirecter.tsx +++ b/src/components/SceneRedirecter.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Navigate } from 'react-router-dom-v5-compat'; +import { Navigate } from 'react-router-dom'; import { PluginPage } from '@grafana/runtime'; import { CheckAlertType, CheckAlertWithRunbookUrl } from 'types'; diff --git a/src/components/ScenesProvider.tsx b/src/components/ScenesProvider.tsx new file mode 100644 index 000000000..880295c26 --- /dev/null +++ b/src/components/ScenesProvider.tsx @@ -0,0 +1,25 @@ +import React, { type ReactNode, useEffect, useState } from 'react'; +import { initPluginTranslations } from '@grafana/i18n'; +import { Spinner } from '@grafana/ui'; +import pluginJson from 'plugin.json'; + +// Initialize i18n (workaround for scenes#1322) +const i18nPromise = initPluginTranslations(pluginJson.id); +let i18nReady = false; +i18nPromise.then(() => { + i18nReady = true; +}); + +export function ScenesProvider({ children }: { children: ReactNode }) { + const [ready, setReady] = useState(i18nReady); + + useEffect(() => { + i18nPromise.then(() => setReady(true)); + }, []); + + if (!ready) { + return ; + } + + return children; +} diff --git a/src/configPage/PluginConfigPage/PluginConfigPage.test.tsx b/src/configPage/PluginConfigPage/PluginConfigPage.test.tsx index ea631772d..b9c5bdec5 100644 --- a/src/configPage/PluginConfigPage/PluginConfigPage.test.tsx +++ b/src/configPage/PluginConfigPage/PluginConfigPage.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { CompatRouter, Route, Routes } from 'react-router-dom-v5-compat'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { render, screen, waitFor, within } from '@testing-library/react'; import { SMDataSource } from '../../datasource/DataSource'; @@ -13,11 +12,9 @@ jest.mock('./PluginConfigPage.utils'); function Wrapper({ children }: { children: React.ReactNode }) { return ( - - - - - + + + ); } diff --git a/src/contexts/SMDatasourceContext.test.tsx b/src/contexts/SMDatasourceContext.test.tsx index 6ba73d444..34a0e45ba 100644 --- a/src/contexts/SMDatasourceContext.test.tsx +++ b/src/contexts/SMDatasourceContext.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { CompatRouter, Route, Routes } from 'react-router-dom-v5-compat'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { screen } from '@testing-library/react'; import { SM_META } from 'test/fixtures/meta'; @@ -21,17 +20,15 @@ jest.mock('utils', () => { }; }); -const Wrapper = ({ children, history, meta }: ComponentWrapperProps) => { +const Wrapper = ({ children, initialEntries, meta }: ComponentWrapperProps) => { return ( - - - - - - + + + + diff --git a/src/hooks/useConfirmBeforeUnload.test.ts b/src/hooks/useConfirmBeforeUnload.test.ts index e98d0f87e..e76a86044 100644 --- a/src/hooks/useConfirmBeforeUnload.test.ts +++ b/src/hooks/useConfirmBeforeUnload.test.ts @@ -1,10 +1,10 @@ -import { useBeforeUnload } from 'react-router-dom-v5-compat'; +import { useBeforeUnload } from 'react-router-dom'; import { renderHook } from '@testing-library/react'; import { useConfirmBeforeUnload } from './useConfirmBeforeUnload'; -jest.mock('react-router-dom-v5-compat', () => { - const originalModule = jest.requireActual('react-router-dom-v5-compat'); +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); return { ...originalModule, useBeforeUnload: jest.fn(), diff --git a/src/hooks/useConfirmBeforeUnload.ts b/src/hooks/useConfirmBeforeUnload.ts index 50e7ea397..479f78744 100644 --- a/src/hooks/useConfirmBeforeUnload.ts +++ b/src/hooks/useConfirmBeforeUnload.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { useBeforeUnload } from 'react-router-dom-v5-compat'; +import { useBeforeUnload } from 'react-router-dom'; import { useSessionStorage } from 'usehooks-ts'; import { DEV_STORAGE_KEYS } from 'components/DevTools/DevTools.constants'; diff --git a/src/hooks/useNavigateToCheckDashboard.ts b/src/hooks/useNavigateToCheckDashboard.ts index a6bfefe65..7990aaded 100644 --- a/src/hooks/useNavigateToCheckDashboard.ts +++ b/src/hooks/useNavigateToCheckDashboard.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { useNavigate } from 'react-router-dom-v5-compat'; import { dateTimeFormat } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; import { AppRoutes } from '../routing/types'; import { Check } from '../types'; @@ -10,22 +10,18 @@ import { generateRoutePath } from '../routing/utils'; import { formatDuration, getAdditionalDuration } from '../utils'; export function useNavigateToCheckDashboard() { - const navigate = useNavigate(); - return useCallback( - (result: Check, isNew: boolean) => { - const { frequency } = result; - const additionalDuration = getAdditionalDuration(frequency, 20); - const duration = formatDuration(additionalDuration, true); - const created = Math.round(result.created! * 1000); - const dateTime = dateTimeFormat(created, { format: 'yyyy-MM-DD HH:mm:ss', timeZone: `utc` }); - const from = isNew ? dateTime : `now$2B${DEFAULT_QUERY_FROM_TIME}`; + return useCallback((result: Check, isNew: boolean) => { + const { frequency } = result; + const additionalDuration = getAdditionalDuration(frequency, 20); + const duration = formatDuration(additionalDuration, true); + const created = Math.round(result.created! * 1000); + const dateTime = dateTimeFormat(created, { format: 'yyyy-MM-DD HH:mm:ss', timeZone: `utc` }); + const from = isNew ? dateTime : `now$2B${DEFAULT_QUERY_FROM_TIME}`; - navigate( - `${generateRoutePath(AppRoutes.CheckDashboard, { - id: result.id!, - })}?from=${from}&to=now%2B${duration}` - ); - }, - [navigate] - ); + locationService.push( + `${generateRoutePath(AppRoutes.CheckDashboard, { + id: result.id!, + })}?from=${from}&to=now%2B${duration}` + ); + }, []); } diff --git a/src/hooks/useNavigation.ts b/src/hooks/useNavigation.ts index 0a2bce86e..04ed9360a 100644 --- a/src/hooks/useNavigation.ts +++ b/src/hooks/useNavigation.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { useNavigate } from 'react-router-dom-v5-compat'; +import { locationService } from '@grafana/runtime'; import { PLUGIN_URL_PATH } from 'routing/constants'; @@ -8,19 +8,14 @@ export type QueryParamMap = { }; export function useNavigation() { - const navigate = useNavigate(); + return useCallback((url: string, queryParams?: QueryParamMap, external?: boolean) => { + const normalized = url.startsWith('/') ? url.slice(1) : url; + const params = queryParams ? `?${new URLSearchParams(queryParams).toString()}` : ''; - return useCallback( - (url: string, queryParams?: QueryParamMap, external?: boolean, additionalState?: any) => { - const normalized = url.startsWith('/') ? url.slice(1) : url; - const params = queryParams ? `?${new URLSearchParams(queryParams).toString()}` : ''; - - if (external) { - window.location.href = `${normalized}${params}`; - } else { - navigate(`${PLUGIN_URL_PATH}${normalized}${params}`, additionalState); - } - }, - [navigate] - ); + if (external) { + window.location.href = `${normalized}${params}`; + } else { + locationService.push(`${PLUGIN_URL_PATH}${normalized}${params}`); + } + }, []); } diff --git a/src/hooks/useQueryParametersState.test.tsx b/src/hooks/useQueryParametersState.test.tsx index bd7bf58d1..563639bfa 100644 --- a/src/hooks/useQueryParametersState.test.tsx +++ b/src/hooks/useQueryParametersState.test.tsx @@ -1,19 +1,24 @@ -import { useLocation as useLocationFromReactRouter } from 'react-router-dom-v5-compat'; +import { useLocation as useLocationFromReactRouter } from 'react-router-dom'; +import { locationService } from '@grafana/runtime'; import { act, renderHook } from '@testing-library/react'; import { useQueryParametersState } from './useQueryParametersState'; -const navigateMock = jest.fn(); - -// useLocation: jest.fn(), - -jest.mock('react-router-dom-v5-compat', () => ({ - ...jest.requireActual('react-router-dom-v5-compat'), - useNavigate: jest.fn(() => navigateMock), +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), useLocation: jest.fn(), })); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + locationService: { + push: jest.fn(), + replace: jest.fn(), + }, +})); + const useLocation = useLocationFromReactRouter as jest.MockedFunction; +const mockLocationService = locationService as jest.Mocked; describe('useQueryParametersState', () => { afterEach(() => { @@ -34,7 +39,7 @@ describe('useQueryParametersState', () => { const { result } = renderHook(() => useQueryParametersState({ key: 'myKey', initialValue })); expect(result.current[0]).toEqual({ count: 0 }); - expect(navigateMock).toHaveBeenCalledTimes(0); + expect(mockLocationService.replace).toHaveBeenCalledTimes(0); }); test('Updates query params', () => { @@ -60,10 +65,8 @@ describe('useQueryParametersState', () => { updateState(newValue); }); - expect(navigateMock).toHaveBeenCalledTimes(1); - expect(navigateMock).toHaveBeenCalledWith(`/?myKey=${encodeURIComponent(JSON.stringify(newValue))}`, { - replace: true, - }); + expect(mockLocationService.replace).toHaveBeenCalledTimes(1); + expect(mockLocationService.replace).toHaveBeenCalledWith(`/?myKey=${encodeURIComponent(JSON.stringify(newValue))}`); }); test('Removes query params', () => { @@ -89,8 +92,8 @@ describe('useQueryParametersState', () => { expect(result.current[0]).toEqual(initialValue); - expect(navigateMock).toHaveBeenCalledTimes(1); - expect(navigateMock).toHaveBeenCalledWith('/', { replace: true }); + expect(mockLocationService.replace).toHaveBeenCalledTimes(1); + expect(mockLocationService.replace).toHaveBeenCalledWith('/'); }); test('Does not remove pre-existing query params when deleting a key', () => { @@ -117,7 +120,7 @@ describe('useQueryParametersState', () => { expect(result.current[0]).toEqual(initialValue); - expect(navigateMock).toHaveBeenCalledTimes(1); + expect(mockLocationService.replace).toHaveBeenCalledTimes(1); const { result: anotherKeyState } = renderHook(() => useQueryParametersState({ key: 'anotherKey', initialValue: '' }) ); diff --git a/src/hooks/useQueryParametersState.ts b/src/hooks/useQueryParametersState.ts index 181ceb618..fad90e338 100644 --- a/src/hooks/useQueryParametersState.ts +++ b/src/hooks/useQueryParametersState.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom-v5-compat'; +import { useLocation } from 'react-router-dom'; +import { locationService } from '@grafana/runtime'; import { useURLSearchParams } from 'hooks/useURLSearchParams'; @@ -23,7 +24,6 @@ export const useQueryParametersState = ({ decode = JSON.parse, strategy = HistoryStrategy.Replace, }: QueryParametersStateProps): [ValueType, (value: ValueType | null) => void] => { - const navigate = useNavigate(); const location = useLocation(); const urlSearchParams = useURLSearchParams(); @@ -50,17 +50,17 @@ export const useQueryParametersState = ({ (href: string) => { switch (strategy) { case HistoryStrategy.Push: - navigate(href); + locationService.push(href); break; case HistoryStrategy.Replace: - navigate(href, { replace: true }); + locationService.replace(href); break; default: - navigate(href); + locationService.push(href); break; } }, - [strategy, navigate] + [strategy] ); return [parsedExistingValue || initialValue, updateState]; diff --git a/src/hooks/useURLSearchParams.ts b/src/hooks/useURLSearchParams.ts index a976fb704..67fc2821e 100644 --- a/src/hooks/useURLSearchParams.ts +++ b/src/hooks/useURLSearchParams.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useLocation } from 'react-router-dom-v5-compat'; +import { useLocation } from 'react-router-dom'; export function useURLSearchParams() { const { search } = useLocation(); diff --git a/src/page/CheckList/CheckList.tsx b/src/page/CheckList/CheckList.tsx index abf928a4e..e759030f8 100644 --- a/src/page/CheckList/CheckList.tsx +++ b/src/page/CheckList/CheckList.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom-v5-compat'; +import { useLocation } from 'react-router-dom'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { PluginPage } from '@grafana/runtime'; +import { locationService, PluginPage } from '@grafana/runtime'; import { Pagination, useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; import { getTotalChecksPerMonth } from 'checkUsageCalc'; @@ -53,7 +53,6 @@ type CheckListContentProps = { const CheckListContent = ({ onChangeViewType, viewType }: CheckListContentProps) => { useSuspenseProbes(); // we need to block rendering until we have the probe list so not to initially render a check list that might have probe filters - const navigate = useNavigate(); const location = useLocation(); const { data: checks } = useSuspenseChecks(); const { data: reachabilitySuccessRates = [] } = useChecksReachabilitySuccessRate(); @@ -134,7 +133,7 @@ const CheckListContent = ({ onChangeViewType, viewType }: CheckListContentProps) }; const handleResetFilters = () => { - navigate(`${location.pathname}${sortType ? `?sort=${sortType}` : ''}`); + locationService.push(`${location.pathname}${sortType ? `?sort=${sortType}` : ''}`); }; const handleLabelSelect = (label: Label) => { diff --git a/src/page/ChecksPage.test.tsx b/src/page/ChecksPage.test.tsx index 287906989..c4da7e5d5 100644 --- a/src/page/ChecksPage.test.tsx +++ b/src/page/ChecksPage.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { CompatRouter, Route, Routes } from 'react-router-dom-v5-compat'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { screen, waitFor, within } from '@testing-library/react'; import { DataTestIds } from 'test/dataTestIds'; @@ -23,21 +22,19 @@ import { SMDatasourceProvider } from '../contexts/SMDatasourceContext'; import { getQueryClient } from '../data/queryClient'; import { SM_META } from '../test/fixtures/meta'; -function RouteWrapper({ children, meta, history }: ComponentWrapperProps) { +function RouteWrapper({ children, meta, initialEntries }: ComponentWrapperProps) { return ( - - - - - - - - + + + + + + diff --git a/src/page/ConfigPageLayout/ConfigPageLayout.test.tsx b/src/page/ConfigPageLayout/ConfigPageLayout.test.tsx index 019093c27..07cdc97b5 100644 --- a/src/page/ConfigPageLayout/ConfigPageLayout.test.tsx +++ b/src/page/ConfigPageLayout/ConfigPageLayout.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { CompatRouter, Route, Routes } from 'react-router-dom-v5-compat'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { render } from '@testing-library/react'; import { AppRoutes } from 'routing/types'; @@ -12,15 +11,13 @@ import { ConfigPageLayout } from './ConfigPageLayout'; function Wrapper({ initialEntries = ['/'] }) { return ( - - - - index
} /> - access-tokens} /> - terraform} /> - - - + + }> + index} /> + access-tokens} /> + terraform} /> + + ); } diff --git a/src/page/ConfigPageLayout/ConfigPageLayout.tsx b/src/page/ConfigPageLayout/ConfigPageLayout.tsx index 3a02eef42..0b3a5c071 100644 --- a/src/page/ConfigPageLayout/ConfigPageLayout.tsx +++ b/src/page/ConfigPageLayout/ConfigPageLayout.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo } from 'react'; -import { matchPath, Outlet, useLocation } from 'react-router-dom-v5-compat'; +import { matchPath, Outlet, useLocation } from 'react-router-dom'; import { NavModelItem } from '@grafana/data'; import { PluginPage } from '@grafana/runtime'; diff --git a/src/page/DashboardPage.tsx b/src/page/DashboardPage.tsx index bc92712d0..67857d947 100644 --- a/src/page/DashboardPage.tsx +++ b/src/page/DashboardPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useParams } from 'react-router-dom-v5-compat'; +import { useParams } from 'react-router-dom'; import { Spinner } from '@grafana/ui'; import { CheckPageParams, CheckType } from 'types'; diff --git a/src/page/EditCheck/EditCheckV2.tsx b/src/page/EditCheck/EditCheckV2.tsx index fd796b15b..3980aaa5b 100644 --- a/src/page/EditCheck/EditCheckV2.tsx +++ b/src/page/EditCheck/EditCheckV2.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo } from 'react'; -import { useParams } from 'react-router-dom-v5-compat'; +import { useParams } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; import { PluginPage } from '@grafana/runtime'; import { Alert, Button, LinkButton, Modal, Text, useStyles2 } from '@grafana/ui'; diff --git a/src/page/EditProbe/EditProbe.tsx b/src/page/EditProbe/EditProbe.tsx index 13ba6a13a..3ee5bb850 100644 --- a/src/page/EditProbe/EditProbe.tsx +++ b/src/page/EditProbe/EditProbe.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom-v5-compat'; -import { PluginPage } from '@grafana/runtime'; +import { useParams } from 'react-router-dom'; +import { locationService, PluginPage } from '@grafana/runtime'; import { LinkButton, TextLink } from '@grafana/ui'; import { ExtendedProbe, type Probe, type ProbePageParams } from 'types'; @@ -21,15 +21,14 @@ import { getErrorInfo, getTitle } from './EditProbe.utils'; export const EditProbe = ({ forceViewMode }: { forceViewMode?: boolean }) => { const [probe, setProbe] = useState(); const [errorMessage, setErrorMessage] = useState(''); - const navigate = useNavigate(); const { canWriteProbes } = useCanEditProbe(probe); useEffect(() => { // This is mainly here to handle legacy links redirect if (probe && !canWriteProbes && !forceViewMode) { - navigate(generateRoutePath(AppRoutes.ViewProbe, { id: probe.id! }), { replace: true }); + locationService.replace(generateRoutePath(AppRoutes.ViewProbe, { id: probe.id! })); } - }, [canWriteProbes, navigate, probe, forceViewMode]); + }, [canWriteProbes, probe, forceViewMode]); if (errorMessage) { return ( diff --git a/src/page/NewCheck/NewCheckV2.tsx b/src/page/NewCheck/NewCheckV2.tsx index 5c69a9b01..08b84aa9e 100644 --- a/src/page/NewCheck/NewCheckV2.tsx +++ b/src/page/NewCheck/NewCheckV2.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react'; -import { useNavigate, useParams, useSearchParams } from 'react-router-dom-v5-compat'; +import { useLocation, useParams, useSearchParams } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; -import { PluginPage } from '@grafana/runtime'; +import { locationService, PluginPage } from '@grafana/runtime'; import { TextLink, useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; import { DataTestIds } from 'test/dataTestIds'; @@ -44,16 +44,16 @@ export function NewCheckV2() { }, ]); - const navigate = useNavigate(); + const location = useLocation(); const handleSubmit = useHandleSubmitCheckster(); const handleCheckTypeChange = useCallback( (newCheckType: CheckType) => { const search = new URLSearchParams(urlSearchParams); search.set(CHECK_TYPE_PARAM_NAME, newCheckType); - navigate({ search: search.toString() }, { replace: true }); + locationService.replace(`${location.pathname}?${search.toString()}`); }, - [navigate, urlSearchParams] + [location.pathname, urlSearchParams] ); const isOverlimit = useIsOverlimit(false, checkType); diff --git a/src/page/NotFound/CheckNotFound.tsx b/src/page/NotFound/CheckNotFound.tsx index 8749b0afb..5a241f71d 100644 --- a/src/page/NotFound/CheckNotFound.tsx +++ b/src/page/NotFound/CheckNotFound.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useParams } from 'react-router-dom-v5-compat'; +import { useParams } from 'react-router-dom'; import { TextLink } from '@grafana/ui'; import { createNavModel } from 'utils'; diff --git a/src/routing/InitialisedRouter.tsx b/src/routing/InitialisedRouter.tsx index b75adb544..51698643f 100644 --- a/src/routing/InitialisedRouter.tsx +++ b/src/routing/InitialisedRouter.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { Navigate, Route, Routes } from 'react-router-dom-v5-compat'; +import { Navigate, Route, Routes } from 'react-router-dom'; import { TextLink } from '@grafana/ui'; import { FeatureName } from 'types'; @@ -12,6 +12,7 @@ import { useLimits } from 'hooks/useLimits'; import { QueryParamMap, useNavigation } from 'hooks/useNavigation'; import { useURLSearchParams } from 'hooks/useURLSearchParams'; import { SceneRedirecter } from 'components/SceneRedirecter'; +import { ScenesProvider } from 'components/ScenesProvider'; import { AlertingPage } from 'page/AlertingPage'; import { CheckList } from 'page/CheckList'; import { ChooseCheckGroup } from 'page/ChooseCheckGroup'; @@ -62,7 +63,9 @@ export const InitialisedRouter = () => { path={AppRoutes.Home} element={ canReadChecks ? ( - + + + ) : ( ) @@ -76,7 +79,9 @@ export const InitialisedRouter = () => { index element={ canReadChecks ? ( - + + + ) : ( ) @@ -131,7 +136,7 @@ export const InitialisedRouter = () => { } /> - + }> } /> } /> } /> diff --git a/src/routing/UninitialisedRouter.tsx b/src/routing/UninitialisedRouter.tsx index 8935bb854..78e655d79 100644 --- a/src/routing/UninitialisedRouter.tsx +++ b/src/routing/UninitialisedRouter.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Navigate, Route, Routes } from 'react-router-dom-v5-compat'; +import { Navigate, Route, Routes } from 'react-router-dom'; import { AppRoutes } from 'routing/types'; import { useMeta } from 'hooks/useMeta'; @@ -28,7 +28,7 @@ export const UninitialisedRouter = () => { } /> } /> } /> - + }> } /> } /> diff --git a/src/routing/utils.ts b/src/routing/utils.ts index 321246cbf..1a2495ce2 100644 --- a/src/routing/utils.ts +++ b/src/routing/utils.ts @@ -1,4 +1,4 @@ -import { generatePath, type PathParam } from 'react-router-dom-v5-compat'; +import { generatePath, type PathParam } from 'react-router-dom'; import { CheckType, CheckTypeGroup } from 'types'; import { PLUGIN_URL_PATH } from 'routing/constants'; diff --git a/src/scenes/components/TimepointExplorer/TimepointList.tsx b/src/scenes/components/TimepointExplorer/TimepointList.tsx index ffa8458b1..1a825d015 100644 --- a/src/scenes/components/TimepointExplorer/TimepointList.tsx +++ b/src/scenes/components/TimepointExplorer/TimepointList.tsx @@ -48,7 +48,6 @@ export const TimepointList = () => { }, 100); useResizeObserver({ - // @ts-expect-error https://github.com/juliencrn/usehooks-ts/issues/663 ref, onResize: () => { onResize(ref.current?.clientWidth ?? 0); diff --git a/src/scenes/components/TimepointExplorer/TimepointMinimap.tsx b/src/scenes/components/TimepointExplorer/TimepointMinimap.tsx index 19dd21d4b..85cd262e8 100644 --- a/src/scenes/components/TimepointExplorer/TimepointMinimap.tsx +++ b/src/scenes/components/TimepointExplorer/TimepointMinimap.tsx @@ -116,7 +116,6 @@ const TimepointMinimapContent = () => { : []; useResizeObserver({ - // @ts-expect-error ref, onResize: (element) => { setMiniMapWidth(element.width ?? 0); diff --git a/src/scenes/components/TimepointExplorer/TimepointViewer.tsx b/src/scenes/components/TimepointExplorer/TimepointViewer.tsx index bcaf65a27..4b99d733d 100644 --- a/src/scenes/components/TimepointExplorer/TimepointViewer.tsx +++ b/src/scenes/components/TimepointExplorer/TimepointViewer.tsx @@ -91,7 +91,6 @@ const TimepointViewerContent = ({ logsView, probeNameToView, timepoint }: Timepo useRefetchInterval(enableRefetch, refetch); useResizeObserver({ - // @ts-expect-error https://github.com/juliencrn/usehooks-ts/issues/663 ref: elRef, onResize: (element) => { setViewerWidth(element.width ?? 0); diff --git a/src/test/helpers/TestRouteInfo.tsx b/src/test/helpers/TestRouteInfo.tsx index a2f82182c..9dbede7a3 100644 --- a/src/test/helpers/TestRouteInfo.tsx +++ b/src/test/helpers/TestRouteInfo.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useLocation } from 'react-router-dom-v5-compat'; +import { useLocation } from 'react-router-dom'; import { DataTestIds } from '../dataTestIds'; diff --git a/src/test/helpers/useLocationServiceHistory.ts b/src/test/helpers/useLocationServiceHistory.ts new file mode 100644 index 000000000..24c838390 --- /dev/null +++ b/src/test/helpers/useLocationServiceHistory.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; +import { locationService } from '@grafana/runtime'; + +/** + * Hook that syncs locationService's history with React state for use with + * the low-level `` component. This ensures React Router and + * locationService share the same history instance. + * + * @param initialPath - Initial path to navigate to. Defaults to '/'. + * @returns `history` and `location` to spread onto ``. + * + * @see https://grafana.com/developers/plugin-tools/migration-guides/update-from-grafana-versions/migrate-10_0_x-to-10_1_x#4-fix-test-failures-with-location-service-methods + */ +export function useLocationServiceHistory(initialPath = '/') { + const history = locationService.getHistory(); + const [location, setLocation] = useState(() => { + history.replace(initialPath); + return { ...history.location }; + }); + + useEffect(() => { + const unlisten = history.listen((update) => { + // history v4/v5 passes { location, action } + const newLocation = (update as unknown as { location: typeof location }).location; + setLocation({ ...newLocation }); + }); + return unlisten; + }, [history]); + + return { history, location }; +} diff --git a/src/test/mocks/@grafana/runtime.tsx b/src/test/mocks/@grafana/runtime.tsx index c60727e2d..7bfbc04fe 100644 --- a/src/test/mocks/@grafana/runtime.tsx +++ b/src/test/mocks/@grafana/runtime.tsx @@ -10,66 +10,136 @@ import { SMDataSource } from 'datasource/DataSource'; import { DataTestIds } from '../../dataTestIds'; +/** + * @grafana/runtime mock for React Router v6. + * + * Provides a minimal history implementation compatible with React Router v6's + * Router component. The test wrapper (render.tsx) resets the location before + * each test for isolation. + */ jest.mock('@grafana/runtime', () => { const actual = jest.requireActual('@grafana/runtime'); + + type Location = { pathname: string; search: string; hash: string; state: unknown; key: string }; + type PathArg = string | { pathname?: string; search?: string; hash?: string }; + + let location: Location = { pathname: '/', search: '', hash: '', state: null, key: 'default' }; + let listeners: Array<(update: { location: Location; action: string }) => void> = []; + let blockers: Array<(location: Location, action: string) => boolean | void> = []; + + const parsePath = (path: PathArg) => { + if (typeof path !== 'string') { + return { pathname: path.pathname || '/', search: path.search || '', hash: path.hash || '' }; + } + const searchIdx = path.indexOf('?'); + const hashIdx = path.indexOf('#'); + return { + pathname: searchIdx >= 0 ? path.slice(0, searchIdx) : hashIdx >= 0 ? path.slice(0, hashIdx) : path, + search: searchIdx >= 0 ? path.slice(searchIdx, hashIdx >= 0 ? hashIdx : undefined) : '', + hash: hashIdx >= 0 ? path.slice(hashIdx) : '', + }; + }; + + const navigate = (path: PathArg, action: string) => { + const next: Location = { ...parsePath(path), state: null, key: Math.random().toString(36).slice(2) }; + for (const blocker of blockers) { + if (blocker(next, action) === false) { + return; + } + } + location = next; + listeners.forEach((l) => l({ location, action })); + }; + + const history = { + get length() { + return 1; + }, + get location() { + return location; + }, + get action() { + return 'POP' as const; + }, + push: (path: PathArg) => navigate(path, 'PUSH'), + replace: (path: PathArg) => navigate(path, 'REPLACE'), + go: () => {}, + back: () => {}, + forward: () => {}, + createHref: (to: PathArg) => (typeof to === 'string' ? to : to.pathname || '/'), + block: (fn: (location: Location, action: string) => boolean | void) => { + blockers.push(fn); + return () => { + blockers = blockers.filter((b) => b !== fn); + }; + }, + listen: (fn: (update: { location: Location; action: string }) => void) => { + listeners.push(fn); + return () => { + listeners = listeners.filter((l) => l !== fn); + }; + }, + }; + + const locationService = { + push: jest.fn((path: PathArg) => history.push(path)), + replace: jest.fn((path: PathArg) => history.replace(path)), + getLocation: jest.fn(() => location), + getHistory: jest.fn(() => history), + getSearch: jest.fn(() => new URLSearchParams(location.search)), + getSearchObject: jest.fn(() => Object.fromEntries(new URLSearchParams(location.search))), + partial: jest.fn((query: Record, replace?: boolean) => { + const params = new URLSearchParams(location.search); + Object.entries(query).forEach(([k, v]) => (v == null ? params.delete(k) : params.set(k, v))); + const search = params.toString(); + const href = search ? `${location.pathname}?${search}` : location.pathname; + replace ? history.replace(href) : history.push(href); + }), + }; + return { ...actual, + locationService, + LocationServiceProvider: actual.LocationServiceProvider, config: { ...actual.config, datasources: { [METRICS_DATASOURCE.name]: METRICS_DATASOURCE, [LOGS_DATASOURCE.name]: LOGS_DATASOURCE, }, - featureToggles: { - ...actual.config.featureToggles, - }, + featureToggles: { ...actual.config.featureToggles }, bootData: { - user: { - ...actual.config.user, - orgRole: OrgRole.Admin, - permissions: FULL_ADMIN_ACCESS, - }, + user: { ...actual.config.user, orgRole: OrgRole.Admin, permissions: FULL_ADMIN_ACCESS }, }, }, getBackendSrv: () => ({ datasourceRequest: axios.request, - fetch: (request: BackendSrvRequest) => { - return from( - axios - .request({ - ...request, - method: request.method, - }) - .catch((e) => { - const error = new Error(e.message); - // @ts-expect-error Match error format with backendsrv - error.data = e.response.data; - // @ts-expect-error Match error format with backendsrv - error.status = e.response.status; - - throw error; - }) - ); - }, + fetch: (request: BackendSrvRequest) => + from( + axios.request({ ...request, method: request.method }).catch((e) => { + const error = new Error(e.message); + // @ts-expect-error Match error format with backendsrv + error.data = e.response.data; + // @ts-expect-error Match error format with backendsrv + error.status = e.response.status; + throw error; + }) + ), }), getDataSourceSrv: () => ({ getList: () => [METRICS_DATASOURCE, LOGS_DATASOURCE, SM_DATASOURCE], get: () => Promise.resolve(new SMDataSource(SM_DATASOURCE)), }), - getLocationSrv: () => ({ - update: (args: any) => args, - }), - PluginPage: ({ actions, children, pageNav }: { actions: any; children: ReactNode; pageNav: NavModelItem }) => { - return ( -
-

{pageNav?.text}

-
{actions}
- {children} -
- {pageNav?.children?.find((child) => child.active)?.text ?? 'No active tab'} -
+ getLocationSrv: () => ({ update: (args: any) => args }), + PluginPage: ({ actions, children, pageNav }: { actions: any; children: ReactNode; pageNav: NavModelItem }) => ( +
+

{pageNav?.text}

+
{actions}
+ {children} +
+ {pageNav?.children?.find((c) => c.active)?.text ?? 'No active tab'}
- ); - }, +
+ ), }; }); diff --git a/src/test/mocks/@grafana/ui.tsx b/src/test/mocks/@grafana/ui.tsx index 7dae52171..e8ea4392b 100644 --- a/src/test/mocks/@grafana/ui.tsx +++ b/src/test/mocks/@grafana/ui.tsx @@ -1,53 +1,72 @@ import React, { forwardRef, PropsWithChildren } from 'react'; import { DataTestIds } from 'test/dataTestIds'; +// Mock Link/TextLink because @grafana/ui uses react-router-dom-v5-compat internally jest.mock('@grafana/ui', () => { const actual = jest.requireActual('@grafana/ui'); - const Icon = forwardRef((props, ref) => ); - Icon.displayName = 'Icon'; + const createRouterLink = (displayName: string) => { + const Component = forwardRef(({ href, children, onClick, external, ...props }, ref) => { + const handleClick = (e: React.MouseEvent) => { + onClick?.(e); + if (external || !href || e.defaultPrevented) { + return; + } + e.preventDefault(); + const { locationService } = require('@grafana/runtime'); + locationService.push(href); + }; + + const externalProps = external ? { target: '_blank', rel: 'noopener noreferrer' } : {}; - // Monaco does not render with jest and is stuck at "Loading..." - // There doesn't seem to be a solution to this at this point, - // mocking it instead. Related github issue: - // https://github.com/suren-atoyan/monaco-react/issues/88 - const CodeEditor = React.forwardRef((props: any, ref: any) => { - return