Add Report Abuse dialog for public gallery notebooks (#265)

![image](https://user-images.githubusercontent.com/693092/95408825-3975a680-08d5-11eb-812b-80f922ab9fc8.png)
This commit is contained in:
Tanuj Mittal 2020-10-12 16:48:05 -07:00 committed by GitHub
parent daba1c4ed4
commit 3b64d75322
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 317 additions and 105 deletions

View File

@ -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<DialogProps, {}> {
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<DialogProps, {}> {
return (
<Dialog {...dialogProps}>
{choiceGroupProps && <ChoiceGroup {...choiceGroupProps} />}
{textFieldProps && <TextField {...textFieldProps} />}
{linkProps && (
<Link href={linkProps.linkUrl} target="_blank">

View File

@ -227,7 +227,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
<Stack.Item styles={{ root: { minWidth: 200 } }}>
<Dropdown options={this.sortingOptions} selectedKey={this.state.sortBy} onChange={this.onDropdownChange} />
</Stack.Item>
{this.props.container?.isGalleryPublishEnabled() && (
{(!this.props.container || this.props.container.isGalleryPublishEnabled()) && (
<Stack.Item>
<InfoComponent />
</Stack.Item>

View File

@ -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<InfoComponentProps> {
private getInfoPanel = (iconName: string, labelText: string, url?: string, onClick?: () => void): JSX.Element => {
return (
<Link href={url} target="_blank">
<Link href={url} target={url && "_blank"} onClick={onClick}>
<div className="infoPanel">
<Icon iconName={iconName} styles={{ root: { verticalAlign: "middle" } }} />
<Label className="infoLabel">{labelText}</Label>
@ -25,6 +29,11 @@ export class InfoComponent extends React.Component {
<Stack.Item>
{this.getInfoPanel("KnowledgeArticle", "Microsoft Terms of Use", CodeOfConductEndpoints.termsOfUse)}
</Stack.Item>
{this.props.onReportAbuseClick !== undefined && (
<Stack.Item>
{this.getInfoPanel("ReportHacked", "Report Abuse", undefined, () => this.props.onReportAbuseClick())}
</Stack.Item>
)}
</Stack>
);
};

View File

@ -77,6 +77,9 @@ exports[`GalleryViewerComponent renders 1`] = `
selectedKey={0}
/>
</StackItem>
<StackItem>
<InfoComponent />
</StackItem>
</Stack>
</Stack>
</PivotItem>

View File

@ -25,7 +25,8 @@ describe("NotebookMetadataComponent", () => {
onTagClick: undefined,
onDownloadClick: undefined,
onFavoriteClick: undefined,
onUnfavoriteClick: undefined
onUnfavoriteClick: undefined,
onReportAbuseClick: undefined
};
const wrapper = shallow(<NotebookMetadataComponent {...props} />);
@ -54,7 +55,8 @@ describe("NotebookMetadataComponent", () => {
onTagClick: undefined,
onDownloadClick: undefined,
onFavoriteClick: undefined,
onUnfavoriteClick: undefined
onUnfavoriteClick: undefined,
onReportAbuseClick: undefined
};
const wrapper = shallow(<NotebookMetadataComponent {...props} />);

View File

@ -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<NotebookMetadataComponentProps> {
@ -41,24 +43,39 @@ export class NotebookMetadataComponent extends React.Component<NotebookMetadataC
return (
<Stack tokens={{ childrenGap: 10 }}>
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 30 }}>
<Text variant="xxLarge" nowrap>
{FileSystemUtil.stripExtension(this.props.data.name, "ipynb")}
</Text>
<Text>
{this.props.isFavorite !== undefined && (
<>
<IconButton
iconProps={{ iconName: this.props.isFavorite ? "HeartFill" : "Heart" }}
onClick={this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick}
/>
{this.props.data.favorites} likes
</>
)}
</Text>
<Stack.Item>
<Text variant="xxLarge" nowrap>
{FileSystemUtil.stripExtension(this.props.data.name, "ipynb")}
</Text>
</Stack.Item>
<Stack.Item>
<Text>
{this.props.isFavorite !== undefined && (
<>
<IconButton
iconProps={{ iconName: this.props.isFavorite ? "HeartFill" : "Heart" }}
onClick={this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick}
/>
{this.props.data.favorites} likes
</>
)}
</Text>
</Stack.Item>
{this.props.downloadButtonText && (
<PrimaryButton text={this.props.downloadButtonText} onClick={this.props.onDownloadClick} />
<Stack.Item>
<PrimaryButton text={this.props.downloadButtonText} onClick={this.props.onDownloadClick} />
</Stack.Item>
)}
<Stack.Item grow>
<></>
</Stack.Item>
<Stack.Item>
<InfoComponent onReportAbuseClick={this.props.onReportAbuseClick} />
</Stack.Item>
</Stack>
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 10 }}>

View File

@ -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<NotebookViewerComponentProps, NotebookViewerComponentState>
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}
/>
</div>
) : (
@ -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<void> => {
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, () => {});
};
}

View File

@ -17,26 +17,38 @@ exports[`NotebookMetadataComponent renders liked notebook 1`] = `
}
verticalAlign="center"
>
<Text
nowrap={true}
variant="xxLarge"
>
name
</Text>
<Text>
<CustomizedIconButton
iconProps={
Object {
"iconName": "HeartFill",
<StackItem>
<Text
nowrap={true}
variant="xxLarge"
>
name
</Text>
</StackItem>
<StackItem>
<Text>
<CustomizedIconButton
iconProps={
Object {
"iconName": "HeartFill",
}
}
}
/>
0
likes
</Text>
</StackItem>
<StackItem>
<CustomizedPrimaryButton
text="Download"
/>
0
likes
</Text>
<CustomizedPrimaryButton
text="Download"
</StackItem>
<StackItem
grow={true}
/>
<StackItem>
<InfoComponent />
</StackItem>
</Stack>
<Stack
horizontal={true}
@ -117,26 +129,38 @@ exports[`NotebookMetadataComponent renders un-liked notebook 1`] = `
}
verticalAlign="center"
>
<Text
nowrap={true}
variant="xxLarge"
>
name
</Text>
<Text>
<CustomizedIconButton
iconProps={
Object {
"iconName": "Heart",
<StackItem>
<Text
nowrap={true}
variant="xxLarge"
>
name
</Text>
</StackItem>
<StackItem>
<Text>
<CustomizedIconButton
iconProps={
Object {
"iconName": "Heart",
}
}
}
/>
0
likes
</Text>
</StackItem>
<StackItem>
<CustomizedPrimaryButton
text="Download"
/>
0
likes
</Text>
<CustomizedPrimaryButton
text="Download"
</StackItem>
<StackItem
grow={true}
/>
<StackItem>
<InfoComponent />
</StackItem>
</Stack>
<Stack
horizontal={true}

View File

@ -88,6 +88,7 @@ import TabsBase from "./Tabs/TabsBase";
import { CommandButtonComponentProps } from "./Controls/CommandButton/CommandButtonComponent";
import { updateUserContext, userContext } from "../UserContext";
import { stringToBlob } from "../Utils/BlobUtils";
import { IChoiceGroupProps } from "office-ui-fabric-react";
BindingHandlersRegisterer.registerBindingHandlers();
// Hold a reference to ComponentRegisterer to prevent transpiler to ignore import
@ -2385,42 +2386,16 @@ export default class Explorer {
}
public showOkCancelModalDialog(
title: string,
msg: string,
okLabel: string,
onOk: () => 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
});
}

View File

@ -166,7 +166,7 @@ export default class NotebookManager {
private promptForCommitMsg = (title: string, primaryButtonLabel: string) => {
return new Promise<string>((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,

View File

@ -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,

View File

@ -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<IJunoResponse<boolean>> {
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<IJunoResponse<IGalleryItem[]>> {
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"
};
}

View File

@ -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<HTMLElement | HTMLInputElement>, 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<HTMLInputElement | HTMLTextAreaElement>, newValue?: string) => {
additionalDetails = newValue;
}
}
);
}
export function downloadItem(
container: Explorer,
junoClient: JunoClient,