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:
parent
3051961093
commit
7a3e54d43e
|
@ -7,6 +7,12 @@ export class AuthorizationEndpoints {
|
|||
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 {
|
||||
public static localhost: string = "https://localhost:12900";
|
||||
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 enableNotebooks = "enablenotebooks";
|
||||
public static readonly enableGalleryPublish = "enablegallerypublish";
|
||||
public static readonly enableCodeOfConduct = "enablecodeofconduct";
|
||||
public static readonly enableLinkInjection = "enablelinkinjection";
|
||||
public static readonly enableSpark = "enablespark";
|
||||
public static readonly livyEndpoint = "livyendpoint";
|
||||
|
|
|
@ -1,69 +1,69 @@
|
|||
import * as DataModels from "../Contracts/DataModels";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
|
||||
export function replaceKnownError(err: string): string {
|
||||
if (
|
||||
window.dataExplorer.subscriptionType() === ViewModels.SubscriptionType.Internal &&
|
||||
err.indexOf("SharedOffer is Disabled for your account") >= 0
|
||||
) {
|
||||
return "Database throughput is not supported for internal subscriptions.";
|
||||
} 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 err;
|
||||
}
|
||||
|
||||
export function parse(err: any): DataModels.ErrorDataModel[] {
|
||||
try {
|
||||
return _parse(err);
|
||||
} catch (e) {
|
||||
return [<DataModels.ErrorDataModel>{ message: JSON.stringify(err) }];
|
||||
}
|
||||
}
|
||||
|
||||
function _parse(err: any): DataModels.ErrorDataModel[] {
|
||||
var normalizedErrors: DataModels.ErrorDataModel[] = [];
|
||||
if (err.message && !err.code) {
|
||||
normalizedErrors.push(err);
|
||||
} else {
|
||||
const innerErrors: any[] = _getInnerErrors(err.message);
|
||||
normalizedErrors = innerErrors.map(innerError =>
|
||||
typeof innerError === "string" ? { message: innerError } : innerError
|
||||
);
|
||||
}
|
||||
|
||||
return normalizedErrors;
|
||||
}
|
||||
|
||||
function _getInnerErrors(message: string): any[] {
|
||||
/*
|
||||
The backend error message has an inner-message which is a stringified object.
|
||||
|
||||
For SQL errors, the "errors" property is an array of SqlErrorDataModel.
|
||||
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"
|
||||
For non-SQL errors the "Errors" propery is an array of string.
|
||||
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"
|
||||
*/
|
||||
|
||||
let innerMessage: any = null;
|
||||
|
||||
const singleLineMessage = message.replace(/[\r\n]|\r|\n/g, "");
|
||||
try {
|
||||
// Multi-Partition error flavor
|
||||
const regExp = /^(.*)ActivityId: (.*)/g;
|
||||
const regString = regExp.exec(singleLineMessage);
|
||||
const innerMessageString = regString[1];
|
||||
innerMessage = JSON.parse(innerMessageString);
|
||||
} catch (e) {
|
||||
// Single-partition error flavor
|
||||
const regExp = /^Message: (.*)ActivityId: (.*), Request URI: (.*)/g;
|
||||
const regString = regExp.exec(singleLineMessage);
|
||||
const innerMessageString = regString[1];
|
||||
innerMessage = JSON.parse(innerMessageString);
|
||||
}
|
||||
|
||||
return innerMessage.errors ? innerMessage.errors : innerMessage.Errors;
|
||||
}
|
||||
import * as DataModels from "../Contracts/DataModels";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
|
||||
export function replaceKnownError(err: string): string {
|
||||
if (
|
||||
window.dataExplorer.subscriptionType() === ViewModels.SubscriptionType.Internal &&
|
||||
err.indexOf("SharedOffer is Disabled for your account") >= 0
|
||||
) {
|
||||
return "Database throughput is not supported for internal subscriptions.";
|
||||
} 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 err;
|
||||
}
|
||||
|
||||
export function parse(err: any): DataModels.ErrorDataModel[] {
|
||||
try {
|
||||
return _parse(err);
|
||||
} catch (e) {
|
||||
return [<DataModels.ErrorDataModel>{ message: JSON.stringify(err) }];
|
||||
}
|
||||
}
|
||||
|
||||
function _parse(err: any): DataModels.ErrorDataModel[] {
|
||||
var normalizedErrors: DataModels.ErrorDataModel[] = [];
|
||||
if (err.message && !err.code) {
|
||||
normalizedErrors.push(err);
|
||||
} else {
|
||||
const innerErrors: any[] = _getInnerErrors(err.message);
|
||||
normalizedErrors = innerErrors.map(innerError =>
|
||||
typeof innerError === "string" ? { message: innerError } : innerError
|
||||
);
|
||||
}
|
||||
|
||||
return normalizedErrors;
|
||||
}
|
||||
|
||||
function _getInnerErrors(message: string): any[] {
|
||||
/*
|
||||
The backend error message has an inner-message which is a stringified object.
|
||||
|
||||
For SQL errors, the "errors" property is an array of SqlErrorDataModel.
|
||||
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"
|
||||
For non-SQL errors the "Errors" propery is an array of string.
|
||||
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"
|
||||
*/
|
||||
|
||||
let innerMessage: any = null;
|
||||
|
||||
const singleLineMessage = message.replace(/[\r\n]|\r|\n/g, "");
|
||||
try {
|
||||
// Multi-Partition error flavor
|
||||
const regExp = /^(.*)ActivityId: (.*)/g;
|
||||
const regString = regExp.exec(singleLineMessage);
|
||||
const innerMessageString = regString[1];
|
||||
innerMessage = JSON.parse(innerMessageString);
|
||||
} catch (e) {
|
||||
// Single-partition error flavor
|
||||
const regExp = /^Message: (.*)ActivityId: (.*), Request URI: (.*)/g;
|
||||
const regString = regExp.exec(singleLineMessage);
|
||||
const innerMessageString = regString[1];
|
||||
innerMessage = JSON.parse(innerMessageString);
|
||||
}
|
||||
|
||||
return innerMessage.errors ? innerMessage.errors : innerMessage.Errors;
|
||||
}
|
||||
|
|
|
@ -49,6 +49,7 @@ export const FeaturePanelComponent: React.FunctionComponent = () => {
|
|||
{ key: "feature.hosteddataexplorerenabled", label: "Hosted Data Explorer (deprecated?)", value: "true" },
|
||||
{ key: "feature.enablettl", label: "Enable TTL", 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",
|
||||
label: "Enable Injecting Notebook Viewer Link into the first cell",
|
||||
|
|
|
@ -161,6 +161,12 @@ exports[`Feature panel renders all flags 1`] = `
|
|||
label="Enable Notebook Gallery Publishing"
|
||||
onChange={[Function]}
|
||||
/>
|
||||
<StyledCheckboxBase
|
||||
checked={false}
|
||||
key="feature.enablecodeofconduct"
|
||||
label="Enable Code Of Conduct Acknowledgement"
|
||||
onChange={[Function]}
|
||||
/>
|
||||
<StyledCheckboxBase
|
||||
checked={false}
|
||||
key="feature.enableLinkInjection"
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@ import {
|
|||
} from "office-ui-fabric-react";
|
||||
import * as React from "react";
|
||||
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 NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
|
||||
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
|
@ -24,6 +24,8 @@ import { GalleryCardComponent, GalleryCardComponentProps } from "./Cards/Gallery
|
|||
import "./GalleryViewerComponent.less";
|
||||
import { HttpStatusCodes } from "../../../Common/Constants";
|
||||
import Explorer from "../../Explorer";
|
||||
import { CodeOfConductComponent } from "./CodeOfConductComponent";
|
||||
import { InfoComponent } from "./InfoComponent/InfoComponent";
|
||||
|
||||
export interface GalleryViewerComponentProps {
|
||||
container?: Explorer;
|
||||
|
@ -60,6 +62,7 @@ interface GalleryViewerComponentState {
|
|||
sortBy: SortBy;
|
||||
searchText: string;
|
||||
dialogProps: DialogProps;
|
||||
isCodeOfConductAccepted: boolean;
|
||||
}
|
||||
|
||||
interface GalleryTabInfo {
|
||||
|
@ -86,6 +89,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
|||
private publicNotebooks: IGalleryItem[];
|
||||
private favoriteNotebooks: IGalleryItem[];
|
||||
private publishedNotebooks: IGalleryItem[];
|
||||
private isCodeOfConductAccepted: boolean;
|
||||
private columnCount: number;
|
||||
private rowCount: number;
|
||||
|
||||
|
@ -100,7 +104,8 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
|||
selectedTab: props.selectedTab,
|
||||
sortBy: props.sortBy,
|
||||
searchText: props.searchText,
|
||||
dialogProps: undefined
|
||||
dialogProps: undefined,
|
||||
isCodeOfConductAccepted: undefined
|
||||
};
|
||||
|
||||
this.sortingOptions = [
|
||||
|
@ -134,9 +139,20 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
|||
const tabs: GalleryTabInfo[] = [this.createTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks)];
|
||||
|
||||
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.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 = {
|
||||
|
@ -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 {
|
||||
return {
|
||||
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 {
|
||||
return (
|
||||
<Stack tokens={{ childrenGap: 10 }}>
|
||||
|
@ -187,8 +227,12 @@ 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() && (
|
||||
<Stack.Item>
|
||||
<InfoComponent />
|
||||
</Stack.Item>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{data && this.createCardsTabContent(data)}
|
||||
</Stack>
|
||||
);
|
||||
|
@ -254,12 +298,19 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
|||
private async loadPublicNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
|
||||
if (!offline) {
|
||||
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) {
|
||||
throw new Error(`Received HTTP ${response.status} when loading public notebooks`);
|
||||
}
|
||||
|
||||
this.publicNotebooks = response.data;
|
||||
} catch (error) {
|
||||
const message = `Failed to load public notebooks: ${error}`;
|
||||
Logger.logError(message, "GalleryViewerComponent/loadPublicNotebooks");
|
||||
|
@ -268,7 +319,8 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
|||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -206,6 +206,7 @@ export default class Explorer {
|
|||
|
||||
// features
|
||||
public isGalleryPublishEnabled: ko.Computed<boolean>;
|
||||
public isCodeOfConductEnabled: ko.Computed<boolean>;
|
||||
public isLinkInjectionEnabled: ko.Computed<boolean>;
|
||||
public isGitHubPaneEnabled: ko.Observable<boolean>;
|
||||
public isPublishNotebookPaneEnabled: ko.Observable<boolean>;
|
||||
|
@ -409,6 +410,9 @@ export default class Explorer {
|
|||
this.isGalleryPublishEnabled = ko.computed<boolean>(() =>
|
||||
this.isFeatureEnabled(Constants.Features.enableGalleryPublish)
|
||||
);
|
||||
this.isCodeOfConductEnabled = ko.computed<boolean>(() =>
|
||||
this.isFeatureEnabled(Constants.Features.enableCodeOfConduct)
|
||||
);
|
||||
this.isLinkInjectionEnabled = ko.computed<boolean>(() =>
|
||||
this.isFeatureEnabled(Constants.Features.enableLinkInjection)
|
||||
);
|
||||
|
@ -2356,9 +2360,15 @@ export default class Explorer {
|
|||
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) {
|
||||
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.isPublishNotebookPaneEnabled(true);
|
||||
}
|
||||
|
|
|
@ -108,13 +108,21 @@ export default class NotebookManager {
|
|||
this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope);
|
||||
}
|
||||
|
||||
public openPublishNotebookPane(
|
||||
public async openPublishNotebookPane(
|
||||
name: string,
|
||||
content: string | ImmutableNotebook,
|
||||
parentDomElement: HTMLElement,
|
||||
isCodeOfConductEnabled: boolean,
|
||||
isLinkInjectionEnabled: boolean
|
||||
): void {
|
||||
this.publishNotebookPaneAdapter.open(name, getFullName(), content, parentDomElement, isLinkInjectionEnabled);
|
||||
): Promise<void> {
|
||||
await this.publishNotebookPaneAdapter.open(
|
||||
name,
|
||||
getFullName(),
|
||||
content,
|
||||
parentDomElement,
|
||||
isCodeOfConductEnabled,
|
||||
isLinkInjectionEnabled
|
||||
);
|
||||
}
|
||||
|
||||
// Octokit's error handler uses any
|
||||
|
|
|
@ -43,10 +43,8 @@ const notebookRecord = makeNotebookRecord({
|
|||
source: 'display(HTML("<h1>Sample html</h1>"))',
|
||||
outputs: List.of({
|
||||
data: Object.freeze({
|
||||
data: {
|
||||
"text/html": "<h1>Sample output</h1>",
|
||||
"text/plain": "<IPython.core.display.HTML object>"
|
||||
}
|
||||
"text/html": "<h1>Sample output</h1>",
|
||||
"text/plain": "<IPython.core.display.HTML object>"
|
||||
} as MediaBundle),
|
||||
output_type: "display_data",
|
||||
metadata: undefined
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import path from "path";
|
||||
import { ImmutableNotebook, ImmutableCodeCell, ImmutableOutput } from "@nteract/commutable";
|
||||
import { ImmutableNotebook, ImmutableCodeCell } from "@nteract/commutable";
|
||||
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
|
||||
import { StringUtils } from "../../Utils/StringUtils";
|
||||
import * as GitHubUtils from "../../Utils/GitHubUtils";
|
||||
|
@ -102,25 +102,19 @@ export class NotebookUtil {
|
|||
}
|
||||
|
||||
public static findFirstCodeCellWithDisplay(notebookObject: ImmutableNotebook): number {
|
||||
let codeCellCount = -1;
|
||||
let codeCellIndex = 0;
|
||||
for (let i = 0; i < notebookObject.cellOrder.size; i++) {
|
||||
const cellId = notebookObject.cellOrder.get(i);
|
||||
if (cellId) {
|
||||
const cell = notebookObject.cellMap.get(cellId);
|
||||
if (cell && cell.cell_type === "code") {
|
||||
codeCellCount++;
|
||||
const codeCell = cell as ImmutableCodeCell;
|
||||
if (codeCell.outputs) {
|
||||
const displayOutput = codeCell.outputs.find((output: ImmutableOutput) => {
|
||||
if (output.output_type === "display_data" || output.output_type === "execute_result") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (displayOutput) {
|
||||
return codeCellCount;
|
||||
}
|
||||
if (cell?.cell_type === "code") {
|
||||
const displayOutput = (cell as ImmutableCodeCell)?.outputs?.find(
|
||||
output => output.output_type === "display_data" || output.output_type === "execute_result"
|
||||
);
|
||||
if (displayOutput) {
|
||||
return codeCellIndex;
|
||||
}
|
||||
codeCellIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ export interface GenericRightPaneProps {
|
|||
onSubmit: () => void;
|
||||
submitButtonText: string;
|
||||
title: string;
|
||||
isSubmitButtonVisible?: boolean;
|
||||
}
|
||||
|
||||
export interface GenericRightPaneState {
|
||||
|
@ -108,6 +109,7 @@ export class GenericRightPaneComponent extends React.Component<GenericRightPaneP
|
|||
<div className="paneFooter">
|
||||
<div className="leftpanel-okbut">
|
||||
<PrimaryButton
|
||||
style={{ visibility: this.props.isSubmitButtonVisible ? "visible" : "hidden" }}
|
||||
ariaLabel="Submit"
|
||||
title="Submit"
|
||||
onClick={this.props.onSubmit}
|
||||
|
|
|
@ -10,6 +10,8 @@ import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRight
|
|||
import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent";
|
||||
import { ImmutableNotebook } from "@nteract/commutable/src";
|
||||
import { toJS } from "@nteract/commutable";
|
||||
import { CodeOfConductComponent } from "../Controls/NotebookGallery/CodeOfConductComponent";
|
||||
import { HttpStatusCodes } from "../../Common/Constants";
|
||||
|
||||
export class PublishNotebookPaneAdapter implements ReactAdapter {
|
||||
parameters: ko.Observable<number>;
|
||||
|
@ -26,6 +28,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
|
|||
private imageSrc: string;
|
||||
private notebookObject: ImmutableNotebook;
|
||||
private parentDomElement: HTMLElement;
|
||||
private isCodeOfConductAccepted: boolean;
|
||||
private isLinkInjectionEnabled: boolean;
|
||||
|
||||
constructor(private container: Explorer, private junoClient: JunoClient) {
|
||||
|
@ -49,7 +52,8 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
|
|||
title: "Publish to gallery",
|
||||
submitButtonText: "Publish",
|
||||
onClose: () => this.close(),
|
||||
onSubmit: () => this.submit()
|
||||
onSubmit: () => this.submit(),
|
||||
isSubmitButtonVisible: this.isCodeOfConductAccepted
|
||||
};
|
||||
|
||||
return <GenericRightPaneComponent {...props} />;
|
||||
|
@ -59,13 +63,31 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
|
|||
window.requestAnimationFrame(() => this.parameters(Date.now()));
|
||||
}
|
||||
|
||||
public open(
|
||||
public async open(
|
||||
name: string,
|
||||
author: string,
|
||||
notebookContent: string | ImmutableNotebook,
|
||||
parentDomElement: HTMLElement,
|
||||
isCodeOfConductEnabled: 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.author = author;
|
||||
if (typeof notebookContent === "string") {
|
||||
|
@ -108,11 +130,9 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
|
|||
this.content,
|
||||
this.isLinkInjectionEnabled
|
||||
);
|
||||
if (!response.data) {
|
||||
throw new Error(`Received HTTP ${response.status} when publishing ${name} to gallery`);
|
||||
if (response.data) {
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Published ${name} to gallery`);
|
||||
}
|
||||
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Published ${name} to gallery`);
|
||||
} catch (error) {
|
||||
this.formError = `Failed to publish ${this.name} to gallery`;
|
||||
this.formErrorDetail = `${error}`;
|
||||
|
@ -162,7 +182,19 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
|
|||
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 => {
|
||||
|
@ -178,5 +210,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
|
|||
this.imageSrc = undefined;
|
||||
this.notebookObject = undefined;
|
||||
this.parentDomElement = undefined;
|
||||
this.isCodeOfConductAccepted = undefined;
|
||||
this.isLinkInjectionEnabled = undefined;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -166,7 +166,7 @@ export default class NotebookTabV2 extends TabsBase {
|
|||
},
|
||||
{
|
||||
iconName: "PublishContent",
|
||||
onCommandClick: () => this.publishToGallery(),
|
||||
onCommandClick: async () => await this.publishToGallery(),
|
||||
commandButtonLabel: publishLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
|
@ -456,9 +456,9 @@ export default class NotebookTabV2 extends TabsBase {
|
|||
);
|
||||
}
|
||||
|
||||
private publishToGallery = () => {
|
||||
private publishToGallery = async () => {
|
||||
const notebookContent = this.notebookComponentAdapter.getContent();
|
||||
this.container.publishNotebook(
|
||||
await this.container.publishNotebook(
|
||||
notebookContent.name,
|
||||
notebookContent.content,
|
||||
this.notebookComponentAdapter.getNotebookParentElement()
|
||||
|
|
|
@ -39,6 +39,15 @@ export interface IGalleryItem {
|
|||
newCellId: string;
|
||||
}
|
||||
|
||||
export interface IPublicGalleryData {
|
||||
metadata: IPublicGalleryMetaData;
|
||||
notebooksData: IGalleryItem[];
|
||||
}
|
||||
|
||||
export interface IPublicGalleryMetaData {
|
||||
acceptedCodeOfConduct: boolean;
|
||||
}
|
||||
|
||||
export interface IUserGallery {
|
||||
favorites: string[];
|
||||
published: string[];
|
||||
|
@ -163,6 +172,61 @@ export class JunoClient {
|
|||
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>> {
|
||||
const response = await window.fetch(this.getNotebookInfoUrl(id));
|
||||
|
||||
|
@ -299,7 +363,6 @@ export class JunoClient {
|
|||
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery`, {
|
||||
method: "PUT",
|
||||
headers: JunoClient.getHeaders(),
|
||||
|
||||
body: isLinkInjectionEnabled
|
||||
? JSON.stringify({
|
||||
name,
|
||||
|
@ -323,6 +386,8 @@ export class JunoClient {
|
|||
let data: IGalleryItem;
|
||||
if (response.status === HttpStatusCodes.OK) {
|
||||
data = await response.json();
|
||||
} else {
|
||||
throw new Error(`HTTP status ${response.status} thrown. ${(await response.json()).Message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
Loading…
Reference in New Issue