diff --git a/package.nls.json b/package.nls.json index 01c38a8b7111..c3aa71c3d06c 100644 --- a/package.nls.json +++ b/package.nls.json @@ -233,6 +233,7 @@ "StartPage.folderDesc": "- Open a
Folder

- Open a
Workspace
", "StartPage.badWebPanelFormatString": "

{0} is not a valid file name

", "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.", diff --git a/src/client/common/serviceRegistry.ts b/src/client/common/serviceRegistry.ts index 8c8dc9aff5e9..bd6e00bd6083 100644 --- a/src/client/common/serviceRegistry.ts +++ b/src/client/common/serviceRegistry.ts @@ -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(IsWindows, IS_WINDOWS); @@ -141,6 +143,10 @@ export function registerTypes(serviceManager: IServiceManager) { IJupyterExtensionDependencyManager, JupyterExtensionDependencyManager, ); + serviceManager.addSingleton( + IJupyterNotInstalledNotificationHelper, + JupyterNotInstalledNotificationHelper, + ); serviceManager.addSingleton(ICommandManager, CommandManager); serviceManager.addSingleton(IConfigurationService, ConfigurationService); serviceManager.addSingleton(IWorkspaceService, WorkspaceService); diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index d266f9805eef..80fd801ba783 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -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 { diff --git a/src/client/jupyter/jupyterNotInstalledNotificationHelper.ts b/src/client/jupyter/jupyterNotInstalledNotificationHelper.ts new file mode 100644 index 000000000000..328fa23ca955 --- /dev/null +++ b/src/client/jupyter/jupyterNotInstalledNotificationHelper.ts @@ -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 { + 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 { + 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); + } +} diff --git a/src/client/jupyter/types.ts b/src/client/jupyter/types.ts index 5eb58c7cf2b2..14d0c868adbf 100644 --- a/src/client/jupyter/types.ts +++ b/src/client/jupyter/types.ts @@ -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 { + 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; +} diff --git a/src/client/telemetry/constants.ts b/src/client/telemetry/constants.ts index c14cbed6d3b3..244a5a42b031 100644 --- a/src/client/telemetry/constants.ts +++ b/src/client/telemetry/constants.ts @@ -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', diff --git a/src/client/telemetry/index.ts b/src/client/telemetry/index.ts index a6aa451997bf..d5b8c714b127 100644 --- a/src/client/telemetry/index.ts +++ b/src/client/telemetry/index.ts @@ -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. @@ -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 }; diff --git a/src/test/common/moduleInstaller.test.ts b/src/test/common/moduleInstaller.test.ts index 1190cfca61c0..6d1b4b504b11 100644 --- a/src/test/common/moduleInstaller.test.ts +++ b/src/test/common/moduleInstaller.test.ts @@ -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); @@ -245,6 +247,10 @@ suite('Module Installer', () => { IJupyterExtensionDependencyManager, JupyterExtensionDependencyManager, ); + ioc.serviceManager.addSingleton( + IJupyterNotInstalledNotificationHelper, + JupyterNotInstalledNotificationHelper, + ); ioc.serviceManager.addSingleton(IBrowserService, BrowserService); ioc.serviceManager.addSingleton(IHttpClient, HttpClient); ioc.serviceManager.addSingleton(IFileDownloader, FileDownloader); diff --git a/src/test/jupyter/jupyterNotInstalledNotificationHelper.unit.test.ts b/src/test/jupyter/jupyterNotInstalledNotificationHelper.unit.test.ts new file mode 100644 index 000000000000..f82b9cb0c8a6 --- /dev/null +++ b/src/test/jupyter/jupyterNotInstalledNotificationHelper.unit.test.ts @@ -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); + }); +});