mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-20 01:11:25 +00:00
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:
committed by
GitHub
parent
3051961093
commit
7a3e54d43e
@@ -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();
|
||||
});
|
||||
});
|
||||
112
src/Explorer/Controls/NotebookGallery/CodeOfConductComponent.tsx
Normal file
112
src/Explorer/Controls/NotebookGallery/CodeOfConductComponent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
Reference in New Issue
Block a user