Skip to content

Commit 9bc90cd

Browse files
authored
Learning Track navigation banner (#17440)
* add middleware to handle `learn` query param * add exception to query-less cache key * add querystring to learning track guides
1 parent 97dfb49 commit 9bc90cd

10 files changed

Lines changed: 176 additions & 21 deletions

File tree

data/ui.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,6 @@ product_sublanding:
160160
tutorial: Tutorial
161161
how_to: How-to guide
162162
reference: Reference
163+
learning_track_nav:
164+
prevGuide: Previous Guide
165+
nextGuide: Next Guide

includes/article.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ <h2 id="in-this-article" class="f5 mb-2"><a class="link-gray-dark" href="#in-thi
6565
</div>
6666
</div>
6767

68-
<div class="d-block border-top border-gray-light mt-4 markdown-body">
68+
<div class="d-block mt-4 markdown-body">
69+
{% if currentLearningTrack and currentLearningTrack.trackName %}
70+
{% include learning-track-nav %}
71+
{% endif %}
6972
{% include helpfulness %}
7073
{% unless page.hidden %}{% include contribution %}{% endunless %}
7174
</div>

includes/learning-track-nav.html

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<div class="py-3 px-4 rounded bg-white border-gradient--purple-pink d-flex flex-justify-between learning-track-nav">
2+
{% assign track = currentLearningTrack %}
3+
4+
<span class="d-flex flex-column">
5+
{% if track.prevGuide %}
6+
<span class="f6 text-gray">{% data ui.learning_track_nav.prevGuide %}</span>
7+
<a href="{{track.prevGuide.href}}?learn={{track.trackName}}" class="text-bold text-gray">{{track.prevGuide.title}}</a>
8+
{% endif %}
9+
</span>
10+
11+
<span class="d-flex flex-column flex-items-end">
12+
{% if track.nextGuide %}
13+
<span class="f6 text-gray">{% data ui.learning_track_nav.nextGuide %}</span>
14+
<a href="{{track.nextGuide.href}}?learn={{track.trackName}}" class="text-bold text-gray text-right">{{track.nextGuide.title}}</a>
15+
{% endif %}
16+
</span>
17+
18+
</div>

layouts/product-sublanding.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ <h1 class="my-3 font-mktg">{{ page.shortTitle }}</h1>
2929
<div class="circle bg-white text-blue border border-white d-inline-flex">{% octicon "star-fill" height="24" class="v-align-middle m-2"%}</div>
3030
<h3 class="font-mktg h2-mktg my-4">{{ featuredTrack.title }}</h3>
3131
<div class="lead-mktg text-white f5 my-4">{{ featuredTrack.description }}</div>
32-
<a class="d-inline-block border border-white text-white px-4 py-2 f5 no-underline text-bold" role="button" href="{{ featuredTrack.guides[0].href }}">
32+
<a class="d-inline-block border border-white text-white px-4 py-2 f5 no-underline text-bold" role="button" href="{{ featuredTrack.guides[0].href }}?learn={{ featuredTrack.trackName }}">
3333
<span class="mr-2">{% octicon "arrow-right" height="20" %}</span>
3434
{% data ui.product_sublanding.start_path %}
3535
</a>
3636
</div>
3737
</li>
3838
{% for guide in featuredTrack.guides %}
3939
<li class="px-2 d-flex flex-shrink-0">
40-
<a href="{{ guide.href }}" class="d-inline-block Box p-5 bg-white border-gray no-underline">
40+
<a href="{{ guide.href }}?learn={{ featuredTrack.trackName }}" class="d-inline-block Box p-5 bg-white border-gray no-underline">
4141
<div class="d-flex flex-justify-between flex-items-center">
4242
<div class="circle bg-white text-blue border-gradient--purple-pink d-inline-flex">
4343
<span class="m-2 f2 lh-condensed-ultra text-center text-bold text-gradient--blue-purple" style="width: 24px; height: 24px;">{{ forloop.index }}</span>
@@ -61,7 +61,7 @@ <h2 class="mb-3 font-mktg">{% data ui.product_sublanding.learning_paths %}</h2>
6161
<!-- Learning tracks -->
6262
<div class="d-flex flex-wrap flex-items-start my-5">
6363
{% for track in page.learningTracks offset:1 %}
64-
<div class="my-3 px-0 px-4 col-12 col-md-6">
64+
<div class="my-3 px-0 px-4 col-12 col-md-6 learning-track">
6565
<div class="Box js-show-more-container">
6666
<div class="Box-header bg-gradient--purple-pink py-4 d-flex flex-auto flex-items-start flex-wrap">
6767
<div class="d-flex flex-auto flex-items-start col-8 col-md-12 col-xl-8">
@@ -75,14 +75,14 @@ <h4 class="mb-3 text-white font-mktg h3-mktg ">
7575
<p class="text-white">{{ track.description }}</p>
7676
</div>
7777
</div>
78-
<a class="d-inline-block border border-white text-white px-3 py-2 f5 no-underline text-bold no-wrap" role="button" href="{{ track.guides[0].href }}">
78+
<a class="d-inline-block border border-white text-white px-3 py-2 f5 no-underline text-bold no-wrap" role="button" href="{{ track.guides[0].href }}?learn={{ track.trackName }}">
7979
{% data ui.product_sublanding.start %}
8080
<span class="ml-2">{% octicon "arrow-right" height="20" %}</span>
8181
</a>
8282
</div>
8383
<div>
8484
{% for guide in track.guides %}
85-
<a class="Box-row d-flex flex-items-center text-gray-dark no-underline js-show-more-item {% if forloop.index > 4 %}d-none{% endif %}" href="{{ guide.href }}">
85+
<a class="Box-row d-flex flex-items-center text-gray-dark no-underline js-show-more-item {% if forloop.index > 4 %}d-none{% endif %}" href="{{ guide.href }}?learn={{ track.trackName }}">
8686
<div class="circle bg-gray d-inline-flex mr-4">
8787
<span class="m-2 f3 lh-condensed-ultra text-center text-bold text-gradient--purple-pink" style="min-width: 20px; height: 20px;">{{ forloop.index }}</span>
8888
</div>

lib/get-link-data.js

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,45 @@ const removeFPTFromPath = require('./remove-fpt-from-path')
55

66
// rawLinks is an array of paths: [ '/foo' ]
77
// we need to convert it to an array of localized objects: [ { href: '/en/foo', title: 'Foo', intro: 'Description here' } ]
8-
module.exports = async (rawLinks, context) => {
8+
module.exports = async (rawLinks, context, option = { title: true, intro: true }) => {
99
if (!rawLinks) return
1010

11+
if (typeof rawLinks === 'string') {
12+
return await processLink(rawLinks, context, option)
13+
}
14+
1115
const links = []
1216

1317
for (const link of rawLinks) {
14-
const linkPath = link.href || link
15-
const version = context.currentVersion === 'homepage' ? nonEnterpriseDefaultVersion : context.currentVersion
16-
const href = removeFPTFromPath(path.join('/', context.currentLanguage, version, linkPath))
18+
const linkObj = await processLink(link, context, option)
19+
if (!linkObj) {
20+
continue
21+
} else {
22+
links.push(linkObj)
23+
}
24+
}
25+
26+
return links
27+
}
28+
29+
const processLink = async (link, context, option) => {
30+
const linkPath = link.href || link
31+
const version = context.currentVersion === 'homepage' ? nonEnterpriseDefaultVersion : context.currentVersion
32+
const href = removeFPTFromPath(path.join('/', context.currentLanguage, version, linkPath))
33+
34+
const linkedPage = findPage(href, context.pages, context.redirects)
35+
if (!linkedPage) return null
1736

18-
const linkedPage = findPage(href, context.pages, context.redirects)
19-
if (!linkedPage) continue
37+
const opts = { textOnly: true, encodeEntities: true }
2038

21-
const opts = { textOnly: true, encodeEntities: true }
39+
const result = { href, page: linkedPage }
2240

23-
links.push({
24-
href,
25-
title: await linkedPage.renderTitle(context, opts),
26-
intro: await linkedPage.renderProp('intro', context, opts),
27-
page: linkedPage
28-
})
41+
if (option.title) {
42+
result.title = await linkedPage.renderTitle(context, opts)
2943
}
3044

31-
return links
45+
if (option.intro) {
46+
result.intro = await linkedPage.renderProp('intro', context, opts)
47+
}
48+
return result
3249
}

lib/page.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ class Page {
208208
const track = context.site.data['learning-tracks'][context.currentProduct][trackName]
209209
if (!track) continue
210210
learningTracks.push({
211+
trackName,
211212
title: await renderContent(track.title, context, { textOnly: true, encodeEntities: true }),
212213
description: await renderContent(track.description, context, { textOnly: true, encodeEntities: true }),
213214
guides: await getLinkData(track.guides, context)

middleware/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ module.exports = function (app) {
8484
app.use(require('./enterprise-server-releases'))
8585
app.use(require('./dev-toc'))
8686
app.use(require('./featured-links'))
87+
app.use(require('./learning-track'))
8788

8889
// *** Rendering, must go last ***
8990
app.get('/*', asyncMiddleware(require('./render-page')))

middleware/learning-track.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
const { getPathWithoutLanguage, getPathWithoutVersion } = require('../lib/path-utils')
2+
const getLinkData = require('../lib/get-link-data')
3+
4+
module.exports = async (req, res, next) => {
5+
const noTrack = () => {
6+
req.context.currentLearningTrack = {}
7+
return next()
8+
}
9+
10+
if (!req.context.page) return next()
11+
12+
const trackName = req.query.learn
13+
if (!trackName) return noTrack()
14+
15+
const track = req.context.site.data['learning-tracks'][req.context.currentProduct][trackName]
16+
if (!track) return noTrack()
17+
18+
const currentLearningTrack = { trackName }
19+
20+
const guidePath = getPathWithoutLanguage(getPathWithoutVersion(req.path))
21+
const guideIndex = track.guides.findIndex((path) => path === guidePath)
22+
23+
if (guideIndex < 0) return noTrack()
24+
25+
if (guideIndex > 0) {
26+
const prevGuidePath = track.guides[guideIndex - 1]
27+
const { href, title } = await getLinkData(prevGuidePath, req.context, { title: true, intro: false })
28+
currentLearningTrack.prevGuide = { href, title }
29+
}
30+
31+
if (guideIndex < track.guides.length - 1) {
32+
const nextGuidePath = track.guides[guideIndex + 1]
33+
const { href, title } = await getLinkData(nextGuidePath, req.context, { title: true, intro: false })
34+
currentLearningTrack.nextGuide = { href, title }
35+
}
36+
37+
req.context.currentLearningTrack = currentLearningTrack
38+
39+
return next()
40+
}

middleware/render-page.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,21 @@ const pageCache = new RedisAccessor({
1818
allowSetFailures: true
1919
})
2020

21+
// a list of query params that *do* alter the rendered page, and therefore should be cached separately
22+
const cacheableQueries = ['learn']
23+
2124
module.exports = async function renderPage (req, res, next) {
2225
const page = req.context.page
2326

2427
// Remove any query string (?...) and/or fragment identifier (#...)
25-
const originalUrl = new URL(req.originalUrl, 'https://docs.github.com').pathname
28+
const { pathname, searchParams } = new URL(req.originalUrl, 'https://docs.github.com')
29+
30+
for (const queryKey in req.query) {
31+
if (!cacheableQueries.includes(queryKey)) {
32+
searchParams.delete(queryKey)
33+
}
34+
}
35+
const originalUrl = pathname + ([...searchParams].length > 0 ? `?${searchParams}` : '')
2636

2737
// Serve from the cache if possible (skip during tests)
2838
const isCacheable = !process.env.CI && process.env.NODE_ENV !== 'test' && req.method === 'GET'

tests/rendering/learning-tracks.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
const { getDOM } = require('../helpers/supertest')
2+
3+
jest.setTimeout(3 * 60 * 1000)
4+
5+
describe('learning tracks', () => {
6+
test('render first track as feature track', async () => {
7+
const $ = await getDOM('/en/actions/guides')
8+
expect($('.feature-track')).toHaveLength(1)
9+
const href = $('.feature-track li a').first().attr('href')
10+
const found = href.match(/.*\?learn=(.*)/i)
11+
expect(found).not.toBeNull()
12+
const trackName = found[1]
13+
14+
// check all the links contain track name
15+
$('.feature-track li a').each((i, elem) => {
16+
expect($(elem).attr('href')).toEqual(expect.stringContaining(`?learn=${trackName}`))
17+
})
18+
})
19+
20+
test('render other tracks', async () => {
21+
const $ = await getDOM('/en/actions/guides')
22+
expect($('.learning-track').length).toBeGreaterThanOrEqual(4)
23+
$('.learning-track').each((i, trackElem) => {
24+
const href = $(trackElem).find('.Box-header a').first().attr('href')
25+
const found = href.match(/.*\?learn=(.*)/i)
26+
expect(found).not.toBeNull()
27+
const trackName = found[1]
28+
29+
// check all the links contain track name
30+
$(trackElem).find('a.Box-row').each((i, elem) => {
31+
expect($(elem).attr('href')).toEqual(expect.stringContaining(`?learn=${trackName}`))
32+
})
33+
})
34+
})
35+
})
36+
37+
describe('navigation banner', () => {
38+
test('render navigation banner when url includes correct learning track name', async () => {
39+
const $ = await getDOM('/en/actions/guides/setting-up-continuous-integration-using-workflow-templates?learn=continuous_integration')
40+
expect($('.learning-track-nav')).toHaveLength(1)
41+
const $navLinks = $('.learning-track-nav a')
42+
expect($navLinks).toHaveLength(2)
43+
$navLinks.each((i, elem) => {
44+
expect($(elem).attr('href')).toEqual(expect.stringContaining('?learn=continuous_integration'))
45+
})
46+
})
47+
48+
test('does not include banner when url does not include `learn` param', async () => {
49+
const $ = await getDOM('/en/actions/guides/setting-up-continuous-integration-using-workflow-templates')
50+
expect($('.learning-track-nav')).toHaveLength(0)
51+
})
52+
53+
test('does not include banner when url has incorrect `learn` param', async () => {
54+
const $ = await getDOM('/en/actions/guides/setting-up-continuous-integration-using-workflow-templates?learn=not_real')
55+
expect($('.learning-track-nav')).toHaveLength(0)
56+
})
57+
58+
test('does not include banner when url is not part of the learning track', async () => {
59+
const $ = await getDOM('/en/actions/learn-github-actions/introduction-to-github-actions?learn=continuous_integration')
60+
expect($('.learning-track-nav')).toHaveLength(0)
61+
})
62+
})

0 commit comments

Comments
 (0)