1
0
mirror of https://github.com/quay/quay.git synced 2026-01-26 06:21:37 +03:00

[redhat-3.16] fix(web): support shorthand organization and repository URLs with redirect (PROJQUAY-9580) (#4459)

fix(web): support shorthand organization and repository URLs with redirect (PROJQUAY-9580)

Users can now navigate directly to organizations and repositories using
shorthand URLs, matching the Angular UI behavior:
- /myorg → /organization/myorg
- /openshift/release → /repository/openshift/release

Implementation improvements:
- Dynamically derives reserved route prefixes from NavigationPath enum
- TypeScript interface for type-safe route parameters
- Comprehensive JSDoc documentation with examples
- Preserves query parameters and hash fragments during redirects
- Factory function for test mock data reusability

Test coverage:
- 11 comprehensive Cypress e2e tests (up from 6)
- Tests for organization and repository redirects
- Query parameter and hash fragment preservation
- Reserved route prefix handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Brady Pratt <bpratt@redhat.com>
Co-authored-by: Brady Pratt <bpratt@redhat.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
OpenShift Cherrypick Robot
2025-11-05 22:06:25 +01:00
committed by GitHub
parent 2d99acdc39
commit e697b59475
2 changed files with 381 additions and 1 deletions

View File

@@ -0,0 +1,284 @@
/// <reference types="cypress" />
/**
* Factory function to create repository mock data
* @param org - Organization/namespace name
* @param name - Repository name
* @param overrides - Optional overrides for default values
* @returns Repository mock object
*/
function mockRepository(
org: string,
name: string,
overrides: Partial<{
description: string;
is_public: boolean;
kind: string;
state: string;
can_write: boolean;
can_admin: boolean;
}> = {},
) {
return {
namespace: org,
name: name,
description: 'Test repository',
is_public: true,
kind: 'image',
state: 'NORMAL',
can_write: true,
can_admin: true,
...overrides,
};
}
describe('Repository Shorthand URL Navigation', () => {
beforeEach(() => {
cy.intercept('GET', '/config', {fixture: 'config.json'}).as('getConfig');
cy.request('GET', `${Cypress.env('REACT_QUAY_APP_API_URL')}/csrf_token`)
.then((response) => response.body.csrf_token)
.then((token) => {
cy.loginByCSRF(token);
});
});
it('redirects shorthand URL /:org/:repo to /repository/:org/:repo', () => {
// Mock the repository API response for a valid repository
cy.intercept('GET', '/api/v1/repository/user1/hello-world*', {
statusCode: 200,
body: mockRepository('user1', 'hello-world'),
}).as('getRepo');
// Visit shorthand URL
cy.visit('/user1/hello-world');
// Wait for redirect and API call
cy.wait('@getRepo');
// Verify URL was redirected to full repository path
cy.location('pathname').should('eq', '/repository/user1/hello-world');
// Verify repository page loaded correctly
cy.get('[data-testid="repo-title"]').should('contain.text', 'hello-world');
});
it('redirects multi-segment repository names correctly', () => {
// Mock the repository API response
cy.intercept('GET', '/api/v1/repository/openshift/release/installer*', {
statusCode: 200,
body: mockRepository('openshift', 'release/installer', {
description: 'OpenShift installer',
can_write: false,
can_admin: false,
}),
}).as('getRepo');
// Visit shorthand URL with multi-segment repo name
cy.visit('/openshift/release/installer');
// Wait for redirect and API call
cy.wait('@getRepo');
// Verify URL was redirected correctly
cy.location('pathname').should(
'eq',
'/repository/openshift/release/installer',
);
// Verify repository page loaded
cy.get('[data-testid="repo-title"]').should(
'contain.text',
'release/installer',
);
});
it('shows 404 error when repository does not exist', () => {
// Mock 404 response from API
cy.intercept('GET', '/api/v1/repository/nonexistent/repo*', {
statusCode: 404,
body: {
error_message: 'Not Found',
error_type: 'not_found',
},
}).as('getRepoNotFound');
// Visit shorthand URL for non-existent repo
cy.visit('/nonexistent/repo');
// Wait for redirect and API call
cy.wait('@getRepoNotFound');
// Verify URL was redirected
cy.location('pathname').should('eq', '/repository/nonexistent/repo');
// Verify error message is displayed
cy.contains('Unable to get repository').should('exist');
cy.contains('HTTP404 - Not Found').should('exist');
});
it('does not redirect reserved route prefixes', () => {
// Mock organization API response
cy.intercept('GET', '/api/v1/organization/testorg', {
statusCode: 404,
body: {
error_message: 'Not Found',
},
}).as('getOrg');
// Visit /user/ path (reserved prefix)
cy.visit('/user/testuser', {failOnStatusCode: false});
// URL should NOT redirect to /repository/user/testuser
cy.location('pathname').should('eq', '/user/testuser');
// Should show organization component (existing behavior)
cy.get('h1').should('contain.text', 'testuser');
});
it('redirects single-segment org URL to /organization/:org', () => {
// Mock organization API response
cy.intercept('GET', '/api/v1/organization/testorg', {
statusCode: 200,
body: {
name: 'testorg',
is_org_admin: false,
},
}).as('getOrg');
// Visit shorthand organization URL
cy.visit('/testorg');
// Wait for redirect and API call
cy.wait('@getOrg');
// Verify URL was redirected to full organization path
cy.location('pathname').should('eq', '/organization/testorg');
// Verify organization page loaded
cy.get('h1').should('contain.text', 'testorg');
});
it('shows error when organization does not exist', () => {
// Mock 404 response from organization API
cy.intercept('GET', '/api/v1/organization/nonexistentorg', {
statusCode: 404,
body: {
error_message: 'Not Found',
},
}).as('getOrgNotFound');
// Visit shorthand URL for non-existent organization
cy.visit('/nonexistentorg');
// Wait for redirect and API call
cy.wait('@getOrgNotFound');
// Verify URL was redirected to organization path
cy.location('pathname').should('eq', '/organization/nonexistentorg');
// Note: The organization page handles the 404, not the router
});
it('preserves query parameters for organization redirects', () => {
// Mock organization API response
cy.intercept('GET', '/api/v1/organization/testorg', {
statusCode: 200,
body: {
name: 'testorg',
is_org_admin: true,
},
}).as('getOrg');
// Visit shorthand organization URL with query parameter
cy.visit('/testorg?tab=teams');
// Wait for redirect and API call
cy.wait('@getOrg');
// Verify URL was redirected with query parameter preserved
cy.location('pathname').should('eq', '/organization/testorg');
cy.location('search').should('eq', '?tab=teams');
});
it('preserves hash fragments for organization redirects', () => {
// Mock organization API response
cy.intercept('GET', '/api/v1/organization/testorg', {
statusCode: 200,
body: {
name: 'testorg',
is_org_admin: true,
},
}).as('getOrg');
// Visit shorthand organization URL with hash fragment
cy.visit('/testorg#section');
// Wait for redirect and API call
cy.wait('@getOrg');
// Verify URL was redirected with hash fragment preserved
cy.location('pathname').should('eq', '/organization/testorg');
cy.location('hash').should('eq', '#section');
});
it('preserves query parameters during redirect', () => {
// Mock the repository API response
cy.intercept('GET', '/api/v1/repository/user1/hello-world*', {
statusCode: 200,
body: mockRepository('user1', 'hello-world'),
}).as('getRepo');
// Visit shorthand URL with query parameter
cy.visit('/user1/hello-world?tab=tags');
// Wait for redirect
cy.wait('@getRepo');
// Verify URL was redirected with query parameter preserved
cy.location('pathname').should('eq', '/repository/user1/hello-world');
cy.location('search').should('eq', '?tab=tags');
// Verify the Tags tab is active
cy.get('[role="tab"][aria-selected="true"]').should('contain.text', 'Tags');
});
it('preserves hash fragments during redirect', () => {
// Mock the repository API response
cy.intercept('GET', '/api/v1/repository/user1/hello-world*', {
statusCode: 200,
body: mockRepository('user1', 'hello-world'),
}).as('getRepo');
// Visit shorthand URL with hash fragment
cy.visit('/user1/hello-world#section');
// Wait for redirect
cy.wait('@getRepo');
// Verify URL was redirected with hash fragment preserved
cy.location('pathname').should('eq', '/repository/user1/hello-world');
cy.location('hash').should('eq', '#section');
});
it('preserves both query parameters and hash fragments during redirect', () => {
// Mock the repository API response
cy.intercept('GET', '/api/v1/repository/user1/hello-world*', {
statusCode: 200,
body: mockRepository('user1', 'hello-world'),
}).as('getRepo');
// Visit shorthand URL with both query parameter and hash fragment
cy.visit('/user1/hello-world?tab=tags#section');
// Wait for redirect
cy.wait('@getRepo');
// Verify URL was redirected with both query parameter and hash preserved
cy.location('pathname').should('eq', '/repository/user1/hello-world');
cy.location('search').should('eq', '?tab=tags');
cy.location('hash').should('eq', '#section');
// Verify the Tags tab is active
cy.get('[role="tab"][aria-selected="true"]').should('contain.text', 'Tags');
});
});

View File

@@ -8,7 +8,14 @@ import {
Page,
} from '@patternfly/react-core';
import {Navigate, Outlet, Route, Routes} from 'react-router-dom';
import {
Navigate,
Outlet,
Route,
Routes,
useParams,
useLocation,
} from 'react-router-dom';
import {QuayHeader} from 'src/components/header/QuayHeader';
import {QuaySidebar} from 'src/components/sidebar/QuaySidebar';
@@ -54,6 +61,93 @@ const UsageLogs = lazy(() => import('./Superuser/UsageLogs/UsageLogs'));
const Messages = lazy(() => import('./Superuser/Messages/Messages'));
const BuildLogs = lazy(() => import('./Superuser/BuildLogs/BuildLogs'));
/**
* Interface for shorthand repository route parameters
*/
interface ShorthandParams {
org: string;
'*': string;
}
/**
* Derives reserved route prefixes from NavigationPath enum and additional static routes.
* These prefixes should NOT be treated as organization names in shorthand repository URLs.
*
* @returns Array of reserved path prefixes that won't redirect to repository pages
*/
function getReservedRoutePrefixes(): string[] {
// Extract first segment from each NavigationPath value
const navigationPrefixes = Object.values(NavigationPath)
.map((path) => {
// Remove leading slash and get first segment
const segments = path.replace(/^\//, '').split('/');
return segments[0];
})
.filter((prefix) => prefix && !prefix.startsWith(':')); // Filter out empty and param placeholders
// Additional static routes not in NavigationPath enum
const staticPrefixes = ['signin', 'createaccount', 'oauth-error'];
// Combine and deduplicate
return [...new Set([...navigationPrefixes, ...staticPrefixes])];
}
/**
* Component to handle shorthand organization and repository URLs.
*
* This component provides backward compatibility with the Angular UI's shorthand URL pattern,
* allowing users to navigate directly to organizations and repositories using shorthand paths.
*
* Behavior:
* - Redirects /:org to /organization/:org (single segment)
* - Redirects /:org/:repo to /repository/:org/:repo (two or more segments)
* - Preserves query parameters and hash fragments during redirect
* - Excludes reserved route prefixes to prevent conflicts with existing routes
*
* Reserved prefixes (dynamically derived from NavigationPath):
* - All first segments from NavigationPath enum (e.g., 'user', 'organization', 'repository', 'overview')
* - Additional static routes ('signin', 'createaccount', 'oauth-error')
*
* @example
* // Valid organization redirects
* /myorg → /organization/myorg
* /projectquay?tab=teams → /organization/projectquay?tab=teams
*
* @example
* // Valid repository redirects
* /openshift/release → /repository/openshift/release
* /user1/hello-world?tab=tags#section → /repository/user1/hello-world?tab=tags#section
*
* @example
* // Returns 404 (reserved prefix - passes through to existing routes)
* /user/testuser → 404 (reserved prefix)
* /organization/myorg → 404 (reserved prefix)
* /repository/foo/bar → 404 (reserved prefix)
*/
function RepositoryShorthandRedirect() {
const params = useParams<ShorthandParams>();
const location = useLocation();
const org = params.org;
const repo = params['*'];
const reservedPrefixes = getReservedRoutePrefixes();
// Show 404 if org matches a reserved route prefix (let existing routes handle it)
if (reservedPrefixes.includes(org)) {
return <NotFound />;
}
// Single segment (/:org) - redirect to organization
if (!repo || repo.trim() === '') {
const redirectTo = `/organization/${org}${location.search}${location.hash}`;
return <Navigate to={redirectTo} replace />;
}
// Two or more segments (/:org/:repo) - redirect to repository
const redirectTo = `/repository/${org}/${repo}${location.search}${location.hash}`;
return <Navigate to={redirectTo} replace />;
}
const NavigationRoutes = [
{
path: NavigationPath.teamMember,
@@ -190,6 +284,8 @@ export function StandaloneMain() {
<Route path={path} key={key} element={Component} />
))}
<Route path="oauth-error" element={<OAuthError />} />
{/* Redirect shorthand repository URLs (e.g., /openshift/release) to /repository/openshift/release */}
<Route path=":org/*" element={<RepositoryShorthandRedirect />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>