Added support for acknowledging code of conduct for using public Notebook Gallery (#117)

* minro code edits

* Added support for acknowledging code of conduct

- Added CodeOfConduct component that shows links and a checkbox that must be acknwledged before seeing the public galley
- Added verbose message for notebook publish error (when another notebook with the same name exists in the gallery)
- Added a feature flag for enabling code of conduct acknowledgement

* Added Info Component

* minor edit

* fixed failign tests

* publish tab displayed only when code of conduct accepted

* added code of conduct fetch during publish

* fixed bug

* added test and addressed PR comments

* changed line endings

* added comment

* addressed PR comments
This commit is contained in:
Srinath Narayanan 2020-08-11 00:37:05 -07:00 committed by GitHub
parent 3051961093
commit 7a3e54d43e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 633 additions and 114 deletions

View File

@ -7,6 +7,12 @@ export class AuthorizationEndpoints {
public static common: string = "https://login.windows.net/"; public static common: string = "https://login.windows.net/";
} }
export class CodeOfConductEndpoints {
public static privacyStatement: string = "https://aka.ms/ms-privacy-policy";
public static codeOfConduct: string = "https://aka.ms/cosmos-code-of-conduct";
public static termsOfUse: string = "https://aka.ms/ms-terms-of-use";
}
export class BackendEndpoints { export class BackendEndpoints {
public static localhost: string = "https://localhost:12900"; public static localhost: string = "https://localhost:12900";
public static dev: string = "https://ext.documents-dev.windows-int.net"; public static dev: string = "https://ext.documents-dev.windows-int.net";
@ -116,6 +122,7 @@ export class Features {
public static readonly enableTtl = "enablettl"; public static readonly enableTtl = "enablettl";
public static readonly enableNotebooks = "enablenotebooks"; public static readonly enableNotebooks = "enablenotebooks";
public static readonly enableGalleryPublish = "enablegallerypublish"; public static readonly enableGalleryPublish = "enablegallerypublish";
public static readonly enableCodeOfConduct = "enablecodeofconduct";
public static readonly enableLinkInjection = "enablelinkinjection"; public static readonly enableLinkInjection = "enablelinkinjection";
public static readonly enableSpark = "enablespark"; public static readonly enableSpark = "enablespark";
public static readonly livyEndpoint = "livyendpoint"; public static readonly livyEndpoint = "livyendpoint";

View File

@ -1,69 +1,69 @@
import * as DataModels from "../Contracts/DataModels"; import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels"; import * as ViewModels from "../Contracts/ViewModels";
export function replaceKnownError(err: string): string { export function replaceKnownError(err: string): string {
if ( if (
window.dataExplorer.subscriptionType() === ViewModels.SubscriptionType.Internal && window.dataExplorer.subscriptionType() === ViewModels.SubscriptionType.Internal &&
err.indexOf("SharedOffer is Disabled for your account") >= 0 err.indexOf("SharedOffer is Disabled for your account") >= 0
) { ) {
return "Database throughput is not supported for internal subscriptions."; return "Database throughput is not supported for internal subscriptions.";
} else if (err.indexOf("Partition key paths must contain only valid") >= 0) { } else if (err.indexOf("Partition key paths must contain only valid") >= 0) {
return "Partition key paths must contain only valid characters and not contain a trailing slash or wildcard character."; return "Partition key paths must contain only valid characters and not contain a trailing slash or wildcard character.";
} }
return err; return err;
} }
export function parse(err: any): DataModels.ErrorDataModel[] { export function parse(err: any): DataModels.ErrorDataModel[] {
try { try {
return _parse(err); return _parse(err);
} catch (e) { } catch (e) {
return [<DataModels.ErrorDataModel>{ message: JSON.stringify(err) }]; return [<DataModels.ErrorDataModel>{ message: JSON.stringify(err) }];
} }
} }
function _parse(err: any): DataModels.ErrorDataModel[] { function _parse(err: any): DataModels.ErrorDataModel[] {
var normalizedErrors: DataModels.ErrorDataModel[] = []; var normalizedErrors: DataModels.ErrorDataModel[] = [];
if (err.message && !err.code) { if (err.message && !err.code) {
normalizedErrors.push(err); normalizedErrors.push(err);
} else { } else {
const innerErrors: any[] = _getInnerErrors(err.message); const innerErrors: any[] = _getInnerErrors(err.message);
normalizedErrors = innerErrors.map(innerError => normalizedErrors = innerErrors.map(innerError =>
typeof innerError === "string" ? { message: innerError } : innerError typeof innerError === "string" ? { message: innerError } : innerError
); );
} }
return normalizedErrors; return normalizedErrors;
} }
function _getInnerErrors(message: string): any[] { function _getInnerErrors(message: string): any[] {
/* /*
The backend error message has an inner-message which is a stringified object. The backend error message has an inner-message which is a stringified object.
For SQL errors, the "errors" property is an array of SqlErrorDataModel. For SQL errors, the "errors" property is an array of SqlErrorDataModel.
Example: Example:
"Message: {"Errors":["Resource with specified id or name already exists"]}\r\nActivityId: 80005000008d40b6a, Request URI: /apps/19000c000c0a0005/services/mctestdocdbprod-MasterService-0-00066ab9937/partitions/900005f9000e676fb8/replicas/13000000000955p" "Message: {"Errors":["Resource with specified id or name already exists"]}\r\nActivityId: 80005000008d40b6a, Request URI: /apps/19000c000c0a0005/services/mctestdocdbprod-MasterService-0-00066ab9937/partitions/900005f9000e676fb8/replicas/13000000000955p"
For non-SQL errors the "Errors" propery is an array of string. For non-SQL errors the "Errors" propery is an array of string.
Example: Example:
"Message: {"errors":[{"severity":"Error","location":{"start":7,"end":8},"code":"SC1001","message":"Syntax error, incorrect syntax near '.'."}]}\r\nActivityId: d3300016d4084e310a, Request URI: /apps/12401f9e1df77/services/dc100232b1f44545/partitions/f86f3bc0001a2f78/replicas/13085003638s" "Message: {"errors":[{"severity":"Error","location":{"start":7,"end":8},"code":"SC1001","message":"Syntax error, incorrect syntax near '.'."}]}\r\nActivityId: d3300016d4084e310a, Request URI: /apps/12401f9e1df77/services/dc100232b1f44545/partitions/f86f3bc0001a2f78/replicas/13085003638s"
*/ */
let innerMessage: any = null; let innerMessage: any = null;
const singleLineMessage = message.replace(/[\r\n]|\r|\n/g, ""); const singleLineMessage = message.replace(/[\r\n]|\r|\n/g, "");
try { try {
// Multi-Partition error flavor // Multi-Partition error flavor
const regExp = /^(.*)ActivityId: (.*)/g; const regExp = /^(.*)ActivityId: (.*)/g;
const regString = regExp.exec(singleLineMessage); const regString = regExp.exec(singleLineMessage);
const innerMessageString = regString[1]; const innerMessageString = regString[1];
innerMessage = JSON.parse(innerMessageString); innerMessage = JSON.parse(innerMessageString);
} catch (e) { } catch (e) {
// Single-partition error flavor // Single-partition error flavor
const regExp = /^Message: (.*)ActivityId: (.*), Request URI: (.*)/g; const regExp = /^Message: (.*)ActivityId: (.*), Request URI: (.*)/g;
const regString = regExp.exec(singleLineMessage); const regString = regExp.exec(singleLineMessage);
const innerMessageString = regString[1]; const innerMessageString = regString[1];
innerMessage = JSON.parse(innerMessageString); innerMessage = JSON.parse(innerMessageString);
} }
return innerMessage.errors ? innerMessage.errors : innerMessage.Errors; return innerMessage.errors ? innerMessage.errors : innerMessage.Errors;
} }

View File

@ -49,6 +49,7 @@ export const FeaturePanelComponent: React.FunctionComponent = () => {
{ key: "feature.hosteddataexplorerenabled", label: "Hosted Data Explorer (deprecated?)", value: "true" }, { key: "feature.hosteddataexplorerenabled", label: "Hosted Data Explorer (deprecated?)", value: "true" },
{ key: "feature.enablettl", label: "Enable TTL", value: "true" }, { key: "feature.enablettl", label: "Enable TTL", value: "true" },
{ key: "feature.enablegallerypublish", label: "Enable Notebook Gallery Publishing", value: "true" }, { key: "feature.enablegallerypublish", label: "Enable Notebook Gallery Publishing", value: "true" },
{ key: "feature.enablecodeofconduct", label: "Enable Code Of Conduct Acknowledgement", value: "true" },
{ {
key: "feature.enableLinkInjection", key: "feature.enableLinkInjection",
label: "Enable Injecting Notebook Viewer Link into the first cell", label: "Enable Injecting Notebook Viewer Link into the first cell",

View File

@ -161,6 +161,12 @@ exports[`Feature panel renders all flags 1`] = `
label="Enable Notebook Gallery Publishing" label="Enable Notebook Gallery Publishing"
onChange={[Function]} onChange={[Function]}
/> />
<StyledCheckboxBase
checked={false}
key="feature.enablecodeofconduct"
label="Enable Code Of Conduct Acknowledgement"
onChange={[Function]}
/>
<StyledCheckboxBase <StyledCheckboxBase
checked={false} checked={false}
key="feature.enableLinkInjection" key="feature.enableLinkInjection"

View File

@ -0,0 +1,43 @@
import { shallow } from "enzyme";
import * as sinon from "sinon";
import React from "react";
import { CodeOfConductComponent, CodeOfConductComponentProps } from "./CodeOfConductComponent";
import { IJunoResponse, JunoClient } from "../../../Juno/JunoClient";
import { HttpStatusCodes } from "../../../Common/Constants";
describe("CodeOfConductComponent", () => {
let sandbox: sinon.SinonSandbox;
let codeOfConductProps: CodeOfConductComponentProps;
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(JunoClient.prototype, "acceptCodeOfConduct").returns({
status: HttpStatusCodes.OK,
data: true
} as IJunoResponse<boolean>);
const junoClient = new JunoClient(undefined);
codeOfConductProps = {
junoClient: junoClient,
onAcceptCodeOfConduct: jest.fn()
};
});
afterEach(() => {
sandbox.restore();
});
it("renders", () => {
const wrapper = shallow(<CodeOfConductComponent {...codeOfConductProps} />);
expect(wrapper).toMatchSnapshot();
});
it("onAcceptedCodeOfConductCalled", async () => {
const wrapper = shallow(<CodeOfConductComponent {...codeOfConductProps} />);
wrapper
.find(".genericPaneSubmitBtn")
.first()
.simulate("click");
await Promise.resolve();
expect(codeOfConductProps.onAcceptCodeOfConduct).toBeCalled();
});
});

View File

@ -0,0 +1,112 @@
import * as React from "react";
import { JunoClient } from "../../../Juno/JunoClient";
import { HttpStatusCodes, CodeOfConductEndpoints } from "../../../Common/Constants";
import * as Logger from "../../../Common/Logger";
import { logConsoleError } from "../../../Utils/NotificationConsoleUtils";
import { Stack, Text, Checkbox, PrimaryButton, Link } from "office-ui-fabric-react";
export interface CodeOfConductComponentProps {
junoClient: JunoClient;
onAcceptCodeOfConduct: (result: boolean) => void;
}
interface CodeOfConductComponentState {
readCodeOfConduct: boolean;
}
export class CodeOfConductComponent extends React.Component<CodeOfConductComponentProps, CodeOfConductComponentState> {
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);
this.state = {
readCodeOfConduct: false
};
this.descriptionPara1 = "Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement";
this.descriptionPara2 =
"Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB.";
this.descriptionPara3 = "In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the ";
this.link1 = { label: "code of conduct", url: CodeOfConductEndpoints.codeOfConduct };
this.link2 = { label: "privacy statement", url: CodeOfConductEndpoints.privacyStatement };
}
private async acceptCodeOfConduct(): Promise<void> {
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`);
}
this.props.onAcceptCodeOfConduct(response.data);
} catch (error) {
const message = `Failed to accept code of conduct: ${error}`;
Logger.logError(message, "CodeOfConductComponent/acceptCodeOfConduct");
logConsoleError(message);
}
}
private onChangeCheckbox = (): void => {
this.setState({ readCodeOfConduct: !this.state.readCodeOfConduct });
};
public render(): JSX.Element {
return (
<Stack tokens={{ childrenGap: 20 }}>
<Stack.Item>
<Text style={{ fontWeight: 500, fontSize: "20px" }}>{this.descriptionPara1}</Text>
</Stack.Item>
<Stack.Item>
<Text>{this.descriptionPara2}</Text>
</Stack.Item>
<Stack.Item>
<Text>
{this.descriptionPara3}
<Link href={this.link1.url} target="_blank">
{this.link1.label}
</Link>
{" and "}
<Link href={this.link2.url} target="_blank">
{this.link2.label}
</Link>
</Text>
</Stack.Item>
<Stack.Item>
<Checkbox
styles={{
label: {
margin: 0,
padding: "2 0 2 0"
},
text: {
fontSize: 12
}
}}
label="I have read and accepted the code of conduct and privacy statement"
onChange={this.onChangeCheckbox}
/>
</Stack.Item>
<Stack.Item>
<PrimaryButton
ariaLabel="Continue"
title="Continue"
onClick={async () => await this.acceptCodeOfConduct()}
tabIndex={0}
className="genericPaneSubmitBtn"
text="Continue"
disabled={!this.state.readCodeOfConduct}
/>
</Stack.Item>
</Stack>
);
}
}

View File

@ -15,7 +15,7 @@ import {
} from "office-ui-fabric-react"; } from "office-ui-fabric-react";
import * as React from "react"; import * as React from "react";
import * as Logger from "../../../Common/Logger"; import * as Logger from "../../../Common/Logger";
import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient"; import { IGalleryItem, JunoClient, IJunoResponse, IPublicGalleryData } from "../../../Juno/JunoClient";
import * as GalleryUtils from "../../../Utils/GalleryUtils"; import * as GalleryUtils from "../../../Utils/GalleryUtils";
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils"; import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
@ -24,6 +24,8 @@ import { GalleryCardComponent, GalleryCardComponentProps } from "./Cards/Gallery
import "./GalleryViewerComponent.less"; import "./GalleryViewerComponent.less";
import { HttpStatusCodes } from "../../../Common/Constants"; import { HttpStatusCodes } from "../../../Common/Constants";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { CodeOfConductComponent } from "./CodeOfConductComponent";
import { InfoComponent } from "./InfoComponent/InfoComponent";
export interface GalleryViewerComponentProps { export interface GalleryViewerComponentProps {
container?: Explorer; container?: Explorer;
@ -60,6 +62,7 @@ interface GalleryViewerComponentState {
sortBy: SortBy; sortBy: SortBy;
searchText: string; searchText: string;
dialogProps: DialogProps; dialogProps: DialogProps;
isCodeOfConductAccepted: boolean;
} }
interface GalleryTabInfo { interface GalleryTabInfo {
@ -86,6 +89,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
private publicNotebooks: IGalleryItem[]; private publicNotebooks: IGalleryItem[];
private favoriteNotebooks: IGalleryItem[]; private favoriteNotebooks: IGalleryItem[];
private publishedNotebooks: IGalleryItem[]; private publishedNotebooks: IGalleryItem[];
private isCodeOfConductAccepted: boolean;
private columnCount: number; private columnCount: number;
private rowCount: number; private rowCount: number;
@ -100,7 +104,8 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
selectedTab: props.selectedTab, selectedTab: props.selectedTab,
sortBy: props.sortBy, sortBy: props.sortBy,
searchText: props.searchText, searchText: props.searchText,
dialogProps: undefined dialogProps: undefined,
isCodeOfConductAccepted: undefined
}; };
this.sortingOptions = [ this.sortingOptions = [
@ -134,9 +139,20 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
const tabs: GalleryTabInfo[] = [this.createTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks)]; const tabs: GalleryTabInfo[] = [this.createTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks)];
if (this.props.container?.isGalleryPublishEnabled()) { if (this.props.container?.isGalleryPublishEnabled()) {
tabs.push(this.createTab(GalleryTab.PublicGallery, this.state.publicNotebooks)); tabs.push(
this.createPublicGalleryTab(
GalleryTab.PublicGallery,
this.state.publicNotebooks,
this.state.isCodeOfConductAccepted
)
);
tabs.push(this.createTab(GalleryTab.Favorites, this.state.favoriteNotebooks)); tabs.push(this.createTab(GalleryTab.Favorites, this.state.favoriteNotebooks));
tabs.push(this.createTab(GalleryTab.Published, this.state.publishedNotebooks));
// explicitly checking if isCodeOfConductAccepted is not false, as it is initially undefined.
// Displaying code of conduct component on gallery load should not be the default behavior.
if (this.state.isCodeOfConductAccepted !== false) {
tabs.push(this.createTab(GalleryTab.Published, this.state.publishedNotebooks));
}
} }
const pivotProps: IPivotProps = { const pivotProps: IPivotProps = {
@ -167,6 +183,17 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
); );
} }
private createPublicGalleryTab(
tab: GalleryTab,
data: IGalleryItem[],
acceptedCodeOfConduct: boolean
): GalleryTabInfo {
return {
tab,
content: this.createPublicGalleryTabContent(data, acceptedCodeOfConduct)
};
}
private createTab(tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo { private createTab(tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo {
return { return {
tab, tab,
@ -174,6 +201,19 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
}; };
} }
private createPublicGalleryTabContent(data: IGalleryItem[], acceptedCodeOfConduct: boolean): JSX.Element {
return acceptedCodeOfConduct === false ? (
<CodeOfConductComponent
junoClient={this.props.junoClient}
onAcceptCodeOfConduct={(result: boolean) => {
this.setState({ isCodeOfConductAccepted: result });
}}
/>
) : (
this.createTabContent(data)
);
}
private createTabContent(data: IGalleryItem[]): JSX.Element { private createTabContent(data: IGalleryItem[]): JSX.Element {
return ( return (
<Stack tokens={{ childrenGap: 10 }}> <Stack tokens={{ childrenGap: 10 }}>
@ -187,8 +227,12 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
<Stack.Item styles={{ root: { minWidth: 200 } }}> <Stack.Item styles={{ root: { minWidth: 200 } }}>
<Dropdown options={this.sortingOptions} selectedKey={this.state.sortBy} onChange={this.onDropdownChange} /> <Dropdown options={this.sortingOptions} selectedKey={this.state.sortBy} onChange={this.onDropdownChange} />
</Stack.Item> </Stack.Item>
{this.props.container?.isGalleryPublishEnabled() && (
<Stack.Item>
<InfoComponent />
</Stack.Item>
)}
</Stack> </Stack>
{data && this.createCardsTabContent(data)} {data && this.createCardsTabContent(data)}
</Stack> </Stack>
); );
@ -254,12 +298,19 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
private async loadPublicNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> { private async loadPublicNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
if (!offline) { if (!offline) {
try { try {
const response = await this.props.junoClient.getPublicNotebooks(); let response: IJunoResponse<IPublicGalleryData> | IJunoResponse<IGalleryItem[]>;
if (this.props.container.isCodeOfConductEnabled()) {
response = await this.props.junoClient.fetchPublicNotebooks();
this.isCodeOfConductAccepted = response.data?.metadata.acceptedCodeOfConduct;
this.publicNotebooks = response.data?.notebooksData;
} else {
response = await this.props.junoClient.getPublicNotebooks();
this.publicNotebooks = response.data;
}
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when loading public notebooks`); throw new Error(`Received HTTP ${response.status} when loading public notebooks`);
} }
this.publicNotebooks = response.data;
} catch (error) { } catch (error) {
const message = `Failed to load public notebooks: ${error}`; const message = `Failed to load public notebooks: ${error}`;
Logger.logError(message, "GalleryViewerComponent/loadPublicNotebooks"); Logger.logError(message, "GalleryViewerComponent/loadPublicNotebooks");
@ -268,7 +319,8 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
} }
this.setState({ this.setState({
publicNotebooks: this.publicNotebooks && [...this.sort(sortBy, this.search(searchText, this.publicNotebooks))] publicNotebooks: this.publicNotebooks && [...this.sort(sortBy, this.search(searchText, this.publicNotebooks))],
isCodeOfConductAccepted: this.isCodeOfConductAccepted
}); });
} }

View File

@ -0,0 +1,26 @@
@import "../../../../../less/Common/Constants.less";
.infoPanel, .infoPanelMain {
display: flex;
align-items: center;
}
.infoPanel {
padding-left: 5px;
padding-right: 5px;
}
.infoLabel, .infoLabelMain {
padding-left: 5px
}
.infoLabel {
font-weight: 400
}
.infoIconMain {
color: @AccentMedium
}
.infoIconMain:hover {
color: @BaseMedium
}

View File

@ -0,0 +1,10 @@
import { shallow } from "enzyme";
import React from "react";
import { InfoComponent } from "./InfoComponent";
describe("InfoComponent", () => {
it("renders", () => {
const wrapper = shallow(<InfoComponent />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,42 @@
import * as React from "react";
import { Icon, Label, Stack, HoverCard, HoverCardType, Link } from "office-ui-fabric-react";
import { CodeOfConductEndpoints } from "../../../../Common/Constants";
import "./InfoComponent.less";
export class InfoComponent extends React.Component {
private getInfoPanel = (iconName: string, labelText: string, url: string): JSX.Element => {
return (
<Link href={url} target="_blank">
<div className="infoPanel">
<Icon iconName={iconName} styles={{ root: { verticalAlign: "middle" } }} />
<Label className="infoLabel">{labelText}</Label>
</div>
</Link>
);
};
private onHover = (): JSX.Element => {
return (
<Stack tokens={{ childrenGap: 5, padding: 5 }}>
<Stack.Item>{this.getInfoPanel("Script", "Code of Conduct", CodeOfConductEndpoints.codeOfConduct)}</Stack.Item>
<Stack.Item>
{this.getInfoPanel("RedEye", "Privacy Statement", CodeOfConductEndpoints.privacyStatement)}
</Stack.Item>
<Stack.Item>
{this.getInfoPanel("KnowledgeArticle", "Microsoft Terms of Use", CodeOfConductEndpoints.termsOfUse)}
</Stack.Item>
</Stack>
);
};
public render(): JSX.Element {
return (
<HoverCard plainCardProps={{ onRenderPlainCard: this.onHover }} instantOpenOnClick type={HoverCardType.plain}>
<div className="infoPanelMain">
<Icon className="infoIconMain" iconName="Help" styles={{ root: { verticalAlign: "middle" } }} />
<Label className="infoLabelMain">Help</Label>
</div>
</HoverCard>
);
}
}

View File

@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InfoComponent renders 1`] = `
<StyledHoverCardBase
instantOpenOnClick={true}
plainCardProps={
Object {
"onRenderPlainCard": [Function],
}
}
type="PlainCard"
>
<div
className="infoPanelMain"
>
<Memo(StyledIconBase)
className="infoIconMain"
iconName="Help"
styles={
Object {
"root": Object {
"verticalAlign": "middle",
},
}
}
/>
<StyledLabelBase
className="infoLabelMain"
>
Help
</StyledLabelBase>
</div>
</StyledHoverCardBase>
`;

View File

@ -0,0 +1,75 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CodeOfConductComponent renders 1`] = `
<Stack
tokens={
Object {
"childrenGap": 20,
}
}
>
<StackItem>
<Text
style={
Object {
"fontSize": "20px",
"fontWeight": 500,
}
}
>
Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement
</Text>
</StackItem>
<StackItem>
<Text>
Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB.
</Text>
</StackItem>
<StackItem>
<Text>
In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the
<StyledLinkBase
href="https://aka.ms/cosmos-code-of-conduct"
target="_blank"
>
code of conduct
</StyledLinkBase>
and
<StyledLinkBase
href="https://aka.ms/ms-privacy-policy"
target="_blank"
>
privacy statement
</StyledLinkBase>
</Text>
</StackItem>
<StackItem>
<StyledCheckboxBase
label="I have read and accepted the code of conduct and privacy statement"
onChange={[Function]}
styles={
Object {
"label": Object {
"margin": 0,
"padding": "2 0 2 0",
},
"text": Object {
"fontSize": 12,
},
}
}
/>
</StackItem>
<StackItem>
<CustomizedPrimaryButton
ariaLabel="Continue"
className="genericPaneSubmitBtn"
disabled={true}
onClick={[Function]}
tabIndex={0}
text="Continue"
title="Continue"
/>
</StackItem>
</Stack>
`;

View File

@ -206,6 +206,7 @@ export default class Explorer {
// features // features
public isGalleryPublishEnabled: ko.Computed<boolean>; public isGalleryPublishEnabled: ko.Computed<boolean>;
public isCodeOfConductEnabled: ko.Computed<boolean>;
public isLinkInjectionEnabled: ko.Computed<boolean>; public isLinkInjectionEnabled: ko.Computed<boolean>;
public isGitHubPaneEnabled: ko.Observable<boolean>; public isGitHubPaneEnabled: ko.Observable<boolean>;
public isPublishNotebookPaneEnabled: ko.Observable<boolean>; public isPublishNotebookPaneEnabled: ko.Observable<boolean>;
@ -409,6 +410,9 @@ export default class Explorer {
this.isGalleryPublishEnabled = ko.computed<boolean>(() => this.isGalleryPublishEnabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableGalleryPublish) this.isFeatureEnabled(Constants.Features.enableGalleryPublish)
); );
this.isCodeOfConductEnabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableCodeOfConduct)
);
this.isLinkInjectionEnabled = ko.computed<boolean>(() => this.isLinkInjectionEnabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableLinkInjection) this.isFeatureEnabled(Constants.Features.enableLinkInjection)
); );
@ -2356,9 +2360,15 @@ export default class Explorer {
return Promise.resolve(false); return Promise.resolve(false);
} }
public publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): void { public async publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): Promise<void> {
if (this.notebookManager) { if (this.notebookManager) {
this.notebookManager.openPublishNotebookPane(name, content, parentDomElement, this.isLinkInjectionEnabled()); await this.notebookManager.openPublishNotebookPane(
name,
content,
parentDomElement,
this.isCodeOfConductEnabled(),
this.isLinkInjectionEnabled()
);
this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter; this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter;
this.isPublishNotebookPaneEnabled(true); this.isPublishNotebookPaneEnabled(true);
} }

View File

@ -108,13 +108,21 @@ export default class NotebookManager {
this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope); this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope);
} }
public openPublishNotebookPane( public async openPublishNotebookPane(
name: string, name: string,
content: string | ImmutableNotebook, content: string | ImmutableNotebook,
parentDomElement: HTMLElement, parentDomElement: HTMLElement,
isCodeOfConductEnabled: boolean,
isLinkInjectionEnabled: boolean isLinkInjectionEnabled: boolean
): void { ): Promise<void> {
this.publishNotebookPaneAdapter.open(name, getFullName(), content, parentDomElement, isLinkInjectionEnabled); await this.publishNotebookPaneAdapter.open(
name,
getFullName(),
content,
parentDomElement,
isCodeOfConductEnabled,
isLinkInjectionEnabled
);
} }
// Octokit's error handler uses any // Octokit's error handler uses any

View File

@ -43,10 +43,8 @@ const notebookRecord = makeNotebookRecord({
source: 'display(HTML("<h1>Sample html</h1>"))', source: 'display(HTML("<h1>Sample html</h1>"))',
outputs: List.of({ outputs: List.of({
data: Object.freeze({ data: Object.freeze({
data: { "text/html": "<h1>Sample output</h1>",
"text/html": "<h1>Sample output</h1>", "text/plain": "<IPython.core.display.HTML object>"
"text/plain": "<IPython.core.display.HTML object>"
}
} as MediaBundle), } as MediaBundle),
output_type: "display_data", output_type: "display_data",
metadata: undefined metadata: undefined

View File

@ -1,5 +1,5 @@
import path from "path"; import path from "path";
import { ImmutableNotebook, ImmutableCodeCell, ImmutableOutput } from "@nteract/commutable"; import { ImmutableNotebook, ImmutableCodeCell } from "@nteract/commutable";
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem"; import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
import { StringUtils } from "../../Utils/StringUtils"; import { StringUtils } from "../../Utils/StringUtils";
import * as GitHubUtils from "../../Utils/GitHubUtils"; import * as GitHubUtils from "../../Utils/GitHubUtils";
@ -102,25 +102,19 @@ export class NotebookUtil {
} }
public static findFirstCodeCellWithDisplay(notebookObject: ImmutableNotebook): number { public static findFirstCodeCellWithDisplay(notebookObject: ImmutableNotebook): number {
let codeCellCount = -1; let codeCellIndex = 0;
for (let i = 0; i < notebookObject.cellOrder.size; i++) { for (let i = 0; i < notebookObject.cellOrder.size; i++) {
const cellId = notebookObject.cellOrder.get(i); const cellId = notebookObject.cellOrder.get(i);
if (cellId) { if (cellId) {
const cell = notebookObject.cellMap.get(cellId); const cell = notebookObject.cellMap.get(cellId);
if (cell && cell.cell_type === "code") { if (cell?.cell_type === "code") {
codeCellCount++; const displayOutput = (cell as ImmutableCodeCell)?.outputs?.find(
const codeCell = cell as ImmutableCodeCell; output => output.output_type === "display_data" || output.output_type === "execute_result"
if (codeCell.outputs) { );
const displayOutput = codeCell.outputs.find((output: ImmutableOutput) => { if (displayOutput) {
if (output.output_type === "display_data" || output.output_type === "execute_result") { return codeCellIndex;
return true;
}
return false;
});
if (displayOutput) {
return codeCellCount;
}
} }
codeCellIndex++;
} }
} }
} }

View File

@ -17,6 +17,7 @@ export interface GenericRightPaneProps {
onSubmit: () => void; onSubmit: () => void;
submitButtonText: string; submitButtonText: string;
title: string; title: string;
isSubmitButtonVisible?: boolean;
} }
export interface GenericRightPaneState { export interface GenericRightPaneState {
@ -108,6 +109,7 @@ export class GenericRightPaneComponent extends React.Component<GenericRightPaneP
<div className="paneFooter"> <div className="paneFooter">
<div className="leftpanel-okbut"> <div className="leftpanel-okbut">
<PrimaryButton <PrimaryButton
style={{ visibility: this.props.isSubmitButtonVisible ? "visible" : "hidden" }}
ariaLabel="Submit" ariaLabel="Submit"
title="Submit" title="Submit"
onClick={this.props.onSubmit} onClick={this.props.onSubmit}

View File

@ -10,6 +10,8 @@ import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRight
import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent"; import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent";
import { ImmutableNotebook } from "@nteract/commutable/src"; import { ImmutableNotebook } from "@nteract/commutable/src";
import { toJS } from "@nteract/commutable"; import { toJS } from "@nteract/commutable";
import { CodeOfConductComponent } from "../Controls/NotebookGallery/CodeOfConductComponent";
import { HttpStatusCodes } from "../../Common/Constants";
export class PublishNotebookPaneAdapter implements ReactAdapter { export class PublishNotebookPaneAdapter implements ReactAdapter {
parameters: ko.Observable<number>; parameters: ko.Observable<number>;
@ -26,6 +28,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
private imageSrc: string; private imageSrc: string;
private notebookObject: ImmutableNotebook; private notebookObject: ImmutableNotebook;
private parentDomElement: HTMLElement; private parentDomElement: HTMLElement;
private isCodeOfConductAccepted: boolean;
private isLinkInjectionEnabled: boolean; private isLinkInjectionEnabled: boolean;
constructor(private container: Explorer, private junoClient: JunoClient) { constructor(private container: Explorer, private junoClient: JunoClient) {
@ -49,7 +52,8 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
title: "Publish to gallery", title: "Publish to gallery",
submitButtonText: "Publish", submitButtonText: "Publish",
onClose: () => this.close(), onClose: () => this.close(),
onSubmit: () => this.submit() onSubmit: () => this.submit(),
isSubmitButtonVisible: this.isCodeOfConductAccepted
}; };
return <GenericRightPaneComponent {...props} />; return <GenericRightPaneComponent {...props} />;
@ -59,13 +63,31 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
window.requestAnimationFrame(() => this.parameters(Date.now())); window.requestAnimationFrame(() => this.parameters(Date.now()));
} }
public open( public async open(
name: string, name: string,
author: string, author: string,
notebookContent: string | ImmutableNotebook, notebookContent: string | ImmutableNotebook,
parentDomElement: HTMLElement, parentDomElement: HTMLElement,
isCodeOfConductEnabled: boolean,
isLinkInjectionEnabled: boolean isLinkInjectionEnabled: boolean
): void { ): Promise<void> {
if (isCodeOfConductEnabled) {
try {
const response = await this.junoClient.isCodeOfConductAccepted();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
}
this.isCodeOfConductAccepted = response.data;
} catch (error) {
const message = `Failed to check if code of conduct was accepted: ${error}`;
Logger.logError(message, "PublishNotebookPaneAdapter/isCodeOfConductAccepted");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
}
} else {
this.isCodeOfConductAccepted = true;
}
this.name = name; this.name = name;
this.author = author; this.author = author;
if (typeof notebookContent === "string") { if (typeof notebookContent === "string") {
@ -108,11 +130,9 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.content, this.content,
this.isLinkInjectionEnabled this.isLinkInjectionEnabled
); );
if (!response.data) { if (response.data) {
throw new Error(`Received HTTP ${response.status} when publishing ${name} to gallery`); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Published ${name} to gallery`);
} }
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Published ${name} to gallery`);
} catch (error) { } catch (error) {
this.formError = `Failed to publish ${this.name} to gallery`; this.formError = `Failed to publish ${this.name} to gallery`;
this.formErrorDetail = `${error}`; this.formErrorDetail = `${error}`;
@ -162,7 +182,19 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
clearFormError: this.clearFormError clearFormError: this.clearFormError
}; };
return <PublishNotebookPaneComponent {...publishNotebookPaneProps} />; return !this.isCodeOfConductAccepted ? (
<div style={{ padding: "15px", marginTop: "10px" }}>
<CodeOfConductComponent
junoClient={this.junoClient}
onAcceptCodeOfConduct={() => {
this.isCodeOfConductAccepted = true;
this.triggerRender();
}}
/>
</div>
) : (
<PublishNotebookPaneComponent {...publishNotebookPaneProps} />
);
}; };
private reset = (): void => { private reset = (): void => {
@ -178,5 +210,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.imageSrc = undefined; this.imageSrc = undefined;
this.notebookObject = undefined; this.notebookObject = undefined;
this.parentDomElement = undefined; this.parentDomElement = undefined;
this.isCodeOfConductAccepted = undefined;
this.isLinkInjectionEnabled = undefined;
}; };
} }

View File

@ -166,7 +166,7 @@ export default class NotebookTabV2 extends TabsBase {
}, },
{ {
iconName: "PublishContent", iconName: "PublishContent",
onCommandClick: () => this.publishToGallery(), onCommandClick: async () => await this.publishToGallery(),
commandButtonLabel: publishLabel, commandButtonLabel: publishLabel,
hasPopup: false, hasPopup: false,
disabled: false, disabled: false,
@ -456,9 +456,9 @@ export default class NotebookTabV2 extends TabsBase {
); );
} }
private publishToGallery = () => { private publishToGallery = async () => {
const notebookContent = this.notebookComponentAdapter.getContent(); const notebookContent = this.notebookComponentAdapter.getContent();
this.container.publishNotebook( await this.container.publishNotebook(
notebookContent.name, notebookContent.name,
notebookContent.content, notebookContent.content,
this.notebookComponentAdapter.getNotebookParentElement() this.notebookComponentAdapter.getNotebookParentElement()

View File

@ -39,6 +39,15 @@ export interface IGalleryItem {
newCellId: string; newCellId: string;
} }
export interface IPublicGalleryData {
metadata: IPublicGalleryMetaData;
notebooksData: IGalleryItem[];
}
export interface IPublicGalleryMetaData {
acceptedCodeOfConduct: boolean;
}
export interface IUserGallery { export interface IUserGallery {
favorites: string[]; favorites: string[];
published: string[]; published: string[];
@ -163,6 +172,61 @@ export class JunoClient {
return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/public`); return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/public`);
} }
// will be renamed once feature.enableCodeOfConduct flag is removed
public async fetchPublicNotebooks(): Promise<IJunoResponse<IPublicGalleryData>> {
const url = `${this.getNotebooksAccountUrl()}/gallery/public`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders()
});
let data: IPublicGalleryData;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async acceptCodeOfConduct(): Promise<IJunoResponse<boolean>> {
const url = `${this.getNotebooksAccountUrl()}/gallery/acceptCodeOfConduct`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders()
});
let data: boolean;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async isCodeOfConductAccepted(): Promise<IJunoResponse<boolean>> {
const url = `${this.getNotebooksAccountUrl()}/gallery/isCodeOfConductAccepted`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders()
});
let data: boolean;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async getNotebookInfo(id: string): Promise<IJunoResponse<IGalleryItem>> { public async getNotebookInfo(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(this.getNotebookInfoUrl(id)); const response = await window.fetch(this.getNotebookInfoUrl(id));
@ -299,7 +363,6 @@ export class JunoClient {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery`, { const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery`, {
method: "PUT", method: "PUT",
headers: JunoClient.getHeaders(), headers: JunoClient.getHeaders(),
body: isLinkInjectionEnabled body: isLinkInjectionEnabled
? JSON.stringify({ ? JSON.stringify({
name, name,
@ -323,6 +386,8 @@ export class JunoClient {
let data: IGalleryItem; let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) { if (response.status === HttpStatusCodes.OK) {
data = await response.json(); data = await response.json();
} else {
throw new Error(`HTTP status ${response.status} thrown. ${(await response.json()).Message}`);
} }
return { return {