Skip to content

Commit 92e74cf

Browse files
authored
Workshop: CSS-driven journey-tag visibility for rendered step content (#46720)
1 parent e8fb63c commit 92e74cf

2 files changed

Lines changed: 192 additions & 1 deletion

File tree

docs/src/components/workshop/WorkshopExperience.astro

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,41 @@ function rewriteGfmTaskLists(html: string) {
230230
return result;
231231
}
232232
233+
function normalizeJourneyToken(value: string) {
234+
return value.trim().toLowerCase().replace(/[^a-z0-9-]/gu, '');
235+
}
236+
237+
const journeyBlockPattern = /<!--\s*journey:\s*([a-z0-9,\s-]+)\s*-->([\s\S]*?)<!--\s*\/journey\s*-->/giu;
238+
const journeyOpenPattern = /<!--\s*journey:\s*[a-z0-9,\s-]+\s*-->/giu;
239+
const journeyClosePattern = /<!--\s*\/journey\s*-->/giu;
240+
241+
function parseJourneyTokens(value: string) {
242+
return uniqueItems(
243+
value
244+
.split(',')
245+
.map(normalizeJourneyToken)
246+
.filter(Boolean),
247+
);
248+
}
249+
250+
function rewriteJourneyBlocks(html: string) {
251+
const wrapped = html.replace(
252+
journeyBlockPattern,
253+
(_match, journeyCsv, body) => {
254+
const journeys = parseJourneyTokens(String(journeyCsv));
255+
if (journeys.length === 0) return body;
256+
const classes = ['aw-workshop-journey-block', ...journeys.map((journey) => `aw-workshop-journey-block-${journey}`)];
257+
return `<div class="${classes.join(' ')}">${body}</div>`;
258+
},
259+
);
260+
261+
return wrapped
262+
.replace(journeyOpenPattern, '')
263+
.replace(journeyClosePattern, '');
264+
}
265+
233266
function rewriteWorkshopHtml(html: string) {
234-
return transformTables(rewriteGfmTaskLists(rewriteGfmAlerts(sanitizeWorkshopHtml(html)))
267+
return transformTables(rewriteJourneyBlocks(rewriteGfmTaskLists(rewriteGfmAlerts(sanitizeWorkshopHtml(html))))
235268
236269
.replace(/<(\/?)h([1-4])\b/gu, (_match, slash, level) => {
237270
return `<${slash}h${Math.min(Number(level) + 1, 4)}`;
@@ -363,6 +396,8 @@ const workshopSteps: WorkshopStep[] = await Promise.all(workshopEntries.map(asyn
363396
const initialFlow = buildWorkshopFlow(workshopDefaults.journeyId, workshopDefaults.scenarioId);
364397
const initialStepKey = initialFlow[0] ?? workshopSteps[0]?.key ?? '';
365398
const initialStep = workshopSteps.find((item) => item.key === initialStepKey) ?? workshopSteps[0];
399+
const initialJourney = workshopJourneys.find((item) => item.id === workshopDefaults.journeyId) ?? workshopJourneys[0];
400+
const initialVisibleJourneyIds = uniqueItems(['all', ...(initialJourney?.contentJourneyIds ?? [])]);
366401
367402
// Precompute step counts for entry path cards using the default scenario (estimate shown before scenario is selected).
368403
const entryPathStepCounts: Record<string, number> = Object.fromEntries(
@@ -389,6 +424,7 @@ const scenarioStepCountsByJourney: Record<string, Record<string, number>> = Obje
389424
<section
390425
class="aw-workshop"
391426
data-workshop-root
427+
data-workshop-visible-journeys={initialVisibleJourneyIds.join(' ')}
392428
data-initial-step={initialStepKey}
393429
>
394430
<div class="aw-workshop-setup" data-workshop-setup>
@@ -848,6 +884,8 @@ const scenarioStepCountsByJourney: Record<string, Record<string, number>> = Obje
848884
const journey = manifest.journeys.find((item) => item.id === state.journeyId) || manifest.journeys[0];
849885
const scenario = manifest.scenarios.find((item) => item.id === state.scenarioId) || manifest.scenarios[0];
850886
const stepIndex = visibleFlow.indexOf(activeStep.key);
887+
const visibleJourneyIds = ['all', ...((journey && journey.contentJourneyIds) || [])];
888+
root.dataset.workshopVisibleJourneys = [...new Set(visibleJourneyIds)].join(' ');
851889
if (tunnelPanel) {
852890
tunnelPanel.hidden = state.journeyId !== 'vscode' || setupStep !== 'scenario';
853891
}
@@ -1405,6 +1443,19 @@ const scenarioStepCountsByJourney: Record<string, Record<string, number>> = Obje
14051443
min-height: 22rem;
14061444
}
14071445

1446+
.aw-workshop-step-content :global(.aw-workshop-journey-block) {
1447+
display: none;
1448+
}
1449+
1450+
.aw-workshop-step-content :global(.aw-workshop-journey-block.aw-workshop-journey-block-all),
1451+
.aw-workshop[data-workshop-visible-journeys~='ui'] .aw-workshop-step-content :global(.aw-workshop-journey-block.aw-workshop-journey-block-ui),
1452+
.aw-workshop[data-workshop-visible-journeys~='terminal'] .aw-workshop-step-content :global(.aw-workshop-journey-block.aw-workshop-journey-block-terminal),
1453+
.aw-workshop[data-workshop-visible-journeys~='local'] .aw-workshop-step-content :global(.aw-workshop-journey-block.aw-workshop-journey-block-local),
1454+
.aw-workshop[data-workshop-visible-journeys~='codespace'] .aw-workshop-step-content :global(.aw-workshop-journey-block.aw-workshop-journey-block-codespace),
1455+
.aw-workshop[data-workshop-visible-journeys~='copilot'] .aw-workshop-step-content :global(.aw-workshop-journey-block.aw-workshop-journey-block-copilot) {
1456+
display: contents;
1457+
}
1458+
14081459
.aw-workshop-step-content :global(h2),
14091460
.aw-workshop-step-content :global(h3),
14101461
.aw-workshop-step-content :global(h4) {

docs/tests/workshop.spec.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,3 +583,143 @@ test.describe('Workshop flow filtering: Copilot scenario-d substitution', () =>
583583
expect(keys).not.toContain('11c-build-pr-reviewer-ui');
584584
});
585585
});
586+
587+
// ---------------------------------------------------------------------------
588+
// Journey block visibility tests — verify that CSS-driven journey section
589+
// visibility works correctly: marker rewriting, attribute state, and
590+
// visibility transitions when switching paths.
591+
// ---------------------------------------------------------------------------
592+
593+
test.describe('Workshop journey block visibility', () => {
594+
test('data-workshop-visible-journeys contains "all" and journey-specific IDs after starting the tutorial', async ({ page }) => {
595+
// ui-learner maps to the github journey, which has contentJourneyIds: ['ui']
596+
await startWorkshop(page);
597+
598+
const visibleJourneys = await page.evaluate(() =>
599+
document.querySelector('[data-workshop-root]')?.getAttribute('data-workshop-visible-journeys') ?? '',
600+
);
601+
// 'all' is always included; 'ui' comes from the github journey's contentJourneyIds
602+
expect(visibleJourneys.split(' ')).toContain('all');
603+
expect(visibleJourneys.split(' ')).toContain('ui');
604+
});
605+
606+
test('data-workshop-visible-journeys updates when switching to a different entry path', async ({ page }) => {
607+
// Start with ui-learner (github journey → contentJourneyIds: ['ui'])
608+
await startWorkshop(page);
609+
610+
const initialJourneys = await page.evaluate(() =>
611+
document.querySelector('[data-workshop-root]')?.getAttribute('data-workshop-visible-journeys') ?? '',
612+
);
613+
expect(initialJourneys.split(' ')).toContain('ui');
614+
615+
// Switch to the terminal path (contentJourneyIds: ['terminal', 'local'])
616+
await page.getByRole('button', { name: /Change route/i }).click();
617+
await page.locator('[data-workshop-entry-path="cli-user"]').click();
618+
await page.locator('[data-workshop-scenario="daily-status"]').click();
619+
await expect(page.locator('[data-workshop-tutorial]')).toBeVisible();
620+
621+
const updatedJourneys = await page.evaluate(() =>
622+
document.querySelector('[data-workshop-root]')?.getAttribute('data-workshop-visible-journeys') ?? '',
623+
);
624+
expect(updatedJourneys.split(' ')).toContain('all');
625+
expect(updatedJourneys.split(' ')).toContain('terminal');
626+
// 'ui' should no longer be listed (terminal journey does not map to ui content)
627+
expect(updatedJourneys.split(' ')).not.toContain('ui');
628+
});
629+
630+
test('no raw journey comment markers survive HTML rewriting in step data', async ({ page }) => {
631+
await startWorkshop(page);
632+
633+
// Any <!-- journey: ... --> or <!-- /journey --> remaining in rendered HTML
634+
// means rewriteJourneyBlocks did not run or failed. Raw markers must never
635+
// appear in the embedded step-data JSON.
636+
const hasRawMarkers = await page.evaluate(() => {
637+
const node = document.getElementById('aw-workshop-step-data');
638+
if (!node) return false;
639+
const steps = JSON.parse(node.textContent?.trim() || '[]') as Array<{ html: string }>;
640+
return steps.some((s) => /<!--\s*\/?journey[:\s]/i.test(s.html));
641+
});
642+
643+
expect(hasRawMarkers).toBe(false);
644+
});
645+
646+
test('journey blocks in step data have base class and one class per comma-separated token', async ({ page }) => {
647+
await startWorkshop(page);
648+
649+
// If any step HTML contains journey blocks, verify each wrapper carries the
650+
// base class plus a distinct class per token. Passes vacuously when the
651+
// current workshop build has no journey-tagged sections.
652+
const result = await page.evaluate(() => {
653+
const node = document.getElementById('aw-workshop-step-data');
654+
if (!node) return { hasJourneyBlocks: false, allValid: true };
655+
const steps = JSON.parse(node.textContent?.trim() || '[]') as Array<{ html: string }>;
656+
657+
const wrapperPattern = /class="([^"]*aw-workshop-journey-block[^"]*)"/g;
658+
let hasJourneyBlocks = false;
659+
let allValid = true;
660+
661+
for (const step of steps) {
662+
for (const match of step.html.matchAll(wrapperPattern)) {
663+
hasJourneyBlocks = true;
664+
const classList = match[1].split(/\s+/);
665+
// Must have the base class
666+
if (!classList.includes('aw-workshop-journey-block')) {
667+
allValid = false;
668+
}
669+
// Must have at least one journey-specific class
670+
if (!classList.some((c) => c.startsWith('aw-workshop-journey-block-') && c !== 'aw-workshop-journey-block')) {
671+
allValid = false;
672+
}
673+
}
674+
}
675+
676+
return { hasJourneyBlocks, allValid };
677+
});
678+
679+
expect(result.allValid).toBe(true);
680+
});
681+
682+
test('journey blocks are hidden by default and revealed via CSS when the active journey matches', async ({ page }) => {
683+
await startWorkshop(page);
684+
685+
// Verify CSS-driven visibility using an injected probe element.
686+
// The github journey (ui-learner path) maps to contentJourneyIds: ['ui'],
687+
// so a block tagged 'ui' should be visible and one tagged 'terminal' should be hidden.
688+
const visibility = await page.evaluate(() => {
689+
const stepContent = document.querySelector('[data-workshop-step-content]') as HTMLElement | null;
690+
if (!stepContent) return null;
691+
692+
const uiBlock = document.createElement('div');
693+
uiBlock.className = 'aw-workshop-journey-block aw-workshop-journey-block-ui';
694+
uiBlock.setAttribute('data-test-probe', 'ui');
695+
696+
const terminalBlock = document.createElement('div');
697+
terminalBlock.className = 'aw-workshop-journey-block aw-workshop-journey-block-terminal';
698+
terminalBlock.setAttribute('data-test-probe', 'terminal');
699+
700+
const allBlock = document.createElement('div');
701+
allBlock.className = 'aw-workshop-journey-block aw-workshop-journey-block-all';
702+
allBlock.setAttribute('data-test-probe', 'all');
703+
704+
stepContent.appendChild(uiBlock);
705+
stepContent.appendChild(terminalBlock);
706+
stepContent.appendChild(allBlock);
707+
708+
return {
709+
uiDisplay: window.getComputedStyle(uiBlock).display,
710+
terminalDisplay: window.getComputedStyle(terminalBlock).display,
711+
allDisplay: window.getComputedStyle(allBlock).display,
712+
};
713+
});
714+
715+
expect(visibility).not.toBeNull();
716+
if (visibility) {
717+
// 'all' blocks are always visible regardless of journey
718+
expect(visibility.allDisplay).toBe('contents');
719+
// 'ui' block is visible because the github journey includes 'ui' in contentJourneyIds
720+
expect(visibility.uiDisplay).toBe('contents');
721+
// 'terminal' block is hidden because the github journey does not include 'terminal'
722+
expect(visibility.terminalDisplay).toBe('none');
723+
}
724+
});
725+
});

0 commit comments

Comments
 (0)