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:
committed by
GitHub
parent
2d99acdc39
commit
e697b59475
284
web/cypress/e2e/repository-shorthand-navigation.cy.ts
Normal file
284
web/cypress/e2e/repository-shorthand-navigation.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user