Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@
"StartPage.folderDesc": "- Open a <div class=\"link\" role=\"button\" onclick={0}>Folder</div><br /> - Open a <div class=\"link\" role=\"button\" onclick={1}>Workspace</div>",
"StartPage.badWebPanelFormatString": "<html><body><h1>{0} is not a valid file name</h1></body></html>",
"Jupyter.extensionRequired": "The Jupyter extension is required to perform that task. Click Yes to open the Jupyter extension installation page.",
"Jupyter.extensionNotInstalled": "This feature is available in the Jupyter extension, which isn't currently installed.",
"TensorBoard.missingSourceFile": "We could not locate the requested source file on disk. Please manually specify the file.",
"TensorBoard.selectMissingSourceFile": "Choose File",
"TensorBoard.selectMissingSourceFileDescription": "The source file's contents may not match the original contents in the trace.",
Expand Down
6 changes: 6 additions & 0 deletions src/client/common/serviceRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ import {
} from './types';
import { IMultiStepInputFactory, MultiStepInputFactory } from './utils/multiStepInput';
import { Random } from './utils/random';
import { JupyterNotInstalledNotificationHelper } from '../jupyter/jupyterNotInstalledNotificationHelper';
import { IJupyterNotInstalledNotificationHelper } from '../jupyter/types';

export function registerTypes(serviceManager: IServiceManager) {
serviceManager.addSingletonInstance<boolean>(IsWindows, IS_WINDOWS);
Expand All @@ -141,6 +143,10 @@ export function registerTypes(serviceManager: IServiceManager) {
IJupyterExtensionDependencyManager,
JupyterExtensionDependencyManager,
);
serviceManager.addSingleton<IJupyterNotInstalledNotificationHelper>(
IJupyterNotInstalledNotificationHelper,
JupyterNotInstalledNotificationHelper,
);
serviceManager.addSingleton<IPythonCommandManager>(ICommandManager, CommandManager);
serviceManager.addSingleton<IConfigurationService>(IConfigurationService, ConfigurationService);
serviceManager.addSingleton<IWorkspaceService>(IWorkspaceService, WorkspaceService);
Expand Down
5 changes: 5 additions & 0 deletions src/client/common/utils/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ export namespace Jupyter {
'Jupyter.extensionRequired',
'The Jupyter extension is required to perform that task. Click Yes to open the Jupyter extension installation page.',
);

export const jupyterExtensionNotInstalled = localize(
'Jupyter.extensionNotInstalled',
"This feature is available in the Jupyter extension, which isn't currently installed.",
);
}

export namespace TensorBoard {
Expand Down
58 changes: 58 additions & 0 deletions src/client/jupyter/jupyterNotInstalledNotificationHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { injectable, inject } from 'inversify';
import { IApplicationShell, IJupyterExtensionDependencyManager } from '../common/application/types';
import { IPersistentStateFactory } from '../common/types';
import { Common, Jupyter } from '../common/utils/localize';
import { sendTelemetryEvent } from '../telemetry';
import { EventName } from '../telemetry/constants';
import { IJupyterNotInstalledNotificationHelper, JupyterNotInstalledOrigin } from './types';

export const jupyterExtensionNotInstalledKey = 'jupyterExtensionNotInstalledKey';

@injectable()
export class JupyterNotInstalledNotificationHelper implements IJupyterNotInstalledNotificationHelper {
constructor(
@inject(IApplicationShell) private appShell: IApplicationShell,
@inject(IPersistentStateFactory) private persistentState: IPersistentStateFactory,
@inject(IJupyterExtensionDependencyManager) private depsManager: IJupyterExtensionDependencyManager,
) {}

public shouldShowJupypterExtensionNotInstalledPrompt(): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like something which can be a diagnostic, like PylanceDefaultDiagnostic you recently implemented.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not something we are going to run on start, but that will be displayed in response to users taking specific actions at different points in the extension. What would be the advantage of using diagnostics in that case compared to showing a notification?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see, sorry I missed that.

Diagnostics already have the do not show again functionality so you don't have to create a separate key, and it's consistent with a similar class we have:

export class InvalidPythonPathInDebuggerService extends BaseDiagnosticsService

which triggers in response to debugging. But eh, it's not much of an advantage, so not changing it is fine.

const doNotShowAgain = this.persistentState.createGlobalPersistentState(jupyterExtensionNotInstalledKey, false);

if (doNotShowAgain.value) {
return false;
}

const isInstalled = this.depsManager.isJupyterExtensionInstalled;

return !isInstalled;
}

public async jupyterNotInstalledPrompt(entrypoint: JupyterNotInstalledOrigin): Promise<void> {
sendTelemetryEvent(EventName.JUPYTER_NOT_INSTALLED_NOTIFICATION_DISPLAYED, undefined, { entrypoint });

const prompts = [Common.doNotShowAgain()];
const telemetrySelections: ['Do not show again'] = ['Do not show again'];

const selection = await this.appShell.showInformationMessage(
Jupyter.jupyterExtensionNotInstalled(),
...prompts,
);

sendTelemetryEvent(EventName.JUPYTER_NOT_INSTALLED_NOTIFICATION_ACTION, undefined, {
selection: selection ? telemetrySelections[prompts.indexOf(selection)] : undefined,
});

if (!selection) {
return;
}

// Never show this prompt again
await this.persistentState
.createGlobalPersistentState(jupyterExtensionNotInstalledKey, false)
.updateValue(true);
}
}
14 changes: 14 additions & 0 deletions src/client/jupyter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,17 @@ enum ColumnType {

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IRowsResponse = any[];

// Note: While #16102 is being worked on, this enum will be updated as we add ways to display this notification.
export enum JupyterNotInstalledOrigin {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While #16102 is being worked on, this enum will be updated as we add ways to display this notification.

StartPageCreateBlankNotebook = 'startpage_create_blank_notebook',
StartPageCreateJupyterNotebook = 'startpage_create_jupyter_notebook',
StartPageCreateSampleNotebook = 'startpage_sample_notebook',
StartPageUseInteractiveWindow = 'startpage_use_interactive_window',
}

export const IJupyterNotInstalledNotificationHelper = Symbol('IJupyterNotInstalledNotificationHelper');
export interface IJupyterNotInstalledNotificationHelper {
shouldShowJupypterExtensionNotInstalledPrompt(): boolean;
jupyterNotInstalledPrompt(entrypoint: JupyterNotInstalledOrigin): Promise<void>;
}
3 changes: 3 additions & 0 deletions src/client/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ export enum EventName {
JEDI_LANGUAGE_SERVER_TELEMETRY = 'JEDI_LANGUAGE_SERVER.EVENT',
JEDI_LANGUAGE_SERVER_REQUEST = 'JEDI_LANGUAGE_SERVER.REQUEST',

JUPYTER_NOT_INSTALLED_NOTIFICATION_DISPLAYED = 'JUPYTER_NOT_INSTALLED_NOTIFICATION_DISPLAYED',
JUPYTER_NOT_INSTALLED_NOTIFICATION_ACTION = 'JUPYTER_NOT_INSTALLED_NOTIFICATION_ACTION',

TENSORBOARD_SESSION_LAUNCH = 'TENSORBOARD.SESSION_LAUNCH',
TENSORBOARD_SESSION_DURATION = 'TENSORBOARD.SESSION_DURATION',
TENSORBOARD_SESSION_DAEMON_STARTUP_DURATION = 'TENSORBOARD.SESSION_DAEMON_STARTUP_DURATION',
Expand Down
26 changes: 26 additions & 0 deletions src/client/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { TestProvider } from '../testing/types';
import { EventName, PlatformErrors } from './constants';
import type { LinterTrigger, TestTool } from './types';
import { JupyterNotInstalledOrigin } from '../jupyter/types';

/**
* Checks whether telemetry is supported.
Expand Down Expand Up @@ -1750,6 +1751,31 @@ export interface IEventNamePropertyMapping {
terminal: TerminalShellType;
};

/**
* Telemetry event sent when the notification about the Jupyter extension not being installed is displayed.
* Since this notification will only be displayed after an action that requires the Jupyter extension,
* the telemetry event will include the action the user took, under the `entrypoint` property.
*/
[EventName.JUPYTER_NOT_INSTALLED_NOTIFICATION_DISPLAYED]: {
/**
* Action that the user took to trigger the notification.
*/
entrypoint: JupyterNotInstalledOrigin;
};

/**
* Telemetry event sent when the notification about the Jupyter extension not being installed is closed.
*/
[EventName.JUPYTER_NOT_INSTALLED_NOTIFICATION_ACTION]: {
/**
* Action selected by the user in response to the notification:
* close the notification using the close button, or "Do not show again".
*
* @type {('Do not show again' | undefined)}
*/
selection: 'Do not show again' | undefined;
};

[Telemetry.WebviewStyleUpdate]: never | undefined;
[Telemetry.WebviewMonacoStyleUpdate]: never | undefined;
[Telemetry.WebviewStartup]: { type: string };
Expand Down
6 changes: 6 additions & 0 deletions src/test/common/moduleInstaller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ import { MockModuleInstaller } from '../mocks/moduleInstaller';
import { MockProcessService } from '../mocks/proc';
import { UnitTestIocContainer } from '../testing/serviceRegistry';
import { closeActiveWindows, initializeTest } from '../initialize';
import { JupyterNotInstalledNotificationHelper } from '../../client/jupyter/jupyterNotInstalledNotificationHelper';
import { IJupyterNotInstalledNotificationHelper } from '../../client/jupyter/types';

chaiUse(chaiAsPromised);

Expand Down Expand Up @@ -245,6 +247,10 @@ suite('Module Installer', () => {
IJupyterExtensionDependencyManager,
JupyterExtensionDependencyManager,
);
ioc.serviceManager.addSingleton<IJupyterNotInstalledNotificationHelper>(
IJupyterNotInstalledNotificationHelper,
JupyterNotInstalledNotificationHelper,
);
ioc.serviceManager.addSingleton<IBrowserService>(IBrowserService, BrowserService);
ioc.serviceManager.addSingleton<IHttpClient>(IHttpClient, HttpClient);
ioc.serviceManager.addSingleton<IFileDownloader>(IFileDownloader, FileDownloader);
Expand Down
151 changes: 151 additions & 0 deletions src/test/jupyter/jupyterNotInstalledNotificationHelper.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import * as assert from 'assert';
import * as sinon from 'sinon';
import { IApplicationShell, IJupyterExtensionDependencyManager } from '../../client/common/application/types';
import { IPersistentStateFactory } from '../../client/common/types';
import { Jupyter, Common } from '../../client/common/utils/localize';
import {
jupyterExtensionNotInstalledKey,
JupyterNotInstalledNotificationHelper,
} from '../../client/jupyter/jupyterNotInstalledNotificationHelper';
import { JupyterNotInstalledOrigin } from '../../client/jupyter/types';

suite('Jupyter not installed notification helper', () => {
teardown(() => {
sinon.restore();
});

test('Notification check should return false if the Jupyter extension is installed', () => {
const createGlobalPersistentStateStub = sinon
.stub()
.withArgs(jupyterExtensionNotInstalledKey, sinon.match.bool)
.returns({ value: undefined });

// Need to define 'isJupyterExtensionInstalled' for it to be stubbed.
const jupyterExtDependencyManager = {
isJupyterExtensionInstalled: false,
} as IJupyterExtensionDependencyManager;
const isJupyterExtensionInstalledStub = sinon.stub().returns(true);
sinon.stub(jupyterExtDependencyManager, 'isJupyterExtensionInstalled').get(isJupyterExtensionInstalledStub);

const notificationHelper = new JupyterNotInstalledNotificationHelper(
{} as IApplicationShell,
({ createGlobalPersistentState: createGlobalPersistentStateStub } as unknown) as IPersistentStateFactory,
jupyterExtDependencyManager,
);

const result = notificationHelper.shouldShowJupypterExtensionNotInstalledPrompt();

assert.strictEqual(result, false);
sinon.assert.calledOnce(createGlobalPersistentStateStub);
sinon.assert.calledWith(createGlobalPersistentStateStub, jupyterExtensionNotInstalledKey, sinon.match.bool);
sinon.assert.calledOnce(isJupyterExtensionInstalledStub);
});

test('Notification check should return false if the doNotShowAgain persistent value is set', () => {
const createGlobalPersistentStateStub = sinon
.stub()
.withArgs(jupyterExtensionNotInstalledKey, sinon.match.bool)
.returns({ value: true });

const jupyterExtDependencyManager = {
isJupyterExtensionInstalled: false,
} as IJupyterExtensionDependencyManager;
const isJupyterExtensionInstalledStub = sinon.stub().returns(false);
sinon.stub(jupyterExtDependencyManager, 'isJupyterExtensionInstalled').get(isJupyterExtensionInstalledStub);

const notificationHelper = new JupyterNotInstalledNotificationHelper(
{} as IApplicationShell,
({ createGlobalPersistentState: createGlobalPersistentStateStub } as unknown) as IPersistentStateFactory,
jupyterExtDependencyManager,
);

const result = notificationHelper.shouldShowJupypterExtensionNotInstalledPrompt();

assert.strictEqual(result, false);
sinon.assert.calledOnce(createGlobalPersistentStateStub);
sinon.assert.calledWith(createGlobalPersistentStateStub, jupyterExtensionNotInstalledKey, sinon.match.bool);
sinon.assert.notCalled(isJupyterExtensionInstalledStub);
});

test('Notification check should return true if the doNotShowAgain persistent value is not set and the Jupyter extension is not installed', () => {
const createGlobalPersistentStateStub = sinon
.stub()
.withArgs(jupyterExtensionNotInstalledKey, sinon.match.bool)
.returns({ value: undefined });

const jupyterExtDependencyManager = {
isJupyterExtensionInstalled: false,
} as IJupyterExtensionDependencyManager;
const isJupyterExtensionInstalledStub = sinon.stub().returns(false);
sinon.stub(jupyterExtDependencyManager, 'isJupyterExtensionInstalled').get(isJupyterExtensionInstalledStub);

const notificationHelper = new JupyterNotInstalledNotificationHelper(
{} as IApplicationShell,
({ createGlobalPersistentState: createGlobalPersistentStateStub } as unknown) as IPersistentStateFactory,
(jupyterExtDependencyManager as unknown) as IJupyterExtensionDependencyManager,
);

const result = notificationHelper.shouldShowJupypterExtensionNotInstalledPrompt();

assert.strictEqual(result, true);
sinon.assert.calledOnce(createGlobalPersistentStateStub);
sinon.assert.calledWith(createGlobalPersistentStateStub, jupyterExtensionNotInstalledKey, sinon.match.bool);
sinon.assert.calledOnce(isJupyterExtensionInstalledStub);
});

test('Selecting "Do not show again" should set the doNotShowAgain persistent value', async () => {
const updateValueStub = sinon.stub();
const createGlobalPersistentStateStub = sinon
.stub()
.withArgs(jupyterExtensionNotInstalledKey, sinon.match.bool)
.returns({ updateValue: updateValueStub });

const showInformationMessageStub = sinon.stub().returns(Promise.resolve(Common.doNotShowAgain));

const notificationHelper = new JupyterNotInstalledNotificationHelper(
({ showInformationMessage: showInformationMessageStub } as unknown) as IApplicationShell,
({ createGlobalPersistentState: createGlobalPersistentStateStub } as unknown) as IPersistentStateFactory,
{} as IJupyterExtensionDependencyManager,
);
await notificationHelper.jupyterNotInstalledPrompt(JupyterNotInstalledOrigin.StartPageCreateBlankNotebook);

sinon.assert.calledOnce(createGlobalPersistentStateStub);
sinon.assert.calledOnce(showInformationMessageStub);
sinon.assert.calledWith(
showInformationMessageStub,
Jupyter.jupyterExtensionNotInstalled(),
Common.doNotShowAgain(),
);
sinon.assert.calledOnce(updateValueStub);
sinon.assert.calledWith(updateValueStub, true);
});

test('Selecting "Do not show again" should make the prompt check return false', async () => {
const persistentState: { value: boolean | undefined; updateValue: (v: boolean) => void } = {
value: undefined,
updateValue(v: boolean) {
this.value = v;
},
};
const createGlobalPersistentStateStub = sinon
.stub()
.withArgs(jupyterExtensionNotInstalledKey, sinon.match.bool)
.returns(persistentState);

const showInformationMessageStub = sinon.stub().returns(Promise.resolve(Common.doNotShowAgain));

const notificationHelper = new JupyterNotInstalledNotificationHelper(
({ showInformationMessage: showInformationMessageStub } as unknown) as IApplicationShell,
({ createGlobalPersistentState: createGlobalPersistentStateStub } as unknown) as IPersistentStateFactory,
{} as IJupyterExtensionDependencyManager,
);
await notificationHelper.jupyterNotInstalledPrompt(JupyterNotInstalledOrigin.StartPageCreateBlankNotebook);

const result = notificationHelper.shouldShowJupypterExtensionNotInstalledPrompt();

assert.strictEqual(result, false);
});
});