diff --git a/src/constants.ts b/src/constants.ts index 7dce15f5692bc..dd6d412188215 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,6 +10,7 @@ import type { Subscription, SubscriptionPlanId, SubscriptionState } from './plus import type { SupportedCloudIntegrationIds } from './plus/integrations/authentication/models'; import type { Integration } from './plus/integrations/integration'; import type { IntegrationId } from './plus/integrations/providers/models'; +import type { OnboardingState } from './plus/webviews/graph/protocol'; import type { TelemetryEventData } from './telemetry/telemetry'; import type { TrackedUsage, TrackedUsageKeys } from './telemetry/usageTracker'; @@ -946,6 +947,7 @@ export type GlobalStorage = { 'launchpad:groups:collapsed': StoredFocusGroup[]; 'launchpad:indicator:hasLoaded': boolean; 'launchpad:indicator:hasInteracted': string; + 'graph:onboarding': Record; } & { [key in `confirm:ai:tos:${AIProviders}`]: boolean } & { [key in `provider:authentication:skip:${string}`]: boolean; } & { [key in `gk:${string}:checkin`]: Stored } & { @@ -1301,6 +1303,11 @@ export type TelemetryEvents = { /** Sent when a VS Code command is executed by a GitLens provided action */ 'command/core': { command: string }; + 'graph/onboarding/state': { + name: string; + state: OnboardingState; + }; + /** Sent when the user takes an action on a launchpad item */ 'launchpad/title/action': LaunchpadEventData & { action: 'feedback' | 'open-on-gkdev' | 'refresh' | 'settings' | 'connect'; diff --git a/src/plus/webviews/graph/graphWebview.ts b/src/plus/webviews/graph/graphWebview.ts index 8f3b6d5a51c31..25c041c524e01 100644 --- a/src/plus/webviews/graph/graphWebview.ts +++ b/src/plus/webviews/graph/graphWebview.ts @@ -133,6 +133,7 @@ import type { GraphUpstreamMetadata, GraphUpstreamStatusContextValue, GraphWorkingTreeStats, + OnboardingState, OpenPullRequestDetailsParams, SearchOpenInViewParams, SearchParams, @@ -174,6 +175,7 @@ import { UpdateExcludeTypeCommand, UpdateGraphConfigurationCommand, UpdateIncludeOnlyRefsCommand, + UpdateOnboardingStateCommand, UpdateRefsVisibilityCommand, UpdateSelectionCommand, } from './protocol'; @@ -683,6 +685,9 @@ export class GraphWebviewProvider implements WebviewProvider | undefined = undefined; @@ -2146,13 +2166,13 @@ export class GraphWebviewProvider implements WebviewProvider { if (this.container.git.repositoryCount === 0) { - return { ...this.host.baseWebviewState, allowed: true, repositories: [] }; + return { ...this.host.baseWebviewState, allowed: true, repositories: [], onboarding: undefined }; } if (this.repository == null) { this.repository = this.container.git.getBestRepositoryOrFirst(); if (this.repository == null) { - return { ...this.host.baseWebviewState, allowed: true, repositories: [] }; + return { ...this.host.baseWebviewState, allowed: true, repositories: [], onboarding: undefined }; } } @@ -2282,6 +2302,7 @@ export class GraphWebviewProvider implements WebviewProvider | undefined; } export interface BranchState extends GitTrackingState { @@ -304,6 +311,15 @@ export interface UpdateSelectionParams { } export const UpdateSelectionCommand = new IpcCommand(scope, 'selection/update'); +export interface UpdateOnboardingStateParams { + name: string; + state: OnboardingState; +} +export const UpdateOnboardingStateCommand = new IpcCommand( + scope, + 'onboarding/update/state', +); + // REQUESTS export interface EnsureRowParams { diff --git a/src/webviews/apps/plus/graph/GraphWrapper.tsx b/src/webviews/apps/plus/graph/GraphWrapper.tsx index 180da9485a621..483ee9e050682 100644 --- a/src/webviews/apps/plus/graph/GraphWrapper.tsx +++ b/src/webviews/apps/plus/graph/GraphWrapper.tsx @@ -14,7 +14,6 @@ import type { } from '@gitkraken/gitkraken-components'; import GraphContainer, { CommitDateTimeSources, refZone } from '@gitkraken/gitkraken-components'; import { VSCodeCheckbox, VSCodeRadio, VSCodeRadioGroup } from '@vscode/webview-ui-toolkit/react'; -import { driver } from 'driver.js'; import type { FormEvent, MouseEvent, ReactElement } from 'react'; import React, { createElement, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { getPlatform } from '@env/platform'; @@ -40,6 +39,7 @@ import type { GraphSearchResults, GraphSearchResultsError, InternalNotificationType, + OnboardingState, State, UpdateGraphConfigurationParams, UpdateStateCallback, @@ -76,6 +76,7 @@ import { GlSearchBox } from '../../shared/components/search/react'; import type { SearchNavigationEventDetail } from '../../shared/components/search/search-box'; import type { DateTimeFormat } from '../../shared/date'; import { formatDate, fromNow } from '../../shared/date'; +import { createOnboarding } from '../../shared/onboarding'; import { GlGraphHover } from './hover/graphHover.react'; import type { GraphMinimapDaySelectedEventDetail } from './minimap/minimap'; import { GlGraphMinimapContainer } from './minimap/minimap-container.react'; @@ -106,6 +107,7 @@ export interface GraphWrapperProps { onExcludeType?: (key: keyof GraphExcludeTypes, value: boolean) => void; onIncludeOnlyRef?: (all: boolean) => void; onUpdateGraphConfiguration?: (changes: UpdateGraphConfigurationParams['changes']) => void; + onOnboardingStateChanged?: (name: string, state: OnboardingState) => void; } const getGraphDateFormatter = (config?: GraphComponentConfig): OnFormatCommitDateTime => { @@ -227,6 +229,7 @@ export function GraphWrapper({ onExcludeType, onIncludeOnlyRef, onUpdateGraphConfiguration, + onOnboardingStateChanged, }: GraphWrapperProps) { const graphRef = useRef(null); @@ -396,55 +399,81 @@ export function GraphWrapper({ useEffect(() => subscriber?.(updateState), []); useLayoutEffect(() => { - const driverObj = driver({ - showProgress: true, - steps: [ - { - popover: { - title: 'Welcome to the Commit Graph', - description: - 'It helps visualize your repository commit history and give you information about branches, commits, and collaborators all in one view.', - }, + const onboardingKey = 'graph-tour'; + const onboardingState = state.onboarding?.[onboardingKey]; + if (onboardingState?.dismissed === true) { + return; + } + + const steps = [ + { + key: `${onboardingKey}-welcome`, + popover: { + title: 'Welcome to the Commit Graph', + description: + 'It helps visualize your repository commit history and give you information about branches, commits, and collaborators all in one view.', }, - { - element: '#graph-repo-actions', - popover: { - title: 'Repository Actions', - description: - "Quickly switch repos, branches, see a branch's PR info, push/pull/fetch, and more.", - }, + }, + { + key: `${onboardingKey}-repo-actions`, + element: '#graph-repo-actions', + popover: { + title: 'Repository Actions', + description: "Quickly switch repos, branches, see a branch's PR info, push/pull/fetch, and more.", }, - { - element: '#graph-search', - popover: { - title: 'Rich Commit Search', - description: - 'Highlight all matching results across your entire repository when searching for a commit, message, author, a changed file or files, or even a specific code change.', - }, + }, + { + key: `${onboardingKey}-search`, + element: '#graph-search', + popover: { + title: 'Rich Commit Search', + description: + 'Highlight all matching results across your entire repository when searching for a commit, message, author, a changed file or files, or even a specific code change.', }, - { - element: '#graph-minimap', - popover: { - title: 'Minimap', - description: - 'Quickly see the activity of the repository, see the HEAD/upstream, branches (local and remote), and easily jump to them. ', - }, + }, + { + key: `${onboardingKey}-minimap`, + element: '#graph-minimap', + popover: { + title: 'Minimap', + description: + 'Quickly see the activity of the repository, see the HEAD/upstream, branches (local and remote), and easily jump to them. ', }, - { - element: '#main', - popover: { - title: 'Commit Graph', - description: 'The Commit Graph is a visualization of your repository history.', - }, + }, + { + key: `${onboardingKey}-graph`, + element: '#main', + popover: { + title: 'Commit Graph', + description: 'The Commit Graph is a visualization of your repository history.', }, - { - popover: { - title: 'Done', - description: "That's it for now. Enjoy! Please see this walkthrough for more information.", - }, + }, + { + key: `${onboardingKey}-done`, + popover: { + title: 'Done', + description: "That's it for now. Enjoy! Please see this walkthrough for more information.", }, - ], - }); + }, + ]; + + const driverObj = createOnboarding( + steps, + { + onCloseClick: ($el, step, options) => { + console.log('onCloseClick', $el, step, options); + onOnboardingStateChanged?.(onboardingKey, { dismissed: true }); + }, + }, + (key, step, options) => { + console.log('onHighlightedByKey', key, step, options); + onOnboardingStateChanged?.(onboardingKey, { + dismissed: false, + completed: key === `${onboardingKey}-done`, + step: key, + }); + }, + ); driverObj.drive(); @@ -1185,8 +1214,8 @@ export function GraphWrapper({ return ( <>
-
-
+
+
{repo && branchState?.provider?.url && ( { onExcludeType={this.onExcludeType.bind(this)} onIncludeOnlyRef={this.onIncludeOnlyRef.bind(this)} onUpdateGraphConfiguration={this.onUpdateGraphConfiguration.bind(this)} + onOnboardingStateChanged={this.onOnboardingStateChanged.bind(this)} />, $root, ); @@ -638,6 +641,10 @@ export class GraphApp extends App { this.sendCommand(UpdateGraphConfigurationCommand, { changes: changes }); } + private onOnboardingStateChanged(name: string, state: OnboardingState) { + this.sendCommand(UpdateOnboardingStateCommand, { name: name, state: state }); + } + private onSelectionChanged(rows: GraphRow[]) { const selection = rows.filter(r => r != null).map(r => ({ id: r.sha, type: r.type as GitGraphRowType })); this.sendCommand(UpdateSelectionCommand, { diff --git a/src/webviews/apps/shared/onboarding.ts b/src/webviews/apps/shared/onboarding.ts new file mode 100644 index 0000000000000..2283bd0d727ec --- /dev/null +++ b/src/webviews/apps/shared/onboarding.ts @@ -0,0 +1,50 @@ +import type { Config, Driver, DriveStep, State } from 'driver.js'; +import { driver } from 'driver.js'; + +export type KeyedDriverHook = ( + key: string, + step: DriveStep, + opts: { + config: Config; + state: State; + element: Element | undefined; + }, +) => void; + +export interface KeyedDriveStep extends DriveStep { + key: string; +} + +export function createOnboarding( + steps: KeyedDriveStep[], + config: Exclude = {}, + onHighlightedByKey?: KeyedDriverHook, +): Driver { + const driverConfig: Config = { + showProgress: true, + ...config, + steps: steps.map(keyedStep => ({ + ...keyedStep, + onHighlighted: + keyedStep.onHighlighted != null || onHighlightedByKey != null + ? (element, step, opts) => { + keyedStep.onHighlighted?.(element, step, opts); + onHighlightedByKey?.(keyedStep.key, step, { ...opts, element: element }); + } + : undefined, + })), + onHighlighted: + onHighlightedByKey != null + ? (element, step, opts) => { + config.onHighlighted?.(element, step, opts); + + const keyedStep = steps.find(s => s.popover === step.popover); + if (keyedStep == null) return; + + onHighlightedByKey(keyedStep.key, step, { ...opts, element: element }); + } + : undefined, + }; + + return driver(driverConfig); +}