Skip to content

Commit ce68fe5

Browse files
docs-botCopilotheiskrhubwriter
authored
🐞 Fix journey version filtering: remove "Untitled Guide" and correct progress counts (#60675)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Kevin Heis <heiskr@users.noreply.github.com> Co-authored-by: hubwriter <hubwriter@github.com>
1 parent 2678b7a commit ce68fe5

File tree

2 files changed

+206
-35
lines changed

2 files changed

+206
-35
lines changed

src/journeys/lib/journey-path-resolver.ts

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -233,38 +233,47 @@ export async function resolveJourneyContext(
233233
}
234234
}
235235

236+
// Build the list of guides available for the current version.
237+
// fetchGuideData returns null for guides that don't exist in the current version,
238+
// so this filters out unavailable guides for correct counts and navigation.
239+
const availableGuides = (
240+
await Promise.all(
241+
track.guides.map(async (guide, i) => {
242+
const guideData = await fetchGuideData(guide.href, context)
243+
return guideData ? { rawIndex: i, ...guideData } : null
244+
}),
245+
)
246+
).filter((g): g is { rawIndex: number; href: string; title: string } => g !== null)
247+
248+
const filteredIndex = availableGuides.findIndex((g) => g.rawIndex === guideIndex)
249+
const filteredCount = availableGuides.length
250+
236251
result = {
237252
trackId: track.id,
238253
trackName: track.id,
239254
trackTitle: track.title,
240255
journeyTitle: journeyPage.title || '',
241256
journeyPath:
242257
journeyPage.permalink || Permalink.relativePathToSuffix(journeyPage.relativePath || ''),
243-
currentGuideIndex: guideIndex,
244-
numberOfGuides: track.guides.length,
258+
currentGuideIndex: filteredIndex >= 0 ? filteredIndex : guideIndex,
259+
numberOfGuides: filteredCount,
245260
alternativeNextStep: renderedAlternativeNextStep,
246261
}
247262

248-
// Set up previous guide
249-
if (guideIndex > 0) {
250-
const prevGuidePath = track.guides[guideIndex - 1].href
251-
const guideData = await fetchGuideData(prevGuidePath, context)
252-
if (guideData) {
253-
result.prevGuide = guideData
254-
}
263+
// Set up previous guide using the version-filtered list
264+
if (filteredIndex > 0) {
265+
const prev = availableGuides[filteredIndex - 1]
266+
result.prevGuide = { href: prev.href, title: prev.title }
255267
}
256268

257-
// Set up next guide
258-
if (guideIndex < track.guides.length - 1) {
259-
const nextGuidePath = track.guides[guideIndex + 1].href
260-
const guideData = await fetchGuideData(nextGuidePath, context)
261-
if (guideData) {
262-
result.nextGuide = guideData
263-
}
269+
// Set up next guide using the version-filtered list
270+
if (filteredIndex >= 0 && filteredIndex < filteredCount - 1) {
271+
const next = availableGuides[filteredIndex + 1]
272+
result.nextGuide = { href: next.href, title: next.title }
264273
}
265274

266-
// Only populate nextTrackFirstGuide when on the last guide of the track
267-
if (guideIndex === track.guides.length - 1) {
275+
// Only populate nextTrackFirstGuide when on the last guide of the filtered track
276+
if (filteredIndex === filteredCount - 1) {
268277
foundTrackIndex = trackIndex
269278

270279
if (
@@ -320,16 +329,18 @@ export async function resolveJourneyTracks(
320329
? await renderContent(track.description, context, { textOnly: true })
321330
: track.description
322331

323-
const guides = await Promise.all(
324-
track.guides.map(async (guide: { href: string; alternativeNextStep?: string }) => {
325-
const linkData = await getLinkData(guide.href, context, { title: true })
326-
const baseHref = linkData?.[0]?.href || guide.href
327-
return {
328-
href: baseHref,
329-
title: linkData?.[0]?.title || 'Untitled Guide',
330-
}
331-
}),
332-
)
332+
const guides = (
333+
await Promise.all(
334+
track.guides.map(async (guide: { href: string; alternativeNextStep?: string }) => {
335+
const linkData = await getLinkData(guide.href, context, { title: true })
336+
if (!linkData?.[0]) return null
337+
return {
338+
href: linkData[0].href,
339+
title: linkData[0].title || '',
340+
}
341+
}),
342+
)
343+
).filter((g): g is { href: string; title: string } => g !== null)
333344

334345
return {
335346
id: track.id,

src/journeys/tests/journey-path-resolver.ts

Lines changed: 167 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
1-
import { describe, expect, test, vi } from 'vitest'
1+
import { afterEach, describe, expect, test, vi } from 'vitest'
22

33
import { resolveJourneyContext, resolveJourneyTracks } from '../lib/journey-path-resolver'
4+
import getLinkData from '@/journeys/lib/get-link-data'
45
import type { Page } from '@/types'
56

67
// Mock modules since we just want to test journey functions, not their dependencies or
78
// against real content files
89
vi.mock('@/journeys/lib/get-link-data', () => ({
9-
default: async (path: string) => [
10-
{
11-
href: `/en/enterprise-cloud@latest${path}`,
12-
title: `Mock Title for ${path}`,
13-
},
14-
],
10+
default: vi.fn(async (rawLinks: string | string[] | undefined) => {
11+
const path = Array.isArray(rawLinks) ? rawLinks[0] : rawLinks
12+
if (!path) return undefined
13+
return [
14+
{
15+
href: `/en/enterprise-cloud@latest${path}`,
16+
title: `Mock Title for ${path}`,
17+
page: {} as unknown as Page,
18+
},
19+
]
20+
}),
1521
}))
1622

23+
const mockGetLinkData = vi.mocked(getLinkData)
24+
1725
vi.mock('@/content-render/index', () => ({
1826
renderContent: async (content: string) => content,
1927
}))
@@ -269,4 +277,156 @@ describe('journey-path-resolver', () => {
269277
expect(result[0].description).toBeUndefined()
270278
})
271279
})
280+
281+
describe('resolveJourneyContext with version-filtered guides', () => {
282+
afterEach(() => {
283+
// Restore the default implementation after each test in this block
284+
mockGetLinkData.mockImplementation(async (rawLinks: string | string[] | undefined) => {
285+
const path = Array.isArray(rawLinks) ? rawLinks[0] : rawLinks
286+
if (!path) return undefined
287+
return [
288+
{
289+
href: `/en/enterprise-cloud@latest${path}`,
290+
title: `Mock Title for ${path}`,
291+
page: {} as unknown as Page,
292+
},
293+
]
294+
})
295+
})
296+
297+
const mockContext = {
298+
currentProduct: 'github',
299+
currentLanguage: 'en',
300+
currentVersion: 'enterprise-cloud@latest',
301+
}
302+
303+
// Track with 4 guides where the 2nd guide is unavailable in the current version:
304+
// raw indices: 0=setup, 1=unavailable, 2=config, 3=deploy
305+
// filtered: 0=setup, 1=config, 2=deploy
306+
const mockPages = {
307+
'enterprise-onboarding/index': {
308+
layout: 'journey-landing',
309+
title: 'Enterprise onboarding',
310+
permalink: '/enterprise-onboarding',
311+
journeyTracks: [
312+
{
313+
id: 'getting_started',
314+
title: 'Getting started',
315+
guides: [
316+
{ href: '/enterprise-onboarding/setup' },
317+
{ href: '/enterprise-onboarding/unavailable' },
318+
{ href: '/enterprise-onboarding/config' },
319+
{ href: '/enterprise-onboarding/deploy' },
320+
],
321+
},
322+
],
323+
},
324+
} as unknown as Record<string, Page>
325+
326+
test('resolveJourneyTracks filters out guides unavailable for the current version', async () => {
327+
mockGetLinkData.mockImplementation(async (rawLinks: string | string[] | undefined) => {
328+
const path = Array.isArray(rawLinks) ? rawLinks[0] : rawLinks
329+
if (path === '/enterprise-onboarding/config') return undefined
330+
if (!path) return undefined
331+
return [
332+
{
333+
href: `/en/enterprise-cloud@latest${path}`,
334+
title: `Mock Title for ${path}`,
335+
page: {} as unknown as Page,
336+
},
337+
]
338+
})
339+
340+
const tracks = [
341+
{
342+
id: 'getting_started',
343+
title: 'Getting started',
344+
guides: [
345+
{ href: '/enterprise-onboarding/setup' },
346+
{ href: '/enterprise-onboarding/config' },
347+
],
348+
},
349+
]
350+
const result = await resolveJourneyTracks(tracks, mockContext)
351+
352+
// /enterprise-onboarding/config is unavailable, so only /enterprise-onboarding/setup remains
353+
expect(result[0].guides).toHaveLength(1)
354+
expect(result[0].guides[0].href).toBe(
355+
'/en/enterprise-cloud@latest/enterprise-onboarding/setup',
356+
)
357+
})
358+
359+
test('numberOfGuides reflects only version-available guides', async () => {
360+
mockGetLinkData.mockImplementation(async (rawLinks: string | string[] | undefined) => {
361+
const path = Array.isArray(rawLinks) ? rawLinks[0] : rawLinks
362+
if (path === '/enterprise-onboarding/unavailable') return undefined
363+
if (!path) return undefined
364+
return [
365+
{
366+
href: `/en/enterprise-cloud@latest${path}`,
367+
title: `Mock Title for ${path}`,
368+
page: {} as unknown as Page,
369+
},
370+
]
371+
})
372+
373+
const result = await resolveJourneyContext(
374+
'/enterprise-onboarding/config',
375+
mockPages,
376+
mockContext,
377+
)
378+
379+
expect(result?.numberOfGuides).toBe(3) // 3 available, not 4 raw
380+
})
381+
382+
test('currentGuideIndex reflects position in version-filtered list', async () => {
383+
mockGetLinkData.mockImplementation(async (rawLinks: string | string[] | undefined) => {
384+
const path = Array.isArray(rawLinks) ? rawLinks[0] : rawLinks
385+
if (path === '/enterprise-onboarding/unavailable') return undefined
386+
if (!path) return undefined
387+
return [
388+
{
389+
href: `/en/enterprise-cloud@latest${path}`,
390+
title: `Mock Title for ${path}`,
391+
page: {} as unknown as Page,
392+
},
393+
]
394+
})
395+
396+
const result = await resolveJourneyContext(
397+
'/enterprise-onboarding/config',
398+
mockPages,
399+
mockContext,
400+
)
401+
402+
// config is at raw index 2, but filtered index 1 (setup=0, config=1, deploy=2)
403+
expect(result?.currentGuideIndex).toBe(1)
404+
})
405+
406+
test('prevGuide skips unavailable guides', async () => {
407+
mockGetLinkData.mockImplementation(async (rawLinks: string | string[] | undefined) => {
408+
const path = Array.isArray(rawLinks) ? rawLinks[0] : rawLinks
409+
if (path === '/enterprise-onboarding/unavailable') return undefined
410+
if (!path) return undefined
411+
return [
412+
{
413+
href: `/en/enterprise-cloud@latest${path}`,
414+
title: `Mock Title for ${path}`,
415+
page: {} as unknown as Page,
416+
},
417+
]
418+
})
419+
420+
// config's predecessor in the raw list is "unavailable", but in filtered list it's "setup"
421+
const result = await resolveJourneyContext(
422+
'/enterprise-onboarding/config',
423+
mockPages,
424+
mockContext,
425+
)
426+
427+
expect(result?.prevGuide?.href).toBe(
428+
'/en/enterprise-cloud@latest/enterprise-onboarding/setup',
429+
)
430+
})
431+
})
272432
})

0 commit comments

Comments
 (0)