Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions infrastructure/sales/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ renders the Sales page at `/`, so no Sales-specific build or upload is needed.
`dev-viewer-request.js` mirrors the live viewer-request function. It has no Sales
branch: Sales requests pass through unchanged and deep links fall back to the root
`/index.html` through the distribution's 403/404 custom error responses. The
function also redirects `/challenges` (with an optional trailing slash) only on
`topgear.topcoder-dev.com` to `/opportunities/challenge`, preserving repeated and
encoded query parameters. This is a temporary, uncached HTTP 302; production is
unchanged. Topgear uses the shared root Platform UI build, its existing CloudFront
alias and Route 53 A record. See [Topgear routing](../topgear/README.md). The
function still routes the Contact and Accounts hosts to their isolated
`contact-app/` and `accounts-preferences-app/` shells. Re-read the live function
before publishing later changes to avoid overwriting newer routing, test with
Expand Down
22 changes: 21 additions & 1 deletion infrastructure/sales/dev-viewer-request.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
/**
* Routes the Contact and Accounts dev hosts to their isolated S3 shells.
* Sales and every other Platform UI host use the shared root build deployed by CircleCI.
* Topgear redirects its legacy challenge listing; Sales and other hosts use the shared root build deployed by CircleCI.
* @param {Object} event CloudFront viewer-request event containing the URI and Host header.
* @returns {Object} Request with an app-specific SPA fallback; assets and unrelated hosts are preserved.
* @throws No exceptions for valid CloudFront request events; performs no network I/O.
*/
function handler(event) {
var request = event.request;
var host = request.headers.host ? request.headers.host.value.toLowerCase() : '';
if ((host === 'topgear.topcoder-dev.com' || host === 'topgear.topcoder-dev.com:443')
&& (request.uri === '/challenges' || request.uri === '/challenges/')) {
var query = [];
var params = request.querystring || {};
Object.keys(params).forEach(function (key) {
var values = params[key].multiValue || [params[key]];
values.forEach(function (item) {
// CloudFront preserves the incoming percent encoding.
query.push(key + '=' + item.value);
});
});
return {
statusCode: 302,
statusDescription: 'Found',
headers: {
location: { value: '/opportunities/challenge' + (query.length ? '?' + query.join('&') : '') },
'cache-control': { value: 'no-store' }
}
};
}
var prefix = '';
if (host === 'contact.topcoder-dev.com' || host === 'contact.topcoder-dev.com:443') {
prefix = '/contact-app/';
Expand Down
30 changes: 30 additions & 0 deletions infrastructure/topgear/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Topgear dev hosting

`topgear.topcoder-dev.com` uses the shared Platform UI build in
`platform-mvp.topcoder-dev.com` through CloudFront `EFA7R1KH3UX5M`
(`d10303ypawq4ub.cloudfront.net`). Its existing Route 53 A alias in public zone
`Z2CIRG3R0ZSGFQ` targets that distribution. The certificate covers this host.
Universal Navigation's dev CORS policy already includes the Topgear origin;
its repository template includes that origin for both environments.

The shared [viewer-request function](../sales/dev-viewer-request.js),
`platform-sales-dev-viewer-request`, redirects only dev Topgear `/challenges`
and `/challenges/` to `/opportunities/challenge`. Repeated filter parameters
and percent encoding are preserved. Other hosts, challenge details, static
assets, and Contact/Accounts routing retain their behavior.

Deploy the Topgear Universal Navigation bundle before the Platform UI build.
Build Platform UI with `LOGICAL_ENV=dev yarn build` and the standard dev environment
configuration, then use the normal dev deployment. Publish the edge function
only after testing its DEVELOPMENT stage and comparing with the live code to
preserve concurrent changes. Run `node --test infrastructure/topgear/routing.test.cjs`
for the routing regression checks.

Verify all three logos, Home and Challenges links, the absence of Leaderboard
and the hero banner, group-filtered challenges, and the HTTP redirect on the dev
host. Production DNS and `/challenges` must stay on community-app until the
production rollout is explicitly scheduled.

Rollback the function to its saved prior code and republish. Roll back the
navigation and Platform UI build using the normal deployment process. There is
no database change and no new DNS change needed for this dev rollout.
50 changes: 50 additions & 0 deletions infrastructure/topgear/routing.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { readFileSync } = require('node:fs');
const vm = require('node:vm');
const ctx = {};
vm.runInNewContext(readFileSync(`${__dirname}/../sales/dev-viewer-request.js`, 'utf8'), ctx);

/** Builds a representative CloudFront request and invokes the edge handler. */
function route(host, uri, querystring = {}) {
const request = { uri, headers: { host: { value: host } }, querystring };
return { request, result: ctx.handler({ request }) };
}

test('redirects both legacy list paths only on dev Topgear, preserving encoded and repeated filters', () => {
for (const path of ['/challenges', '/challenges/']) {
const { result } = route('topgear.topcoder-dev.com', path, {
'groups%5B%5D': { value: 'group-a' },
'types[]': { value: 'CH', multiValue: [{ value: 'CH' }, { value: 'F2F' }] },
search: { value: 'React%20%26%20Java' },
});
assert.equal(result.statusCode, 302);
assert.equal(result.headers.location.value,
'/opportunities/challenge?groups%5B%5D=group-a&types[]=CH&types[]=F2F&search=React%20%26%20Java');
assert.equal(result.headers['cache-control'].value, 'no-store');
}
assert.equal(route('topgear.topcoder-dev.com:443', '/challenges').result.statusCode, 302);
});

test('preserves production, unrelated hosts, challenge details and assets', () => {
for (const [host, path] of [
['topgear.topcoder.com', '/challenges'],
['platform-ui.topcoder-dev.com', '/challenges'],
['sales.topcoder-dev.com', '/'],
['topgear.topcoder-dev.com', '/challenges/123'],
['topgear.topcoder-dev.com', '/opportunities/challenge'],
['topgear.topcoder-dev.com', '/static/js/main.js'],
]) {
const { request, result } = route(host, path);
assert.equal(result, request);
assert.equal(result.uri, path);
}
});

test('preserves the existing isolated Contact and Accounts deployments', () => {
assert.equal(route('contact.topcoder-dev.com', '/').result.uri, '/contact-app/index.html');
assert.equal(route('account-settings.topcoder-dev.com', '/settings').result.uri,
'/accounts-preferences-app/index.html');
assert.equal(route('account-settings.topcoder-dev.com', '/static/js/old.js').result.uri,
'/static/js/old.js');
});
15 changes: 8 additions & 7 deletions src/apps/opportunities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ Community-app's Wipro community (`topgear.<domain>`, formerly also served at
`wipro.<domain>`) is replaced by this app on the `topgear` host. When the
first hostname label is `topgear`:

- the host root redirects to `/opportunities`, and the legacy `/challenges`
aliases continue to redirect to the Opportunities routes;
- the listing renders community-app's TopGear challenge-listing banner
(`assets/topgear-challenges-banner.png`) instead of the masthead, the four
category cells, and the public summary request;
- the host root and legacy `/challenges` listing redirect to
`/opportunities/challenge`; detail aliases still redirect to challenge details;
- Universal Navigation automatically renders the Wipro, Topcoder, and Topgear
logos with Home (`https://topgear-app.wipro.com`) and Challenges
(`/opportunities/challenge`). Deploy the Topgear navigation bundle first;
- the listing has no hero banner, category cells, or public summary request;
- only competitions are offered. `/opportunities/:kind` for any other category
redirects to `/opportunities`, and Browse Competitions with its filters,
sorting, and pagination is unchanged;
redirects to `/opportunities/challenge`, preserving queries and fragments.
Browse Competitions keeps its filters, sorting, and pagination;
- competitions are limited to the TopGear Topcoder group through the Challenge
API `groups[]` parameter. The group defaults to community-app's
`b7f7c0f8-8ee8-409e-9e5c-33404983b635` and is configurable with
Expand Down
Binary file not shown.
25 changes: 0 additions & 25 deletions src/apps/opportunities/src/components/TopgearHero.module.scss

This file was deleted.

22 changes: 0 additions & 22 deletions src/apps/opportunities/src/components/TopgearHero.spec.tsx

This file was deleted.

25 changes: 0 additions & 25 deletions src/apps/opportunities/src/components/TopgearHero.tsx

This file was deleted.

1 change: 0 additions & 1 deletion src/apps/opportunities/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,3 @@ export * from './OpportunityViewToggle'
export * from './ReportIssueModal'
export * from './SubmissionArtifactsModal'
export * from './SubmissionHistoryModal'
export * from './TopgearHero'
2 changes: 1 addition & 1 deletion src/apps/opportunities/src/opportunities.routes.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe('Opportunities application routes', () => {
expect((routes.topgearRoutes[0].element.type as { name?: string }).name)
.toBe('Navigate')
expect(routes.topgearRoutes[0].element.props)
.toEqual({ replace: true, to: '/opportunities' })
.toEqual({ replace: true, to: '/opportunities/challenge' })
expect(routes.legacyOpportunityRoutes.map(route => route.route))
.toEqual(expect.arrayContaining(['/challenges', '/challenges/:challengeId']))
})
Expand Down
4 changes: 2 additions & 2 deletions src/apps/opportunities/src/opportunities.routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Navigate } from 'react-router-dom'
import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core'
import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config'

import { isTopgearCommunity, OPPORTUNITIES_ROOT_ROUTE } from './utils/topgear.utils'
import { isTopgearCommunity, TOPGEAR_CHALLENGES_ROUTE } from './utils/topgear.utils'

const OpportunitiesApp: LazyLoadedComponent = lazyLoad(() => import('./OpportunitiesApp'))
const OpportunitiesPage: LazyLoadedComponent = lazyLoad(() => import('./pages/OpportunitiesPage'))
Expand All @@ -29,7 +29,7 @@ export const toolTitle: string = ToolTitle.opportunities
export const topgearRoutes: ReadonlyArray<PlatformRoute> = (
isTopgearCommunity() ? [
{
element: <Navigate replace to={OPPORTUNITIES_ROOT_ROUTE} />,
element: <Navigate replace to={TOPGEAR_CHALLENGES_ROUTE} />,
id: 'TopGear root redirect',
route: '',
title: toolTitle,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
import '@testing-library/jest-dom'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'

import { LegacyOpportunityRedirectPage } from './LegacyOpportunityRedirectPage'

let mockTopgear = true
jest.mock('../utils/topgear.utils', () => ({
isTopgearCommunity: () => mockTopgear,
TOPGEAR_CHALLENGES_ROUTE: '/opportunities/challenge',
}))

/** Displays the resolved URL, including repeated legacy filters and fragments. */
const Destination = (): JSX.Element => {
const location = useLocation()
return <output data-testid='destination'>{`${location.pathname}${location.search}${location.hash}`}</output>
}

describe('legacy Topgear challenge redirects', () => {
it.each([
[true, '/opportunities/challenge'],
[false, '/opportunities/competitions'],
])('preserves filters and fragments with Topgear=%s', (topgear, target) => {
mockTopgear = topgear
const suffix = '?groups[]=one&types[]=CH&types[]=F2F&search=React%20Native#results'
render(
<MemoryRouter initialEntries={[`/challenges${suffix}`]}>
<Routes>
<Route path='/challenges' element={<LegacyOpportunityRedirectPage list />} />
<Route path='/opportunities/:kind' element={<Destination />} />
</Routes>
</MemoryRouter>,
)
expect(screen.getByTestId('destination'))
.toHaveTextContent(`${target}${suffix}`)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
useParams,
} from 'react-router-dom'

import { isTopgearCommunity, TOPGEAR_CHALLENGES_ROUTE } from '../utils/topgear.utils'

interface LegacyOpportunityRedirectPageProps {
list?: boolean
review?: boolean
Expand Down Expand Up @@ -61,8 +63,9 @@ export const LegacyOpportunityRedirectPage: FC<LegacyOpportunityRedirectPageProp
)
}

const listingPath = isTopgearCommunity() ? TOPGEAR_CHALLENGES_ROUTE : '/opportunities/competitions'
const pathname = props.list || !challengeId
? '/opportunities/competitions'
? listingPath
: `/opportunities/challenge/${encodeURIComponent(challengeId)}`
return (
<Navigate
Expand Down
13 changes: 6 additions & 7 deletions src/apps/opportunities/src/pages/OpportunitiesPage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ jest.mock('../components', () => ({
),
OpportunitySortSelect: () => <span>Sort choices</span>,
OpportunityViewToggle: () => <span>View choices</span>,
TopgearHero: () => <output data-testid='topgear-hero'>TopGear banner</output>,
}))

jest.mock('../services', () => ({
Expand Down Expand Up @@ -169,7 +168,7 @@ describe('OpportunitiesPage', () => {
.not.toBeInTheDocument()
})

it('shows the TopGear banner and lists only the community group on the TopGear host', async () => {
it('lists only the community group without a hero on the TopGear challenge route', async () => {
mockSubdomain = 'topgear'
mockedGetOpportunityPage.mockResolvedValue({
items: [{ id: 'topgear-challenge', name: 'TopGear challenge' }],
Expand All @@ -181,16 +180,16 @@ describe('OpportunitiesPage', () => {

render(
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
<MemoryRouter initialEntries={['/opportunities']}>
<MemoryRouter initialEntries={['/opportunities/challenge']}>
<Routes>
<Route element={<OpportunitiesPage />} path='/opportunities' />
<Route element={<OpportunitiesPage />} path='/opportunities/:kind' />
</Routes>
</MemoryRouter>
</SWRConfig>,
)

expect(screen.getByTestId('topgear-hero'))
.toBeInTheDocument()
expect(screen.queryByRole('img', { name: /amazing platform/i }))
.not.toBeInTheDocument()
expect(screen.queryByTestId('competition-count'))
.not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Browse Competitions' }))
Expand Down Expand Up @@ -229,7 +228,7 @@ describe('OpportunitiesPage', () => {
)

await waitFor(() => expect(screen.getByTestId('location'))
.toHaveTextContent('/opportunities'))
.toHaveTextContent('/opportunities/challenge'))
await waitFor(() => expect(mockedGetOpportunityPage)
.toHaveBeenCalledWith('competitions', expect.anything()))
expect(mockedGetOpportunityPage)
Expand Down
Loading
Loading