From 38d851c90aa4bd43799455aa18088b672d287062 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Tue, 21 Apr 2026 12:24:52 +0200 Subject: [PATCH 1/4] use new json schema --- web/src/shared/video-tutorials/README.md | 20 +- .../modal/ModalContent/ModalContent.tsx | 26 ++- web/src/shared/video-tutorials/data.ts | 6 +- web/src/shared/video-tutorials/resolved.tsx | 17 +- web/src/shared/video-tutorials/types.ts | 4 +- web/tests/video-tutorials.test.ts | 179 +++++++++++++++++- 6 files changed, 234 insertions(+), 18 deletions(-) diff --git a/web/src/shared/video-tutorials/README.md b/web/src/shared/video-tutorials/README.md index 4f08e91d9..c03024928 100644 --- a/web/src/shared/video-tutorials/README.md +++ b/web/src/shared/video-tutorials/README.md @@ -156,6 +156,10 @@ endpoint on the same server. "title": "Defguard overview", "description": "A high-level walkthrough of Defguard.", "appRoute": "/vpn-overview", + "contextAppRoutes": [ + "/vpn-overview/$locationId", + "/settings/" + ], "docsUrl": "https://docs.defguard.net/introduction" } ] @@ -252,7 +256,8 @@ Route tutorial video fields: | `title` | Yes | Non-empty string. Displayed in the section list and as the heading above the player. | | `description` | Yes | Non-empty string. Displayed as body text below the player in the tutorials modal. | | `appRoute` | Yes | Must start with `/`. Use TanStack Router route definition paths (e.g. `/vpn-overview`, `/vpn-overview/$locationId`), not runtime URLs with concrete param values. | -| `docsUrl` | Yes | Valid URL. Shown as the external documentation link in the tutorials modal. | +| `contextAppRoutes` | No | Optional non-empty array of additional in-app route definition paths where the tutorial should also appear. Each entry must start with `/`. | +| `docsUrl` | No | Optional valid URL. When present, shown as the external documentation link in the tutorials modal. | Wizard placement fields: @@ -359,12 +364,19 @@ route definition string, never an instantiated URL with real param values. For example, when the user is on `/vpn-overview/42`, TanStack Router reports `fullPath` as `/vpn-overview/$locationId` (the template). Canonicalization trims whitespace, ensures a leading `/`, and strips a trailing `/`. The same -canonicalization is applied to every `video.appRoute` value before comparison. +canonicalization is applied to every `video.appRoute` and `video.contextAppRoutes` +value before comparison. + +A route tutorial is shown when the current route matches either: + +- `appRoute` +- any entry in `contextAppRoutes` ### Parameterized routes -A video with `appRoute: "/vpn-overview/$locationId"` matches whenever the user -is on any location detail page, regardless of the concrete `locationId` in the +A video with `appRoute: "/vpn-overview"` and +`contextAppRoutes: ["/vpn-overview/$locationId"]` matches both the overview page +and any location detail page, regardless of the concrete `locationId` in the URL. Tutorials are associated with route shapes, not specific records. Do not use runtime URLs as `appRoute`. A value like `/vpn-overview/42` will diff --git a/web/src/shared/video-tutorials/components/modal/ModalContent/ModalContent.tsx b/web/src/shared/video-tutorials/components/modal/ModalContent/ModalContent.tsx index 9792b5296..7afb90165 100644 --- a/web/src/shared/video-tutorials/components/modal/ModalContent/ModalContent.tsx +++ b/web/src/shared/video-tutorials/components/modal/ModalContent/ModalContent.tsx @@ -67,16 +67,22 @@ export const ModalContent = ({ })} - - - {m.cmp_video_tutorials_modal_learn_more()} - - + {selectedVideo.docsUrl && ( + + + {m.cmp_video_tutorials_modal_learn_more()} + + + )} diff --git a/web/src/shared/video-tutorials/data.ts b/web/src/shared/video-tutorials/data.ts index 1e498173e..eb70cc408 100644 --- a/web/src/shared/video-tutorials/data.ts +++ b/web/src/shared/video-tutorials/data.ts @@ -35,7 +35,11 @@ const videoTutorialSchema = z title: z.string().min(1, 'title must be non-empty'), description: z.string().min(1, 'description must be non-empty'), appRoute: z.string().regex(/^\//, 'appRoute must start with "/"'), - docsUrl: z.string().url('docsUrl must be a valid URL'), + contextAppRoutes: z + .array(z.string().regex(/^\//, 'contextAppRoutes entries must start with "/"')) + .min(1, 'contextAppRoutes must contain at least one item') + .optional(), + docsUrl: z.string().url('docsUrl must be a valid URL').optional(), }) .strip(); diff --git a/web/src/shared/video-tutorials/resolved.tsx b/web/src/shared/video-tutorials/resolved.tsx index a9a65589b..5b69e0373 100644 --- a/web/src/shared/video-tutorials/resolved.tsx +++ b/web/src/shared/video-tutorials/resolved.tsx @@ -14,6 +14,21 @@ const EMPTY_VIDEO_TUTORIALS: VideoTutorial[] = []; const EMPTY_SECTIONS: VideoTutorialsSection[] = []; const EMPTY_VIDEO_GUIDE_PLACEMENT: VideoGuidePlacement | null = null; +export function matchesVideoRouteContext( + video: VideoTutorial, + routeKey: string, +): boolean { + if (canonicalizeRouteKey(video.appRoute) === routeKey) { + return true; + } + + return Boolean( + video.contextAppRoutes?.some( + (contextRoute) => canonicalizeRouteKey(contextRoute) === routeKey, + ), + ); +} + /** * Derives the canonical route key for the current page from TanStack Router * matches, skipping pathless shell/layout routes. @@ -73,5 +88,5 @@ export function useResolvedVideoTutorials(): VideoTutorial[] { if (!routeKey) return EMPTY_VIDEO_TUTORIALS; return sections .flatMap((s) => s.videos) - .filter((v) => canonicalizeRouteKey(v.appRoute) === routeKey); + .filter((video) => matchesVideoRouteContext(video, routeKey)); } diff --git a/web/src/shared/video-tutorials/types.ts b/web/src/shared/video-tutorials/types.ts index 9ab031bf5..010c11128 100644 --- a/web/src/shared/video-tutorials/types.ts +++ b/web/src/shared/video-tutorials/types.ts @@ -7,8 +7,10 @@ export interface VideoTutorial extends PlayableVideo { description: string; /** In-app route this video is associated with (must start with "/"). */ appRoute: string; + /** Additional in-app routes where this tutorial should also be shown. */ + contextAppRoutes?: string[]; /** External documentation URL. */ - docsUrl: string; + docsUrl?: string; } export interface VideoGuideDocLink { diff --git a/web/tests/video-tutorials.test.ts b/web/tests/video-tutorials.test.ts index 2ac26d2f3..6aaf760be 100644 --- a/web/tests/video-tutorials.test.ts +++ b/web/tests/video-tutorials.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { parseVideoTutorials } from '../src/shared/video-tutorials/data'; +import { matchesVideoRouteContext } from '../src/shared/video-tutorials/resolved'; import { resolveSections, resolveVideoGuidePlacement, @@ -89,12 +90,20 @@ describe('parseVersion', () => { // Shared fixture helpers // --------------------------------------------------------------------------- -const makeVideo = (id: string, appRoute: string) => ({ +const makeVideo = ( + id: string, + appRoute: string, + overrides: Partial<{ + contextAppRoutes: string[]; + docsUrl: string | undefined; + }> = {}, +) => ({ youtubeVideoId: id, title: `Video ${id}`, description: `Description for ${id}`, appRoute, docsUrl: 'https://docs.defguard.net/test', + ...overrides, }); const makeMappings = (): VideoTutorialsMappings => ({ @@ -271,6 +280,34 @@ describe('resolveSections', () => { }); }); +// --------------------------------------------------------------------------- +// matchesVideoRouteContext +// --------------------------------------------------------------------------- + +describe('matchesVideoRouteContext', () => { + it('should match the primary appRoute', () => { + const video = makeVideo('abcDEFghiJK', '/users'); + + expect(matchesVideoRouteContext(video, '/users')).toBe(true); + }); + + it('should match a contextAppRoutes entry', () => { + const video = makeVideo('abcDEFghiJK', '/users', { + contextAppRoutes: ['/groups', '/settings/'], + }); + + expect(matchesVideoRouteContext(video, '/settings')).toBe(true); + }); + + it('should return false when neither appRoute nor contextAppRoutes match', () => { + const video = makeVideo('abcDEFghiJK', '/users', { + contextAppRoutes: ['/groups'], + }); + + expect(matchesVideoRouteContext(video, '/settings')).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // resolveVideoGuidePlacement // --------------------------------------------------------------------------- @@ -523,6 +560,63 @@ describe('parseVideoTutorials', () => { ); }); + it('should accept missing docsUrl', () => { + const raw = { + versions: { + '2.2': { + sections: [ + { + name: 'Test', + videos: [ + { + youtubeVideoId: 'abcDEFghiJK', + title: 'Test video', + description: 'A test description', + appRoute: '/users', + }, + ], + }, + ], + }, + }, + }; + + const result = parseVideoTutorials(raw); + + expect(result['2.2'].sections[0].videos[0].docsUrl).toBeUndefined(); + }); + + it('should accept contextAppRoutes when present', () => { + const raw = { + versions: { + '2.2': { + sections: [ + { + name: 'Test', + videos: [ + { + youtubeVideoId: 'abcDEFghiJK', + title: 'Test video', + description: 'A test description', + appRoute: '/users', + contextAppRoutes: ['/groups', '/settings/'], + docsUrl: 'https://docs.defguard.net/users', + }, + ], + }, + ], + }, + }, + }; + + const result = parseVideoTutorials(raw); + + expect(result['2.2'].sections[0].videos[0].contextAppRoutes).toEqual([ + '/groups', + '/settings/', + ]); + }); + it('should reject an invalid youtubeVideoId (not 11 chars)', () => { const raw = { versions: { @@ -619,6 +713,58 @@ describe('parseVideoTutorials', () => { expect(() => parseVideoTutorials(raw)).toThrow(); }); + it('should reject contextAppRoutes entries missing a leading slash', () => { + const raw = { + versions: { + '2.2': { + sections: [ + { + name: 'Test', + videos: [ + { + youtubeVideoId: 'abcDEFghiJK', + title: 'Title', + description: 'Desc', + appRoute: '/users', + contextAppRoutes: ['groups'], + docsUrl: 'https://docs.defguard.net', + }, + ], + }, + ], + }, + }, + }; + + expect(() => parseVideoTutorials(raw)).toThrow(); + }); + + it('should reject an empty contextAppRoutes array', () => { + const raw = { + versions: { + '2.2': { + sections: [ + { + name: 'Test', + videos: [ + { + youtubeVideoId: 'abcDEFghiJK', + title: 'Title', + description: 'Desc', + appRoute: '/users', + contextAppRoutes: [], + docsUrl: 'https://docs.defguard.net', + }, + ], + }, + ], + }, + }, + }; + + expect(() => parseVideoTutorials(raw)).toThrow(); + }); + it('should reject an invalid docsUrl', () => { const raw = { versions: { @@ -698,6 +844,37 @@ describe('parseVideoTutorials', () => { ).toBeUndefined(); }); + it('should preserve contextAppRoutes while stripping unknown video fields', () => { + const raw = { + versions: { + '2.2': { + sections: [ + { + name: 'Test', + videos: [ + { + youtubeVideoId: 'abcDEFghiJK', + title: 'Test', + description: 'Desc', + appRoute: '/users', + contextAppRoutes: ['/groups'], + docsUrl: 'https://docs.defguard.net', + unknownField: 'ignored', + }, + ], + }, + ], + }, + }, + }; + const result = parseVideoTutorials(raw); + + expect(result['2.2'].sections[0].videos[0].contextAppRoutes).toEqual(['/groups']); + expect( + (result['2.2'].sections[0].videos[0] as Record)['unknownField'], + ).toBeUndefined(); + }); + it('should strip unknown fields from sections', () => { const raw = { versions: { From 13802990568a260cf994a76fb610b1da2dcf2e3c Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Wed, 22 Apr 2026 10:59:01 +0200 Subject: [PATCH 2/4] use isPresent helper --- .../components/modal/ModalContent/ModalContent.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/shared/video-tutorials/components/modal/ModalContent/ModalContent.tsx b/web/src/shared/video-tutorials/components/modal/ModalContent/ModalContent.tsx index 7afb90165..2209629d2 100644 --- a/web/src/shared/video-tutorials/components/modal/ModalContent/ModalContent.tsx +++ b/web/src/shared/video-tutorials/components/modal/ModalContent/ModalContent.tsx @@ -3,6 +3,7 @@ import { m } from '../../../../../paraglide/messages'; import { Icon } from '../../../../defguard-ui/components/Icon/Icon'; import { IconButton } from '../../../../defguard-ui/components/IconButton/IconButton'; import { Direction } from '../../../../defguard-ui/types'; +import { isPresent } from '../../../../defguard-ui/utils/isPresent'; import { getNavRoot } from '../../../route-key'; import { getRouteLabel } from '../../../route-label'; import { useVideoTutorialsModal } from '../../../store'; @@ -67,7 +68,7 @@ export const ModalContent = ({ })} - {selectedVideo.docsUrl && ( + {isPresent(selectedVideo.docsUrl) && ( Date: Wed, 22 Apr 2026 11:02:38 +0200 Subject: [PATCH 3/4] fix test helper --- web/tests/video-tutorials.test.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/web/tests/video-tutorials.test.ts b/web/tests/video-tutorials.test.ts index 6aaf760be..904d37131 100644 --- a/web/tests/video-tutorials.test.ts +++ b/web/tests/video-tutorials.test.ts @@ -93,17 +93,15 @@ describe('parseVersion', () => { const makeVideo = ( id: string, appRoute: string, - overrides: Partial<{ - contextAppRoutes: string[]; - docsUrl: string | undefined; - }> = {}, + contextAppRoutes?: string[], + docsUrl = 'https://docs.defguard.net/test', ) => ({ youtubeVideoId: id, title: `Video ${id}`, description: `Description for ${id}`, appRoute, - docsUrl: 'https://docs.defguard.net/test', - ...overrides, + contextAppRoutes, + docsUrl, }); const makeMappings = (): VideoTutorialsMappings => ({ @@ -292,17 +290,13 @@ describe('matchesVideoRouteContext', () => { }); it('should match a contextAppRoutes entry', () => { - const video = makeVideo('abcDEFghiJK', '/users', { - contextAppRoutes: ['/groups', '/settings/'], - }); + const video = makeVideo('abcDEFghiJK', '/users', ['/groups', '/settings/']); expect(matchesVideoRouteContext(video, '/settings')).toBe(true); }); it('should return false when neither appRoute nor contextAppRoutes match', () => { - const video = makeVideo('abcDEFghiJK', '/users', { - contextAppRoutes: ['/groups'], - }); + const video = makeVideo('abcDEFghiJK', '/users', ['/groups']); expect(matchesVideoRouteContext(video, '/settings')).toBe(false); }); From b8751aae9cf2885a9c969ec36e44f45755bb6607 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Wed, 22 Apr 2026 11:17:17 +0200 Subject: [PATCH 4/4] cargo update --- Cargo.lock | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c744740c..e57b4231e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4057,9 +4057,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.77" +version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ "bitflags 2.11.1", "cfg-if", @@ -4089,9 +4089,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.113" +version = "0.9.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" dependencies = [ "cc", "libc", @@ -5276,9 +5276,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -5657,9 +5657,9 @@ dependencies = [ [[package]] name = "sha3" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ "digest", "keccak", @@ -6725,9 +6725,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uaparser" @@ -7009,11 +7009,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -7022,7 +7022,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -7594,9 +7594,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] @@ -7610,6 +7610,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0"