diff --git a/src/Explorer/Controls/DialogReactComponent/DialogComponent.tsx b/src/Explorer/Controls/DialogReactComponent/DialogComponent.tsx index 5ae22fb75..70d9a0989 100644 --- a/src/Explorer/Controls/DialogReactComponent/DialogComponent.tsx +++ b/src/Explorer/Controls/DialogReactComponent/DialogComponent.tsx @@ -3,7 +3,7 @@ import { Dialog, DialogType, DialogFooter, IDialogProps } from "office-ui-fabric import { IButtonProps, PrimaryButton, DefaultButton } from "office-ui-fabric-react/lib/Button"; import { ITextFieldProps, TextField } from "office-ui-fabric-react/lib/TextField"; import { Link } from "office-ui-fabric-react/lib/Link"; -import { FontIcon } from "office-ui-fabric-react"; +import { ChoiceGroup, FontIcon, IChoiceGroupProps } from "office-ui-fabric-react"; export interface TextFieldProps extends ITextFieldProps { label: string; @@ -24,6 +24,7 @@ export interface DialogProps { subText: string; isModal: boolean; visible: boolean; + choiceGroupProps?: IChoiceGroupProps; textFieldProps?: TextFieldProps; linkProps?: LinkProps; primaryButtonText: string; @@ -65,6 +66,7 @@ export class DialogComponent extends React.Component { minWidth: DIALOG_MIN_WIDTH, maxWidth: DIALOG_MAX_WIDTH }; + const choiceGroupProps: IChoiceGroupProps = this.props.choiceGroupProps; const textFieldProps: ITextFieldProps = this.props.textFieldProps; const linkProps: LinkProps = this.props.linkProps; const primaryButtonProps: IButtonProps = { @@ -82,6 +84,7 @@ export class DialogComponent extends React.Component { return ( + {choiceGroupProps && } {textFieldProps && } {linkProps && ( diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx index 5922aa017..2ba19a736 100644 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx @@ -227,7 +227,7 @@ export class GalleryViewerComponent extends React.Component - {this.props.container?.isGalleryPublishEnabled() && ( + {(!this.props.container || this.props.container.isGalleryPublishEnabled()) && ( diff --git a/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx b/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx index f14b7c350..1d10a49a7 100644 --- a/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx @@ -3,10 +3,14 @@ import { Icon, Label, Stack, HoverCard, HoverCardType, Link } from "office-ui-fa import { CodeOfConductEndpoints } from "../../../../Common/Constants"; import "./InfoComponent.less"; -export class InfoComponent extends React.Component { - private getInfoPanel = (iconName: string, labelText: string, url: string): JSX.Element => { +export interface InfoComponentProps { + onReportAbuseClick?: () => void; +} + +export class InfoComponent extends React.Component { + private getInfoPanel = (iconName: string, labelText: string, url?: string, onClick?: () => void): JSX.Element => { return ( - +
@@ -25,6 +29,11 @@ export class InfoComponent extends React.Component { {this.getInfoPanel("KnowledgeArticle", "Microsoft Terms of Use", CodeOfConductEndpoints.termsOfUse)} + {this.props.onReportAbuseClick !== undefined && ( + + {this.getInfoPanel("ReportHacked", "Report Abuse", undefined, () => this.props.onReportAbuseClick())} + + )} ); }; diff --git a/src/Explorer/Controls/NotebookGallery/__snapshots__/GalleryViewerComponent.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/__snapshots__/GalleryViewerComponent.test.tsx.snap index c97d24c79..969fa1676 100644 --- a/src/Explorer/Controls/NotebookGallery/__snapshots__/GalleryViewerComponent.test.tsx.snap +++ b/src/Explorer/Controls/NotebookGallery/__snapshots__/GalleryViewerComponent.test.tsx.snap @@ -77,6 +77,9 @@ exports[`GalleryViewerComponent renders 1`] = ` selectedKey={0} /> + + + diff --git a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx index e81f2cf42..2de6e4ded 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx +++ b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx @@ -25,7 +25,8 @@ describe("NotebookMetadataComponent", () => { onTagClick: undefined, onDownloadClick: undefined, onFavoriteClick: undefined, - onUnfavoriteClick: undefined + onUnfavoriteClick: undefined, + onReportAbuseClick: undefined }; const wrapper = shallow(); @@ -54,7 +55,8 @@ describe("NotebookMetadataComponent", () => { onTagClick: undefined, onDownloadClick: undefined, onFavoriteClick: undefined, - onUnfavoriteClick: undefined + onUnfavoriteClick: undefined, + onReportAbuseClick: undefined }; const wrapper = shallow(); diff --git a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx index 9bbf2c0e6..d41118f0e 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx +++ b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx @@ -17,6 +17,7 @@ import { IGalleryItem } from "../../../Juno/JunoClient"; import { FileSystemUtil } from "../../Notebook/FileSystemUtil"; import "./NotebookViewerComponent.less"; import CosmosDBLogo from "../../../../images/CosmosDB-logo.svg"; +import { InfoComponent } from "../NotebookGallery/InfoComponent/InfoComponent"; export interface NotebookMetadataComponentProps { data: IGalleryItem; @@ -26,6 +27,7 @@ export interface NotebookMetadataComponentProps { onFavoriteClick: () => void; onUnfavoriteClick: () => void; onDownloadClick: () => void; + onReportAbuseClick: () => void; } export class NotebookMetadataComponent extends React.Component { @@ -41,24 +43,39 @@ export class NotebookMetadataComponent extends React.Component - - {FileSystemUtil.stripExtension(this.props.data.name, "ipynb")} - - - {this.props.isFavorite !== undefined && ( - <> - - {this.props.data.favorites} likes - - )} - + + + {FileSystemUtil.stripExtension(this.props.data.name, "ipynb")} + + + + + + {this.props.isFavorite !== undefined && ( + <> + + {this.props.data.favorites} likes + + )} + + {this.props.downloadButtonText && ( - + + + )} + + + <> + + + + + diff --git a/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx b/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx index afc4eb076..43e305583 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx @@ -3,11 +3,10 @@ */ import { Notebook } from "@nteract/commutable"; import { createContentRef } from "@nteract/core"; -import { Icon, Link, ProgressIndicator } from "office-ui-fabric-react"; +import { IChoiceGroupProps, Icon, Link, ProgressIndicator } from "office-ui-fabric-react"; import * as React from "react"; import { contents } from "rx-jupyter"; import * as Logger from "../../../Common/Logger"; -import * as ViewModels from "../../../Contracts/ViewModels"; import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient"; import * as GalleryUtils from "../../../Utils/GalleryUtils"; import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils"; @@ -15,12 +14,13 @@ import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationCon import { NotebookClientV2 } from "../../Notebook/NotebookClientV2"; import { NotebookComponentBootstrapper } from "../../Notebook/NotebookComponent/NotebookComponentBootstrapper"; import NotebookReadOnlyRenderer from "../../Notebook/NotebookRenderer/NotebookReadOnlyRenderer"; -import { DialogComponent, DialogProps } from "../DialogReactComponent/DialogComponent"; +import { DialogComponent, DialogProps, TextFieldProps } from "../DialogReactComponent/DialogComponent"; import { NotebookMetadataComponent } from "./NotebookMetadataComponent"; import "./NotebookViewerComponent.less"; import Explorer from "../../Explorer"; import { NotebookV4 } from "@nteract/commutable/lib/v4"; import { SessionStorageUtility } from "../../../Shared/StorageUtility"; +import { DialogHost } from "../../../Utils/GalleryUtils"; export interface NotebookViewerComponentProps { container?: Explorer; @@ -43,10 +43,8 @@ interface NotebookViewerComponentState { showProgressBar: boolean; } -export class NotebookViewerComponent extends React.Component< - NotebookViewerComponentProps, - NotebookViewerComponentState -> { +export class NotebookViewerComponent extends React.Component + implements DialogHost { private clientManager: NotebookClientV2; private notebookComponentBootstrapper: NotebookComponentBootstrapper; @@ -140,6 +138,7 @@ export class NotebookViewerComponent extends React.Component< onFavoriteClick={this.favoriteItem} onUnfavoriteClick={this.unfavoriteItem} onDownloadClick={this.downloadItem} + onReportAbuseClick={this.state.galleryItem.isSample ? undefined : this.reportAbuse} />
) : ( @@ -179,6 +178,39 @@ export class NotebookViewerComponent extends React.Component< }; } + // DialogHost + showOkCancelModalDialog( + title: string, + msg: string, + okLabel: string, + onOk: () => void, + cancelLabel: string, + onCancel: () => void, + choiceGroupProps?: IChoiceGroupProps, + textFieldProps?: TextFieldProps + ): void { + this.setState({ + dialogProps: { + isModal: true, + visible: true, + title, + subText: msg, + primaryButtonText: okLabel, + secondaryButtonText: cancelLabel, + onPrimaryButtonClick: () => { + this.setState({ dialogProps: undefined }); + onOk && onOk(); + }, + onSecondaryButtonClick: () => { + this.setState({ dialogProps: undefined }); + onCancel && onCancel(); + }, + choiceGroupProps, + textFieldProps + } + }); + } + private favoriteItem = async (): Promise => { GalleryUtils.favoriteItem(this.props.container, this.props.junoClient, this.state.galleryItem, item => this.setState({ galleryItem: item, isFavorite: true }) @@ -196,4 +228,8 @@ export class NotebookViewerComponent extends React.Component< this.setState({ galleryItem: item }) ); }; + + private reportAbuse = (): void => { + GalleryUtils.reportAbuse(this.props.junoClient, this.state.galleryItem, this, () => {}); + }; } diff --git a/src/Explorer/Controls/NotebookViewer/__snapshots__/NotebookMetadataComponent.test.tsx.snap b/src/Explorer/Controls/NotebookViewer/__snapshots__/NotebookMetadataComponent.test.tsx.snap index 6926f3ac4..dd61088fe 100644 --- a/src/Explorer/Controls/NotebookViewer/__snapshots__/NotebookMetadataComponent.test.tsx.snap +++ b/src/Explorer/Controls/NotebookViewer/__snapshots__/NotebookMetadataComponent.test.tsx.snap @@ -17,26 +17,38 @@ exports[`NotebookMetadataComponent renders liked notebook 1`] = ` } verticalAlign="center" > - - name - - - + + name + + + + + + 0 + likes + + + + - 0 - likes - - + + + + - - name - - - + + name + + + + + + 0 + likes + + + + - 0 - likes - - + + + + void, - cancelLabel: string, - onCancel: () => void - ): void { - this._dialogProps({ - isModal: true, - visible: true, - title, - subText: msg, - primaryButtonText: okLabel, - secondaryButtonText: cancelLabel, - onPrimaryButtonClick: () => { - this._closeModalDialog(); - onOk && onOk(); - }, - onSecondaryButtonClick: () => { - this._closeModalDialog(); - onCancel && onCancel(); - } - }); - } - - public showOkCancelTextFieldModalDialog( title: string, msg: string, okLabel: string, onOk: () => void, cancelLabel: string, onCancel: () => void, - textFieldProps: TextFieldProps, + choiceGroupProps?: IChoiceGroupProps, + textFieldProps?: TextFieldProps, isPrimaryButtonDisabled?: boolean ): void { - let textFieldValue: string = null; this._dialogProps({ isModal: true, visible: true, @@ -2436,8 +2411,9 @@ export default class Explorer { this._closeModalDialog(); onCancel && onCancel(); }, - primaryButtonDisabled: isPrimaryButtonDisabled, - textFieldProps + choiceGroupProps, + textFieldProps, + primaryButtonDisabled: isPrimaryButtonDisabled }); } diff --git a/src/Explorer/Notebook/NotebookManager.ts b/src/Explorer/Notebook/NotebookManager.ts index 8afab432c..2fe8748d0 100644 --- a/src/Explorer/Notebook/NotebookManager.ts +++ b/src/Explorer/Notebook/NotebookManager.ts @@ -166,7 +166,7 @@ export default class NotebookManager { private promptForCommitMsg = (title: string, primaryButtonLabel: string) => { return new Promise((resolve, reject) => { let commitMsg = "Committed from Azure Cosmos DB Notebooks"; - this.params.container.showOkCancelTextFieldModalDialog( + this.params.container.showOkCancelModalDialog( title || "Commit", undefined, primaryButtonLabel || "Commit", @@ -181,6 +181,7 @@ export default class NotebookManager { }, "Cancel", () => reject(new Error("Commit dialog canceled")), + undefined, { label: "Commit message", autoAdjustHeight: true, diff --git a/src/Juno/JunoClient.test.ts b/src/Juno/JunoClient.test.ts index 99758b356..9f951ead0 100644 --- a/src/Juno/JunoClient.test.ts +++ b/src/Juno/JunoClient.test.ts @@ -1,6 +1,5 @@ import ko from "knockout"; -import { HttpStatusCodes } from "../Common/Constants"; -import * as ViewModels from "../Contracts/ViewModels"; +import { HttpHeaders, HttpStatusCodes } from "../Common/Constants"; import { IPinnedRepo, JunoClient, IGalleryItem } from "./JunoClient"; import { configContext } from "../ConfigContext"; import { getAuthorizationHeader } from "../Utils/AuthorizationUtils"; @@ -237,7 +236,7 @@ describe("Gallery", () => { method: "PATCH", headers: { [authorizationHeader.header]: authorizationHeader.token, - "content-type": "application/json" + [HttpHeaders.contentType]: "application/json" } } ); @@ -260,7 +259,7 @@ describe("Gallery", () => { method: "PATCH", headers: { [authorizationHeader.header]: authorizationHeader.token, - "content-type": "application/json" + [HttpHeaders.contentType]: "application/json" } } ); @@ -281,7 +280,7 @@ describe("Gallery", () => { method: "PATCH", headers: { [authorizationHeader.header]: authorizationHeader.token, - "content-type": "application/json" + [HttpHeaders.contentType]: "application/json" } }); }); @@ -299,7 +298,7 @@ describe("Gallery", () => { expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/favorites`, { headers: { [authorizationHeader.header]: authorizationHeader.token, - "content-type": "application/json" + [HttpHeaders.contentType]: "application/json" } }); }); @@ -317,7 +316,7 @@ describe("Gallery", () => { expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/published`, { headers: { [authorizationHeader.header]: authorizationHeader.token, - "content-type": "application/json" + [HttpHeaders.contentType]: "application/json" } }); }); @@ -337,7 +336,7 @@ describe("Gallery", () => { method: "DELETE", headers: { [authorizationHeader.header]: authorizationHeader.token, - "content-type": "application/json" + [HttpHeaders.contentType]: "application/json" } }); }); @@ -364,7 +363,7 @@ describe("Gallery", () => { method: "PUT", headers: { [authorizationHeader.header]: authorizationHeader.token, - "content-type": "application/json" + [HttpHeaders.contentType]: "application/json" }, body: JSON.stringify({ name, diff --git a/src/Juno/JunoClient.ts b/src/Juno/JunoClient.ts index fbc59e85d..987740e52 100644 --- a/src/Juno/JunoClient.ts +++ b/src/Juno/JunoClient.ts @@ -1,5 +1,5 @@ import ko from "knockout"; -import { HttpStatusCodes } from "../Common/Constants"; +import { HttpHeaders, HttpStatusCodes } from "../Common/Constants"; import { configContext } from "../ConfigContext"; import * as DataModels from "../Contracts/DataModels"; import { AuthorizeAccessComponent } from "../Explorer/Controls/GitHub/AuthorizeAccessComponent"; @@ -404,6 +404,30 @@ export class JunoClient { return `${this.getNotebooksUrl()}/gallery/${id}`; } + public async reportAbuse(notebookId: string, abuseCategory: string, notes: string): Promise> { + const response = await window.fetch(`${this.getNotebooksUrl()}/avert/reportAbuse`, { + method: "POST", + body: JSON.stringify({ + notebookId, + abuseCategory, + notes + }), + headers: { + [HttpHeaders.contentType]: "application/json" + } + }); + + let data: boolean; + if (response.status === HttpStatusCodes.OK) { + data = await response.json(); + } + + return { + status: response.status, + data + }; + } + private async getNotebooks(input: RequestInfo, init?: RequestInit): Promise> { const response = await window.fetch(input, init); @@ -430,7 +454,7 @@ export class JunoClient { const authorizationHeader = getAuthorizationHeader(); return { [authorizationHeader.header]: authorizationHeader.token, - "content-type": "application/json" + [HttpHeaders.contentType]: "application/json" }; } diff --git a/src/Utils/GalleryUtils.ts b/src/Utils/GalleryUtils.ts index 5767b793a..31b2d2a99 100644 --- a/src/Utils/GalleryUtils.ts +++ b/src/Utils/GalleryUtils.ts @@ -8,6 +8,52 @@ import { GalleryViewerComponent } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent"; import Explorer from "../Explorer/Explorer"; +import { IChoiceGroupOption, IChoiceGroupProps } from "office-ui-fabric-react"; +import { TextFieldProps } from "../Explorer/Controls/DialogReactComponent/DialogComponent"; + +const defaultSelectedAbuseCategory = "Other"; +const abuseCategories: IChoiceGroupOption[] = [ + { + key: "ChildEndangermentExploitation", + text: "Child endangerment or exploitation" + }, + { + key: "ContentInfringement", + text: "Content infringement" + }, + { + key: "OffensiveContent", + text: "Offensive content" + }, + { + key: "Terrorism", + text: "Terrorism" + }, + { + key: "ThreatsCyberbullyingHarassment", + text: "Threats, cyber bullying or harassment" + }, + { + key: "VirusSpywareMalware", + text: "Virus, spyware or malware" + }, + { + key: "Fraud", + text: "Fraud" + }, + { + key: "HateSpeech", + text: "Hate speech" + }, + { + key: "ImminentHarmToPersonsOrProperty", + text: "Imminent harm to persons or property" + }, + { + key: "Other", + text: "Other" + } +]; export enum NotebookViewerParams { NotebookUrl = "notebookUrl", @@ -33,6 +79,78 @@ export interface GalleryViewerProps { searchText: string; } +export interface DialogHost { + showOkCancelModalDialog( + title: string, + msg: string, + okLabel: string, + onOk: () => void, + cancelLabel: string, + onCancel: () => void, + choiceGroupProps?: IChoiceGroupProps, + textFieldProps?: TextFieldProps + ): void; +} + +export function reportAbuse( + junoClient: JunoClient, + data: IGalleryItem, + dialogHost: DialogHost, + onComplete: (success: boolean) => void +): void { + const notebookId = data.id; + let abuseCategory = defaultSelectedAbuseCategory; + let additionalDetails: string; + + dialogHost.showOkCancelModalDialog( + "Report Abuse", + undefined, + "Report Abuse", + async () => { + const clearSubmitReportNotification = NotificationConsoleUtils.logConsoleProgress( + `Submitting your report on ${data.name} violating code of conduct` + ); + + try { + const response = await junoClient.reportAbuse(notebookId, abuseCategory, additionalDetails); + if (!response.data) { + throw new Error(`Received HTTP ${response.status} when submitting report for ${data.name}`); + } + + NotificationConsoleUtils.logConsoleInfo( + `Your report on ${data.name} has been submitted. Thank you for reporting the violation.` + ); + onComplete(response.data); + } catch (error) { + const message = `Failed to submit report on ${data.name} violating code of conduct: ${error}`; + Logger.logError(message, "GalleryUtils/reportAbuse"); + NotificationConsoleUtils.logConsoleInfo(message); + } + + clearSubmitReportNotification(); + }, + "Cancel", + undefined, + { + label: "How does this content violate the code of conduct?", + options: abuseCategories, + defaultSelectedKey: defaultSelectedAbuseCategory, + onChange: (_event?: React.FormEvent, option?: IChoiceGroupOption) => { + abuseCategory = option?.key; + } + }, + { + label: "You can also include additional relevant details on the offensive content", + multiline: true, + rows: 3, + autoAdjustHeight: false, + onChange: (_event: React.FormEvent, newValue?: string) => { + additionalDetails = newValue; + } + } + ); +} + export function downloadItem( container: Explorer, junoClient: JunoClient,