diff --git a/docs/external-redirects.md b/docs/external-redirects.md new file mode 100644 index 0000000000..c84f8b1ae4 --- /dev/null +++ b/docs/external-redirects.md @@ -0,0 +1,23 @@ +# External Redirects + +The supported external redirect paths in the application. + +## Supported Redirects + +### Pipeline SDK Redirects + +Redirecting from Pipeline SDK output URLs to internal dashboard routes. + +#### Supported URL Patterns + +1. **Experiment Details** + ``` + /external/pipelinesSdk/{namespace}/#/experiments/details/{experimentId} + ``` + Redirects to the internal experiment runs route for the specified experiment. + +2. **Run Details** + ``` + /external/pipelinesSdk/{namespace}/#/runs/details/{runId} + ``` + Redirects to the internal pipeline run details route for the specified run. diff --git a/frontend/src/__tests__/cypress/cypress/pages/externalRedirect.ts b/frontend/src/__tests__/cypress/cypress/pages/externalRedirect.ts new file mode 100644 index 0000000000..2c8b8c9a54 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/externalRedirect.ts @@ -0,0 +1,32 @@ +class ExternalRedirect { + visit(path: string) { + cy.visitWithLogin(path); + this.wait(); + } + + private wait() { + cy.findByTestId('redirect-error').should('not.exist'); + cy.testA11y(); + } + + findErrorState() { + return cy.findByTestId('redirect-error'); + } + + findHomeButton() { + return cy.findByRole('button', { name: 'Go to Home' }); + } +} + +class PipelinesSdkRedirect { + findPipelinesButton() { + return cy.findByRole('button', { name: 'Go to Pipelines' }); + } + + findExperimentsButton() { + return cy.findByRole('button', { name: 'Go to Experiments' }); + } +} + +export const externalRedirect = new ExternalRedirect(); +export const pipelinesSdkRedirect = new PipelinesSdkRedirect(); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/externalRedirects.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/externalRedirects.cy.ts new file mode 100644 index 0000000000..3bba564f64 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/externalRedirects.cy.ts @@ -0,0 +1,41 @@ +import { + externalRedirect, + pipelinesSdkRedirect, +} from '~/__tests__/cypress/cypress/pages/externalRedirect'; + +describe('External Redirects', () => { + describe('Pipeline SDK Redirects', () => { + it('should redirect experiment URLs correctly', () => { + // Test experiment URL redirect + externalRedirect.visit('/external/pipelinesSdk/test-namespace/#/experiments/details/123'); + cy.url().should('include', '/experiments/test-namespace/123/runs'); + }); + + it('should redirect run URLs correctly', () => { + // Test run URL redirect + externalRedirect.visit('/external/pipelinesSdk/test-namespace/#/runs/details/456'); + cy.url().should('include', '/pipelineRuns/test-namespace/runs/456'); + }); + + it('should handle invalid URL format', () => { + externalRedirect.visit('/external/pipelinesSdk/test-namespace/#/invalid'); + externalRedirect.findErrorState().should('exist'); + pipelinesSdkRedirect.findPipelinesButton().should('exist'); + pipelinesSdkRedirect.findExperimentsButton().should('exist'); + }); + }); + + describe('External Redirect Not Found', () => { + it('should show not found page for invalid external routes', () => { + externalRedirect.visit('/external/invalid-path'); + externalRedirect.findErrorState().should('exist'); + externalRedirect.findHomeButton().should('exist'); + }); + + it('should allow navigation back to home', () => { + externalRedirect.visit('/external/invalid-path'); + externalRedirect.findHomeButton().click(); + cy.url().should('include', '/'); + }); + }); +}); diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/AppRoutes.tsx index 1749385da5..a20b057f1d 100644 --- a/frontend/src/app/AppRoutes.tsx +++ b/frontend/src/app/AppRoutes.tsx @@ -76,6 +76,8 @@ const StorageClassesPage = React.lazy(() => import('../pages/storageClasses/Stor const ModelRegistryRoutes = React.lazy(() => import('../pages/modelRegistry/ModelRegistryRoutes')); +const ExternalRoutes = React.lazy(() => import('../pages/external/ExternalRoutes')); + const AppRoutes: React.FC = () => { const { isAdmin, isAllowed } = useUser(); const isJupyterEnabled = useCheckJupyterEnabled(); @@ -94,6 +96,7 @@ const AppRoutes: React.FC = () => { }> + } /> {isHomeAvailable ? ( <> } /> diff --git a/frontend/src/pages/ApplicationsPage.tsx b/frontend/src/pages/ApplicationsPage.tsx index 64db623390..c3ff3de4c1 100644 --- a/frontend/src/pages/ApplicationsPage.tsx +++ b/frontend/src/pages/ApplicationsPage.tsx @@ -21,6 +21,7 @@ type ApplicationsPageProps = { loaded: boolean; empty: boolean; loadError?: Error; + loadErrorPage?: React.ReactNode; children?: React.ReactNode; errorMessage?: string; emptyMessage?: string; @@ -41,6 +42,7 @@ const ApplicationsPage: React.FC = ({ loaded, empty, loadError, + loadErrorPage, children, errorMessage, emptyMessage, @@ -77,7 +79,7 @@ const ApplicationsPage: React.FC = ({ const renderContents = () => { if (loadError) { - return ( + return !loadErrorPage ? ( = ({ {loadError.message} + ) : ( + loadErrorPage ); } diff --git a/frontend/src/pages/external/ExternalRoutes.tsx b/frontend/src/pages/external/ExternalRoutes.tsx new file mode 100644 index 0000000000..15fea35151 --- /dev/null +++ b/frontend/src/pages/external/ExternalRoutes.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Route, Routes } from 'react-router-dom'; +import PipelinesSdkRedirect from './redirectComponents/PipelinesSdkRedirects'; +import ExternalRedirectNotFound from './redirectComponents/ExternalRedirectNotFound'; + +/** + * Be sure to keep this file in sync with the documentation in `docs/external-redirects.md`. + */ +const ExternalRoutes: React.FC = () => ( + + } /> + } /> + +); + +export default ExternalRoutes; diff --git a/frontend/src/pages/external/RedirectErrorState.tsx b/frontend/src/pages/external/RedirectErrorState.tsx new file mode 100644 index 0000000000..456f6e2e69 --- /dev/null +++ b/frontend/src/pages/external/RedirectErrorState.tsx @@ -0,0 +1,71 @@ +import { + PageSection, + EmptyState, + EmptyStateVariant, + EmptyStateBody, + EmptyStateFooter, + EmptyStateActions, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import React from 'react'; + +type RedirectErrorStateProps = { + title?: string; + errorMessage?: string; + actions?: React.ReactNode | React.ReactNode[]; +}; + +/** + * A component that displays an error state with optional title, message and actions + * Used for showing redirect/navigation errors with fallback options + * + * Props for the RedirectErrorState component + * @property {string} [title] - Optional title text to display in the error state + * @property {string} [errorMessage] - Optional error message to display + * @property {React.ReactNode | React.ReactNode[]} [actions] - Custom action buttons/elements to display + * + * + * @example + * ```tsx + * // With custom actions + * + * + * + * + * } + * /> + * ``` + */ + +const RedirectErrorState: React.FC = ({ + title, + errorMessage, + actions, +}) => ( + + + {errorMessage && {errorMessage}} + {actions && ( + + {actions} + + )} + + +); + +export default RedirectErrorState; diff --git a/frontend/src/pages/external/redirectComponents/ExternalRedirectNotFound.tsx b/frontend/src/pages/external/redirectComponents/ExternalRedirectNotFound.tsx new file mode 100644 index 0000000000..b7e5c86325 --- /dev/null +++ b/frontend/src/pages/external/redirectComponents/ExternalRedirectNotFound.tsx @@ -0,0 +1,32 @@ +import { Button } from '@patternfly/react-core'; +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import ApplicationsPage from '~/pages/ApplicationsPage'; +import RedirectErrorState from '~/pages/external/RedirectErrorState'; + +const ExternalRedirectNotFound: React.FC = () => { + const navigate = useNavigate(); + + return ( + + + + } + /> + } + /> + ); +}; + +export default ExternalRedirectNotFound; diff --git a/frontend/src/pages/external/redirectComponents/PipelinesSdkRedirects.tsx b/frontend/src/pages/external/redirectComponents/PipelinesSdkRedirects.tsx new file mode 100644 index 0000000000..ce49fc8393 --- /dev/null +++ b/frontend/src/pages/external/redirectComponents/PipelinesSdkRedirects.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { useParams, useLocation, useNavigate } from 'react-router-dom'; +import { Button } from '@patternfly/react-core'; +import { experimentRunsRoute, globalPipelineRunDetailsRoute } from '~/routes'; +import ApplicationsPage from '~/pages/ApplicationsPage'; +import { useRedirect } from '~/utilities/useRedirect'; +import RedirectErrorState from '~/pages/external/RedirectErrorState'; + +/** + * Handles redirects from Pipeline SDK URLs to internal routes. + * + * Matches and redirects: + * - Experiment URL: /external/pipelinesSdk/{namespace}/#/experiments/details/{experimentId} + * - Run URL: /external/pipelinesSdk/{namespace}/#/runs/details/{runId} + */ +const PipelinesSdkRedirects: React.FC = () => { + const { namespace } = useParams<{ namespace: string }>(); + const location = useLocation(); + const navigate = useNavigate(); + + const createRedirectPath = React.useCallback(() => { + if (!namespace) { + throw new Error('Missing namespace parameter'); + } + + // Extract experimentId from hash + const experimentMatch = location.hash.match(/\/experiments\/details\/([^/]+)$/); + if (experimentMatch) { + const experimentId = experimentMatch[1]; + return experimentRunsRoute(namespace, experimentId); + } + + // Extract runId from hash + const runMatch = location.hash.match(/\/runs\/details\/([^/]+)$/); + if (runMatch) { + const runId = runMatch[1]; + return globalPipelineRunDetailsRoute(namespace, runId); + } + + throw new Error('The URL format is invalid.'); + }, [namespace, location.hash]); + + const { error } = useRedirect(createRedirectPath); + + return ( + + + + + } + /> + } + /> + ); +}; + +export default PipelinesSdkRedirects; diff --git a/frontend/src/utilities/__tests__/useRedirect.spec.ts b/frontend/src/utilities/__tests__/useRedirect.spec.ts new file mode 100644 index 0000000000..c2063abcaa --- /dev/null +++ b/frontend/src/utilities/__tests__/useRedirect.spec.ts @@ -0,0 +1,123 @@ +import { testHook } from '~/__tests__/unit/testUtils/hooks'; +import { useRedirect } from '~/utilities/useRedirect'; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, +})); + +describe('useRedirect', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle successful redirect', async () => { + const createRedirectPath = jest.fn().mockReturnValue('/success-path'); + const onComplete = jest.fn(); + const renderResult = testHook(useRedirect)(createRedirectPath, { onComplete }); + + let state = renderResult.result.current; + expect(state.loaded).toBe(false); + expect(state.error).toBeUndefined(); + + await renderResult.waitForNextUpdate(); + state = renderResult.result.current; + + expect(createRedirectPath).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/success-path', undefined); + expect(onComplete).toHaveBeenCalled(); + expect(state.loaded).toBe(true); + expect(state.error).toBeUndefined(); + }); + + it('should handle async redirect path creation', async () => { + const createRedirectPath = jest.fn().mockResolvedValue('/async-path'); + const renderResult = testHook(useRedirect)(createRedirectPath); + + let state = renderResult.result.current; + expect(state.loaded).toBe(false); + expect(state.error).toBeUndefined(); + + await renderResult.waitForNextUpdate(); + state = renderResult.result.current; + + expect(createRedirectPath).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/async-path', undefined); + expect(state.loaded).toBe(true); + expect(state.error).toBeUndefined(); + }); + + it('should handle redirect with navigation options', async () => { + const createRedirectPath = jest.fn().mockReturnValue('/path'); + const navigateOptions = { replace: true }; + const renderResult = testHook(useRedirect)(createRedirectPath, { navigateOptions }); + let state = renderResult.result.current; + expect(state.loaded).toBe(false); + expect(state.error).toBeUndefined(); + + await renderResult.waitForNextUpdate(); + state = renderResult.result.current; + + expect(mockNavigate).toHaveBeenCalledWith('/path', navigateOptions); + }); + + it('should handle error when path is undefined', async () => { + const createRedirectPath = jest.fn().mockRejectedValue(new Error('No path available')); + const onError = jest.fn(); + const renderResult = testHook(useRedirect)(createRedirectPath, { onError }); + + let state = renderResult.result.current; + expect(state.loaded).toBe(false); + expect(state.error).toBeUndefined(); + + await renderResult.waitForNextUpdate(); + state = renderResult.result.current; + + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + expect(state.loaded).toBe(true); + expect(state.error).toBeInstanceOf(Error); + }); + + it('should handle error in path creation', async () => { + const error = new Error('Failed to create path'); + const createRedirectPath = jest.fn().mockRejectedValue(error); + const onError = jest.fn(); + const renderResult = testHook(useRedirect)(createRedirectPath, { onError }); + + let state = renderResult.result.current; + expect(state.loaded).toBe(false); + expect(state.error).toBeUndefined(); + + await renderResult.waitForNextUpdate(); + state = renderResult.result.current; + + expect(onError).toHaveBeenCalledWith(error); + expect(state.loaded).toBe(true); + expect(state.error).toBe(error); + }); + + it('should not redirect to not-found when notFoundOnError is false', async () => { + const createRedirectPath = jest.fn().mockRejectedValue(new Error()); + const renderResult = testHook(useRedirect)(createRedirectPath); + + let state = renderResult.result.current; + expect(state.loaded).toBe(false); + expect(state.error).toBeUndefined(); + + await renderResult.waitForNextUpdate(); + state = renderResult.result.current; + + expect(mockNavigate).not.toHaveBeenCalled(); + + expect(state.loaded).toBe(true); + expect(state.error).toBeInstanceOf(Error); + }); + + it('should be stable', () => { + const createRedirectPath = jest.fn().mockReturnValue('/path'); + const renderResult = testHook(useRedirect)(createRedirectPath); + renderResult.rerender(createRedirectPath); + expect(renderResult).hookToBeStable({ loaded: true, error: undefined }); + expect(renderResult).hookToHaveUpdateCount(3); + }); +}); diff --git a/frontend/src/utilities/useRedirect.ts b/frontend/src/utilities/useRedirect.ts new file mode 100644 index 0000000000..921ab748c5 --- /dev/null +++ b/frontend/src/utilities/useRedirect.ts @@ -0,0 +1,91 @@ +import { NavigateOptions, useNavigate } from 'react-router-dom'; +import React from 'react'; + +export type RedirectState = { + loaded: boolean; + error?: Error; +}; + +export type RedirectOptions = { + /** Additional options for the navigate function */ + navigateOptions?: NavigateOptions; + /** Callback when redirect is complete */ + onComplete?: () => void; + /** Callback when redirect fails */ + onError?: (error: Error) => void; +}; + +/** + * Hook for managing redirects with loading states. Automatically redirects on mount. + * @param createRedirectPath Function that creates the redirect path, can be async for data fetching + * @param options Redirect options + * @returns Redirect state object containing loading and error states + * + * @example + * ```tsx + * // Basic usage + * const { loaded, error } = useRedirect(() => '/foo'); + * + * // With async path creation + * const { loaded, error } = useRedirect(async () => { + * const data = await fetchData(); + * return `/bar/${data.id}`; + * }); + * + * // With options + * const { loaded, error } = useRedirect(() => '/foobar', { + * navigateOptions: { replace: true }, + * onComplete: () => console.log('Redirected'), + * onError: (error) => console.error(error) + * }); + * + * // Usage in a component + * const createRedirectPath = React.useCallback(() => '/some/path', []); + * + * const { loaded, error } = useRedirect(createRedirectPath); + * + * return ( + * navigate('/foo/bar')}>Go to Home} + * />} + * /> + * ); + * ``` + */ +export const useRedirect = ( + createRedirectPath: () => string | Promise, + options: RedirectOptions = {}, +): RedirectState => { + const { navigateOptions, onComplete, onError } = options; + const navigate = useNavigate(); + const [state, setState] = React.useState({ + loaded: false, + error: undefined, + }); + + React.useEffect(() => { + const performRedirect = async () => { + try { + setState({ loaded: false, error: undefined }); + const path = await createRedirectPath(); + navigate(path, navigateOptions); + setState({ loaded: true, error: undefined }); + onComplete?.(); + } catch (e) { + const error = e instanceof Error ? e : new Error('Failed to redirect'); + setState({ loaded: true, error }); + onError?.(error); + } + }; + + performRedirect(); + }, [createRedirectPath, navigate, navigateOptions, onComplete, onError]); + + return state; +};