Skip to content

Commit c84eaff

Browse files
authored
Add version cookie to remember user's version preference (#59532)
1 parent b6aae68 commit c84eaff

File tree

9 files changed

+197
-26
lines changed

9 files changed

+197
-26
lines changed

src/frame/lib/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const DEFAULT_MAX_REQUEST_TIMEOUT = isDev ? 15_000 : 10_000
1212

1313
export const ROOT = process.env.ROOT || '.'
1414
export const USER_LANGUAGE_COOKIE_NAME = 'user_language'
15+
export const USER_VERSION_COOKIE_NAME = 'user_version'
1516
export const TRANSLATIONS_ROOT = process.env.TRANSLATIONS_ROOT || 'translations'
1617
export const MAX_REQUEST_TIMEOUT = process.env.REQUEST_TIMEOUT
1718
? parseInt(process.env.REQUEST_TIMEOUT, 10)

src/frame/middleware/cache-control.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ export function languageCacheControl(res: Response): void {
9393
res.set('vary', 'accept-language, x-user-language')
9494
}
9595

96+
// Vary on both language and version for homepage redirects
97+
// x-user-version is a custom request header derived from req.cookie:user_version
98+
export function languageAndVersionCacheControl(res: Response): void {
99+
defaultCacheControl(res)
100+
res.set('vary', 'accept-language, x-user-language, x-user-version')
101+
}
102+
96103
// Long cache control for versioned assets: images, CSS, JS...
97104
export const assetCacheControl = cacheControlFactory(60 * 60 * 24 * 7, { immutable: true })
98105

src/frame/middleware/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import handleErrors from '@/observability/middleware/handle-errors'
1717
import handleNextDataPath from './handle-next-data-path'
1818
import detectLanguage from '@/languages/middleware/detect-language'
19+
import detectVersion from '@/versions/middleware/detect-version'
1920
import reloadTree from './reload-tree'
2021
import context from './context/context'
2122
import shortVersions from '@/versions/middleware/short-versions'
@@ -213,6 +214,7 @@ export default function index(app: Express) {
213214
// *** Config and context for redirects ***
214215
app.use(urlDecode) // Must come before detectLanguage to decode @ symbols in version segments
215216
app.use(detectLanguage) // Must come before context, breadcrumbs, find-page, handle-errors, homepages
217+
app.use(detectVersion) // Must come before handle-redirects for version cookie support
216218
app.use(asyncMiddleware(reloadTree)) // Must come before context
217219
app.use(asyncMiddleware(context)) // Must come before early-access-*, handle-redirects
218220
app.use(shortVersions) // Support version shorthands

src/observability/logger/lib/logger-context.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type LoggerContext = {
1616
body?: any
1717
language?: string
1818
userLanguage?: string
19+
userVersion?: string
1920
version?: string
2021
pagePath?: string
2122
}
@@ -51,6 +52,8 @@ const INCLUDE_HEADERS = [
5152
// Language
5253
'x-user-language',
5354
'accept-language',
55+
// Version
56+
'x-user-version',
5457
// Host
5558
'host',
5659
'x-host',

src/redirects/middleware/handle-redirects.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import patterns from '@/frame/lib/patterns'
44
import { pathLanguagePrefixed } from '@/languages/lib/languages-server'
55
import { deprecatedWithFunctionalRedirects } from '@/versions/lib/enterprise-server-releases'
66
import getRedirect from '../lib/get-redirect'
7-
import { defaultCacheControl, languageCacheControl } from '@/frame/middleware/cache-control'
7+
import {
8+
defaultCacheControl,
9+
languageCacheControl,
10+
languageAndVersionCacheControl,
11+
} from '@/frame/middleware/cache-control'
812
import { ExtendedRequest, URLSearchParamsTypes } from '@/types'
913

1014
export default function handleRedirects(req: ExtendedRequest, res: Response, next: NextFunction) {
@@ -27,13 +31,21 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex
2731
// blanket redirects for languageless homepage
2832
if (req.path === '/') {
2933
const language = getLanguage(req)
30-
languageCacheControl(res)
34+
languageAndVersionCacheControl(res)
35+
36+
// Build redirect path, optionally including user's preferred version
37+
let redirectPath = `/${language}`
38+
const userVersion = req.userVersion
39+
if (userVersion && userVersion !== 'free-pro-team@latest') {
40+
redirectPath += `/${userVersion}`
41+
}
42+
3143
// Forward query params to the new URL
3244
let queryParams = new URLSearchParams((req?.query as any) || '').toString()
3345
if (queryParams) {
3446
queryParams = `?${queryParams}`
3547
}
36-
return res.redirect(302, `/${language}${queryParams}`)
48+
return res.redirect(302, redirectPath + queryParams)
3749
}
3850

3951
// begin redirect handling

src/types/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type ExtendedRequest = Request & {
2424
context?: Context
2525
language?: string
2626
userLanguage?: string
27+
userVersion?: string
2728
FailBot?: Failbot
2829
}
2930

src/versions/components/VersionPicker.tsx

Lines changed: 84 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { useRouter } from 'next/router'
2+
import { useState } from 'react'
3+
import { ActionMenu, ActionList } from '@primer/react'
24
import { ArrowRightIcon, InfoIcon } from '@primer/octicons-react'
5+
import cx from 'classnames'
36

7+
import Cookies from '@/frame/components/lib/cookies'
8+
import { USER_VERSION_COOKIE_NAME } from '@/frame/lib/constants'
49
import { useMainContext } from '@/frame/components/context/MainContext'
510
import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion'
611
import { useTranslation } from '@/languages/components/useTranslation'
7-
import { Picker } from '@/tools/components/Picker'
812

913
import styles from './VersionPicker.module.scss'
1014

@@ -16,8 +20,9 @@ export const VersionPicker = ({ xs }: Props) => {
1620
const router = useRouter()
1721
const { currentVersion } = useVersion()
1822
const mainContext = useMainContext()
19-
// Use TypeScript's "not null assertion" because mainContext.page` should
20-
// will present in mainContext if it's gotten to the stage of React
23+
const [open, setOpen] = useState(false)
24+
// Use TypeScript's "not null assertion" because mainContext.page should
25+
// be present in mainContext if it's gotten to the stage of React
2126
// rendering.
2227
const page = mainContext.page!
2328
const { allVersions, enterpriseServerVersions } = mainContext
@@ -32,13 +37,26 @@ export const VersionPicker = ({ xs }: Props) => {
3237
return prefix + router.asPath.replace(`/${currentVersion}`, '')
3338
}
3439

35-
const allLinks = (page.applicableVersions || []).map((pageVersion) => ({
40+
type VersionPickerLink = {
41+
text: string
42+
selected: boolean
43+
href: string
44+
extra: {
45+
arrow: boolean
46+
info: boolean
47+
version?: string
48+
}
49+
divider: boolean
50+
}
51+
52+
const allLinks: VersionPickerLink[] = (page.applicableVersions || []).map((pageVersion) => ({
3653
text: allVersions[pageVersion].versionTitle,
3754
selected: currentVersion === pageVersion,
3855
href: versionToHref(pageVersion),
3956
extra: {
4057
arrow: false,
4158
info: false,
59+
version: pageVersion,
4260
},
4361
divider: false,
4462
}))
@@ -54,6 +72,7 @@ export const VersionPicker = ({ xs }: Props) => {
5472
extra: {
5573
arrow: false,
5674
info: false,
75+
version: undefined,
5776
},
5877
divider: true,
5978
})
@@ -66,6 +85,7 @@ export const VersionPicker = ({ xs }: Props) => {
6685
extra: {
6786
arrow: true,
6887
info: false,
88+
version: undefined,
6989
},
7090
divider: false,
7191
})
@@ -81,32 +101,73 @@ export const VersionPicker = ({ xs }: Props) => {
81101
extra: {
82102
arrow: false,
83103
info: true,
104+
version: undefined,
84105
},
85106
divider: false,
86107
})
87108
}
88109

110+
const selectedOption = allLinks.find((item) => item.selected)
111+
112+
const handleVersionSelect = (item: VersionPickerLink) => {
113+
// Save the user's version preference when they actively select one
114+
if (item.extra?.version) {
115+
try {
116+
Cookies.set(USER_VERSION_COOKIE_NAME, item.extra.version)
117+
} catch (err) {
118+
console.warn('Unable to set preferred version cookie', err)
119+
}
120+
}
121+
setOpen(false)
122+
// Navigate after setting cookie
123+
if (item.href) {
124+
router.push(item.href)
125+
}
126+
}
127+
89128
return (
90129
<div data-testid="version-picker" className={xs ? 'd-flex' : ''}>
91-
<Picker
92-
defaultText={t('version_picker_default_text')}
93-
items={allLinks}
94-
alignment="end"
95-
pickerLabel={xs ? `Version\n` : `Version: `}
96-
dataTestId="field"
97-
descriptionFontSize={xs ? 6 : 5}
98-
renderItem={(item) => {
99-
return (
100-
<div data-testid="version-picker-item" className={styles.itemsWidth}>
101-
{item.text}
102-
{item.extra?.arrow && (
103-
<ArrowRightIcon verticalAlign="middle" size={15} className="ml-1" />
104-
)}
105-
{item.extra?.info && <InfoIcon verticalAlign="middle" size={15} className="ml-1" />}
106-
</div>
107-
)
108-
}}
109-
/>
130+
<ActionMenu open={open} onOpenChange={setOpen}>
131+
<ActionMenu.Button
132+
variant="invisible"
133+
className={`color-fg-default width-full p-1 pl-2 pr-2`}
134+
>
135+
<span className={styles.pickerLabel}>{xs ? `Version\n` : `Version: `}</span>
136+
<span className={`f${xs ? 6 : 5} color-fg-muted text-normal`} data-testid="field">
137+
{selectedOption?.text || t('version_picker_default_text')}
138+
</span>
139+
</ActionMenu.Button>
140+
<ActionMenu.Overlay width="auto" align="end">
141+
<ActionList selectionVariant="single" role="menu">
142+
{allLinks.map((item, i) =>
143+
item.divider ? (
144+
<ActionList.Divider key={`divider${i}`} />
145+
) : (
146+
<ActionList.Item
147+
key={item.text}
148+
active={item.selected}
149+
onSelect={(e) => {
150+
e.preventDefault()
151+
handleVersionSelect(item)
152+
}}
153+
className={cx((item.extra?.arrow || item.extra?.info) && styles.extrasDisplay)}
154+
role={item.extra?.arrow || item.extra?.info ? 'menuitem' : 'menuitemradio'}
155+
>
156+
<div data-testid="version-picker-item" className={styles.itemsWidth}>
157+
{item.text}
158+
{item.extra?.arrow && (
159+
<ArrowRightIcon verticalAlign="middle" size={15} className="ml-1" />
160+
)}
161+
{item.extra?.info && (
162+
<InfoIcon verticalAlign="middle" size={15} className="ml-1" />
163+
)}
164+
</div>
165+
</ActionList.Item>
166+
),
167+
)}
168+
</ActionList>
169+
</ActionMenu.Overlay>
170+
</ActionMenu>
110171
</div>
111172
)
112173
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { Response, NextFunction } from 'express'
2+
3+
import { USER_VERSION_COOKIE_NAME } from '@/frame/lib/constants'
4+
import { allVersionKeys } from '@/versions/lib/all-versions'
5+
import { updateLoggerContext } from '@/observability/logger/lib/logger-context'
6+
import type { ExtendedRequest } from '@/types'
7+
8+
function isValidVersion(version: string): boolean {
9+
return allVersionKeys.includes(version)
10+
}
11+
12+
export function getUserVersionFromCookie(req: ExtendedRequest): string | undefined {
13+
const value = req.cookies?.[USER_VERSION_COOKIE_NAME]
14+
if (value && isValidVersion(value)) {
15+
return value
16+
}
17+
return undefined
18+
}
19+
20+
export default function detectVersion(req: ExtendedRequest, res: Response, next: NextFunction) {
21+
req.userVersion = getUserVersionFromCookie(req)
22+
updateLoggerContext({
23+
userVersion: req.userVersion,
24+
})
25+
return next()
26+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { get } from '@/tests/helpers/e2etest'
4+
import { USER_VERSION_COOKIE_NAME } from '@/frame/lib/constants'
5+
6+
describe('version cookie redirects', () => {
7+
test('homepage redirects to preferred version from cookie', async () => {
8+
const res = await get('/', {
9+
headers: {
10+
Cookie: `${USER_VERSION_COOKIE_NAME}=enterprise-cloud@latest`,
11+
},
12+
followRedirects: false,
13+
})
14+
expect(res.statusCode).toBe(302)
15+
expect(res.headers.location).toBe('/en/enterprise-cloud@latest')
16+
expect(res.headers.vary).toContain('x-user-version')
17+
})
18+
19+
test('homepage redirects to /en when no version cookie', async () => {
20+
const res = await get('/', { followRedirects: false })
21+
expect(res.statusCode).toBe(302)
22+
expect(res.headers.location).toBe('/en')
23+
expect(res.headers.vary).toContain('x-user-version')
24+
})
25+
26+
test('homepage redirects to /en when fpt version in cookie', async () => {
27+
const res = await get('/', {
28+
headers: {
29+
Cookie: `${USER_VERSION_COOKIE_NAME}=free-pro-team@latest`,
30+
},
31+
followRedirects: false,
32+
})
33+
expect(res.statusCode).toBe(302)
34+
expect(res.headers.location).toBe('/en')
35+
})
36+
37+
test('ignores invalid version in cookie', async () => {
38+
const res = await get('/', {
39+
headers: {
40+
Cookie: `${USER_VERSION_COOKIE_NAME}=invalid-version`,
41+
},
42+
followRedirects: false,
43+
})
44+
expect(res.statusCode).toBe(302)
45+
expect(res.headers.location).toBe('/en')
46+
})
47+
48+
test('homepage redirects to enterprise-server version from cookie', async () => {
49+
const res = await get('/', {
50+
headers: {
51+
Cookie: `${USER_VERSION_COOKIE_NAME}=enterprise-server@3.15`,
52+
},
53+
followRedirects: false,
54+
})
55+
expect(res.statusCode).toBe(302)
56+
expect(res.headers.location).toBe('/en/enterprise-server@3.15')
57+
})
58+
})

0 commit comments

Comments
 (0)