diff --git a/infrastructure/sales/README.md b/infrastructure/sales/README.md index f4c59c9d5..8fc4132f5 100644 --- a/infrastructure/sales/README.md +++ b/infrastructure/sales/README.md @@ -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 diff --git a/infrastructure/sales/dev-viewer-request.js b/infrastructure/sales/dev-viewer-request.js index eb03be60d..3e0f69f06 100644 --- a/infrastructure/sales/dev-viewer-request.js +++ b/infrastructure/sales/dev-viewer-request.js @@ -1,6 +1,6 @@ /** * 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. @@ -8,6 +8,26 @@ 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/'; diff --git a/infrastructure/topgear/README.md b/infrastructure/topgear/README.md new file mode 100644 index 000000000..0fbac4abe --- /dev/null +++ b/infrastructure/topgear/README.md @@ -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. diff --git a/infrastructure/topgear/routing.test.cjs b/infrastructure/topgear/routing.test.cjs new file mode 100644 index 000000000..0c5994b48 --- /dev/null +++ b/infrastructure/topgear/routing.test.cjs @@ -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'); +}); diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index c9f979dc1..1ff6fdf97 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -12,14 +12,15 @@ Community-app's Wipro community (`topgear.`, formerly also served at `wipro.`) 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 diff --git a/src/apps/opportunities/src/assets/topgear-challenges-banner.png b/src/apps/opportunities/src/assets/topgear-challenges-banner.png deleted file mode 100644 index 63cdf2e6e..000000000 Binary files a/src/apps/opportunities/src/assets/topgear-challenges-banner.png and /dev/null differ diff --git a/src/apps/opportunities/src/components/TopgearHero.module.scss b/src/apps/opportunities/src/components/TopgearHero.module.scss deleted file mode 100644 index b23de9c18..000000000 --- a/src/apps/opportunities/src/components/TopgearHero.module.scss +++ /dev/null @@ -1,25 +0,0 @@ -.hero { - background: #b9b3ab; - line-height: 0; -} - -.banner { - display: block; - height: 350px; - object-fit: cover; - object-position: 50% 0; - width: 100%; -} - -@media (max-width: 1199px) { - .banner { - height: 260px; - } -} - -@media (max-width: 640px) { - .banner { - height: 180px; - object-position: 30% 0; - } -} diff --git a/src/apps/opportunities/src/components/TopgearHero.spec.tsx b/src/apps/opportunities/src/components/TopgearHero.spec.tsx deleted file mode 100644 index 551a4ecb9..000000000 --- a/src/apps/opportunities/src/components/TopgearHero.spec.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ -import '@testing-library/jest-dom' -import { render, screen } from '@testing-library/react' - -import { TopgearHero } from './TopgearHero' - -describe('TopgearHero', () => { - it('renders the community-app TopGear banner without category cells or totals', () => { - render() - - expect(screen.getByRole('region', { name: 'TopGear challenges' })) - .toBeInTheDocument() - expect(screen.getByRole('img', { - name: 'TopGear: amazing platform for design, testing and development challenges', - })) - .toHaveAttribute('src', expect.stringContaining('topgear-challenges-banner')) - expect(screen.queryByRole('link')) - .not.toBeInTheDocument() - expect(screen.queryByText(/Find your next/)) - .not.toBeInTheDocument() - }) -}) diff --git a/src/apps/opportunities/src/components/TopgearHero.tsx b/src/apps/opportunities/src/components/TopgearHero.tsx deleted file mode 100644 index 888bcb2c0..000000000 --- a/src/apps/opportunities/src/components/TopgearHero.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { FC } from 'react' - -import topgearBanner from '../assets/topgear-challenges-banner.png' - -import styles from './TopgearHero.module.scss' - -/** - * Renders community-app's TopGear challenge-listing banner in place of the - * Opportunities masthead. TopGear members only browse competitions, so the - * category cells and public summary totals are not shown on that host. - * - * @returns the full-width TopGear banner. - * @throws Does not throw. - */ -export const TopgearHero: FC = () => ( -
- TopGear: amazing platform for design, testing and development challenges -
-) - -export default TopgearHero diff --git a/src/apps/opportunities/src/components/index.ts b/src/apps/opportunities/src/components/index.ts index 86e3d622d..ab6b42a1e 100644 --- a/src/apps/opportunities/src/components/index.ts +++ b/src/apps/opportunities/src/components/index.ts @@ -17,4 +17,3 @@ export * from './OpportunityViewToggle' export * from './ReportIssueModal' export * from './SubmissionArtifactsModal' export * from './SubmissionHistoryModal' -export * from './TopgearHero' diff --git a/src/apps/opportunities/src/opportunities.routes.spec.tsx b/src/apps/opportunities/src/opportunities.routes.spec.tsx index aec885ea4..7636b06a7 100644 --- a/src/apps/opportunities/src/opportunities.routes.spec.tsx +++ b/src/apps/opportunities/src/opportunities.routes.spec.tsx @@ -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'])) }) diff --git a/src/apps/opportunities/src/opportunities.routes.tsx b/src/apps/opportunities/src/opportunities.routes.tsx index 1872b66cd..9ea8c6b60 100644 --- a/src/apps/opportunities/src/opportunities.routes.tsx +++ b/src/apps/opportunities/src/opportunities.routes.tsx @@ -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')) @@ -29,7 +29,7 @@ export const toolTitle: string = ToolTitle.opportunities export const topgearRoutes: ReadonlyArray = ( isTopgearCommunity() ? [ { - element: , + element: , id: 'TopGear root redirect', route: '', title: toolTitle, diff --git a/src/apps/opportunities/src/pages/LegacyOpportunityRedirectPage.spec.tsx b/src/apps/opportunities/src/pages/LegacyOpportunityRedirectPage.spec.tsx new file mode 100644 index 000000000..00782cd54 --- /dev/null +++ b/src/apps/opportunities/src/pages/LegacyOpportunityRedirectPage.spec.tsx @@ -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 {`${location.pathname}${location.search}${location.hash}`} +} + +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( + + + } /> + } /> + + , + ) + expect(screen.getByTestId('destination')) + .toHaveTextContent(`${target}${suffix}`) + }) +}) diff --git a/src/apps/opportunities/src/pages/LegacyOpportunityRedirectPage.tsx b/src/apps/opportunities/src/pages/LegacyOpportunityRedirectPage.tsx index 9d2b81bcb..8b81f4bbc 100644 --- a/src/apps/opportunities/src/pages/LegacyOpportunityRedirectPage.tsx +++ b/src/apps/opportunities/src/pages/LegacyOpportunityRedirectPage.tsx @@ -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 @@ -61,8 +63,9 @@ export const LegacyOpportunityRedirectPage: FC ({ ), OpportunitySortSelect: () => Sort choices, OpportunityViewToggle: () => View choices, - TopgearHero: () => TopGear banner, })) jest.mock('../services', () => ({ @@ -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' }], @@ -181,16 +180,16 @@ describe('OpportunitiesPage', () => { render( new Map() }}> - + - } path='/opportunities' /> + } path='/opportunities/:kind' /> , ) - 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' })) @@ -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) diff --git a/src/apps/opportunities/src/pages/OpportunitiesPage.tsx b/src/apps/opportunities/src/pages/OpportunitiesPage.tsx index 94549bca3..3ddfe36ba 100644 --- a/src/apps/opportunities/src/pages/OpportunitiesPage.tsx +++ b/src/apps/opportunities/src/pages/OpportunitiesPage.tsx @@ -7,7 +7,7 @@ import { useMemo, useState, } from 'react' -import { Navigate, useParams, useSearchParams } from 'react-router-dom' +import { Navigate, useLocation, useParams, useSearchParams } from 'react-router-dom' import useSWR, { SWRResponse } from 'swr' import { @@ -24,7 +24,6 @@ import { OpportunityPagination, OpportunitySortSelect, OpportunityViewToggle, - TopgearHero, } from '../components' import { OpportunityFilters, @@ -51,7 +50,7 @@ import { } from '../utils/opportunity-listing.utils' import { isTopgearCommunity, - OPPORTUNITIES_ROOT_ROUTE, + TOPGEAR_CHALLENGES_ROUTE, topgearGroupIds, } from '../utils/topgear.utils' import { opportunityViewContext, OpportunityViewContextData } from '../opportunities.context' @@ -459,6 +458,7 @@ const OpportunityListing: FC = (props: OpportunityListi */ export const OpportunitiesPage: FC = () => { const params = useParams<{ kind?: string }>() + const location = useLocation() const topgear = isTopgearCommunity() const kind = topgear ? 'competitions' : resolveOpportunityKind(params.kind) const viewContext: OpportunityViewContextData = useContext(opportunityViewContext) @@ -478,15 +478,13 @@ export const OpportunitiesPage: FC = () => { .catch(() => undefined) } - if (topgear && params.kind && params.kind !== 'competitions') { - return + if (topgear && params.kind !== 'challenge') { + return } return (
- {topgear ? ( - - ) : ( + {!topgear && ( { }) it('keeps the canonical Opportunities listing route', () => { - expect(OPPORTUNITIES_ROOT_ROUTE) - .toBe('/opportunities') + expect(TOPGEAR_CHALLENGES_ROUTE) + .toBe('/opportunities/challenge') }) }) diff --git a/src/apps/opportunities/src/utils/topgear.utils.ts b/src/apps/opportunities/src/utils/topgear.utils.ts index c108f2429..fc06237c8 100644 --- a/src/apps/opportunities/src/utils/topgear.utils.ts +++ b/src/apps/opportunities/src/utils/topgear.utils.ts @@ -1,7 +1,7 @@ import { AppSubdomain, EnvironmentConfig } from '~/config' -/** Canonical Opportunities listing route on every Platform UI host. */ -export const OPPORTUNITIES_ROOT_ROUTE: string = '/opportunities' +/** Canonical challenge listing route on the Topgear community hosts. */ +export const TOPGEAR_CHALLENGES_ROUTE: string = '/opportunities/challenge' /** * Checks whether Platform UI is served on the TopGear community host diff --git a/src/apps/platform/src/components/app-header/README.md b/src/apps/platform/src/components/app-header/README.md index 930d9d9f6..ba27935d2 100644 --- a/src/apps/platform/src/components/app-header/README.md +++ b/src/apps/platform/src/components/app-header/README.md @@ -26,3 +26,8 @@ The Header uses the `tcUniNav` method to initialize the nav with: - action handler callbacks (e.g. navigation, log in, log out, sign up) Because the Platform UI hosts multiple tools, each time the user navigates to a new tool, the Header updates the uni-nav w/the active tool info. + +On `topgear.topcoder.com` and `topgear.topcoder-dev.com`, Universal Navigation +automatically selects its Topgear header for the existing `type: 'tool'` +integration. No other host changes its header. Deploy the navigation Topgear +change before enabling the Platform UI community routes in production.