From 8e6a1cab979f3d0cdcc7533d642970e451a9873b Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Sun, 19 Apr 2026 09:13:47 +0200 Subject: [PATCH 01/10] step-aware wizard video tutorial section --- .../wizard/WizardPage/WizardPage.tsx | 2 +- web/src/shared/video-tutorials/README.md | 36 ++++- web/src/shared/video-tutorials/data.ts | 9 +- web/src/shared/video-tutorials/resolved.tsx | 3 +- web/src/shared/video-tutorials/resolver.ts | 15 +- web/src/shared/video-tutorials/types.ts | 7 +- web/tests/video-tutorials.test.ts | 149 ++++++++++++++---- 7 files changed, 179 insertions(+), 42 deletions(-) diff --git a/web/src/shared/components/wizard/WizardPage/WizardPage.tsx b/web/src/shared/components/wizard/WizardPage/WizardPage.tsx index ae316521c..afbbf70ef 100644 --- a/web/src/shared/components/wizard/WizardPage/WizardPage.tsx +++ b/web/src/shared/components/wizard/WizardPage/WizardPage.tsx @@ -41,7 +41,7 @@ export const WizardPage = ({ ...containerProps }: Props) => { const activeStep = steps[activeStepId]; - const videoGuide = useWizardVideoGuidePlacement(videoGuidePlacementKey); + const videoGuide = useWizardVideoGuidePlacement(videoGuidePlacementKey, activeStepId); const visibleSteps = useMemo( () => diff --git a/web/src/shared/video-tutorials/README.md b/web/src/shared/video-tutorials/README.md index 8e1f11f76..607fa554d 100644 --- a/web/src/shared/video-tutorials/README.md +++ b/web/src/shared/video-tutorials/README.md @@ -161,10 +161,20 @@ endpoint on the same server. ], "placements": { "migrationWizard": { - "youtubeVideoId": "xyz987GHI12", - "title": "Migration wizard guide", - "docsTitle": "Defguard Configuration Guide", - "docsUrl": "https://docs.defguard.net/migration" + "default": { + "youtubeVideoId": "xyz987GHI12", + "title": "Migration wizard guide", + "docsTitle": "Defguard Configuration Guide", + "docsUrl": "https://docs.defguard.net/migration" + }, + "steps": { + "ca": { + "youtubeVideoId": "aaaBBBccc11", + "title": "Certificate authority guide", + "docsTitle": "Certificate authority documentation", + "docsUrl": "https://docs.defguard.net/migration/ca" + } + } } } } @@ -193,6 +203,16 @@ Migration placement fields: | `docsTitle` | Yes | Text shown in the migration documentation card. | | `docsUrl` | Yes | External URL opened from the migration documentation card. | +Migration wizard placement structure: + +- `default`: optional fallback guide used when the current step has no dedicated entry +- `steps`: optional map of step key to guide data + +The keys in `steps` should match `MigrationWizardStep` values used by the frontend, +for example `general`, `ca`, `caSummary`, `edgeDeployment`, `edge`, +`edgeAdoption`, `internalUrlSettings`, `internalUrlSslConfig`, +`externalUrlSettings`, `externalUrlSslConfig`, `confirmation`, and `welcome`. + ### Section structure Each version value is an object with: @@ -225,12 +245,18 @@ Rules: Consumers built on top of that selected version: - `resolveSections()` returns `selectedVersion.sections` -- `resolveVideoGuidePlacement()` returns `selectedVersion.placements?[placementKey]` +- `resolveVideoGuidePlacement()` returns a step-aware placement from the selected version There is no fallback to older versions once a newer eligible version has been selected. If `2.2` is selected and omits `placements.migrationWizard`, the migration wizard shows nothing even if `2.1` defined that placement. +Within the selected version, wizard guide resolution uses this fallback order: + +1. `placements[placementKey].steps[currentStep]` +2. `placements[placementKey].default` +3. `null` + --- ## appRoute matching diff --git a/web/src/shared/video-tutorials/data.ts b/web/src/shared/video-tutorials/data.ts index d18e07626..a77913264 100644 --- a/web/src/shared/video-tutorials/data.ts +++ b/web/src/shared/video-tutorials/data.ts @@ -60,9 +60,16 @@ const migrationWizardPlacementSchema = z }) .strip(); +const migrationWizardPlacementGroupSchema = z + .object({ + default: migrationWizardPlacementSchema.optional(), + steps: z.record(z.string(), migrationWizardPlacementSchema).optional(), + }) + .strip(); + const placementsSchema = z .object({ - migrationWizard: migrationWizardPlacementSchema.optional(), + migrationWizard: migrationWizardPlacementGroupSchema.optional(), }) .strip(); diff --git a/web/src/shared/video-tutorials/resolved.tsx b/web/src/shared/video-tutorials/resolved.tsx index efa259170..8bf46a268 100644 --- a/web/src/shared/video-tutorials/resolved.tsx +++ b/web/src/shared/video-tutorials/resolved.tsx @@ -45,12 +45,13 @@ export function useVideoTutorialsSections(): VideoTutorialsSection[] { export function useWizardVideoGuidePlacement( placementKey: string | undefined, + stepKey?: string | number, ): VideoGuidePlacement | null { const { data } = useQuery(videoTutorialsQueryOptions); const appVersion = useApp((s) => s.appInfo.version); if (!placementKey || !data || !appVersion) return EMPTY_VIDEO_GUIDE_PLACEMENT; - return resolveVideoGuidePlacement(data, appVersion, placementKey); + return resolveVideoGuidePlacement(data, appVersion, placementKey, stepKey); } /** diff --git a/web/src/shared/video-tutorials/resolver.ts b/web/src/shared/video-tutorials/resolver.ts index 7f5ad3bb0..f32a2e09e 100644 --- a/web/src/shared/video-tutorials/resolver.ts +++ b/web/src/shared/video-tutorials/resolver.ts @@ -53,10 +53,23 @@ export function resolveVideoGuidePlacement( mappings: VideoTutorialsMappings, appVersionRaw: string, placementKey: string | undefined, + stepKey?: string | number, ): VideoGuidePlacement | null { if (!placementKey) { return null; } - return resolveVersion(mappings, appVersionRaw)?.placements?.[placementKey] ?? null; + const placementGroup = resolveVersion(mappings, appVersionRaw)?.placements?.[ + placementKey + ]; + if (!placementGroup) { + return null; + } + + const resolvedStepKey = typeof stepKey === 'string' ? stepKey : undefined; + if (resolvedStepKey && placementGroup.steps?.[resolvedStepKey]) { + return placementGroup.steps[resolvedStepKey] ?? null; + } + + return placementGroup.default ?? null; } diff --git a/web/src/shared/video-tutorials/types.ts b/web/src/shared/video-tutorials/types.ts index 0d2687ce8..5f11b6b33 100644 --- a/web/src/shared/video-tutorials/types.ts +++ b/web/src/shared/video-tutorials/types.ts @@ -18,13 +18,18 @@ export interface VideoGuidePlacement extends PlayableVideo { docsUrl: string; } +export interface VideoGuidePlacementGroup { + default?: VideoGuidePlacement; + steps?: Record; +} + export interface VideoTutorialsSection { name: string; videos: VideoTutorial[]; } export interface VideoTutorialsPlacements { - [key: string]: VideoGuidePlacement | undefined; + [key: string]: VideoGuidePlacementGroup | undefined; } export interface VideoTutorialsVersionEntry { diff --git a/web/tests/video-tutorials.test.ts b/web/tests/video-tutorials.test.ts index 2b0d5e31a..a2f7564da 100644 --- a/web/tests/video-tutorials.test.ts +++ b/web/tests/video-tutorials.test.ts @@ -119,10 +119,20 @@ const makeMappings = (): VideoTutorialsMappings => ({ ], placements: { migrationWizard: { - youtubeVideoId: 'abcDEFghiJK', - title: 'Migration wizard guide', - docsTitle: 'Migration wizard documentation', - docsUrl: 'https://docs.defguard.net/migration', + default: { + youtubeVideoId: 'abcDEFghiJK', + title: 'Migration wizard guide', + docsTitle: 'Migration wizard documentation', + docsUrl: 'https://docs.defguard.net/migration', + }, + steps: { + ca: { + youtubeVideoId: 'caGuide220a', + title: 'Certificate authority guide', + docsTitle: 'Certificate authority documentation', + docsUrl: 'https://docs.defguard.net/migration/ca', + }, + }, }, }, }, @@ -137,7 +147,7 @@ describe('resolveVersion', () => { const result = resolveVersion(makeMappings(), '2.3.0'); expect(result?.sections).toHaveLength(2); - expect(result?.placements?.migrationWizard?.youtubeVideoId).toBe('abcDEFghiJK'); + expect(result?.placements?.migrationWizard?.default?.youtubeVideoId).toBe('abcDEFghiJK'); }); it('should return null for an unparseable app version', () => { @@ -193,8 +203,19 @@ describe('resolveSections', () => { // --------------------------------------------------------------------------- describe('resolveVideoGuidePlacement', () => { - it('should return the placement from the newest eligible version', () => { - const result = resolveVideoGuidePlacement(makeMappings(), '2.3.0', 'migrationWizard'); + it('should return the step-specific placement from the newest eligible version', () => { + const result = resolveVideoGuidePlacement(makeMappings(), '2.3.0', 'migrationWizard', 'ca'); + + expect(result?.title).toBe('Certificate authority guide'); + }); + + it('should fall back to the default placement when step-specific entry is missing', () => { + const result = resolveVideoGuidePlacement( + makeMappings(), + '2.3.0', + 'migrationWizard', + 'general', + ); expect(result?.title).toBe('Migration wizard guide'); }); @@ -205,10 +226,12 @@ describe('resolveVideoGuidePlacement', () => { sections: [], placements: { migrationWizard: { - youtubeVideoId: 'abcDEFghiJK', - title: 'Migration wizard guide', - docsTitle: 'Migration wizard documentation', - docsUrl: 'https://docs.defguard.net/migration', + default: { + youtubeVideoId: 'abcDEFghiJK', + title: 'Migration wizard guide', + docsTitle: 'Migration wizard documentation', + docsUrl: 'https://docs.defguard.net/migration', + }, }, }, }, @@ -217,7 +240,7 @@ describe('resolveVideoGuidePlacement', () => { }, }; - const result = resolveVideoGuidePlacement(mappings, '2.2', 'migrationWizard'); + const result = resolveVideoGuidePlacement(mappings, '2.2', 'migrationWizard', 'ca'); expect(result).toBeNull(); }); @@ -229,11 +252,28 @@ describe('resolveVideoGuidePlacement', () => { }, }; - expect(resolveVideoGuidePlacement(mappings, '2.2', 'migrationWizard')).toBeNull(); + expect(resolveVideoGuidePlacement(mappings, '2.2', 'migrationWizard', 'ca')).toBeNull(); + }); + + it('should return null when neither default nor step-specific placement exists', () => { + const mappings: VideoTutorialsMappings = { + '2.2': { + sections: [], + placements: { + migrationWizard: { + steps: {}, + }, + }, + }, + }; + + expect(resolveVideoGuidePlacement(mappings, '2.2', 'migrationWizard', 'ca')).toBeNull(); }); it('should return null for an unsupported placement key', () => { - expect(resolveVideoGuidePlacement(makeMappings(), '2.3.0', 'unknownPlacement')).toBeNull(); + expect( + resolveVideoGuidePlacement(makeMappings(), '2.3.0', 'unknownPlacement', 'ca'), + ).toBeNull(); }); }); @@ -260,10 +300,20 @@ const validRaw = { ], placements: { migrationWizard: { - youtubeVideoId: 'xyz987GHI12', - title: 'Migration guide', - docsTitle: 'Defguard Configuration Guide', - docsUrl: 'https://docs.defguard.net/migration', + default: { + youtubeVideoId: 'xyz987GHI12', + title: 'Migration guide', + docsTitle: 'Defguard Configuration Guide', + docsUrl: 'https://docs.defguard.net/migration', + }, + steps: { + general: { + youtubeVideoId: 'genGuide220a', + title: 'General configuration guide', + docsTitle: 'General configuration documentation', + docsUrl: 'https://docs.defguard.net/migration/general', + }, + }, }, }, }, @@ -277,10 +327,15 @@ describe('parseVideoTutorials', () => { expect(result['2.2'].sections).toHaveLength(1); expect(result['2.2'].sections[0].name).toBe('Identity'); expect(result['2.2'].sections[0].videos[0].youtubeVideoId).toBe('abcDEFghiJK'); - expect(result['2.2'].placements?.migrationWizard?.youtubeVideoId).toBe('xyz987GHI12'); - expect(result['2.2'].placements?.migrationWizard?.docsTitle).toBe( + expect(result['2.2'].placements?.migrationWizard?.default?.youtubeVideoId).toBe( + 'xyz987GHI12', + ); + expect(result['2.2'].placements?.migrationWizard?.default?.docsTitle).toBe( 'Defguard Configuration Guide', ); + expect(result['2.2'].placements?.migrationWizard?.steps?.general?.youtubeVideoId).toBe( + 'genGuide220a', + ); }); it('should reject an invalid youtubeVideoId (not 11 chars)', () => { @@ -529,10 +584,12 @@ describe('parseVideoTutorials', () => { sections: [], placements: { migrationWizard: { - youtubeVideoId: 'xyz987GHI12', - title: 'Migration guide', - docsTitle: 'Defguard Configuration Guide', - docsUrl: 'not-a-url', + default: { + youtubeVideoId: 'xyz987GHI12', + title: 'Migration guide', + docsTitle: 'Defguard Configuration Guide', + docsUrl: 'not-a-url', + }, }, }, }, @@ -550,10 +607,12 @@ describe('parseVideoTutorials', () => { extraVersionField: 'ignored', placements: { migrationWizard: { - youtubeVideoId: 'xyz987GHI12', - title: 'Migration guide', - docsTitle: 'Defguard Configuration Guide', - docsUrl: 'https://docs.defguard.net/migration', + default: { + youtubeVideoId: 'xyz987GHI12', + title: 'Migration guide', + docsTitle: 'Defguard Configuration Guide', + docsUrl: 'https://docs.defguard.net/migration', + }, extraPlacementField: 'ignored', }, }, @@ -576,10 +635,36 @@ describe('parseVideoTutorials', () => { sections: [], placements: { migrationWizard: { - youtubeVideoId: 'xyz987GHI12', - title: 'Migration guide', - docsTitle: '', - docsUrl: 'https://docs.defguard.net/migration', + default: { + youtubeVideoId: 'xyz987GHI12', + title: 'Migration guide', + docsTitle: '', + docsUrl: 'https://docs.defguard.net/migration', + }, + }, + }, + }, + }, + }; + + expect(() => parseVideoTutorials(raw)).toThrow(); + }); + + it('should reject invalid migrationWizard step docsUrl', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + migrationWizard: { + steps: { + ca: { + youtubeVideoId: 'xyz987GHI12', + title: 'Migration guide', + docsTitle: 'Certificate authority guide', + docsUrl: 'not-a-url', + }, + }, }, }, }, From f1092865e8e7e71c757eb5f6010db172f67ddc41 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 20 Apr 2026 06:57:20 +0200 Subject: [PATCH 02/10] step-aware video tutorial section for initial setup and auto-adoption wizards --- crates/defguard_setup/src/handlers/mod.rs | 1 + crates/defguard_setup/src/handlers/version.rs | 14 ++ crates/defguard_setup/src/migration.rs | 2 + crates/defguard_setup/src/setup_server.rs | 2 + .../autoAdoption/AutoAdoptionSetupPage.tsx | 1 + web/src/pages/SetupPage/initial/SetupPage.tsx | 1 + web/src/routes/_wizard/migration/index.tsx | 14 +- web/src/shared/api/api.ts | 3 + web/src/shared/api/types.ts | 4 + web/src/shared/query.ts | 9 ++ web/src/shared/video-tutorials/README.md | 99 +++++++++---- web/src/shared/video-tutorials/data.ts | 14 +- web/src/shared/video-tutorials/resolved.tsx | 4 +- web/tests/video-tutorials.test.ts | 133 ++++++++++++++++-- 14 files changed, 235 insertions(+), 66 deletions(-) create mode 100644 crates/defguard_setup/src/handlers/version.rs diff --git a/crates/defguard_setup/src/handlers/mod.rs b/crates/defguard_setup/src/handlers/mod.rs index bad0ae0ef..85afba4bd 100644 --- a/crates/defguard_setup/src/handlers/mod.rs +++ b/crates/defguard_setup/src/handlers/mod.rs @@ -2,3 +2,4 @@ pub mod auto_wizard; pub mod initial_wizard; pub mod migration; pub mod session_info; +pub mod version; diff --git a/crates/defguard_setup/src/handlers/version.rs b/crates/defguard_setup/src/handlers/version.rs new file mode 100644 index 000000000..0041b6212 --- /dev/null +++ b/crates/defguard_setup/src/handlers/version.rs @@ -0,0 +1,14 @@ +use axum::{Extension, Json}; +use semver::Version; +use serde::Serialize; + +#[derive(Serialize)] +pub struct VersionResponse { + version: String, +} + +pub async fn get_version(Extension(version): Extension) -> Json { + Json(VersionResponse { + version: version.to_string(), + }) +} diff --git a/crates/defguard_setup/src/migration.rs b/crates/defguard_setup/src/migration.rs index ae2f50cbc..54f0f5b5e 100644 --- a/crates/defguard_setup/src/migration.rs +++ b/crates/defguard_setup/src/migration.rs @@ -52,6 +52,7 @@ use crate::handlers::{ finish_setup, get_migration_state, migration_set_external_url_settings, migration_set_internal_url_settings, update_migration_state, }, + version::get_version, }; /// FIXME: This is a workaround which enables us to reuse the same API handlers @@ -104,6 +105,7 @@ pub fn build_migration_webapp( "/api/v1", Router::new() .route("/health", get(health_check)) + .route("/version", get(get_version)) .route("/info", get(get_app_info)) .route("/me", get(me)) .route("/session-info", get(get_session_info)) diff --git a/crates/defguard_setup/src/setup_server.rs b/crates/defguard_setup/src/setup_server.rs index 5ee6afa5d..15c549fbd 100644 --- a/crates/defguard_setup/src/setup_server.rs +++ b/crates/defguard_setup/src/setup_server.rs @@ -36,6 +36,7 @@ use crate::handlers::{ setup_login, setup_session, upload_ca, }, session_info::get_session_info, + version::get_version, }; pub fn build_setup_webapp( @@ -54,6 +55,7 @@ pub fn build_setup_webapp( "/api/v1", Router::<()>::new() .route("/health", get(health_check)) + .route("/version", get(get_version)) .route("/settings_essentials", get(get_settings_essentials)) .route("/session-info", get(get_session_info)) .route("/network/display", get(get_locations_display)) diff --git a/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx b/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx index cec07481d..b5fb3d4e7 100644 --- a/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx +++ b/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx @@ -262,6 +262,7 @@ export const AutoAdoptionSetupPage = () => { subtitle={m.initial_setup_auto_adoption_wizard_subtitle()} title={m.initial_setup_auto_adoption_wizard_title()} steps={stepsConfig} + videoGuidePlacementKey="autoAdoptionWizard" isOnWelcomePage={!isAutoAdoptionFlowStarted} welcomePageConfig={{ title: m.initial_setup_auto_adoption_welcome_title(), diff --git a/web/src/pages/SetupPage/initial/SetupPage.tsx b/web/src/pages/SetupPage/initial/SetupPage.tsx index 2aae3a788..63745ed81 100644 --- a/web/src/pages/SetupPage/initial/SetupPage.tsx +++ b/web/src/pages/SetupPage/initial/SetupPage.tsx @@ -159,6 +159,7 @@ export const SetupPage = () => { title={m.initial_setup_wizard_title()} steps={stepsConfig} id="setup-wizard" + videoGuidePlacementKey="initialSetupWizard" isOnWelcomePage={isOnWelcomePage} welcomePageConfig={{ title: m.initial_setup_welcome_title(), diff --git a/web/src/routes/_wizard/migration/index.tsx b/web/src/routes/_wizard/migration/index.tsx index fe5b8e490..2c241ca42 100644 --- a/web/src/routes/_wizard/migration/index.tsx +++ b/web/src/routes/_wizard/migration/index.tsx @@ -4,26 +4,14 @@ import { MigrationWizardPage } from '../../../pages/MigrationWizardPage/Migratio import { useMigrationWizardStore } from '../../../pages/MigrationWizardPage/store/useMigrationWizardStore'; import { ActiveWizard } from '../../../shared/api/types'; import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; -import { AppInfoProvider } from '../../../shared/providers/AppInfoProvider'; -import { AppUserProvider } from '../../../shared/providers/AppUserProvider'; import { getMigrationStateQueryOptions, getSessionInfoQueryOptions, getSettingsQueryOptions, } from '../../../shared/query'; -const MigrationWizardRoute = () => { - return ( - - - - - - ); -}; - export const Route = createFileRoute('/_wizard/migration/')({ - component: MigrationWizardRoute, + component: MigrationWizardPage, pendingComponent: AppLoaderPage, beforeLoad: async ({ context }) => { const sessionInfo = (await context.queryClient.fetchQuery(getSessionInfoQueryOptions)) diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index f445185d3..0dc37c5cf 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -115,6 +115,7 @@ import type { UserProfileResponse, ValidateDeviceIpsRequest, ValidateIpAssignmentRequest, + VersionResponse, WebauthnLoginStartResponse, WebauthnRegisterFinishRequest, WebauthnRegisterStartResponse, @@ -191,6 +192,7 @@ const api = { }, app: { info: () => client.get('/info'), + version: () => client.get('/version'), updates: () => client.get('/updates'), }, user: { @@ -566,6 +568,7 @@ const api = { getActivityLog: (data?: ActivityLogRequestParams) => fetchPage(`/activity_log`, data), info: () => client.get('/info'), + getVersion: () => client.get('/version'), getLicenseInfo: () => client.get(`/enterprise_info`), support: { getSupportData: () => client.get('/support/configuration'), diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index d49056fe0..af5b75590 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -488,6 +488,10 @@ export interface ApplicationInfo { ldap_info: LdapInfo; } +export interface VersionResponse { + version: string; +} + export interface UpdateInfo { version: string; release_date: string; diff --git a/web/src/shared/query.ts b/web/src/shared/query.ts index 627f6916f..6e1cac38d 100644 --- a/web/src/shared/query.ts +++ b/web/src/shared/query.ts @@ -271,6 +271,15 @@ export const getSessionInfoQueryOptions = queryOptions({ refetchOnWindowFocus: false, }); +export const getVersionQueryOptions = queryOptions({ + queryFn: api.getVersion, + queryKey: ['version'], + select: (resp) => resp.data.version, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: false, +}); + export const getSettingsEssentialsQueryOptions = queryOptions({ queryFn: api.settings.getSettingsEssentials, queryKey: ['settings_essentials'], diff --git a/web/src/shared/video-tutorials/README.md b/web/src/shared/video-tutorials/README.md index 607fa554d..f8ed01590 100644 --- a/web/src/shared/video-tutorials/README.md +++ b/web/src/shared/video-tutorials/README.md @@ -4,14 +4,15 @@ The video tutorials module displays YouTube-based help content sourced from the update service. It now powers two kinds of UI: - route-bound tutorials inside the authenticated app shell -- placement-specific help content, currently the migration wizard sidebar block +- placement-specific help content in wizard sidebars The configuration is fetched as static JSON from `https://pkgs.defguard.net/api/content/video-tutorials` by default. Route-based tutorials are mounted in `src/routes/_authorized/_default.tsx` and -remain available across the authenticated layout. Placement-based migration -content is rendered inside the migration wizard flow under `/_wizard/migration`. +remain available across the authenticated layout. Placement-based content is +rendered inside wizard step layouts under `/_wizard/migration` and +`/_wizard/setup`. A launcher button (`NavTutorialsButton`) is shown in the navigation only when the resolved app version contains at least one tutorial section with videos. @@ -25,9 +26,10 @@ when at least one route-matched video is available for the current page. Clicking it opens a floating list of video cards with thumbnails and titles. Clicking on a specific card opens a modal with an embedded YouTube player. -The migration wizard uses the same JSON source, but reads a dedicated -`placements.migrationWizard` entry from the resolved version and renders a -thumbnail plus documentation card in the sidebar. +Wizards use the same JSON source, but read a dedicated placement entry from the +resolved version and render a thumbnail plus documentation card in the sidebar. +Current placement keys are `migrationWizard`, `initialSetupWizard`, and +`autoAdoptionWizard`. While a video is loading a skeleton placeholder is shown; if the video fails to load within 8 seconds, a "Video unavailable" message is displayed instead with @@ -159,11 +161,11 @@ endpoint on the same server. ] } ], - "placements": { - "migrationWizard": { - "default": { - "youtubeVideoId": "xyz987GHI12", - "title": "Migration wizard guide", + "placements": { + "migrationWizard": { + "default": { + "youtubeVideoId": "xyz987GHI12", + "title": "Migration wizard guide", "docsTitle": "Defguard Configuration Guide", "docsUrl": "https://docs.defguard.net/migration" }, @@ -173,11 +175,43 @@ endpoint on the same server. "title": "Certificate authority guide", "docsTitle": "Certificate authority documentation", "docsUrl": "https://docs.defguard.net/migration/ca" - } - } - } - } - } + } + } + }, + "initialSetupWizard": { + "default": { + "youtubeVideoId": "bbbCCCddd22", + "title": "Initial setup guide", + "docsTitle": "Initial setup documentation", + "docsUrl": "https://docs.defguard.net/setup" + }, + "steps": { + "adminUser": { + "youtubeVideoId": "cccDDDeee33", + "title": "Admin user guide", + "docsTitle": "Admin user documentation", + "docsUrl": "https://docs.defguard.net/setup/admin-user" + } + } + }, + "autoAdoptionWizard": { + "default": { + "youtubeVideoId": "dddEEEfff44", + "title": "Auto adoption guide", + "docsTitle": "Auto adoption documentation", + "docsUrl": "https://docs.defguard.net/auto-adoption" + }, + "steps": { + "vpnSettings": { + "youtubeVideoId": "eeeFFFggg55", + "title": "VPN settings guide", + "docsTitle": "VPN settings documentation", + "docsUrl": "https://docs.defguard.net/auto-adoption/vpn-settings" + } + } + } + } + } } } ``` @@ -194,24 +228,33 @@ Route tutorial video fields: | `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. | -Migration placement fields: +Wizard placement fields: | Field | Required | Description | |---|---|---| -| `youtubeVideoId` | Yes | Used to render the thumbnail and embedded player in the migration sidebar. | +| `youtubeVideoId` | Yes | Used to render the thumbnail and embedded player in the wizard sidebar. | | `title` | Yes | Displayed next to the thumbnail and used as the iframe title. | -| `docsTitle` | Yes | Text shown in the migration documentation card. | -| `docsUrl` | Yes | External URL opened from the migration documentation card. | +| `docsTitle` | Yes | Text shown in the wizard documentation card. | +| `docsUrl` | Yes | External URL opened from the wizard documentation card. | -Migration wizard placement structure: +Wizard placement structure: - `default`: optional fallback guide used when the current step has no dedicated entry - `steps`: optional map of step key to guide data -The keys in `steps` should match `MigrationWizardStep` values used by the frontend, -for example `general`, `ca`, `caSummary`, `edgeDeployment`, `edge`, -`edgeAdoption`, `internalUrlSettings`, `internalUrlSslConfig`, -`externalUrlSettings`, `externalUrlSslConfig`, `confirmation`, and `welcome`. +`placements` is a string-keyed record, so placement keys are not hardcoded in the +schema. The application currently uses these keys: + +- `migrationWizard` +- `initialSetupWizard` +- `autoAdoptionWizard` + +The keys in each placement's `steps` map should match frontend step IDs. +Examples: + +- Migration: `general`, `ca`, `caSummary`, `edgeDeployment`, `edge`, `edgeAdoption`, `internalUrlSettings`, `internalUrlSslConfig`, `externalUrlSettings`, `externalUrlSslConfig`, `confirmation`, `welcome` +- Initial setup: `adminUser`, `generalConfig`, `certificateAuthority`, `certificateAuthoritySummary`, `edgeDeploy`, `edgeComponent`, `edgeAdoption`, `internalUrlSettings`, `internalUrlSslConfig`, `externalUrlSettings`, `externalUrlSslConfig`, `confirmation` +- Auto adoption: `adminUser`, `internalUrlSettings`, `internalUrlSslConfig`, `externalUrlSettings`, `externalUrlSslConfig`, `vpnSettings`, `mfaSetup`, `summary` ### Section structure @@ -220,6 +263,8 @@ Each version value is an object with: - `sections`: ordered route-based tutorial sections - `placements`: optional surface-specific content entries +`sections` is required for every version entry, but it may be an empty array. + Sections are displayed in the order they appear in the array; videos within a section are displayed in their array order. @@ -248,8 +293,8 @@ Consumers built on top of that selected version: - `resolveVideoGuidePlacement()` returns a step-aware placement from the selected version There is no fallback to older versions once a newer eligible version has been -selected. If `2.2` is selected and omits `placements.migrationWizard`, the -migration wizard shows nothing even if `2.1` defined that placement. +selected. If `2.2` is selected and omits a placement key, that wizard shows +nothing even if `2.1` defined that placement. Within the selected version, wizard guide resolution uses this fallback order: diff --git a/web/src/shared/video-tutorials/data.ts b/web/src/shared/video-tutorials/data.ts index a77913264..a93b8b897 100644 --- a/web/src/shared/video-tutorials/data.ts +++ b/web/src/shared/video-tutorials/data.ts @@ -46,7 +46,7 @@ const sectionSchema = z }) .strip(); -const migrationWizardPlacementSchema = z +const placementSchema = z .object({ youtubeVideoId: z .string() @@ -60,18 +60,14 @@ const migrationWizardPlacementSchema = z }) .strip(); -const migrationWizardPlacementGroupSchema = z +const placementGroupSchema = z .object({ - default: migrationWizardPlacementSchema.optional(), - steps: z.record(z.string(), migrationWizardPlacementSchema).optional(), + default: placementSchema.optional(), + steps: z.record(z.string(), placementSchema).optional(), }) .strip(); -const placementsSchema = z - .object({ - migrationWizard: migrationWizardPlacementGroupSchema.optional(), - }) - .strip(); +const placementsSchema = z.record(z.string(), placementGroupSchema); const versionEntrySchema = z .object({ diff --git a/web/src/shared/video-tutorials/resolved.tsx b/web/src/shared/video-tutorials/resolved.tsx index 8bf46a268..6b9bc14e8 100644 --- a/web/src/shared/video-tutorials/resolved.tsx +++ b/web/src/shared/video-tutorials/resolved.tsx @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { useMatches } from '@tanstack/react-router'; import { useApp } from '../hooks/useApp'; -import { videoTutorialsQueryOptions } from '../query'; +import { getVersionQueryOptions, videoTutorialsQueryOptions } from '../query'; import { resolveSections, resolveVideoGuidePlacement } from './resolver'; import { canonicalizeRouteKey } from './route-key'; import type { VideoGuidePlacement, VideoTutorial, VideoTutorialsSection } from './types'; @@ -48,7 +48,7 @@ export function useWizardVideoGuidePlacement( stepKey?: string | number, ): VideoGuidePlacement | null { const { data } = useQuery(videoTutorialsQueryOptions); - const appVersion = useApp((s) => s.appInfo.version); + const { data: appVersion } = useQuery(getVersionQueryOptions); if (!placementKey || !data || !appVersion) return EMPTY_VIDEO_GUIDE_PLACEMENT; return resolveVideoGuidePlacement(data, appVersion, placementKey, stepKey); diff --git a/web/tests/video-tutorials.test.ts b/web/tests/video-tutorials.test.ts index a2f7564da..b477b6d8c 100644 --- a/web/tests/video-tutorials.test.ts +++ b/web/tests/video-tutorials.test.ts @@ -134,6 +134,38 @@ const makeMappings = (): VideoTutorialsMappings => ({ }, }, }, + initialSetupWizard: { + default: { + youtubeVideoId: 'setGuide220', + title: 'Initial setup guide', + docsTitle: 'Initial setup documentation', + docsUrl: 'https://docs.defguard.net/setup', + }, + steps: { + adminUser: { + youtubeVideoId: 'admGuide220', + title: 'Admin user guide', + docsTitle: 'Admin user documentation', + docsUrl: 'https://docs.defguard.net/setup/admin-user', + }, + }, + }, + autoAdoptionWizard: { + default: { + youtubeVideoId: 'autoGuid220', + title: 'Auto adoption guide', + docsTitle: 'Auto adoption documentation', + docsUrl: 'https://docs.defguard.net/auto-adoption', + }, + steps: { + vpnSettings: { + youtubeVideoId: 'vpnGuide220', + title: 'VPN settings guide', + docsTitle: 'VPN settings documentation', + docsUrl: 'https://docs.defguard.net/auto-adoption/vpn-settings', + }, + }, + }, }, }, }); @@ -148,6 +180,9 @@ describe('resolveVersion', () => { expect(result?.sections).toHaveLength(2); expect(result?.placements?.migrationWizard?.default?.youtubeVideoId).toBe('abcDEFghiJK'); + expect(result?.placements?.initialSetupWizard?.default?.youtubeVideoId).toBe( + 'setGuide220', + ); }); it('should return null for an unparseable app version', () => { @@ -275,6 +310,39 @@ describe('resolveVideoGuidePlacement', () => { resolveVideoGuidePlacement(makeMappings(), '2.3.0', 'unknownPlacement', 'ca'), ).toBeNull(); }); + + it('should resolve a step-specific placement for initial setup wizard', () => { + const result = resolveVideoGuidePlacement( + makeMappings(), + '2.3.0', + 'initialSetupWizard', + 'adminUser', + ); + + expect(result?.title).toBe('Admin user guide'); + }); + + it('should resolve a default placement for auto adoption wizard when step is missing', () => { + const result = resolveVideoGuidePlacement( + makeMappings(), + '2.3.0', + 'autoAdoptionWizard', + 'summary', + ); + + expect(result?.title).toBe('Auto adoption guide'); + }); + + it('should resolve a step-specific placement for auto adoption wizard', () => { + const result = resolveVideoGuidePlacement( + makeMappings(), + '2.3.0', + 'autoAdoptionWizard', + 'vpnSettings', + ); + + expect(result?.title).toBe('VPN settings guide'); + }); }); // --------------------------------------------------------------------------- @@ -308,13 +376,21 @@ const validRaw = { }, steps: { general: { - youtubeVideoId: 'genGuide220a', + youtubeVideoId: 'genGuide220', title: 'General configuration guide', docsTitle: 'General configuration documentation', docsUrl: 'https://docs.defguard.net/migration/general', }, }, }, + initialSetupWizard: { + default: { + youtubeVideoId: 'setGuide220', + title: 'Setup guide', + docsTitle: 'Setup docs', + docsUrl: 'https://docs.defguard.net/setup', + }, + }, }, }, }, @@ -334,7 +410,10 @@ describe('parseVideoTutorials', () => { 'Defguard Configuration Guide', ); expect(result['2.2'].placements?.migrationWizard?.steps?.general?.youtubeVideoId).toBe( - 'genGuide220a', + 'genGuide220', + ); + expect(result['2.2'].placements?.initialSetupWizard?.default?.youtubeVideoId).toBe( + 'setGuide220', ); }); @@ -577,17 +656,17 @@ describe('parseVideoTutorials', () => { expect(result['2.2'].sections[0].videos).toHaveLength(0); }); - it('should reject invalid migrationWizard docsUrl', () => { + it('should reject invalid placement docsUrl', () => { const raw = { versions: { '2.2': { sections: [], placements: { - migrationWizard: { + initialSetupWizard: { default: { youtubeVideoId: 'xyz987GHI12', - title: 'Migration guide', - docsTitle: 'Defguard Configuration Guide', + title: 'Setup guide', + docsTitle: 'Setup Guide', docsUrl: 'not-a-url', }, }, @@ -628,18 +707,18 @@ describe('parseVideoTutorials', () => { ).toBeUndefined(); }); - it('should reject an empty migrationWizard docsTitle', () => { + it('should reject an empty placement docsTitle', () => { const raw = { versions: { '2.2': { sections: [], placements: { - migrationWizard: { + autoAdoptionWizard: { default: { youtubeVideoId: 'xyz987GHI12', - title: 'Migration guide', + title: 'Auto adoption guide', docsTitle: '', - docsUrl: 'https://docs.defguard.net/migration', + docsUrl: 'https://docs.defguard.net/auto-adoption', }, }, }, @@ -650,18 +729,18 @@ describe('parseVideoTutorials', () => { expect(() => parseVideoTutorials(raw)).toThrow(); }); - it('should reject invalid migrationWizard step docsUrl', () => { + it('should reject invalid generic placement step docsUrl', () => { const raw = { versions: { '2.2': { sections: [], placements: { - migrationWizard: { + autoAdoptionWizard: { steps: { - ca: { + vpnSettings: { youtubeVideoId: 'xyz987GHI12', - title: 'Migration guide', - docsTitle: 'Certificate authority guide', + title: 'VPN guide', + docsTitle: 'VPN settings guide', docsUrl: 'not-a-url', }, }, @@ -673,4 +752,28 @@ describe('parseVideoTutorials', () => { expect(() => parseVideoTutorials(raw)).toThrow(); }); + + it('should accept generic placement keys', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + anyWizardKey: { + default: { + youtubeVideoId: 'xyz987GHI12', + title: 'Generic guide', + docsTitle: 'Generic docs', + docsUrl: 'https://docs.defguard.net/generic', + }, + }, + }, + }, + }, + }; + + const result = parseVideoTutorials(raw); + + expect(result['2.2'].placements?.anyWizardKey?.default?.title).toBe('Generic guide'); + }); }); From 41c91267da8f2fa330c147630288b137ac157bef Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 20 Apr 2026 07:00:33 +0200 Subject: [PATCH 03/10] remove unused migration server endpoints; remove unused api queries --- crates/defguard_setup/src/migration.rs | 4 ---- web/src/shared/api/api.ts | 2 -- web/src/shared/query.ts | 2 +- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/crates/defguard_setup/src/migration.rs b/crates/defguard_setup/src/migration.rs index 54f0f5b5e..0f8b9cf35 100644 --- a/crates/defguard_setup/src/migration.rs +++ b/crates/defguard_setup/src/migration.rs @@ -20,7 +20,6 @@ use defguard_core::{ grpc::GatewayEvent, handle_404, handlers::{ - app_info::get_app_info, auth::{ authenticate, email_mfa_code, email_mfa_enable, email_mfa_init, logout, mfa_disable, mfa_enable, recovery_code, request_email_mfa_code, totp_code, totp_enable, totp_secret, @@ -30,7 +29,6 @@ use defguard_core::{ resource_display::get_locations_display, session_info::get_session_info, settings::{get_settings, get_settings_essentials, patch_settings}, - user::me, wireguard::{count_networks, list_networks}, }, health_check, @@ -106,8 +104,6 @@ pub fn build_migration_webapp( Router::new() .route("/health", get(health_check)) .route("/version", get(get_version)) - .route("/info", get(get_app_info)) - .route("/me", get(me)) .route("/session-info", get(get_session_info)) .route("/settings_essentials", get(get_settings_essentials)) .route("/settings", get(get_settings).patch(patch_settings)) diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index 0dc37c5cf..6d4819df6 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -567,8 +567,6 @@ const api = { getSessionInfo: () => client.get(`/session-info`), getActivityLog: (data?: ActivityLogRequestParams) => fetchPage(`/activity_log`, data), - info: () => client.get('/info'), - getVersion: () => client.get('/version'), getLicenseInfo: () => client.get(`/enterprise_info`), support: { getSupportData: () => client.get('/support/configuration'), diff --git a/web/src/shared/query.ts b/web/src/shared/query.ts index 6e1cac38d..60757406f 100644 --- a/web/src/shared/query.ts +++ b/web/src/shared/query.ts @@ -272,7 +272,7 @@ export const getSessionInfoQueryOptions = queryOptions({ }); export const getVersionQueryOptions = queryOptions({ - queryFn: api.getVersion, + queryFn: api.app.version, queryKey: ['version'], select: (resp) => resp.data.version, refetchOnMount: true, From c2a6d4c9d76ee39333c98a447d9e6cec8fd020f2 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 20 Apr 2026 07:06:43 +0200 Subject: [PATCH 04/10] add spacing below video tutorial section --- .../components/wizard/WizardVideoGuide/WizardVideoGuide.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx b/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx index 446a89a85..d5fe3eae4 100644 --- a/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx +++ b/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx @@ -60,6 +60,7 @@ export const WizardVideoGuide = ({ videoGuide }: Props) => { + Date: Mon, 20 Apr 2026 07:33:52 +0200 Subject: [PATCH 05/10] cargo fmt --- crates/defguard_setup/src/handlers/version.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/defguard_setup/src/handlers/version.rs b/crates/defguard_setup/src/handlers/version.rs index 0041b6212..0888fa656 100644 --- a/crates/defguard_setup/src/handlers/version.rs +++ b/crates/defguard_setup/src/handlers/version.rs @@ -4,11 +4,11 @@ use serde::Serialize; #[derive(Serialize)] pub struct VersionResponse { - version: String, + version: String, } pub async fn get_version(Extension(version): Extension) -> Json { - Json(VersionResponse { - version: version.to_string(), - }) + Json(VersionResponse { + version: version.to_string(), + }) } From 5403a5dd3507803167dbbaf67480c4a57948f61f Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 20 Apr 2026 08:11:48 +0200 Subject: [PATCH 06/10] prevent unnecessary queries --- web/src/shared/video-tutorials/resolved.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/web/src/shared/video-tutorials/resolved.tsx b/web/src/shared/video-tutorials/resolved.tsx index 6b9bc14e8..8f6bc6e57 100644 --- a/web/src/shared/video-tutorials/resolved.tsx +++ b/web/src/shared/video-tutorials/resolved.tsx @@ -47,8 +47,15 @@ export function useWizardVideoGuidePlacement( placementKey: string | undefined, stepKey?: string | number, ): VideoGuidePlacement | null { - const { data } = useQuery(videoTutorialsQueryOptions); - const { data: appVersion } = useQuery(getVersionQueryOptions); + const isEnabled = Boolean(placementKey); + const { data } = useQuery({ + ...videoTutorialsQueryOptions, + enabled: isEnabled, + }); + const { data: appVersion } = useQuery({ + ...getVersionQueryOptions, + enabled: isEnabled, + }); if (!placementKey || !data || !appVersion) return EMPTY_VIDEO_GUIDE_PLACEMENT; return resolveVideoGuidePlacement(data, appVersion, placementKey, stepKey); From 2bfa4db26dfa194e013c62d7bd4a7dd13eabbfc4 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 20 Apr 2026 08:21:09 +0200 Subject: [PATCH 07/10] tighter step types --- web/src/shared/components/wizard/types.ts | 4 ++-- web/src/shared/video-tutorials/resolved.tsx | 2 +- web/src/shared/video-tutorials/resolver.ts | 7 +++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/web/src/shared/components/wizard/types.ts b/web/src/shared/components/wizard/types.ts index f42de4f0d..db27a987c 100644 --- a/web/src/shared/components/wizard/types.ts +++ b/web/src/shared/components/wizard/types.ts @@ -3,7 +3,7 @@ import type { HTMLProps } from 'react'; export interface WizardPageConfig { title: string; subtitle: string; - activeStep: string | number; + activeStep: string; steps: WizardPageStepsConfig; videoGuidePlacementKey?: string; relatedDocs?: WizardDocsLink[]; @@ -17,7 +17,7 @@ export interface WizardDocsLink { } export interface WizardPageStep { - id: number | string; + id: string; order: number; label: string; description?: string; diff --git a/web/src/shared/video-tutorials/resolved.tsx b/web/src/shared/video-tutorials/resolved.tsx index 8f6bc6e57..a9a65589b 100644 --- a/web/src/shared/video-tutorials/resolved.tsx +++ b/web/src/shared/video-tutorials/resolved.tsx @@ -45,7 +45,7 @@ export function useVideoTutorialsSections(): VideoTutorialsSection[] { export function useWizardVideoGuidePlacement( placementKey: string | undefined, - stepKey?: string | number, + stepKey?: string, ): VideoGuidePlacement | null { const isEnabled = Boolean(placementKey); const { data } = useQuery({ diff --git a/web/src/shared/video-tutorials/resolver.ts b/web/src/shared/video-tutorials/resolver.ts index f32a2e09e..7dfc19a10 100644 --- a/web/src/shared/video-tutorials/resolver.ts +++ b/web/src/shared/video-tutorials/resolver.ts @@ -53,7 +53,7 @@ export function resolveVideoGuidePlacement( mappings: VideoTutorialsMappings, appVersionRaw: string, placementKey: string | undefined, - stepKey?: string | number, + stepKey?: string, ): VideoGuidePlacement | null { if (!placementKey) { return null; @@ -66,9 +66,8 @@ export function resolveVideoGuidePlacement( return null; } - const resolvedStepKey = typeof stepKey === 'string' ? stepKey : undefined; - if (resolvedStepKey && placementGroup.steps?.[resolvedStepKey]) { - return placementGroup.steps[resolvedStepKey] ?? null; + if (stepKey && placementGroup.steps?.[stepKey]) { + return placementGroup.steps[stepKey] ?? null; } return placementGroup.default ?? null; From 0c2d5dfe7aae42db39ddae570f24f7d5ca9029ab Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 20 Apr 2026 12:36:51 +0200 Subject: [PATCH 08/10] new json structure: - video and docs are optional - multiple documentation links --- .../WizardVideoGuide/WizardVideoGuide.tsx | 104 +++-- .../wizard/WizardVideoGuide/style.scss | 6 + web/src/shared/video-tutorials/README.md | 147 ++++--- web/src/shared/video-tutorials/data.ts | 35 +- web/src/shared/video-tutorials/types.ts | 9 +- web/tests/video-tutorials.test.ts | 410 +++++++++++++++--- 6 files changed, 536 insertions(+), 175 deletions(-) diff --git a/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx b/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx index d5fe3eae4..bd79ca98e 100644 --- a/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx +++ b/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx @@ -18,56 +18,78 @@ type Props = { export const WizardVideoGuide = ({ videoGuide }: Props) => { const [isVideoOpen, setIsVideoOpen] = useState(false); + const hasVideo = Boolean(videoGuide.video); + const hasDocs = Boolean(videoGuide.docs?.length); + + if (!hasVideo && !hasDocs) { + return null; + } return ( <>
-
- {m.migration_wizard_support_video_guide_helper()} - - {m.migration_wizard_support_video_guide()} - -
- - - -
- - - {m.migration_wizard_support_related_documentation()} - -
- -
- - {videoGuide.docsTitle} - -
+ {hasVideo && ( + <> +
+ {m.migration_wizard_support_video_guide_helper()} + + {m.migration_wizard_support_video_guide()} + +
+ + + + )} + + {hasVideo && hasDocs && } + + {hasDocs && ( + <> +
+ + + {m.migration_wizard_support_related_documentation()} + +
+ +
+ {videoGuide.docs!.map((doc) => ( +
+ + {doc.docsTitle} + +
+ ))} +
+ + )}
- setIsVideoOpen(false)} - afterClose={() => setIsVideoOpen(false)} - /> + {hasVideo && ( + setIsVideoOpen(false)} + afterClose={() => setIsVideoOpen(false)} + /> + )} ); }; diff --git a/web/src/shared/components/wizard/WizardVideoGuide/style.scss b/web/src/shared/components/wizard/WizardVideoGuide/style.scss index b2aed5220..06907d36c 100644 --- a/web/src/shared/components/wizard/WizardVideoGuide/style.scss +++ b/web/src/shared/components/wizard/WizardVideoGuide/style.scss @@ -40,4 +40,10 @@ font: var(--t-body-sm-400); } } + + .doc-cards { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + } } diff --git a/web/src/shared/video-tutorials/README.md b/web/src/shared/video-tutorials/README.md index f8ed01590..4f08e91d9 100644 --- a/web/src/shared/video-tutorials/README.md +++ b/web/src/shared/video-tutorials/README.md @@ -161,58 +161,84 @@ endpoint on the same server. ] } ], - "placements": { - "migrationWizard": { - "default": { - "youtubeVideoId": "xyz987GHI12", - "title": "Migration wizard guide", - "docsTitle": "Defguard Configuration Guide", - "docsUrl": "https://docs.defguard.net/migration" + "placements": { + "migrationWizard": { + "default": { + "video": { + "youtubeVideoId": "xyz987GHI12", + "title": "Migration wizard guide" + }, + "docs": [ + { + "docsTitle": "Defguard Configuration Guide", + "docsUrl": "https://docs.defguard.net/migration" + } + ] + }, + "steps": { + "ca": { + "video": { + "youtubeVideoId": "aaaBBBccc11", + "title": "Certificate authority guide" + }, + "docs": [ + { + "docsTitle": "Certificate authority documentation", + "docsUrl": "https://docs.defguard.net/migration/ca" + }, + { + "docsTitle": "Certificate authority troubleshooting", + "docsUrl": "https://docs.defguard.net/migration/ca/troubleshooting" + } + ] + } + } + }, + "initialSetupWizard": { + "default": { + "video": { + "youtubeVideoId": "bbbCCCddd22", + "title": "Initial setup guide" + }, + "docs": [ + { + "docsTitle": "Initial setup documentation", + "docsUrl": "https://docs.defguard.net/setup" + } + ] + }, + "steps": { + "adminUser": { + "docs": [ + { + "docsTitle": "Admin user documentation", + "docsUrl": "https://docs.defguard.net/setup/admin-user" + } + ] + } + } }, - "steps": { - "ca": { - "youtubeVideoId": "aaaBBBccc11", - "title": "Certificate authority guide", - "docsTitle": "Certificate authority documentation", - "docsUrl": "https://docs.defguard.net/migration/ca" - } - } - }, - "initialSetupWizard": { - "default": { - "youtubeVideoId": "bbbCCCddd22", - "title": "Initial setup guide", - "docsTitle": "Initial setup documentation", - "docsUrl": "https://docs.defguard.net/setup" - }, - "steps": { - "adminUser": { - "youtubeVideoId": "cccDDDeee33", - "title": "Admin user guide", - "docsTitle": "Admin user documentation", - "docsUrl": "https://docs.defguard.net/setup/admin-user" - } - } - }, - "autoAdoptionWizard": { - "default": { - "youtubeVideoId": "dddEEEfff44", - "title": "Auto adoption guide", - "docsTitle": "Auto adoption documentation", - "docsUrl": "https://docs.defguard.net/auto-adoption" - }, - "steps": { - "vpnSettings": { - "youtubeVideoId": "eeeFFFggg55", - "title": "VPN settings guide", - "docsTitle": "VPN settings documentation", - "docsUrl": "https://docs.defguard.net/auto-adoption/vpn-settings" - } - } - } - } - } - } + "autoAdoptionWizard": { + "default": { + "docs": [ + { + "docsTitle": "Auto adoption documentation", + "docsUrl": "https://docs.defguard.net/auto-adoption" + } + ] + }, + "steps": { + "vpnSettings": { + "video": { + "youtubeVideoId": "eeeFFFggg55", + "title": "VPN settings guide" + } + } + } + } + } + } + } } ``` @@ -230,10 +256,22 @@ Route tutorial video fields: Wizard placement fields: +| Field | Required | Description | +|---|---|---| +| `video` | No | Optional video block shown in the wizard sidebar. | +| `docs` | No | Optional non-empty array of documentation links shown one under another in the wizard sidebar. | + +`video` fields: + | Field | Required | Description | |---|---|---| | `youtubeVideoId` | Yes | Used to render the thumbnail and embedded player in the wizard sidebar. | | `title` | Yes | Displayed next to the thumbnail and used as the iframe title. | + +`docs` item fields: + +| Field | Required | Description | +|---|---|---| | `docsTitle` | Yes | Text shown in the wizard documentation card. | | `docsUrl` | Yes | External URL opened from the wizard documentation card. | @@ -242,6 +280,9 @@ Wizard placement structure: - `default`: optional fallback guide used when the current step has no dedicated entry - `steps`: optional map of step key to guide data +Each placement object must define at least one of `video` or `docs`. +If `docs` is present, it must contain at least one item. + `placements` is a string-keyed record, so placement keys are not hardcoded in the schema. The application currently uses these keys: @@ -302,6 +343,10 @@ Within the selected version, wizard guide resolution uses this fallback order: 2. `placements[placementKey].default` 3. `null` +Fallback to `default` only happens when the whole step entry is missing. +If a step entry exists with only `docs` or only `video`, it is used as-is and is +not merged with `default`. + --- ## appRoute matching diff --git a/web/src/shared/video-tutorials/data.ts b/web/src/shared/video-tutorials/data.ts index a93b8b897..1e498173e 100644 --- a/web/src/shared/video-tutorials/data.ts +++ b/web/src/shared/video-tutorials/data.ts @@ -48,15 +48,32 @@ const sectionSchema = z const placementSchema = z .object({ - youtubeVideoId: z - .string() - .regex( - /^[A-Za-z0-9_-]{11}$/, - 'youtubeVideoId must be exactly 11 alphanumeric/-/_ chars', - ), - title: z.string().min(1, 'title must be non-empty'), - docsTitle: z.string().min(1, 'docsTitle must be non-empty'), - docsUrl: z.string().url('docsUrl must be a valid URL'), + video: z + .object({ + youtubeVideoId: z + .string() + .regex( + /^[A-Za-z0-9_-]{11}$/, + 'youtubeVideoId must be exactly 11 alphanumeric/-/_ chars', + ), + title: z.string().min(1, 'title must be non-empty'), + }) + .strip() + .optional(), + docs: z + .array( + z + .object({ + docsTitle: z.string().min(1, 'docsTitle must be non-empty'), + docsUrl: z.string().url('docsUrl must be a valid URL'), + }) + .strip(), + ) + .min(1, 'docs must contain at least one item') + .optional(), + }) + .refine((value) => Boolean(value.video || value.docs), { + message: 'placement must define at least one of video or docs', }) .strip(); diff --git a/web/src/shared/video-tutorials/types.ts b/web/src/shared/video-tutorials/types.ts index 5f11b6b33..9ab031bf5 100644 --- a/web/src/shared/video-tutorials/types.ts +++ b/web/src/shared/video-tutorials/types.ts @@ -11,13 +11,18 @@ export interface VideoTutorial extends PlayableVideo { docsUrl: string; } -export interface VideoGuidePlacement extends PlayableVideo { - /** Documentation link title shown in the migration wizard card. */ +export interface VideoGuideDocLink { + /** Documentation link title shown in the wizard card. */ docsTitle: string; /** External documentation URL. */ docsUrl: string; } +export interface VideoGuidePlacement { + video?: PlayableVideo; + docs?: VideoGuideDocLink[]; +} + export interface VideoGuidePlacementGroup { default?: VideoGuidePlacement; steps?: Record; diff --git a/web/tests/video-tutorials.test.ts b/web/tests/video-tutorials.test.ts index b477b6d8c..2ac26d2f3 100644 --- a/web/tests/video-tutorials.test.ts +++ b/web/tests/video-tutorials.test.ts @@ -120,49 +120,85 @@ const makeMappings = (): VideoTutorialsMappings => ({ placements: { migrationWizard: { default: { - youtubeVideoId: 'abcDEFghiJK', - title: 'Migration wizard guide', - docsTitle: 'Migration wizard documentation', - docsUrl: 'https://docs.defguard.net/migration', + video: { + youtubeVideoId: 'abcDEFghiJK', + title: 'Migration wizard guide', + }, + docs: [ + { + docsTitle: 'Migration wizard documentation', + docsUrl: 'https://docs.defguard.net/migration', + }, + ], }, steps: { ca: { - youtubeVideoId: 'caGuide220a', - title: 'Certificate authority guide', - docsTitle: 'Certificate authority documentation', - docsUrl: 'https://docs.defguard.net/migration/ca', + video: { + youtubeVideoId: 'caGuide220a', + title: 'Certificate authority guide', + }, + docs: [ + { + docsTitle: 'Certificate authority documentation', + docsUrl: 'https://docs.defguard.net/migration/ca', + }, + ], }, }, }, initialSetupWizard: { default: { - youtubeVideoId: 'setGuide220', - title: 'Initial setup guide', - docsTitle: 'Initial setup documentation', - docsUrl: 'https://docs.defguard.net/setup', + video: { + youtubeVideoId: 'setGuide220', + title: 'Initial setup guide', + }, + docs: [ + { + docsTitle: 'Initial setup documentation', + docsUrl: 'https://docs.defguard.net/setup', + }, + ], }, steps: { adminUser: { - youtubeVideoId: 'admGuide220', - title: 'Admin user guide', - docsTitle: 'Admin user documentation', - docsUrl: 'https://docs.defguard.net/setup/admin-user', + video: { + youtubeVideoId: 'admGuide220', + title: 'Admin user guide', + }, + docs: [ + { + docsTitle: 'Admin user documentation', + docsUrl: 'https://docs.defguard.net/setup/admin-user', + }, + ], }, }, }, autoAdoptionWizard: { default: { - youtubeVideoId: 'autoGuid220', - title: 'Auto adoption guide', - docsTitle: 'Auto adoption documentation', - docsUrl: 'https://docs.defguard.net/auto-adoption', + video: { + youtubeVideoId: 'autoGuid220', + title: 'Auto adoption guide', + }, + docs: [ + { + docsTitle: 'Auto adoption documentation', + docsUrl: 'https://docs.defguard.net/auto-adoption', + }, + ], }, steps: { vpnSettings: { - youtubeVideoId: 'vpnGuide220', - title: 'VPN settings guide', - docsTitle: 'VPN settings documentation', - docsUrl: 'https://docs.defguard.net/auto-adoption/vpn-settings', + video: { + youtubeVideoId: 'vpnGuide220', + title: 'VPN settings guide', + }, + docs: [ + { + docsTitle: 'VPN settings documentation', + docsUrl: 'https://docs.defguard.net/auto-adoption/vpn-settings', + }, + ], }, }, }, @@ -179,8 +215,10 @@ describe('resolveVersion', () => { const result = resolveVersion(makeMappings(), '2.3.0'); expect(result?.sections).toHaveLength(2); - expect(result?.placements?.migrationWizard?.default?.youtubeVideoId).toBe('abcDEFghiJK'); - expect(result?.placements?.initialSetupWizard?.default?.youtubeVideoId).toBe( + expect(result?.placements?.migrationWizard?.default?.video?.youtubeVideoId).toBe( + 'abcDEFghiJK', + ); + expect(result?.placements?.initialSetupWizard?.default?.video?.youtubeVideoId).toBe( 'setGuide220', ); }); @@ -241,7 +279,7 @@ describe('resolveVideoGuidePlacement', () => { it('should return the step-specific placement from the newest eligible version', () => { const result = resolveVideoGuidePlacement(makeMappings(), '2.3.0', 'migrationWizard', 'ca'); - expect(result?.title).toBe('Certificate authority guide'); + expect(result?.video?.title).toBe('Certificate authority guide'); }); it('should fall back to the default placement when step-specific entry is missing', () => { @@ -252,7 +290,7 @@ describe('resolveVideoGuidePlacement', () => { 'general', ); - expect(result?.title).toBe('Migration wizard guide'); + expect(result?.video?.title).toBe('Migration wizard guide'); }); it('should not fall back to an older placement once a newer eligible version is selected', () => { @@ -262,10 +300,16 @@ describe('resolveVideoGuidePlacement', () => { placements: { migrationWizard: { default: { - youtubeVideoId: 'abcDEFghiJK', - title: 'Migration wizard guide', - docsTitle: 'Migration wizard documentation', - docsUrl: 'https://docs.defguard.net/migration', + video: { + youtubeVideoId: 'abcDEFghiJK', + title: 'Migration wizard guide', + }, + docs: [ + { + docsTitle: 'Migration wizard documentation', + docsUrl: 'https://docs.defguard.net/migration', + }, + ], }, }, }, @@ -319,7 +363,7 @@ describe('resolveVideoGuidePlacement', () => { 'adminUser', ); - expect(result?.title).toBe('Admin user guide'); + expect(result?.video?.title).toBe('Admin user guide'); }); it('should resolve a default placement for auto adoption wizard when step is missing', () => { @@ -330,7 +374,7 @@ describe('resolveVideoGuidePlacement', () => { 'summary', ); - expect(result?.title).toBe('Auto adoption guide'); + expect(result?.video?.title).toBe('Auto adoption guide'); }); it('should resolve a step-specific placement for auto adoption wizard', () => { @@ -341,7 +385,51 @@ describe('resolveVideoGuidePlacement', () => { 'vpnSettings', ); - expect(result?.title).toBe('VPN settings guide'); + expect(result?.video?.title).toBe('VPN settings guide'); + }); + + it('should not merge a step placement with the default placement', () => { + const mappings: VideoTutorialsMappings = { + '2.2': { + sections: [], + placements: { + migrationWizard: { + default: { + video: { + youtubeVideoId: 'abcDEFghiJK', + title: 'Migration wizard guide', + }, + docs: [ + { + docsTitle: 'Migration wizard documentation', + docsUrl: 'https://docs.defguard.net/migration', + }, + ], + }, + steps: { + ca: { + docs: [ + { + docsTitle: 'Certificate authority documentation', + docsUrl: 'https://docs.defguard.net/migration/ca', + }, + ], + }, + }, + }, + }, + }, + }; + + const result = resolveVideoGuidePlacement(mappings, '2.2', 'migrationWizard', 'ca'); + + expect(result?.video).toBeUndefined(); + expect(result?.docs).toEqual([ + { + docsTitle: 'Certificate authority documentation', + docsUrl: 'https://docs.defguard.net/migration/ca', + }, + ]); }); }); @@ -369,26 +457,44 @@ const validRaw = { placements: { migrationWizard: { default: { - youtubeVideoId: 'xyz987GHI12', - title: 'Migration guide', - docsTitle: 'Defguard Configuration Guide', - docsUrl: 'https://docs.defguard.net/migration', + video: { + youtubeVideoId: 'xyz987GHI12', + title: 'Migration guide', + }, + docs: [ + { + docsTitle: 'Defguard Configuration Guide', + docsUrl: 'https://docs.defguard.net/migration', + }, + ], }, steps: { general: { - youtubeVideoId: 'genGuide220', - title: 'General configuration guide', - docsTitle: 'General configuration documentation', - docsUrl: 'https://docs.defguard.net/migration/general', + video: { + youtubeVideoId: 'genGuide220', + title: 'General configuration guide', + }, + docs: [ + { + docsTitle: 'General configuration documentation', + docsUrl: 'https://docs.defguard.net/migration/general', + }, + ], }, }, }, initialSetupWizard: { default: { - youtubeVideoId: 'setGuide220', - title: 'Setup guide', - docsTitle: 'Setup docs', - docsUrl: 'https://docs.defguard.net/setup', + video: { + youtubeVideoId: 'setGuide220', + title: 'Setup guide', + }, + docs: [ + { + docsTitle: 'Setup docs', + docsUrl: 'https://docs.defguard.net/setup', + }, + ], }, }, }, @@ -403,16 +509,16 @@ describe('parseVideoTutorials', () => { expect(result['2.2'].sections).toHaveLength(1); expect(result['2.2'].sections[0].name).toBe('Identity'); expect(result['2.2'].sections[0].videos[0].youtubeVideoId).toBe('abcDEFghiJK'); - expect(result['2.2'].placements?.migrationWizard?.default?.youtubeVideoId).toBe( + expect(result['2.2'].placements?.migrationWizard?.default?.video?.youtubeVideoId).toBe( 'xyz987GHI12', ); - expect(result['2.2'].placements?.migrationWizard?.default?.docsTitle).toBe( + expect(result['2.2'].placements?.migrationWizard?.default?.docs?.[0]?.docsTitle).toBe( 'Defguard Configuration Guide', ); - expect(result['2.2'].placements?.migrationWizard?.steps?.general?.youtubeVideoId).toBe( + expect(result['2.2'].placements?.migrationWizard?.steps?.general?.video?.youtubeVideoId).toBe( 'genGuide220', ); - expect(result['2.2'].placements?.initialSetupWizard?.default?.youtubeVideoId).toBe( + expect(result['2.2'].placements?.initialSetupWizard?.default?.video?.youtubeVideoId).toBe( 'setGuide220', ); }); @@ -664,10 +770,12 @@ describe('parseVideoTutorials', () => { placements: { initialSetupWizard: { default: { - youtubeVideoId: 'xyz987GHI12', - title: 'Setup guide', - docsTitle: 'Setup Guide', - docsUrl: 'not-a-url', + docs: [ + { + docsTitle: 'Setup Guide', + docsUrl: 'not-a-url', + }, + ], }, }, }, @@ -687,10 +795,18 @@ describe('parseVideoTutorials', () => { placements: { migrationWizard: { default: { - youtubeVideoId: 'xyz987GHI12', - title: 'Migration guide', - docsTitle: 'Defguard Configuration Guide', - docsUrl: 'https://docs.defguard.net/migration', + video: { + youtubeVideoId: 'xyz987GHI12', + title: 'Migration guide', + ignoredVideoField: 'ignored', + }, + docs: [ + { + docsTitle: 'Defguard Configuration Guide', + docsUrl: 'https://docs.defguard.net/migration', + ignoredDocsField: 'ignored', + }, + ], }, extraPlacementField: 'ignored', }, @@ -705,6 +821,16 @@ describe('parseVideoTutorials', () => { expect( (result['2.2'].placements?.migrationWizard as Record)['extraPlacementField'], ).toBeUndefined(); + expect( + (result['2.2'].placements?.migrationWizard?.default?.video as Record)[ + 'ignoredVideoField' + ], + ).toBeUndefined(); + expect( + (result['2.2'].placements?.migrationWizard?.default?.docs?.[0] as Record)[ + 'ignoredDocsField' + ], + ).toBeUndefined(); }); it('should reject an empty placement docsTitle', () => { @@ -715,10 +841,12 @@ describe('parseVideoTutorials', () => { placements: { autoAdoptionWizard: { default: { - youtubeVideoId: 'xyz987GHI12', - title: 'Auto adoption guide', - docsTitle: '', - docsUrl: 'https://docs.defguard.net/auto-adoption', + docs: [ + { + docsTitle: '', + docsUrl: 'https://docs.defguard.net/auto-adoption', + }, + ], }, }, }, @@ -738,10 +866,12 @@ describe('parseVideoTutorials', () => { autoAdoptionWizard: { steps: { vpnSettings: { - youtubeVideoId: 'xyz987GHI12', - title: 'VPN guide', - docsTitle: 'VPN settings guide', - docsUrl: 'not-a-url', + docs: [ + { + docsTitle: 'VPN settings guide', + docsUrl: 'not-a-url', + }, + ], }, }, }, @@ -761,10 +891,16 @@ describe('parseVideoTutorials', () => { placements: { anyWizardKey: { default: { - youtubeVideoId: 'xyz987GHI12', - title: 'Generic guide', - docsTitle: 'Generic docs', - docsUrl: 'https://docs.defguard.net/generic', + video: { + youtubeVideoId: 'xyz987GHI12', + title: 'Generic guide', + }, + docs: [ + { + docsTitle: 'Generic docs', + docsUrl: 'https://docs.defguard.net/generic', + }, + ], }, }, }, @@ -774,6 +910,136 @@ describe('parseVideoTutorials', () => { const result = parseVideoTutorials(raw); - expect(result['2.2'].placements?.anyWizardKey?.default?.title).toBe('Generic guide'); + expect(result['2.2'].placements?.anyWizardKey?.default?.video?.title).toBe( + 'Generic guide', + ); + }); + + it('should accept a placement with only video', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + anyWizardKey: { + default: { + video: { + youtubeVideoId: 'xyz987GHI12', + title: 'Generic guide', + }, + }, + }, + }, + }, + }, + }; + + const result = parseVideoTutorials(raw); + + expect(result['2.2'].placements?.anyWizardKey?.default).toEqual({ + video: { + youtubeVideoId: 'xyz987GHI12', + title: 'Generic guide', + }, + }); + }); + + it('should accept a placement with only docs', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + anyWizardKey: { + default: { + docs: [ + { + docsTitle: 'Generic docs', + docsUrl: 'https://docs.defguard.net/generic', + }, + ], + }, + }, + }, + }, + }, + }; + + const result = parseVideoTutorials(raw); + + expect(result['2.2'].placements?.anyWizardKey?.default).toEqual({ + docs: [ + { + docsTitle: 'Generic docs', + docsUrl: 'https://docs.defguard.net/generic', + }, + ], + }); + }); + + it('should accept multiple docs links in a placement', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + anyWizardKey: { + default: { + docs: [ + { + docsTitle: 'Generic docs', + docsUrl: 'https://docs.defguard.net/generic', + }, + { + docsTitle: 'More docs', + docsUrl: 'https://docs.defguard.net/generic/more', + }, + ], + }, + }, + }, + }, + }, + }; + + const result = parseVideoTutorials(raw); + + expect(result['2.2'].placements?.anyWizardKey?.default?.docs).toHaveLength(2); + }); + + it('should reject an empty docs array when docs is present', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + anyWizardKey: { + default: { + docs: [], + }, + }, + }, + }, + }, + }; + + expect(() => parseVideoTutorials(raw)).toThrow(); + }); + + it('should reject a placement with neither video nor docs', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + anyWizardKey: { + default: {}, + }, + }, + }, + }, + }; + + expect(() => parseVideoTutorials(raw)).toThrow(); }); }); From 29556088e1009814b0f58f5008c32aa2d830f355 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 20 Apr 2026 12:40:20 +0200 Subject: [PATCH 09/10] display docs links in the same div --- .../wizard/WizardVideoGuide/WizardVideoGuide.tsx | 4 ++-- .../shared/components/wizard/WizardVideoGuide/style.scss | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx b/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx index bd79ca98e..1128f5a34 100644 --- a/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx +++ b/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx @@ -68,9 +68,9 @@ export const WizardVideoGuide = ({ videoGuide }: Props) => { -
+
{videoGuide.docs!.map((doc) => ( -
+
{doc.docsTitle} diff --git a/web/src/shared/components/wizard/WizardVideoGuide/style.scss b/web/src/shared/components/wizard/WizardVideoGuide/style.scss index 06907d36c..f121751a8 100644 --- a/web/src/shared/components/wizard/WizardVideoGuide/style.scss +++ b/web/src/shared/components/wizard/WizardVideoGuide/style.scss @@ -31,19 +31,20 @@ } .doc-card { + display: flex; + flex-direction: column; padding: var(--spacing-lg); border: var(--border-1) solid var(--border-default); border-radius: var(--radius-lg); background-color: var(--bg-default); + gap: var(--spacing-md); .external-link { font: var(--t-body-sm-400); } } - .doc-cards { + .doc-link-row { display: flex; - flex-direction: column; - gap: var(--spacing-md); } } From 2ee62e23045d1ad5a9b36d9c4a7fe3f2f10a92c5 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 20 Apr 2026 12:49:34 +0200 Subject: [PATCH 10/10] fix linting --- .../WizardVideoGuide/WizardVideoGuide.tsx | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx b/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx index 1128f5a34..86dc4573a 100644 --- a/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx +++ b/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx @@ -18,10 +18,10 @@ type Props = { export const WizardVideoGuide = ({ videoGuide }: Props) => { const [isVideoOpen, setIsVideoOpen] = useState(false); - const hasVideo = Boolean(videoGuide.video); + const video = videoGuide.video; const hasDocs = Boolean(videoGuide.docs?.length); - if (!hasVideo && !hasDocs) { + if (!video && !hasDocs) { return null; } @@ -30,7 +30,7 @@ export const WizardVideoGuide = ({ videoGuide }: Props) => {
- {hasVideo && ( + {video && ( <>
{m.migration_wizard_support_video_guide_helper()} @@ -39,10 +39,14 @@ export const WizardVideoGuide = ({ videoGuide }: Props) => {
- )} - {hasVideo && hasDocs && } + {video && hasDocs && } {hasDocs && ( <> @@ -69,7 +73,7 @@ export const WizardVideoGuide = ({ videoGuide }: Props) => {
- {videoGuide.docs!.map((doc) => ( + {videoGuide.docs?.map((doc) => (
{doc.docsTitle} @@ -82,9 +86,9 @@ export const WizardVideoGuide = ({ videoGuide }: Props) => {
- {hasVideo && ( + {video && ( setIsVideoOpen(false)} afterClose={() => setIsVideoOpen(false)}