diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 44d106669..4d56c53d9 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -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"; diff --git a/src/Common/ErrorParserUtility.ts b/src/Common/ErrorParserUtility.ts index 04af62f5a..95d1ace34 100644 --- a/src/Common/ErrorParserUtility.ts +++ b/src/Common/ErrorParserUtility.ts @@ -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 [{ 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 [{ 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; +} diff --git a/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx b/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx index bbbb3d96d..b0f1733f7 100644 --- a/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx +++ b/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx @@ -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", diff --git a/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap b/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap index 885ace128..7f4a39014 100644 --- a/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap +++ b/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap @@ -161,6 +161,12 @@ exports[`Feature panel renders all flags 1`] = ` label="Enable Notebook Gallery Publishing" onChange={[Function]} /> + { + 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); + const junoClient = new JunoClient(undefined); + codeOfConductProps = { + junoClient: junoClient, + onAcceptCodeOfConduct: jest.fn() + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("renders", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it("onAcceptedCodeOfConductCalled", async () => { + const wrapper = shallow(); + wrapper + .find(".genericPaneSubmitBtn") + .first() + .simulate("click"); + await Promise.resolve(); + expect(codeOfConductProps.onAcceptCodeOfConduct).toBeCalled(); + }); +}); diff --git a/src/Explorer/Controls/NotebookGallery/CodeOfConductComponent.tsx b/src/Explorer/Controls/NotebookGallery/CodeOfConductComponent.tsx new file mode 100644 index 000000000..02dc407e0 --- /dev/null +++ b/src/Explorer/Controls/NotebookGallery/CodeOfConductComponent.tsx @@ -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 { + 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 { + 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 ( + + + {this.descriptionPara1} + + + + {this.descriptionPara2} + + + + + {this.descriptionPara3} + + {this.link1.label} + + {" and "} + + {this.link2.label} + + + + + + + + + + await this.acceptCodeOfConduct()} + tabIndex={0} + className="genericPaneSubmitBtn" + text="Continue" + disabled={!this.state.readCodeOfConduct} + /> + + + ); + } +} diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx index 34758dc1d..0241f76eb 100644 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx @@ -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 { + this.setState({ isCodeOfConductAccepted: result }); + }} + /> + ) : ( + this.createTabContent(data) + ); + } + private createTabContent(data: IGalleryItem[]): JSX.Element { return ( @@ -187,8 +227,12 @@ export class GalleryViewerComponent extends React.Component + {this.props.container?.isGalleryPublishEnabled() && ( + + + + )} - {data && this.createCardsTabContent(data)} ); @@ -254,12 +298,19 @@ export class GalleryViewerComponent extends React.Component { if (!offline) { try { - const response = await this.props.junoClient.getPublicNotebooks(); + let response: IJunoResponse | IJunoResponse; + 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 { + it("renders", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx b/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx new file mode 100644 index 000000000..f14b7c350 --- /dev/null +++ b/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx @@ -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 ( + +
+ + +
+ + ); + }; + + private onHover = (): JSX.Element => { + return ( + + {this.getInfoPanel("Script", "Code of Conduct", CodeOfConductEndpoints.codeOfConduct)} + + {this.getInfoPanel("RedEye", "Privacy Statement", CodeOfConductEndpoints.privacyStatement)} + + + {this.getInfoPanel("KnowledgeArticle", "Microsoft Terms of Use", CodeOfConductEndpoints.termsOfUse)} + + + ); + }; + + public render(): JSX.Element { + return ( + +
+ + +
+
+ ); + } +} diff --git a/src/Explorer/Controls/NotebookGallery/InfoComponent/__snapshots__/InfoComponent.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/InfoComponent/__snapshots__/InfoComponent.test.tsx.snap new file mode 100644 index 000000000..4aea859f5 --- /dev/null +++ b/src/Explorer/Controls/NotebookGallery/InfoComponent/__snapshots__/InfoComponent.test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InfoComponent renders 1`] = ` + +
+ + + Help + +
+
+`; diff --git a/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap new file mode 100644 index 000000000..3362852dd --- /dev/null +++ b/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap @@ -0,0 +1,75 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CodeOfConductComponent renders 1`] = ` + + + + Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement + + + + + Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB. + + + + + In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the + + code of conduct + + and + + privacy statement + + + + + + + + + + +`; diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index 59fa442de..1761cfa7b 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -206,6 +206,7 @@ export default class Explorer { // features public isGalleryPublishEnabled: ko.Computed; + public isCodeOfConductEnabled: ko.Computed; public isLinkInjectionEnabled: ko.Computed; public isGitHubPaneEnabled: ko.Observable; public isPublishNotebookPaneEnabled: ko.Observable; @@ -409,6 +410,9 @@ export default class Explorer { this.isGalleryPublishEnabled = ko.computed(() => this.isFeatureEnabled(Constants.Features.enableGalleryPublish) ); + this.isCodeOfConductEnabled = ko.computed(() => + this.isFeatureEnabled(Constants.Features.enableCodeOfConduct) + ); this.isLinkInjectionEnabled = ko.computed(() => 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 { 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); } diff --git a/src/Explorer/Notebook/NotebookManager.ts b/src/Explorer/Notebook/NotebookManager.ts index b1d606985..582b4fda8 100644 --- a/src/Explorer/Notebook/NotebookManager.ts +++ b/src/Explorer/Notebook/NotebookManager.ts @@ -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 { + await this.publishNotebookPaneAdapter.open( + name, + getFullName(), + content, + parentDomElement, + isCodeOfConductEnabled, + isLinkInjectionEnabled + ); } // Octokit's error handler uses any diff --git a/src/Explorer/Notebook/NotebookUtil.test.ts b/src/Explorer/Notebook/NotebookUtil.test.ts index e43aa2c9c..2f6c1136f 100644 --- a/src/Explorer/Notebook/NotebookUtil.test.ts +++ b/src/Explorer/Notebook/NotebookUtil.test.ts @@ -43,10 +43,8 @@ const notebookRecord = makeNotebookRecord({ source: 'display(HTML("

Sample html

"))', outputs: List.of({ data: Object.freeze({ - data: { - "text/html": "

Sample output

", - "text/plain": "" - } + "text/html": "

Sample output

", + "text/plain": "" } as MediaBundle), output_type: "display_data", metadata: undefined diff --git a/src/Explorer/Notebook/NotebookUtil.ts b/src/Explorer/Notebook/NotebookUtil.ts index be4e38480..27ad34a53 100644 --- a/src/Explorer/Notebook/NotebookUtil.ts +++ b/src/Explorer/Notebook/NotebookUtil.ts @@ -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++; } } } diff --git a/src/Explorer/Panes/GenericRightPaneComponent.tsx b/src/Explorer/Panes/GenericRightPaneComponent.tsx index 4b17bdc8f..f4e4d2285 100644 --- a/src/Explorer/Panes/GenericRightPaneComponent.tsx +++ b/src/Explorer/Panes/GenericRightPaneComponent.tsx @@ -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
; @@ -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 ; @@ -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 { + 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 ; + return !this.isCodeOfConductAccepted ? ( +
+ { + this.isCodeOfConductAccepted = true; + this.triggerRender(); + }} + /> +
+ ) : ( + + ); }; 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; }; } diff --git a/src/Explorer/Tabs/NotebookV2Tab.ts b/src/Explorer/Tabs/NotebookV2Tab.ts index 7f6320f38..3b01ead38 100644 --- a/src/Explorer/Tabs/NotebookV2Tab.ts +++ b/src/Explorer/Tabs/NotebookV2Tab.ts @@ -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() diff --git a/src/Juno/JunoClient.ts b/src/Juno/JunoClient.ts index 1f5677de6..fbc59e85d 100644 --- a/src/Juno/JunoClient.ts +++ b/src/Juno/JunoClient.ts @@ -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> { + 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> { + 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> { + 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> { 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 {