Skip to content

Commit bba8962

Browse files
authored
[AXON 401] Onboarding quick pick experiment implementation (#423)
* AXON-400: init spike implementation * AXON-400: do cloud login * AXON-400 init server login * [AXON-400] some fixes for tests rebase * AXON-400: add analytics and support going back func during server login process * AXON-400: cleaning up + adding utils file rebase * AXON-400: add exp * AXON-400: add view flow event * AXON-400: reset flow when starting * AXON-400: better error handling + fix bb remote auth * AXON-400: add UT * AXON-400: more utils + UT * AXON-400: refactor for better readability + more UTs * AXON-400: more updates * AXON-400: cleanup duped code * AXON-400: changelog * AXON-400 update content * AXON-400: fix type * AXON-400: hide modal after completion
1 parent 9beddac commit bba8962

18 files changed

+1903
-512
lines changed

CHANGELOG.md

Lines changed: 504 additions & 496 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,11 @@
396396
"command": "atlascode.disableHelpExplorer",
397397
"title": "Disable Help Explorer",
398398
"category": "Atlassian"
399+
},
400+
{
401+
"command": "atlascode.showOnboardingFlow",
402+
"title": "Show Onboarding Flow",
403+
"category": "Atlassian"
399404
}
400405
],
401406
"viewsContainers": {

src/analytics.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,18 @@ export async function featureChangeEvent(featureId: string, enabled: boolean): P
7676
return trackEvent(action, 'feature', { actionSubjectId: featureId });
7777
}
7878

79-
export async function authenticatedEvent(site: DetailedSiteInfo, isOnboarding?: boolean): Promise<TrackEvent> {
79+
export async function authenticatedEvent(
80+
site: DetailedSiteInfo,
81+
isOnboarding?: boolean,
82+
source?: string,
83+
): Promise<TrackEvent> {
8084
return instanceTrackEvent(site, 'authenticated', 'atlascode', {
81-
attributes: { machineId: Container.machineId, hostProduct: site.product.name, onboarding: isOnboarding },
85+
attributes: {
86+
machineId: Container.machineId,
87+
hostProduct: site.product.name,
88+
onboarding: isOnboarding,
89+
authSource: source,
90+
},
8291
});
8392
}
8493

@@ -503,6 +512,7 @@ export async function authenticateButtonEvent(
503512
isCloud: boolean,
504513
isRemote: boolean,
505514
isWebUI: boolean,
515+
isSkipped: boolean = false,
506516
): Promise<UIEvent> {
507517
const e = {
508518
tenantIdType: null,
@@ -518,6 +528,7 @@ export async function authenticateButtonEvent(
518528
hostProduct: site.product.name,
519529
isRemote: isRemote,
520530
isWebUI: isWebUI,
531+
isSkipped: isSkipped,
521532
},
522533
},
523534
};

src/atlclients/loginManager.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ describe('LoginManager', () => {
115115
await loginManager.userInitiatedOAuthLogin(site, 'callback');
116116

117117
expect(oauthDancer.doDance).toHaveBeenCalledWith(provider, site, 'callback');
118-
expect(loginManager['saveDetails']).toHaveBeenCalledWith(provider, site, resp, undefined);
118+
expect(loginManager['saveDetails']).toHaveBeenCalledWith(provider, site, resp, undefined, undefined);
119119
});
120120
});
121121

src/atlclients/loginManager.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,19 @@ export class LoginManager {
4545
}
4646

4747
// this is *only* called when login buttons are clicked by the user
48-
public async userInitiatedOAuthLogin(site: SiteInfo, callback: string, isOnboarding?: boolean): Promise<void> {
48+
public async userInitiatedOAuthLogin(
49+
site: SiteInfo,
50+
callback: string,
51+
isOnboarding?: boolean,
52+
source?: string,
53+
): Promise<void> {
4954
const provider = oauthProviderForSite(site);
5055
if (!provider) {
5156
throw new Error(`No provider found for ${site.host}`);
5257
}
5358

5459
const resp = await this._dancer.doDance(provider, site, callback);
55-
await this.saveDetails(provider, site, resp, isOnboarding);
60+
await this.saveDetails(provider, site, resp, isOnboarding, source);
5661
}
5762

5863
public async initRemoteAuth(state: Object) {
@@ -72,7 +77,13 @@ export class LoginManager {
7277
await this.saveDetails(provider, site, resp, false);
7378
}
7479

75-
private async saveDetails(provider: OAuthProvider, site: SiteInfo, resp: OAuthResponse, isOnboarding?: boolean) {
80+
private async saveDetails(
81+
provider: OAuthProvider,
82+
site: SiteInfo,
83+
resp: OAuthResponse,
84+
isOnboarding?: boolean,
85+
source?: string,
86+
) {
7687
try {
7788
const oauthInfo: OAuthInfo = {
7889
access: resp.access,
@@ -95,7 +106,7 @@ export class LoginManager {
95106
siteDetails.map(async (siteInfo) => {
96107
await this._credentialManager.saveAuthInfo(siteInfo, oauthInfo);
97108
this._siteManager.addSites([siteInfo]);
98-
authenticatedEvent(siteInfo, isOnboarding).then((e) => {
109+
authenticatedEvent(siteInfo, isOnboarding, source).then((e) => {
99110
this._analyticsClient.sendTrackEvent(e);
100111
});
101112
}),
@@ -122,12 +133,17 @@ export class LoginManager {
122133
return [];
123134
}
124135

125-
public async userInitiatedServerLogin(site: SiteInfo, authInfo: AuthInfo, isOnboarding?: boolean): Promise<void> {
136+
public async userInitiatedServerLogin(
137+
site: SiteInfo,
138+
authInfo: AuthInfo,
139+
isOnboarding?: boolean,
140+
source?: string,
141+
): Promise<void> {
126142
if (isBasicAuthInfo(authInfo) || isPATAuthInfo(authInfo)) {
127143
try {
128144
const siteDetails = await this.saveDetailsForSite(site, authInfo);
129145

130-
authenticatedEvent(siteDetails, isOnboarding).then((e) => {
146+
authenticatedEvent(siteDetails, isOnboarding, source).then((e) => {
131147
this._analyticsClient.sendTrackEvent(e);
132148
});
133149
} catch (err) {

src/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export enum Commands {
9191
ToDoIssue = 'atlascode.jira.todoIssue',
9292
InProgressIssue = 'atlascode.jira.inProgressIssue',
9393
DoneIssue = 'atlascode.jira.doneIssue',
94+
ShowOnboardingFlow = 'atlascode.showOnboardingFlow',
9495
}
9596

9697
export function registerCommands(vscodeContext: ExtensionContext) {
@@ -235,5 +236,6 @@ export function registerCommands(vscodeContext: ExtensionContext) {
235236
commands.registerCommand(Commands.BitbucketOpenPullRequest, (data: { pullRequestUrl: string }) => {
236237
Container.openPullRequestHandler(data.pullRequestUrl);
237238
}),
239+
commands.registerCommand(Commands.ShowOnboardingFlow, () => Container.onboardingProvider.start()),
238240
);
239241
}

src/container.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { SectionChangeMessage } from './lib/ipc/toUI/config';
2727
import { StartWorkIssueMessage } from './lib/ipc/toUI/startWork';
2828
import { CommonActionMessageHandler } from './lib/webview/controller/common/commonActionMessageHandler';
2929
import { Logger } from './logger';
30+
import OnboardingProvider from './onboarding/onboardingProvider';
3031
import { Pipeline } from './pipelines/model';
3132
import { SiteManager } from './siteManager';
3233
import { AtlascodeUriHandler, ONBOARDING_URL, SETTINGS_URL } from './uriHandler';
@@ -195,6 +196,8 @@ export class Container {
195196
SearchJiraHelper.initialize();
196197
context.subscriptions.push(new CustomJQLViewProvider());
197198
context.subscriptions.push((this._assignedWorkItemsView = new AssignedWorkItemsViewProvider()));
199+
200+
this._onboardingProvider = new OnboardingProvider();
198201
}
199202

200203
static focus() {
@@ -391,4 +394,9 @@ export class Container {
391394
public static get pmfStats() {
392395
return this._pmfStats;
393396
}
397+
398+
private static _onboardingProvider: OnboardingProvider;
399+
public static get onboardingProvider() {
400+
return this._onboardingProvider;
401+
}
394402
}

src/extension.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
} from './pipelines/yaml/pipelinesYamlHelper';
2424
import { registerResources } from './resources';
2525
import { GitExtension } from './typings/git';
26-
import { FeatureFlagClient, Features } from './util/featureFlags';
26+
import { Experiments, FeatureFlagClient, Features } from './util/featureFlags';
2727
import { NotificationManagerImpl } from './views/notifications/notificationManager';
2828

2929
const AnalyticDelay = 5000;
@@ -72,7 +72,12 @@ export async function activate(context: ExtensionContext) {
7272

7373
// new user for auth exp
7474
if (previousVersion === undefined) {
75-
showOnboardingPage();
75+
const expVal = FeatureFlagClient.checkExperimentValue(Experiments.AtlascodeOnboardingExperiment);
76+
if (expVal) {
77+
commands.executeCommand(Commands.ShowOnboardingFlow);
78+
} else {
79+
commands.executeCommand(Commands.ShowOnboardingPage);
80+
}
7681
} else {
7782
showWelcomePage(atlascodeVersion, previousVersion);
7883
}
@@ -188,10 +193,6 @@ async function sendAnalytics(version: string, globalState: Memento) {
188193
});
189194
}
190195

191-
function showOnboardingPage() {
192-
commands.executeCommand(Commands.ShowOnboardingPage);
193-
}
194-
195196
// this method is called when your extension is deactivated
196197
export function deactivate() {
197198
unregisterErrorReporting();
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// @ts-nocheck
2+
3+
jest.mock('vscode', () => {
4+
return {
5+
window: {
6+
showErrorMessage: jest.fn(),
7+
},
8+
commands: {
9+
executeCommand: jest.fn(),
10+
},
11+
env: {
12+
remoteName: undefined,
13+
uiKind: 1,
14+
openExternal: jest.fn(),
15+
},
16+
ThemeIcon: jest.fn((id: string) => ({ id })),
17+
QuickInputButtons: {
18+
Back: { iconPath: 'back', tooltip: 'Back' },
19+
},
20+
Uri: {
21+
parse: jest.fn((url: string) => ({ url })),
22+
},
23+
Disposable: jest.fn(),
24+
UIKind: {
25+
Desktop: 1,
26+
Web: 2,
27+
Remote: 3,
28+
},
29+
};
30+
});
31+
32+
jest.mock('../commands', () => ({
33+
Commands: {
34+
RefreshAssignedWorkItemsExplorer: 'RefreshAssignedWorkItemsExplorer',
35+
RefreshCustomJqlExplorer: 'RefreshCustomJqlExplorer',
36+
BitbucketRefreshPullRequests: 'BitbucketRefreshPullRequests',
37+
RefreshPipelines: 'RefreshPipelines',
38+
},
39+
}));
40+
41+
jest.mock('../uriHandler/atlascodeUriHandler', () => ({
42+
AtlascodeUriHandler: jest.fn(),
43+
}));
44+
45+
import { window } from 'vscode';
46+
47+
import { ProductJira } from '../atlclients/authInfo';
48+
import { Container } from '../container';
49+
import { EXTENSION_URL } from '../uriHandler/atlascodeUriHandler';
50+
import OnboardingProvider from './onboardingProvider';
51+
import { OnboardingStep } from './utils';
52+
53+
jest.mock('../container', () => ({
54+
Container: {
55+
analyticsClient: {
56+
sendScreenEvent: jest.fn(),
57+
sendUIEvent: jest.fn(),
58+
sendTrackEvent: jest.fn(),
59+
},
60+
loginManager: {
61+
userInitiatedOAuthLogin: jest.fn(() => Promise.resolve()),
62+
userInitiatedServerLogin: jest.fn(() => Promise.resolve()),
63+
},
64+
focus: jest.fn(),
65+
},
66+
}));
67+
68+
jest.mock('../analytics', () => ({
69+
authenticateButtonEvent: jest.fn(() => Promise.resolve({})),
70+
errorEvent: jest.fn(() => Promise.resolve({})),
71+
viewScreenEvent: jest.fn((id) => Promise.resolve(id)),
72+
}));
73+
74+
jest.mock('./utils', () => ({
75+
OnboardingStep: {
76+
Jira: 1,
77+
Bitbucket: 2,
78+
},
79+
onboardingQuickPickItems: jest.fn(),
80+
}));
81+
82+
jest.mock('./onboardingQuickPickManager', () => {
83+
return {
84+
default: jest.fn().mockImplementation(() => ({
85+
show: jest.fn(),
86+
hide: jest.fn(),
87+
setBusy: jest.fn(),
88+
})),
89+
};
90+
});
91+
92+
jest.mock('./onboardingQuickInputManager', () => {
93+
return {
94+
default: jest.fn().mockImplementation(() => ({
95+
start: jest.fn(),
96+
})),
97+
};
98+
});
99+
100+
describe('OnboardingProvider', () => {
101+
let provider: OnboardingProvider;
102+
103+
beforeEach(() => {
104+
jest.clearAllMocks();
105+
provider = new OnboardingProvider();
106+
});
107+
108+
it('should initialize with correct objects', () => {
109+
expect(provider).toBeDefined();
110+
expect(provider._analyticsClient).toBeDefined();
111+
expect(provider._jiraQuickPickManager).toBeDefined();
112+
expect(provider._bitbucketQuickPickManager).toBeDefined();
113+
expect(provider._quickInputManager).toBeDefined();
114+
});
115+
116+
it('should show Jira onboarding quick pick on start', () => {
117+
provider.start();
118+
119+
expect(Container.focus).toHaveBeenCalled();
120+
expect(provider._jiraQuickPickManager.show).toHaveBeenCalled();
121+
});
122+
123+
it('should handle Jira quick pick accept for cloud', async () => {
124+
const item = { onboardingId: 'onboarding:cloud' };
125+
const showSpy = jest.spyOn(provider, '_handleCloud');
126+
127+
await provider._quickPickOnDidAccept(item, ProductJira);
128+
129+
expect(showSpy).toHaveBeenCalledWith(ProductJira);
130+
});
131+
132+
it('should handle Jira quick pick accept for server', async () => {
133+
const item = { onboardingId: 'onboarding:server' };
134+
135+
await provider._quickPickOnDidAccept(item, ProductJira);
136+
137+
expect(provider._quickInputManager.start).toHaveBeenCalledWith(ProductJira, 'Server');
138+
});
139+
140+
it('should handle Jira quick pick accept for skip', async () => {
141+
const item = { onboardingId: 'onboarding:skip' };
142+
const skipSpy = jest.spyOn(provider, '_handleSkip');
143+
await provider._quickPickOnDidAccept(item, ProductJira);
144+
145+
expect(skipSpy).toHaveBeenCalledWith(ProductJira);
146+
});
147+
148+
it('should envoke userInitiatedOAuthLogin on Jira cloud onboarding', async () => {
149+
const item = { onboardingId: 'onboarding:cloud' };
150+
const siteInfo = { product: ProductJira, host: 'atlassian.net' };
151+
const nextSpy = jest.spyOn(provider, '_handleNext');
152+
await provider._quickPickOnDidAccept(item, ProductJira);
153+
154+
expect(Container.loginManager.userInitiatedOAuthLogin).toHaveBeenCalledWith(
155+
siteInfo,
156+
EXTENSION_URL,
157+
true,
158+
'atlascodeOnboardingQuickPick',
159+
);
160+
expect(nextSpy).toHaveBeenCalledWith(OnboardingStep.Jira);
161+
});
162+
163+
it('should handle error during Jira cloud onboarding', async () => {
164+
const item = { onboardingId: 'onboarding:cloud' };
165+
const error = new Error('Test error');
166+
jest.spyOn(Container.loginManager, 'userInitiatedOAuthLogin').mockRejectedValue(error);
167+
168+
const returnMessage = 'Failed to authenticate with Jira Cloud: Test error';
169+
170+
const errorSpy = jest.spyOn(provider, '_handleError');
171+
172+
provider._quickPickOnDidAccept(item, ProductJira);
173+
174+
await new Promise(process.nextTick);
175+
expect(errorSpy).toHaveBeenCalledWith(returnMessage, Error(returnMessage));
176+
expect(window.showErrorMessage).toHaveBeenCalledWith(returnMessage);
177+
});
178+
});

0 commit comments

Comments
 (0)