diff --git a/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx b/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx index 64268ec20..893e1dee4 100644 --- a/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx @@ -79,12 +79,16 @@ export class GalleryCardComponent extends React.Component - {this.props.data.tags?.map((tag, index, array) => ( - - this.onClick(event, () => this.props.onTagClick(tag))}>{tag} - {index === array.length - 1 ? <> : ", "} - - ))} + {this.props.data.tags ? ( + this.props.data.tags.map((tag, index, array) => ( + + this.onClick(event, () => this.props.onTagClick(tag))}>{tag} + {index === array.length - 1 ? <> : ", "} + + )) + ) : ( +
+ )}
{ + private viewCodeOfConductTraced: boolean; private descriptionPara1: string; private descriptionPara2: string; private descriptionPara3: string; private link1: { label: string; url: string }; - private link2: { label: string; url: string }; constructor(props: CodeOfConductComponentProps) { super(props); @@ -27,23 +29,34 @@ export class CodeOfConductComponent extends React.Component { + const startKey = traceStart(Action.NotebooksGalleryAcceptCodeOfConduct); + try { const response = await this.props.junoClient.acceptCodeOfConduct(); if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { throw new Error(`Received HTTP ${response.status} when accepting code of conduct`); } + traceSuccess(Action.NotebooksGalleryAcceptCodeOfConduct, startKey); + this.props.onAcceptCodeOfConduct(response.data); } catch (error) { + traceFailure( + Action.NotebooksGalleryAcceptCodeOfConduct, + { + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey + ); + handleError(error, "CodeOfConductComponent/acceptCodeOfConduct", "Failed to accept code of conduct"); } } @@ -53,6 +66,11 @@ export class CodeOfConductComponent extends React.Component @@ -69,10 +87,6 @@ export class CodeOfConductComponent extends React.Component {this.link1.label} - {" and "} - - {this.link2.label} - @@ -87,7 +101,7 @@ export class CodeOfConductComponent extends React.Component diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx index 5fb919c41..2233ffc3c 100644 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx @@ -9,6 +9,7 @@ import { IPivotProps, IRectangle, Label, + Link, List, Overlay, Pivot, @@ -28,6 +29,8 @@ import Explorer from "../../Explorer"; import { CodeOfConductComponent } from "./CodeOfConductComponent"; import { InfoComponent } from "./InfoComponent/InfoComponent"; import { handleError } from "../../../Common/ErrorHandlingUtils"; +import { trace } from "../../../Shared/Telemetry/TelemetryProcessor"; +import { Action } from "../../../Shared/Telemetry/TelemetryConstants"; export interface GalleryViewerComponentProps { container?: Explorer; @@ -87,6 +90,12 @@ export class GalleryViewerComponent extends React.Component { + if (!this.viewGalleryTraced) { + this.viewGalleryTraced = true; + trace(Action.NotebooksGalleryViewGallery); + } + + switch (this.state.selectedTab) { + case GalleryTab.OfficialSamples: + if (!this.viewOfficialSamplesTraced) { + this.resetViewGalleryTabTracedFlags(); + this.viewOfficialSamplesTraced = true; + trace(Action.NotebooksGalleryViewOfficialSamples); + } + break; + case GalleryTab.PublicGallery: + if (!this.viewPublicGalleryTraced) { + this.resetViewGalleryTabTracedFlags(); + this.viewPublicGalleryTraced = true; + trace(Action.NotebooksGalleryViewPublicGallery); + } + break; + case GalleryTab.Favorites: + if (!this.viewFavoritesTraced) { + this.resetViewGalleryTabTracedFlags(); + this.viewFavoritesTraced = true; + trace(Action.NotebooksGalleryViewFavorites); + } + break; + case GalleryTab.Published: + if (!this.viewPublishedNotebooksTraced) { + this.resetViewGalleryTabTracedFlags(); + this.viewPublishedNotebooksTraced = true; + trace(Action.NotebooksGalleryViewPublishedNotebooks); + } + break; + default: + throw new Error(`Unknown selected tab ${this.state.selectedTab}`); + } + }; + + private resetViewGalleryTabTracedFlags = (): void => { + this.viewOfficialSamplesTraced = false; + this.viewPublicGalleryTraced = false; + this.viewFavoritesTraced = false; + this.viewPublishedNotebooksTraced = false; + }; + private isEmptyData = (data: IGalleryItem[]): boolean => { return !data || data.length === 0; }; - private createEmptyTabContent = (iconName: string, line1: string, line2: string): JSX.Element => { + private createEmptyTabContent = (iconName: string, line1: JSX.Element, line2: JSX.Element): JSX.Element => { return ( @@ -223,8 +281,12 @@ export class GalleryViewerComponent extends React.ComponentYou don't have any favorites yet, + <> + Favorite any notebook from the{" "} + this.setState({ selectedTab: GalleryTab.OfficialSamples })}>official samples{" "} + or this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery + ) : this.createSearchBarHeader(this.createCardsTabContent(data)), }; @@ -236,8 +298,11 @@ export class GalleryViewerComponent extends React.Component + You have not published anything to the{" "} + this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery yet + , + <>Publish your notebooks to share your work with other users ) : this.createPublishedNotebooksTabContent(data), }; @@ -250,7 +315,7 @@ export class GalleryViewerComponent extends React.Component 0 && this.createPublishedNotebooksSectionContent( undefined, - "You have successfully published the following notebook(s) to public gallery and shared with other Azure Cosmos DB users.", + "You have successfully published and shared the following notebook(s) to the public gallery.", this.createCardsTabContent(published) )} {underReview?.length > 0 && @@ -278,8 +343,10 @@ export class GalleryViewerComponent extends React.Component { return ( - {title && {title}} - {description && {description}} + {title && ( + {title} + )} + {description && {description}} {content} ); @@ -344,7 +411,7 @@ export class GalleryViewerComponent extends React.Component + diff --git a/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap index 3362852dd..5b1f48bb1 100644 --- a/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap +++ b/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap @@ -17,35 +17,28 @@ exports[`CodeOfConductComponent renders 1`] = ` } } > - Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement + Azure Cosmos DB Notebook Gallery - Code of Conduct - Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB. + The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB. - In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the + In order to view and publish your samples to the gallery, you must accept the - code of conduct - - and - - privacy statement + code of conduct. { + const startKey = traceStart(Action.NotebooksGalleryViewNotebook, { + notebookUrl: this.props.notebookUrl, + notebookId: this.props.galleryItem?.id, + }); + try { const response = await fetch(this.props.notebookUrl); if (!response.ok) { @@ -84,6 +91,12 @@ export class NotebookViewerComponent throw new Error(`Received HTTP ${response.status} while fetching ${this.props.notebookUrl}`); } + traceSuccess( + Action.NotebooksGalleryViewNotebook, + { notebookUrl: this.props.notebookUrl, notebookId: this.props.galleryItem?.id }, + startKey + ); + const notebook: Notebook = await response.json(); this.removeNotebookViewerLink(notebook, this.props.galleryItem?.newCellId); this.notebookComponentBootstrapper.setContent("json", notebook); @@ -98,6 +111,17 @@ export class NotebookViewerComponent SessionStorageUtility.setEntry(this.props.galleryItem?.id, "true"); } } catch (error) { + traceFailure( + Action.NotebooksGalleryViewNotebook, + { + notebookUrl: this.props.notebookUrl, + notebookId: this.props.galleryItem?.id, + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey + ); + this.setState({ showProgressBar: false }); handleError(error, "NotebookViewerComponent/loadNotebookContent", "Failed to load notebook content"); } diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index a902238da..d2b635849 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -2253,7 +2253,7 @@ export default class Explorer { return Promise.resolve(false); } - public async publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): Promise { + public async publishNotebook(name: string, content: string | unknown, parentDomElement?: HTMLElement): Promise { if (this.notebookManager) { await this.notebookManager.openPublishNotebookPane( name, diff --git a/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx index 79f83e5a8..c7de6e630 100644 --- a/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx @@ -10,8 +10,10 @@ import { ImmutableNotebook } from "@nteract/commutable/src"; import { toJS } from "@nteract/commutable"; import { CodeOfConductComponent } from "../Controls/NotebookGallery/CodeOfConductComponent"; import { HttpStatusCodes } from "../../Common/Constants"; -import { handleError, getErrorMessage } from "../../Common/ErrorHandlingUtils"; +import { handleError, getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; import { GalleryTab } from "../Controls/NotebookGallery/GalleryViewerComponent"; +import { traceFailure, traceStart, traceSuccess } from "../../Shared/Telemetry/TelemetryProcessor"; +import { Action } from "../../Shared/Telemetry/TelemetryConstants"; export class PublishNotebookPaneAdapter implements ReactAdapter { parameters: ko.Observable; @@ -141,11 +143,18 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { this.isExecuting = true; this.triggerRender(); + let startKey: number; + try { if (!this.name || !this.description || !this.author) { throw new Error("Name, description, and author are required"); } + startKey = traceStart(Action.NotebooksGalleryPublish, { + databaseAccountName: this.container.databaseAccount()?.name, + defaultExperience: this.container.defaultExperience(), + }); + const response = await this.junoClient.publishNotebook( this.name, this.description, @@ -158,7 +167,10 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { const data = response.data; if (data) { + let isPublishPending = false; + if (data.pendingScanJobIds?.length > 0) { + isPublishPending = true; NotificationConsoleUtils.logConsoleInfo( `Content of ${this.name} is currently being scanned for illegal content. It will not be available in the public gallery until the review is complete (may take a few days).` ); @@ -166,8 +178,30 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { NotificationConsoleUtils.logConsoleInfo(`Published ${this.name} to gallery`); this.container.openGallery(GalleryTab.Published); } + + traceSuccess( + Action.NotebooksGalleryPublish, + { + databaseAccountName: this.container.databaseAccount()?.name, + defaultExperience: this.container.defaultExperience(), + notebookId: data.id, + isPublishPending, + }, + startKey + ); } } catch (error) { + traceFailure( + Action.NotebooksGalleryPublish, + { + databaseAccountName: this.container.databaseAccount()?.name, + defaultExperience: this.container.defaultExperience(), + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey + ); + const errorMessage = getErrorMessage(error); this.formError = `Failed to publish ${this.name} to gallery`; this.formErrorDetail = `${errorMessage}`; diff --git a/src/Explorer/Panes/PublishNotebookPaneComponent.tsx b/src/Explorer/Panes/PublishNotebookPaneComponent.tsx index 40d97fdd4..5c8697ec7 100644 --- a/src/Explorer/Panes/PublishNotebookPaneComponent.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneComponent.tsx @@ -14,7 +14,7 @@ export interface PublishNotebookPaneProps { notebookAuthor: string; notebookCreatedDate: string; notebookObject: ImmutableNotebook; - notebookParentDomElement: HTMLElement; + notebookParentDomElement?: HTMLElement; onChangeName: (newValue: string) => void; onChangeDescription: (newValue: string) => void; onChangeTags: (newValue: string) => void; @@ -110,7 +110,7 @@ export class PublishNotebookPaneComponent extends React.Component ({ text: value, key: value })), + options: options.map((value: string) => ({ text: value, key: value })), onChange: async (event, options) => { this.props.clearFormError(); if (options.text === ImageTypes.TakeScreenshot) { @@ -301,9 +305,9 @@ export class PublishNotebookPaneComponent extends React.Component - This notebook has your data. Please make sure you delete any sensitive data/output before publishing. + When published, this notebook will appear in the Azure Cosmos DB notebooks public gallery. Make sure you have removed any sensitive data or output before publishing. @@ -65,14 +65,6 @@ exports[`PublishNotebookPaneComponent renders 1`] = ` "key": "Custom Image", "text": "Custom Image", }, - Object { - "key": "Take Screenshot", - "text": "Take Screenshot", - }, - Object { - "key": "Use First Display Output", - "text": "Use First Display Output", - }, ] } /> @@ -112,9 +104,8 @@ exports[`PublishNotebookPaneComponent renders 1`] = ` "views": 0, } } - isFavorite={false} - showDelete={true} - showDownload={true} + showDelete={false} + showDownload={false} /> diff --git a/src/Explorer/Tree/ResourceTreeAdapter.tsx b/src/Explorer/Tree/ResourceTreeAdapter.tsx index 6ccbe5a2f..fd5597043 100644 --- a/src/Explorer/Tree/ResourceTreeAdapter.tsx +++ b/src/Explorer/Tree/ResourceTreeAdapter.tsx @@ -716,6 +716,19 @@ export class ResourceTreeAdapter implements ReactAdapter { }, ]; + if (item.type === NotebookContentItemType.Notebook) { + items.push({ + label: "Publish to gallery", + iconSrc: undefined, // TODO + onClick: async () => { + const content = await this.container.readFile(item); + if (content) { + await this.container.publishNotebook(item.name, content); + } + }, + }); + } + // "Copy to ..." isn't needed if github locations are not available if (!this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) { items = items.filter((item) => item.label !== "Copy to ..."); diff --git a/src/Shared/Telemetry/TelemetryConstants.ts b/src/Shared/Telemetry/TelemetryConstants.ts index 061b8052d..208ac9518 100644 --- a/src/Shared/Telemetry/TelemetryConstants.ts +++ b/src/Shared/Telemetry/TelemetryConstants.ts @@ -92,6 +92,23 @@ export enum Action { SettingsV2Updated, SettingsV2Discarded, MongoIndexUpdated, + NotebooksGalleryPublish, + NotebooksGalleryReportAbuse, + NotebooksGalleryClickReportAbuse, + NotebooksGalleryViewCodeOfConduct, + NotebooksGalleryAcceptCodeOfConduct, + NotebooksGalleryFavorite, + NotebooksGalleryUnfavorite, + NotebooksGalleryClickDelete, + NotebooksGalleryDelete, + NotebooksGalleryClickDownload, + NotebooksGalleryDownload, + NotebooksGalleryViewNotebook, + NotebooksGalleryViewGallery, + NotebooksGalleryViewOfficialSamples, + NotebooksGalleryViewPublicGallery, + NotebooksGalleryViewFavorites, + NotebooksGalleryViewPublishedNotebooks, } export const ActionModifiers = { diff --git a/src/Utils/GalleryUtils.ts b/src/Utils/GalleryUtils.ts index 70fbd8a29..6602f9e82 100644 --- a/src/Utils/GalleryUtils.ts +++ b/src/Utils/GalleryUtils.ts @@ -9,8 +9,10 @@ import { import Explorer from "../Explorer/Explorer"; import { IChoiceGroupOption, IChoiceGroupProps, IProgressIndicatorProps } from "office-ui-fabric-react"; import { TextFieldProps } from "../Explorer/Controls/DialogReactComponent/DialogComponent"; -import { handleError } from "../Common/ErrorHandlingUtils"; +import { getErrorMessage, getErrorStack, handleError } from "../Common/ErrorHandlingUtils"; import { HttpStatusCodes } from "../Common/Constants"; +import { trace, traceFailure, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor"; +import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants"; const defaultSelectedAbuseCategory = "Other"; const abuseCategories: IChoiceGroupOption[] = [ @@ -109,6 +111,8 @@ export function reportAbuse( dialogHost: DialogHost, onComplete: (success: boolean) => void ): void { + trace(Action.NotebooksGalleryClickReportAbuse, ActionModifiers.Mark, { notebookId: data.id }); + const notebookId = data.id; let abuseCategory = defaultSelectedAbuseCategory; let additionalDetails: string; @@ -131,6 +135,8 @@ export function reportAbuse( true ); + const startKey = traceStart(Action.NotebooksGalleryReportAbuse, { notebookId: data.id }); + try { const response = await junoClient.reportAbuse(notebookId, abuseCategory, additionalDetails); if (response.status !== HttpStatusCodes.Accepted) { @@ -147,8 +153,20 @@ export function reportAbuse( } ); + traceSuccess(Action.NotebooksGalleryReportAbuse, { notebookId: data.id, abuseCategory }, startKey); + onComplete(response.data); } catch (error) { + traceFailure( + Action.NotebooksGalleryReportAbuse, + { + notebookId: data.id, + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey + ); + handleError( error, "GalleryUtils/reportAbuse", @@ -195,6 +213,12 @@ export function downloadItem( data: IGalleryItem, onComplete: (item: IGalleryItem) => void ): void { + trace(Action.NotebooksGalleryClickDownload, ActionModifiers.Mark, { + notebookId: data.id, + downloadCount: data.downloads, + isSample: data.isSample, + }); + const name = data.name; container.showOkCancelModalDialog( "Download to My Notebooks", @@ -206,6 +230,11 @@ export function downloadItem( `Downloading ${name} to My Notebooks` ); + const startKey = traceStart(Action.NotebooksGalleryDownload, { + notebookId: data.id, + downloadCount: data.downloads, + }); + try { const response = await junoClient.getNotebookContent(data.id); if (!response.data) { @@ -220,9 +249,25 @@ export function downloadItem( const increaseDownloadResponse = await junoClient.increaseNotebookDownloadCount(data.id); if (increaseDownloadResponse.data) { + traceSuccess( + Action.NotebooksGalleryDownload, + { notebookId: data.id, downloadCount: increaseDownloadResponse.data.downloads }, + startKey + ); onComplete(increaseDownloadResponse.data); } } catch (error) { + traceFailure( + Action.NotebooksGalleryDownload, + { + notebookId: data.id, + downloadCount: data.downloads, + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey + ); + handleError(error, "GalleryUtils/downloadItem", `Failed to download ${data.name}`); } @@ -240,14 +285,36 @@ export async function favoriteItem( onComplete: (item: IGalleryItem) => void ): Promise { if (container) { + const startKey = traceStart(Action.NotebooksGalleryFavorite, { + notebookId: data.id, + favoriteCount: data.favorites, + }); + try { const response = await junoClient.favoriteNotebook(data.id); if (!response.data) { throw new Error(`Received HTTP ${response.status} when favoriting ${data.name}`); } + traceSuccess( + Action.NotebooksGalleryFavorite, + { notebookId: data.id, favoriteCount: response.data.favorites }, + startKey + ); + onComplete(response.data); } catch (error) { + traceFailure( + Action.NotebooksGalleryFavorite, + { + notebookId: data.id, + favoriteCount: data.favorites, + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey + ); + handleError(error, "GalleryUtils/favoriteItem", `Failed to favorite ${data.name}`); } } @@ -260,14 +327,36 @@ export async function unfavoriteItem( onComplete: (item: IGalleryItem) => void ): Promise { if (container) { + const startKey = traceStart(Action.NotebooksGalleryUnfavorite, { + notebookId: data.id, + favoriteCount: data.favorites, + }); + try { const response = await junoClient.unfavoriteNotebook(data.id); if (!response.data) { throw new Error(`Received HTTP ${response.status} when unfavoriting ${data.name}`); } + traceSuccess( + Action.NotebooksGalleryUnfavorite, + { notebookId: data.id, favoriteCount: response.data.favorites }, + startKey + ); + onComplete(response.data); } catch (error) { + traceFailure( + Action.NotebooksGalleryUnfavorite, + { + notebookId: data.id, + favoriteCount: data.favorites, + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey + ); + handleError(error, "GalleryUtils/unfavoriteItem", `Failed to unfavorite ${data.name}`); } } @@ -280,6 +369,8 @@ export function deleteItem( onComplete: (item: IGalleryItem) => void ): void { if (container) { + trace(Action.NotebooksGalleryClickDelete, ActionModifiers.Mark, { notebookId: data.id }); + container.showOkCancelModalDialog( "Remove published notebook", `Would you like to remove ${data.name} from the gallery?`, @@ -291,15 +382,25 @@ export function deleteItem( `Removing ${name} from gallery` ); + const startKey = traceStart(Action.NotebooksGalleryDelete, { notebookId: data.id }); + try { const response = await junoClient.deleteNotebook(data.id); if (!response.data) { throw new Error(`Received HTTP ${response.status} while removing ${name}`); } + traceSuccess(Action.NotebooksGalleryDelete, { notebookId: data.id }, startKey); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully removed ${name} from gallery`); onComplete(response.data); } catch (error) { + traceFailure( + Action.NotebooksGalleryDelete, + { notebookId: data.id, error: getErrorMessage(error), errorStack: getErrorStack(error) }, + startKey + ); + handleError(error, "GalleryUtils/deleteItem", `Failed to remove ${name} from gallery`); }
Name