show journey nav and support journey article branching text frontmatter (#58848)
This commit is contained in:
@@ -259,7 +259,9 @@ includeGuides:
|
||||
- `id` (required): Unique identifier for the journey. The id only needs to be unique for journeys within a single journey landing page.
|
||||
- `title` (required): Display title for the journey (supports Liquid variables)
|
||||
- `description` (optional): Description of the journey (supports Liquid variables)
|
||||
- `guides` (required): Array of article paths that make up this journey
|
||||
- `guides` (required): Array of guide objects that make up this journey. Each guide object has:
|
||||
- `href` (required): Path to the article
|
||||
- `alternativeNextStep` (optional): Custom text to guide users to alternative paths in the journey. Supports Liquid variables and `[AUTOTITLE]`.
|
||||
- Only applicable when used with `layout: journey-landing`.
|
||||
- Optional.
|
||||
|
||||
@@ -271,15 +273,16 @@ journeyTracks:
|
||||
title: 'Getting started with {% data variables.product.prodname_actions %}'
|
||||
description: 'Learn the basics of GitHub Actions.'
|
||||
guides:
|
||||
- '/actions/quickstart'
|
||||
- '/actions/learn-github-actions'
|
||||
- '/actions/using-workflows'
|
||||
- href: '/actions/quickstart'
|
||||
- href: '/actions/learn-github-actions'
|
||||
alternativeNextStep: 'Want to skip ahead? See [AUTOTITLE](/actions/using-workflows).'
|
||||
- href: '/actions/using-workflows'
|
||||
- id: 'advanced'
|
||||
title: 'Advanced {% data variables.product.prodname_actions %}'
|
||||
description: 'Dive deeper into advanced features.'
|
||||
guides:
|
||||
- '/actions/using-workflows/workflow-syntax-for-github-actions'
|
||||
- '/actions/deployment/deploying-with-github-actions'
|
||||
- href: '/actions/using-workflows/workflow-syntax-for-github-actions'
|
||||
- href: '/actions/deployment/deploying-with-github-actions'
|
||||
```
|
||||
|
||||
### `type`
|
||||
|
||||
@@ -257,7 +257,9 @@ includeGuides:
|
||||
* `id` (required): Unique identifier for the journey. The id only needs to be unique for journeys within a single journey landing page.
|
||||
* `title` (required): Display title for the journey (supports Liquid variables)
|
||||
* `description` (optional): Description of the journey (supports Liquid variables)
|
||||
* `guides` (required): Array of article paths that make up this journey
|
||||
* `guides` (required): Array of guide objects that make up this journey. Each guide object has:
|
||||
* `href` (required): Path to the article
|
||||
* `alternativeNextStep` (optional): Custom text to guide users to alternative paths in the journey. Supports Liquid variables and `[AUTOTITLE]`.
|
||||
* Only applicable when used with `layout: journey-landing`.
|
||||
* Optional.
|
||||
|
||||
@@ -269,15 +271,16 @@ journeyTracks:
|
||||
title: 'Getting started with {% data variables.product.prodname_actions %}'
|
||||
description: 'Learn the basics of GitHub Actions.'
|
||||
guides:
|
||||
- '/actions/quickstart'
|
||||
- '/actions/learn-github-actions'
|
||||
- '/actions/using-workflows'
|
||||
- href: '/actions/quickstart'
|
||||
- href: '/actions/learn-github-actions'
|
||||
alternativeNextStep: 'Want to skip ahead? See [AUTOTITLE](/actions/using-workflows).'
|
||||
- href: '/actions/using-workflows'
|
||||
- id: 'advanced'
|
||||
title: 'Advanced {% data variables.product.prodname_actions %}'
|
||||
description: 'Dive deeper into advanced features.'
|
||||
guides:
|
||||
- '/actions/using-workflows/workflow-syntax-for-github-actions'
|
||||
- '/actions/deployment/deploying-with-github-actions'
|
||||
- href: '/actions/using-workflows/workflow-syntax-for-github-actions'
|
||||
- href: '/actions/deployment/deploying-with-github-actions'
|
||||
```
|
||||
|
||||
### `type`
|
||||
|
||||
@@ -16,56 +16,56 @@ journeyTracks:
|
||||
title: 'Getting started with your enterprise'
|
||||
description: 'Master the fundamentals of {% data variables.product.prodname_ghe_cloud %} and get started with a trial.'
|
||||
guides:
|
||||
- '/enterprise-onboarding/getting-started-with-your-enterprise/choose-an-enterprise-type'
|
||||
- '/enterprise-onboarding/getting-started-with-your-enterprise/setting-up-a-trial-of-github-enterprise'
|
||||
- '/enterprise-onboarding/getting-started-with-your-enterprise/adding-users-to-your-enterprise'
|
||||
- '/enterprise-onboarding/getting-started-with-your-enterprise/about-enterprise-billing'
|
||||
- '/enterprise-onboarding/getting-started-with-your-enterprise/about-migrating-to-github-enterprise-cloud'
|
||||
- href: '/enterprise-onboarding/getting-started-with-your-enterprise/choose-an-enterprise-type'
|
||||
- href: '/enterprise-onboarding/getting-started-with-your-enterprise/setting-up-a-trial-of-github-enterprise'
|
||||
- href: '/enterprise-onboarding/getting-started-with-your-enterprise/adding-users-to-your-enterprise'
|
||||
- href: '/enterprise-onboarding/getting-started-with-your-enterprise/about-enterprise-billing'
|
||||
- href: '/enterprise-onboarding/getting-started-with-your-enterprise/about-migrating-to-github-enterprise-cloud'
|
||||
- id: 'setting_up_organizations_and_teams'
|
||||
title: 'Setting up organizations and teams in your enterprise'
|
||||
description: 'Organize work effectively and ensure people have the access they need to resources and administrative settings.'
|
||||
guides:
|
||||
- '/enterprise-onboarding/setting-up-organizations-and-teams/best-practices'
|
||||
- '/enterprise-onboarding/setting-up-organizations-and-teams/setting-up-an-organization'
|
||||
- '/enterprise-onboarding/setting-up-organizations-and-teams/about-roles-in-an-enterprise'
|
||||
- '/enterprise-onboarding/setting-up-organizations-and-teams/identify-role-requirements'
|
||||
- '/enterprise-onboarding/setting-up-organizations-and-teams/creating-custom-roles'
|
||||
- '/enterprise-onboarding/setting-up-organizations-and-teams/about-teams-in-an-enterprise'
|
||||
- '/enterprise-onboarding/setting-up-organizations-and-teams/creating-teams'
|
||||
- '/enterprise-onboarding/setting-up-organizations-and-teams/assigning-roles-to-teams-and-users'
|
||||
- '/enterprise-onboarding/setting-up-organizations-and-teams/use-innersource'
|
||||
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/best-practices'
|
||||
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/setting-up-an-organization'
|
||||
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/about-roles-in-an-enterprise'
|
||||
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/identify-role-requirements'
|
||||
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/creating-custom-roles'
|
||||
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/about-teams-in-an-enterprise'
|
||||
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/creating-teams'
|
||||
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/assigning-roles-to-teams-and-users'
|
||||
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/use-innersource'
|
||||
- id: 'support_for_your_enterprise'
|
||||
title: 'Creating a support model for your enterprise'
|
||||
description: 'Find out how to get help and choose who will be able to contact Support.'
|
||||
guides:
|
||||
- '/enterprise-onboarding/support-for-your-enterprise/understanding-support'
|
||||
- '/enterprise-onboarding/support-for-your-enterprise/using-the-support-portal'
|
||||
- '/enterprise-onboarding/support-for-your-enterprise/managing-support-entitlements'
|
||||
- href: '/enterprise-onboarding/support-for-your-enterprise/understanding-support'
|
||||
- href: '/enterprise-onboarding/support-for-your-enterprise/using-the-support-portal'
|
||||
- href: '/enterprise-onboarding/support-for-your-enterprise/managing-support-entitlements'
|
||||
- id: 'govern_people_and_repositories'
|
||||
title: 'Governing people and repositories'
|
||||
description: 'Implement policies, custom properties, and rulesets to govern users and repositories across your enterprise.'
|
||||
guides:
|
||||
- '/enterprise-onboarding/govern-people-and-repositories/about-enterprise-policies'
|
||||
- '/enterprise-onboarding/govern-people-and-repositories/create-custom-properties'
|
||||
- '/enterprise-onboarding/govern-people-and-repositories/create-repository-policies'
|
||||
- '/enterprise-onboarding/govern-people-and-repositories/protect-branches'
|
||||
- '/enterprise-onboarding/govern-people-and-repositories/using-the-audit-log-for-your-enterprise'
|
||||
- '/enterprise-onboarding/govern-people-and-repositories/about-enterprise-security'
|
||||
- href: '/enterprise-onboarding/govern-people-and-repositories/about-enterprise-policies'
|
||||
- href: '/enterprise-onboarding/govern-people-and-repositories/create-custom-properties'
|
||||
- href: '/enterprise-onboarding/govern-people-and-repositories/create-repository-policies'
|
||||
- href: '/enterprise-onboarding/govern-people-and-repositories/protect-branches'
|
||||
- href: '/enterprise-onboarding/govern-people-and-repositories/using-the-audit-log-for-your-enterprise'
|
||||
- href: '/enterprise-onboarding/govern-people-and-repositories/about-enterprise-security'
|
||||
- id: 'github_apps'
|
||||
title: 'Automating processes with GitHub Apps'
|
||||
description: 'Create and install apps to automate processes securely in your enterprise and organizations.'
|
||||
guides:
|
||||
- '/enterprise-onboarding/github-apps/create-enterprise-apps'
|
||||
- '/enterprise-onboarding/github-apps/install-enterprise-apps'
|
||||
- href: '/enterprise-onboarding/github-apps/create-enterprise-apps'
|
||||
- href: '/enterprise-onboarding/github-apps/install-enterprise-apps'
|
||||
- id: 'github_actions_for_your_enterprise'
|
||||
title: 'Setting up CI/CD with GitHub Actions'
|
||||
description: 'Explore {% data variables.product.prodname_actions %}, plan your rollout, and get started.'
|
||||
guides:
|
||||
- '/enterprise-onboarding/github-actions-for-your-enterprise/about-github-actions-for-enterprises'
|
||||
- '/enterprise-onboarding/github-actions-for-your-enterprise/actions-components'
|
||||
- '/enterprise-onboarding/github-actions-for-your-enterprise/planning-a-rollout-of-github-actions'
|
||||
- '/enterprise-onboarding/github-actions-for-your-enterprise/migrating-your-enterprise-to-github-actions'
|
||||
- '/enterprise-onboarding/github-actions-for-your-enterprise/getting-started-with-github-actions-for-github-enterprise-cloud'
|
||||
- href: '/enterprise-onboarding/github-actions-for-your-enterprise/about-github-actions-for-enterprises'
|
||||
- href: '/enterprise-onboarding/github-actions-for-your-enterprise/actions-components'
|
||||
- href: '/enterprise-onboarding/github-actions-for-your-enterprise/planning-a-rollout-of-github-actions'
|
||||
- href: '/enterprise-onboarding/github-actions-for-your-enterprise/migrating-your-enterprise-to-github-actions'
|
||||
- href: '/enterprise-onboarding/github-actions-for-your-enterprise/getting-started-with-github-actions-for-github-enterprise-cloud'
|
||||
versions:
|
||||
ghec: '*'
|
||||
topics:
|
||||
|
||||
@@ -28,12 +28,12 @@ journeyTracks:
|
||||
title: 'Get started'
|
||||
description: 'Master the fundamentals of {% data variables.product.github %} and Git.'
|
||||
guides:
|
||||
- '/get-started/start-your-journey/about-github-and-git'
|
||||
- '/get-started/start-your-journey/creating-an-account-on-github'
|
||||
- '/get-started/start-your-journey/hello-world'
|
||||
- '/get-started/start-your-journey/setting-up-your-profile'
|
||||
- '/get-started/start-your-journey/finding-inspiration-on-github'
|
||||
- '/get-started/start-your-journey/downloading-files-from-github'
|
||||
- '/get-started/start-your-journey/uploading-a-project-to-github'
|
||||
- '/get-started/start-your-journey/git-and-github-learning-resources'
|
||||
- href: '/get-started/start-your-journey/about-github-and-git'
|
||||
- href: '/get-started/start-your-journey/creating-an-account-on-github'
|
||||
- href: '/get-started/start-your-journey/hello-world'
|
||||
- href: '/get-started/start-your-journey/setting-up-your-profile'
|
||||
- href: '/get-started/start-your-journey/finding-inspiration-on-github'
|
||||
- href: '/get-started/start-your-journey/downloading-files-from-github'
|
||||
- href: '/get-started/start-your-journey/uploading-a-project-to-github'
|
||||
- href: '/get-started/start-your-journey/git-and-github-learning-resources'
|
||||
---
|
||||
|
||||
@@ -71,14 +71,19 @@ export const journeyTracksGuidePathExists = {
|
||||
const trackObj = track as Record<string, unknown>
|
||||
if (trackObj.guides && Array.isArray(trackObj.guides)) {
|
||||
for (let guideIndex = 0; guideIndex < trackObj.guides.length; guideIndex++) {
|
||||
const guide: string = trackObj.guides[guideIndex]
|
||||
if (typeof guide === 'string') {
|
||||
if (!isValidGuidePath(guide, params.name)) {
|
||||
const guideObj = trackObj.guides[guideIndex]
|
||||
|
||||
// Validate guide is an object with expected properties
|
||||
if (!guideObj || typeof guideObj !== 'object') continue
|
||||
|
||||
// Validate href property
|
||||
if ('href' in guideObj && typeof guideObj.href === 'string') {
|
||||
if (!isValidGuidePath(guideObj.href, params.name)) {
|
||||
addError(
|
||||
onError,
|
||||
journeyTracksLineNumber,
|
||||
`Journey track guide path does not exist: ${guide} (track ${trackIndex + 1}, guide ${guideIndex + 1})`,
|
||||
guide,
|
||||
`Journey track guide path does not exist: ${guideObj.href} (track ${trackIndex + 1}, guide ${guideIndex + 1})`,
|
||||
guideObj.href,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,16 +75,38 @@ export const journeyTracksLiquid = {
|
||||
|
||||
if (track.guides && Array.isArray(track.guides)) {
|
||||
for (let guideIndex = 0; guideIndex < track.guides.length; guideIndex++) {
|
||||
const guide: string = track.guides[guideIndex]
|
||||
if (typeof guide === 'string') {
|
||||
const guideObj = track.guides[guideIndex]
|
||||
|
||||
// Validate guide is an object with expected properties
|
||||
if (!guideObj || typeof guideObj !== 'object') continue
|
||||
|
||||
// Validate href property
|
||||
if ('href' in guideObj && typeof guideObj.href === 'string') {
|
||||
try {
|
||||
liquid.parse(guide)
|
||||
liquid.parse(guideObj.href)
|
||||
} catch (error: any) {
|
||||
addError(
|
||||
onError,
|
||||
trackLineNumber,
|
||||
`Invalid Liquid syntax in journey track guide (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error.message}`,
|
||||
guide,
|
||||
`Invalid Liquid syntax in journey track guide href (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error.message}`,
|
||||
guideObj.href,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate alternativeNextStep property if present
|
||||
if (
|
||||
'alternativeNextStep' in guideObj &&
|
||||
typeof guideObj.alternativeNextStep === 'string'
|
||||
) {
|
||||
try {
|
||||
liquid.parse(guideObj.alternativeNextStep)
|
||||
} catch (error: any) {
|
||||
addError(
|
||||
onError,
|
||||
trackLineNumber,
|
||||
`Invalid Liquid syntax in journey track guide alternativeNextStep (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error.message}`,
|
||||
guideObj.alternativeNextStep,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,15 +11,15 @@ journeyTracks:
|
||||
- id: duplicate-id
|
||||
title: "First Track"
|
||||
guides:
|
||||
- /article-one
|
||||
- href: /article-one
|
||||
- id: unique-id
|
||||
title: "Unique Track"
|
||||
guides:
|
||||
- /article-two
|
||||
- href: /article-two
|
||||
- id: duplicate-id
|
||||
title: "Second Track with Same ID"
|
||||
guides:
|
||||
- /subdir/article-three
|
||||
- href: /subdir/article-three
|
||||
---
|
||||
|
||||
# Journey with Duplicate IDs
|
||||
|
||||
@@ -11,9 +11,9 @@ journeyTracks:
|
||||
- id: track-1
|
||||
title: "Track with Invalid Guides"
|
||||
guides:
|
||||
- /article-one
|
||||
- /nonexistent/guide
|
||||
- /another/invalid/path
|
||||
- href: /article-one
|
||||
- href: /nonexistent/guide
|
||||
- href: /another/invalid/path
|
||||
---
|
||||
|
||||
# Journey with Invalid Paths
|
||||
|
||||
@@ -11,7 +11,7 @@ journeyTracks:
|
||||
- id: track-1
|
||||
title: "Should be ignored"
|
||||
guides:
|
||||
- /nonexistent/path
|
||||
- href: /nonexistent/path
|
||||
---
|
||||
|
||||
# Non-Journey Page
|
||||
|
||||
@@ -11,12 +11,12 @@ journeyTracks:
|
||||
- id: track-1
|
||||
title: "Getting Started Track"
|
||||
guides:
|
||||
- /article-one
|
||||
- /article-two
|
||||
- href: /article-one
|
||||
- href: /article-two
|
||||
- id: track-2
|
||||
title: "Advanced Track"
|
||||
guides:
|
||||
- /subdir/article-three
|
||||
- href: /subdir/article-three
|
||||
---
|
||||
|
||||
# Valid Journey Landing
|
||||
|
||||
@@ -40,7 +40,7 @@ journeyTracks:
|
||||
title: "Track with {% invalid liquid"
|
||||
description: "Description with {{ unclosed liquid"
|
||||
guides:
|
||||
- /article-one
|
||||
- href: /article-one
|
||||
---
|
||||
|
||||
# Journey with Liquid Issues
|
||||
@@ -55,6 +55,36 @@ This journey landing page has invalid liquid syntax in journeyTracks.
|
||||
expect(result['test-invalid-liquid.md'][0].ruleDescription).toMatch(/liquid syntax/i)
|
||||
expect(result['test-invalid-liquid.md'][1].ruleDescription).toMatch(/liquid syntax/i)
|
||||
})
|
||||
|
||||
test('invalid liquid syntax in alternativeNextStep fails', async () => {
|
||||
const invalidAlternativeNextStepContent = `---
|
||||
title: Journey with Invalid Alternative Next Step
|
||||
layout: journey-landing
|
||||
versions:
|
||||
fpt: '*'
|
||||
ghec: '*'
|
||||
ghes: '*'
|
||||
journeyTracks:
|
||||
- id: track-1
|
||||
title: "Test Track"
|
||||
guides:
|
||||
- href: /article-one
|
||||
alternativeNextStep: "Want to skip? See {% invalid liquid syntax"
|
||||
---
|
||||
|
||||
# Journey with Invalid Alternative Next Step
|
||||
`
|
||||
const result = await runRule(journeyTracksLiquid, {
|
||||
strings: { 'test-invalid-alternative-next-step.md': invalidAlternativeNextStepContent },
|
||||
...fmOptions,
|
||||
})
|
||||
|
||||
expect(result['test-invalid-alternative-next-step.md']).toHaveLength(1)
|
||||
expect(result['test-invalid-alternative-next-step.md'][0].errorDetail).toMatch(
|
||||
/alternativeNextStep/,
|
||||
)
|
||||
expect(result['test-invalid-alternative-next-step.md'][0].errorDetail).toMatch(/liquid syntax/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('journey-tracks-guide-path-exists', () => {
|
||||
|
||||
@@ -18,4 +18,5 @@ children:
|
||||
- /page-with-permissions-and-product-callout
|
||||
- /table-with-ifversions
|
||||
- /code-snippet-with-hashbang
|
||||
- /journey-test-article
|
||||
---
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: Journey Test Article
|
||||
intro: This article is used for testing journey branching text.
|
||||
versions:
|
||||
fpt: '*'
|
||||
ghes: '*'
|
||||
ghec: '*'
|
||||
---
|
||||
|
||||
## Test Article
|
||||
|
||||
This article exists solely for testing journey navigation features.
|
||||
@@ -21,14 +21,14 @@ journeyTracks:
|
||||
title: 'Getting started'
|
||||
description: 'Learn the basics of our platform.'
|
||||
guides:
|
||||
- '/get-started/start-your-journey/hello-world'
|
||||
- '/get-started/foo/bar'
|
||||
- href: '/get-started/start-your-journey/hello-world'
|
||||
- href: '/get-started/foo/bar'
|
||||
- id: 'advanced'
|
||||
title: 'Advanced topics'
|
||||
description: 'Dive deeper into advanced features.'
|
||||
guides:
|
||||
- '/get-started/foo/autotitling'
|
||||
- '/get-started/start-your-journey/hello-world'
|
||||
- href: '/get-started/foo/autotitling'
|
||||
- href: '/get-started/start-your-journey/hello-world'
|
||||
children:
|
||||
- /start-your-journey
|
||||
- /foo
|
||||
|
||||
@@ -7,18 +7,19 @@ versions:
|
||||
ghes: '*'
|
||||
ghec: '*'
|
||||
journeyTracks:
|
||||
- id: 'getting_started'
|
||||
title: 'Getting started'
|
||||
description: 'Learn the basics of our platform.'
|
||||
- id: 'first_track'
|
||||
title: 'First Track'
|
||||
description: 'The first track in the journey.'
|
||||
guides:
|
||||
- '/get-started/start-your-journey/hello-world'
|
||||
- '/get-started/foo/bar'
|
||||
- id: 'advanced'
|
||||
title: 'Advanced topics'
|
||||
description: 'Dive deeper into advanced features.'
|
||||
- href: '/get-started/start-your-journey/hello-world'
|
||||
- href: '/get-started/foo/journey-test-article'
|
||||
alternativeNextStep: 'Want to skip ahead? See [AUTOTITLE](/get-started/start-your-journey/hello-world)'
|
||||
- id: 'second_track'
|
||||
title: 'Next Track'
|
||||
description: 'The second track in the journey.'
|
||||
guides:
|
||||
- '/get-started/foo/autotitling'
|
||||
- '/get-started/start-your-journey/hello-world'
|
||||
- href: '/get-started/foo/autotitling'
|
||||
- href: '/get-started/start-your-journey/hello-world'
|
||||
---
|
||||
|
||||
This is a test page for journey tracks.
|
||||
|
||||
@@ -1139,6 +1139,53 @@ test.describe('Journey Tracks', () => {
|
||||
expect(trackContent).not.toContain('{%')
|
||||
expect(trackContent).not.toContain('%}')
|
||||
})
|
||||
|
||||
test('journey navigation components show on article pages', async ({ page }) => {
|
||||
// go to an article that's part of a journey track
|
||||
await page.goto('/get-started/start-your-journey/hello-world')
|
||||
|
||||
// journey card should be visible in sidebar
|
||||
const journeyCard = page.locator('[data-testid="journey-track-card"]')
|
||||
await expect(journeyCard).toBeVisible()
|
||||
|
||||
// journey footer nav should be visible
|
||||
const journeyNav = page.locator('[data-testid="journey-track-nav"]')
|
||||
await expect(journeyNav).toBeVisible()
|
||||
})
|
||||
|
||||
test('journey footer nav component links to first article in next track from last article in previous track', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/get-started/foo/bar')
|
||||
|
||||
const journeyNav = page.locator('[data-testid="journey-track-nav"]')
|
||||
await expect(journeyNav).toBeVisible()
|
||||
|
||||
// Link should display the next track's title and go to its first article
|
||||
const nextTrackLink = journeyNav.locator('a').filter({ hasText: 'Advanced topics' })
|
||||
await expect(nextTrackLink).toBeVisible()
|
||||
|
||||
const href = await nextTrackLink.getAttribute('href')
|
||||
expect(href).toContain('/get-started/foo/autotitling')
|
||||
})
|
||||
|
||||
test('journey card displays branching text when present', async ({ page }) => {
|
||||
await page.goto('/get-started/foo/journey-test-article')
|
||||
|
||||
const journeyCard = page.locator('[data-testid="journey-track-card"]')
|
||||
await expect(journeyCard).toBeVisible()
|
||||
|
||||
// Branching text should be rendered with markdown links
|
||||
await expect(journeyCard).toContainText('Want to skip ahead?')
|
||||
|
||||
// AUTOTITLE should be resolved to actual article title
|
||||
const branchingLink = journeyCard.locator('a').filter({ hasText: 'Hello World' })
|
||||
await expect(branchingLink).toBeVisible()
|
||||
await expect(journeyCard).not.toContainText('AUTOTITLE')
|
||||
|
||||
const href = await branchingLink.getAttribute('href')
|
||||
expect(href).toContain('/get-started/start-your-journey/hello-world')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('LandingArticleGridWithFilter component', () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Link } from '@/frame/components/Link'
|
||||
import { useTranslation } from '@/languages/components/useTranslation'
|
||||
import { LinkPreviewPopover } from '@/links/components/LinkPreviewPopover'
|
||||
import { UtmPreserver } from '@/frame/components/UtmPreserver'
|
||||
import { JourneyTrackCard, JourneyTrackNav } from '@/journeys/components'
|
||||
|
||||
const ClientSideRefresh = dynamic(() => import('@/frame/components/ClientSideRefresh'), {
|
||||
ssr: false,
|
||||
@@ -42,10 +43,12 @@ export const ArticlePage = () => {
|
||||
productVideoUrl,
|
||||
miniTocItems,
|
||||
currentLearningTrack,
|
||||
currentJourneyTrack,
|
||||
supportPortalVaIframeProps,
|
||||
currentLayout,
|
||||
} = useArticleContext()
|
||||
const isLearningPath = !!currentLearningTrack?.trackName
|
||||
const isJourneyTrack = !!currentJourneyTrack?.trackId
|
||||
const { t } = useTranslation(['pages'])
|
||||
|
||||
const introProp = (
|
||||
@@ -72,6 +75,7 @@ export const ArticlePage = () => {
|
||||
const toc = (
|
||||
<>
|
||||
{isLearningPath && <LearningTrackCard track={currentLearningTrack} />}
|
||||
{isJourneyTrack && <JourneyTrackCard journey={currentJourneyTrack} />}
|
||||
{miniTocItems.length > 1 && <MiniTocs miniTocItems={miniTocItems} />}
|
||||
</>
|
||||
)
|
||||
@@ -122,6 +126,11 @@ export const ArticlePage = () => {
|
||||
<LearningTrackNav track={currentLearningTrack} />
|
||||
</div>
|
||||
) : null}
|
||||
{isJourneyTrack ? (
|
||||
<div className="container-lg mt-4 px-3">
|
||||
<JourneyTrackNav context={currentJourneyTrack} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="container-xl px-3 px-md-6 my-4">
|
||||
@@ -148,6 +157,11 @@ export const ArticlePage = () => {
|
||||
<LearningTrackNav track={currentLearningTrack} />
|
||||
</div>
|
||||
) : null}
|
||||
{isJourneyTrack ? (
|
||||
<div className="container-lg mt-4 px-3">
|
||||
<JourneyTrackNav context={currentJourneyTrack} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</DefaultLayout>
|
||||
|
||||
@@ -247,7 +247,20 @@ export const schema: Schema = {
|
||||
guides: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
type: 'object',
|
||||
required: ['href'],
|
||||
properties: {
|
||||
href: {
|
||||
type: 'string',
|
||||
description: 'Path to the article in the journey track',
|
||||
},
|
||||
alternativeNextStep: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional branching text for the article when guiding users through the journey',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
description: 'Array of article paths that make up this journey track',
|
||||
},
|
||||
|
||||
@@ -13,9 +13,8 @@ export function JourneyTrackCard({ journey }: Props) {
|
||||
const { locale } = useRouter()
|
||||
const { currentVersion } = useVersion()
|
||||
const { t } = useTranslation('journey_track_nav')
|
||||
const { trackTitle, journeyTitle, journeyPath, nextGuide, numberOfGuides, currentGuideIndex } =
|
||||
journey
|
||||
const fullPath = `/${locale}/${currentVersion}${journeyPath}?feature=journey-landing`
|
||||
const { trackTitle, journeyPath, nextGuide, numberOfGuides, currentGuideIndex } = journey
|
||||
const fullPath = `/${locale}/${currentVersion}${journeyPath}`
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -25,10 +24,9 @@ export function JourneyTrackCard({ journey }: Props) {
|
||||
<div className="d-flex flex-column width-full">
|
||||
<h2 className="h4">
|
||||
<Link href={fullPath} className="mb-1 text-underline">
|
||||
{journeyTitle}
|
||||
{trackTitle}
|
||||
</Link>
|
||||
</h2>
|
||||
<span className="f6 color-fg-muted mb-2">{trackTitle}</span>
|
||||
<span className="f5 color-fg-muted">
|
||||
{t('current_progress')
|
||||
.replace('{n}', `${numberOfGuides}`)
|
||||
@@ -49,6 +47,12 @@ export function JourneyTrackCard({ journey }: Props) {
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
{journey.alternativeNextStep && (
|
||||
<div
|
||||
className="mt-4"
|
||||
dangerouslySetInnerHTML={{ __html: journey.alternativeNextStep }}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ type Props = {
|
||||
|
||||
export function JourneyTrackNav({ context }: Props) {
|
||||
const { t } = useTranslation('journey_track_nav')
|
||||
const { prevGuide, nextGuide, trackTitle, currentGuideIndex, numberOfGuides } = context
|
||||
const { prevGuide, nextGuide, nextTrackFirstGuide } = context
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -26,24 +26,22 @@ export function JourneyTrackNav({ context }: Props) {
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="f5 d-flex flex-column flex-items-center">
|
||||
<span className="color-fg-muted">{trackTitle}</span>
|
||||
<span className="color-fg-muted">
|
||||
{t('current_progress')
|
||||
.replace('{n}', `${numberOfGuides}`)
|
||||
.replace('{i}', `${currentGuideIndex + 1}`)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="f5 d-flex flex-column flex-items-end">
|
||||
{nextGuide && (
|
||||
{nextGuide ? (
|
||||
<>
|
||||
<span className="color-fg-default">{t('next_article')}</span>
|
||||
<Link href={nextGuide.href} className="text-bold color-fg text-right">
|
||||
{nextGuide.title}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
) : nextTrackFirstGuide ? (
|
||||
<>
|
||||
<span className="color-fg-default">{t('next_article')}</span>
|
||||
<Link href={nextTrackFirstGuide.href} className="text-bold color-fg text-right">
|
||||
{nextTrackFirstGuide.trackTitle}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,11 @@ export interface JourneyContext {
|
||||
journeyPath: string
|
||||
currentGuideIndex: number
|
||||
numberOfGuides: number
|
||||
nextTrackFirstGuide?: {
|
||||
href: string
|
||||
title: string
|
||||
trackTitle: string
|
||||
}
|
||||
nextGuide?: {
|
||||
href: string
|
||||
title: string
|
||||
@@ -21,6 +26,7 @@ export interface JourneyContext {
|
||||
href: string
|
||||
title: string
|
||||
}
|
||||
alternativeNextStep?: string
|
||||
}
|
||||
|
||||
export interface JourneyTrack {
|
||||
@@ -43,7 +49,10 @@ type JourneyPage = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
guides: string[]
|
||||
guides: Array<{
|
||||
href: string
|
||||
alternativeNextStep?: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -83,6 +92,32 @@ function normalizeGuidePath(path: string): string {
|
||||
: `/${withoutLanguage || path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to fetch guide data (href and title) for a given path
|
||||
*/
|
||||
async function fetchGuideData(
|
||||
guidePath: string,
|
||||
context: ContentContext,
|
||||
): Promise<{ href: string; title: string } | null> {
|
||||
try {
|
||||
const resultData = await getLinkData(guidePath, context, {
|
||||
title: true,
|
||||
intro: false,
|
||||
fullTitle: false,
|
||||
})
|
||||
if (resultData && resultData.length > 0) {
|
||||
const linkResult = resultData[0]
|
||||
return {
|
||||
href: linkResult.href,
|
||||
title: linkResult.title || '',
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not get link data for guide:', guidePath, error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the journey context for a given article path.
|
||||
*
|
||||
@@ -117,6 +152,8 @@ export async function resolveJourneyContext(
|
||||
}
|
||||
}
|
||||
|
||||
let trackIndex = 0
|
||||
let foundTrackIndex = 0
|
||||
for (const track of journeyPage.journeyTracks) {
|
||||
if (!track.guides || !Array.isArray(track.guides)) continue
|
||||
|
||||
@@ -124,7 +161,7 @@ export async function resolveJourneyContext(
|
||||
let guideIndex = -1
|
||||
|
||||
for (let i = 0; i < track.guides.length; i++) {
|
||||
const guidePath = track.guides[i]
|
||||
const guidePath = track.guides[i].href
|
||||
let renderedGuidePath = guidePath
|
||||
|
||||
// Handle Liquid conditionals in guide paths
|
||||
@@ -148,6 +185,21 @@ export async function resolveJourneyContext(
|
||||
}
|
||||
|
||||
if (guideIndex >= 0) {
|
||||
const alternativeNextStep = track.guides[guideIndex].alternativeNextStep || ''
|
||||
let renderedAlternativeNextStep = alternativeNextStep
|
||||
|
||||
// Handle Liquid conditionals in branching text which likely has links
|
||||
try {
|
||||
renderedAlternativeNextStep = await executeWithFallback(
|
||||
context,
|
||||
() => renderContent(alternativeNextStep, context),
|
||||
() => alternativeNextStep,
|
||||
)
|
||||
} catch {
|
||||
// If rendering fails, use the original branching text rather than erroring
|
||||
renderedAlternativeNextStep = alternativeNextStep
|
||||
}
|
||||
|
||||
result = {
|
||||
trackId: track.id,
|
||||
trackName: track.id,
|
||||
@@ -157,52 +209,51 @@ export async function resolveJourneyContext(
|
||||
journeyPage.permalink || Permalink.relativePathToSuffix(journeyPage.relativePath || ''),
|
||||
currentGuideIndex: guideIndex,
|
||||
numberOfGuides: track.guides.length,
|
||||
alternativeNextStep: renderedAlternativeNextStep,
|
||||
}
|
||||
|
||||
// Set up previous guide
|
||||
if (guideIndex > 0) {
|
||||
const prevGuidePath = track.guides[guideIndex - 1]
|
||||
try {
|
||||
const resultData = await getLinkData(prevGuidePath, context, {
|
||||
title: true,
|
||||
intro: false,
|
||||
fullTitle: false,
|
||||
})
|
||||
if (resultData && resultData.length > 0) {
|
||||
const linkResult = resultData[0]
|
||||
result.prevGuide = {
|
||||
href: linkResult.href,
|
||||
title: linkResult.title || '',
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not get link data for previous guide:', prevGuidePath, error)
|
||||
const prevGuidePath = track.guides[guideIndex - 1].href
|
||||
const guideData = await fetchGuideData(prevGuidePath, context)
|
||||
if (guideData) {
|
||||
result.prevGuide = guideData
|
||||
}
|
||||
}
|
||||
|
||||
// Set up next guide
|
||||
if (guideIndex < track.guides.length - 1) {
|
||||
const nextGuidePath = track.guides[guideIndex + 1]
|
||||
try {
|
||||
const resultData = await getLinkData(nextGuidePath, context, {
|
||||
title: true,
|
||||
intro: false,
|
||||
fullTitle: false,
|
||||
})
|
||||
if (resultData && resultData.length > 0) {
|
||||
const linkResult = resultData[0]
|
||||
result.nextGuide = {
|
||||
href: linkResult.href,
|
||||
title: linkResult.title || '',
|
||||
const nextGuidePath = track.guides[guideIndex + 1].href
|
||||
const guideData = await fetchGuideData(nextGuidePath, context)
|
||||
if (guideData) {
|
||||
result.nextGuide = guideData
|
||||
}
|
||||
}
|
||||
|
||||
// Only populate nextTrackFirstGuide when on the last guide of the track
|
||||
if (guideIndex === track.guides.length - 1) {
|
||||
foundTrackIndex = trackIndex
|
||||
|
||||
if (
|
||||
journeyPage.journeyTracks[foundTrackIndex + 1] &&
|
||||
journeyPage.journeyTracks[foundTrackIndex + 1].guides.length > 0
|
||||
) {
|
||||
const nextTrack = journeyPage.journeyTracks[foundTrackIndex + 1]
|
||||
const nextTrackFirstGuidePath = nextTrack.guides[0].href
|
||||
const guideData = await fetchGuideData(nextTrackFirstGuidePath, context)
|
||||
if (guideData) {
|
||||
result.nextTrackFirstGuide = {
|
||||
...guideData,
|
||||
trackTitle: nextTrack.title,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not get link data for next guide:', nextGuidePath, error)
|
||||
}
|
||||
}
|
||||
|
||||
break // Found the track, stop searching
|
||||
}
|
||||
|
||||
trackIndex++
|
||||
}
|
||||
|
||||
if (result) break // Found the journey, stop searching
|
||||
@@ -217,11 +268,15 @@ export async function resolveJourneyContext(
|
||||
* Returns an array of JourneyTrack objects with titles, descriptions, and guide links.
|
||||
*/
|
||||
export async function resolveJourneyTracks(
|
||||
journeyTracks: any[],
|
||||
journeyTracks: JourneyPage['journeyTracks'],
|
||||
context: ContentContext,
|
||||
): Promise<JourneyTrack[]> {
|
||||
if (!journeyTracks || journeyTracks.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result = await Promise.all(
|
||||
journeyTracks.map(async (track: any) => {
|
||||
journeyTracks.map(async (track) => {
|
||||
// Render Liquid templates in title and description
|
||||
const renderedTitle = await renderContent(track.title, context, { textOnly: true })
|
||||
const renderedDescription = track.description
|
||||
@@ -229,9 +284,9 @@ export async function resolveJourneyTracks(
|
||||
: undefined
|
||||
|
||||
const guides = await Promise.all(
|
||||
track.guides.map(async (guidePath: string) => {
|
||||
const linkData = await getLinkData(guidePath, context, { title: true })
|
||||
const baseHref = linkData?.[0]?.href || guidePath
|
||||
track.guides.map(async (guide: { href: string; alternativeNextStep?: string }) => {
|
||||
const linkData = await getLinkData(guide.href, context, { title: true })
|
||||
const baseHref = linkData?.[0]?.href || guide.href
|
||||
return {
|
||||
href: baseHref,
|
||||
title: linkData?.[0]?.title || 'Untitled Guide',
|
||||
|
||||
@@ -9,29 +9,21 @@ export default async function journeyTrack(
|
||||
if (!req.context) throw new Error('request is not contextualized')
|
||||
if (!req.context.page) return next()
|
||||
|
||||
// Only run journey resolution if the page has journey tracks defined
|
||||
if (!(req.context.page as any).journeyTracks) {
|
||||
req.context.currentJourneyTrack = null
|
||||
return next()
|
||||
}
|
||||
|
||||
try {
|
||||
// Import and use the journey resolver which uses renderContent, need the
|
||||
// async import since it uses fs Node apis
|
||||
const journeyResolver = await import('../lib/journey-path-resolver')
|
||||
|
||||
// resolve the journey tracks which renders the journey content like the
|
||||
// description to handle liquid rendering
|
||||
const resolvedTracks = await journeyResolver.resolveJourneyTracks(
|
||||
(req.context.page as any).journeyTracks,
|
||||
req.context,
|
||||
)
|
||||
// If this page has journey tracks defined, resolve them for the landing page
|
||||
if ((req.context.page as any).journeyTracks) {
|
||||
const resolvedTracks = await journeyResolver.resolveJourneyTracks(
|
||||
(req.context.page as any).journeyTracks,
|
||||
req.context,
|
||||
)
|
||||
|
||||
// Store resolved tracks on the page context for later use in getServerSideProps
|
||||
;(req.context.page as any).resolvedJourneyTracks = resolvedTracks
|
||||
// Store resolved tracks on the page context for later use in getServerSideProps
|
||||
;(req.context.page as any).resolvedJourneyTracks = resolvedTracks
|
||||
}
|
||||
|
||||
// resolve the current journey context since we're on a journey track page
|
||||
// i.e. next/prev articles in the track, this article's position in the track
|
||||
// Always try to resolve journey context (for navigation on guide articles)
|
||||
const journeyContext = await journeyResolver.resolveJourneyContext(
|
||||
req.pagePath || '',
|
||||
req.context.pages || {},
|
||||
|
||||
@@ -46,11 +46,21 @@ describe('journey-path-resolver', () => {
|
||||
title: 'Getting started',
|
||||
description: 'Learn the basics',
|
||||
guides: [
|
||||
'/enterprise-onboarding/setup',
|
||||
'/enterprise-onboarding/config',
|
||||
'/enterprise-onboarding/deploy',
|
||||
{ href: '/enterprise-onboarding/setup' },
|
||||
{
|
||||
href: '/enterprise-onboarding/config',
|
||||
alternativeNextStep:
|
||||
'Ready for more? Visit [AUTOTITLE](/enterprise-onboarding/advanced-setup)',
|
||||
},
|
||||
{ href: '/enterprise-onboarding/deploy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
title: 'Advanced configuration',
|
||||
description: 'Configure advanced options',
|
||||
guides: [{ href: '/enterprise-onboarding/advanced-setup' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -100,6 +110,28 @@ describe('journey-path-resolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('includes alternative next step when provided', async () => {
|
||||
const result = await resolveJourneyContext(
|
||||
'/enterprise-onboarding/config',
|
||||
mockPages,
|
||||
mockContext,
|
||||
)
|
||||
|
||||
expect(result?.alternativeNextStep).toBe(
|
||||
'Ready for more? Visit [AUTOTITLE](/enterprise-onboarding/advanced-setup)',
|
||||
)
|
||||
})
|
||||
|
||||
test('does not populate next track guide when not on last guide', async () => {
|
||||
const result = await resolveJourneyContext(
|
||||
'/enterprise-onboarding/config',
|
||||
mockPages,
|
||||
mockContext,
|
||||
)
|
||||
|
||||
expect(result?.nextTrackFirstGuide).toBeUndefined()
|
||||
})
|
||||
|
||||
test('handles first article in track (no previous)', async () => {
|
||||
const result = await resolveJourneyContext(
|
||||
'/enterprise-onboarding/setup',
|
||||
@@ -122,6 +154,20 @@ describe('journey-path-resolver', () => {
|
||||
expect(result?.currentGuideIndex).toBe(2)
|
||||
})
|
||||
|
||||
test('populates next track guide when on last guide', async () => {
|
||||
const result = await resolveJourneyContext(
|
||||
'/enterprise-onboarding/deploy',
|
||||
mockPages,
|
||||
mockContext,
|
||||
)
|
||||
|
||||
expect(result?.nextTrackFirstGuide).toEqual({
|
||||
href: '/en/enterprise-cloud@latest/enterprise-onboarding/advanced-setup',
|
||||
title: 'Mock Title for /enterprise-onboarding/advanced-setup',
|
||||
trackTitle: 'Advanced configuration',
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizes article paths without leading slash', async () => {
|
||||
// The resolver should handle paths without leading slashes
|
||||
// by normalizing them to match the guide paths in the data
|
||||
@@ -149,13 +195,16 @@ describe('journey-path-resolver', () => {
|
||||
id: 'getting_started',
|
||||
title: 'Getting started with {% data variables.product.company_short %}',
|
||||
description: 'Learn the {% data variables.product.company_short %} basics',
|
||||
guides: ['/enterprise-onboarding/setup', '/enterprise-onboarding/config'],
|
||||
guides: [
|
||||
{ href: '/enterprise-onboarding/setup' },
|
||||
{ href: '/enterprise-onboarding/config' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
title: 'Advanced configuration',
|
||||
description: 'Advanced topics for experts',
|
||||
guides: ['/enterprise-onboarding/advanced-setup'],
|
||||
guides: [{ href: '/enterprise-onboarding/advanced-setup' }],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -210,7 +259,7 @@ describe('journey-path-resolver', () => {
|
||||
{
|
||||
id: 'no_desc',
|
||||
title: 'Track without description',
|
||||
guides: ['/some-guide'],
|
||||
guides: [{ href: '/some-guide' }],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user