Compare commits

...

4 Commits

Author SHA1 Message Date
Tanuj Mittal
84ea3796ec Remove enableGallery feature flag (#68)
* Remove enableGallery feature flag

* Fix bugs

* Add tests to increase coverage

* Move favorites functionality behind feature.enableGalleryPublish flag

* Show code cells in NotebookViewer

* Use cosmos db logo as persona image for sample notebook gallery cards

* Update gallery card snapshot to fix test
2020-07-06 12:10:26 -07:00
Laurent Nguyen
27024ef75c Initial implementation of a generic UI component (#61)
* Add generic component

* Add validation. Rename to widgetRenderer

* Remove test code from splash screen

* Clean up infobox

* Fix styling/layout

* Move test code into unit test

* Replace <input> and <labe> by <TextField> and <Text> respectively. Fix style.

* Replace InfoBoxComponent with UI fabric MessageBar. Fix styling for TextField

* Use MessageBar for error message

* Rename WdigetRendererComponent to SmartUiComponent
2020-07-06 17:16:43 +02:00
Laurent Nguyen
3f34936acd Add more files to strict compile. Update CONTRIBUTING.md (#63)
* Add more files to strict compile. Update CONTRIBUTING.md to recommend FluentUI use

* Remove eslint-disable and use non-null assertion
2020-07-06 17:16:24 +02:00
Steve Faulkner
c51a55013c Upload screenshot for runner failures (#72) 2020-07-02 09:58:36 -05:00
52 changed files with 1443 additions and 484 deletions

View File

@@ -175,6 +175,7 @@ jobs:
- run: nuget pack -Version "2.0.0-github-${GITHUB_SHA}"
- run: nuget push -Source "$NUGET_SOURCE" -ApiKey Az *.nupkg
- uses: actions/upload-artifact@v2
name: packages
with:
path: "*.nupkg"
nugetmpac:
@@ -198,5 +199,6 @@ jobs:
- run: nuget pack -Version "2.0.0-github-${GITHUB_SHA}"
- run: nuget push -Source "$NUGET_SOURCE" -ApiKey Az *.nupkg
- uses: actions/upload-artifact@v2
name: packages
with:
path: "*.nupkg"

View File

@@ -1,7 +1,7 @@
name: Runners
on:
schedule:
- cron: "*/10 * * * *"
- cron: "*/5 * * * *"
jobs:
sqlcreatecollection:
runs-on: ubuntu-latest
@@ -18,3 +18,8 @@ jobs:
PORTAL_RUNNER_SUBSCRIPTION: 69e02f2d-f059-4409-9eac-97e8a276ae2c
PORTAL_RUNNER_RESOURCE_GROUP: runners
PORTAL_RUNNER_DATABASE_ACCOUNT: portal-sql-runner
- uses: actions/upload-artifact@v2
if: failure()
with:
name: screenshots
path: failure.png

3
.gitignore vendored
View File

@@ -16,4 +16,5 @@ notebookapp/*
Contracts/*
.DS_Store
.cache/
.env
.env
failure.png

View File

@@ -30,12 +30,13 @@ For IE support, polyfill is preferred over new usage of lodash or underscore. We
### Typescript
* Follow this [typescript style guide](https://github.com/excelmicro/typescript) which is based on [airbnb's style guide](https://github.com/airbnb/javascript).
* Conventions speficic to this project:
* Use double-quotes for string
* Don't use null, use undefined
* Pascal case for private static readonly fields
* Camel case for classnames in markup
- Use double-quotes for string
- Don't use `null`, use `undefined`
- Pascal case for private static readonly fields
- Camel case for classnames in markup
* Don't use class unless necessary
* Code related to notebooks should be dynamically imported so that it is loaded from a separate bundle only if the account is notebook-enabled. There are already top-level notebook components which are dynamically imported and their dependencies can be statically imported from these files.
* Prefer using [Fluent UI controls](https://developer.microsoft.com/en-us/fluentui#/controls/web) over creating your own, in order to maintain consistency and support a11y.
### React
* Prefer using React class components over function components and hooks unless you have a simple component and require no nested functions:

22
images/CosmosDB-logo.svg Normal file
View File

@@ -0,0 +1,22 @@
<svg id="b089cfca-0de1-451c-a1ca-6680ea50cb4f" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
<defs>
<radialGradient id="b25d0836-964a-4c84-8c20-855f66e8345e" cx="-105.006" cy="-10.409" r="5.954" gradientTransform="translate(117.739 19.644) scale(1.036 1.027)" gradientUnits="userSpaceOnUse">
<stop offset="0.183" stop-color="#5ea0ef"/>
<stop offset="1" stop-color="#0078d4"/>
</radialGradient>
<clipPath id="b36c7f5d-2ef1-4760-8a25-eeb9661f4e47">
<path d="M14.969,7.53A6.137,6.137,0,1,1,7.574,2.987,6.137,6.137,0,0,1,14.969,7.53Z" fill="none"/>
</clipPath>
</defs>
<title>Icon-databases-121</title>
<path d="M2.954,5.266a.175.175,0,0,1-.176-.176h0A2.012,2.012,0,0,0,.769,3.081a.176.176,0,0,1-.176-.175h0a.176.176,0,0,1,.176-.176A2.012,2.012,0,0,0,2.778.72.175.175,0,0,1,2.954.544h0A.175.175,0,0,1,3.13.72h0A2.012,2.012,0,0,0,5.139,2.729a.175.175,0,0,1,.176.176h0a.175.175,0,0,1-.176.176h0A2.011,2.011,0,0,0,3.13,5.09.177.177,0,0,1,2.954,5.266Z" fill="#50e6ff"/>
<path d="M15.611,17.456a.141.141,0,0,1-.141-.141h0a1.609,1.609,0,0,0-1.607-1.607.141.141,0,0,1-.141-.14h0a.141.141,0,0,1,.141-.141h0a1.608,1.608,0,0,0,1.607-1.607.141.141,0,0,1,.141-.141h0a.141.141,0,0,1,.141.141h0a1.608,1.608,0,0,0,1.607,1.607.141.141,0,1,1,0,.282h0a1.609,1.609,0,0,0-1.607,1.607A.141.141,0,0,1,15.611,17.456Z" fill="#50e6ff"/>
<g>
<path d="M14.969,7.53A6.137,6.137,0,1,1,7.574,2.987,6.137,6.137,0,0,1,14.969,7.53Z" fill="url(#b25d0836-964a-4c84-8c20-855f66e8345e)"/>
<g clip-path="url(#b36c7f5d-2ef1-4760-8a25-eeb9661f4e47)">
<path d="M5.709,13.115A1.638,1.638,0,1,0,5.714,9.84,1.307,1.307,0,0,0,5.721,9.7,1.651,1.651,0,0,0,4.06,8.064H2.832a6.251,6.251,0,0,0,1.595,5.051Z" fill="#f2f2f2"/>
<path d="M15.045,7.815c0-.015,0-.03-.007-.044a5.978,5.978,0,0,0-1.406-2.88,1.825,1.825,0,0,0-.289-.09,1.806,1.806,0,0,0-2.3,1.663,2,2,0,0,0-.2-.013,1.737,1.737,0,0,0-.581,3.374,1.451,1.451,0,0,0,.541.1h2.03A13.453,13.453,0,0,0,15.045,7.815Z" fill="#f2f2f2"/>
</g>
</g>
<path d="M17.191,3.832c-.629-1.047-2.1-1.455-4.155-1.149a14.606,14.606,0,0,0-2.082.452,6.456,6.456,0,0,1,1.528.767c.241-.053.483-.116.715-.151A7.49,7.49,0,0,1,14.3,3.662a2.188,2.188,0,0,1,1.959.725h0c.383.638.06,1.729-.886,3a16.723,16.723,0,0,1-4.749,4.051A16.758,16.758,0,0,1,4.8,13.7c-1.564.234-2.682,0-3.065-.636s-.06-1.73.886-2.995c.117-.157.146-.234.279-.392a6.252,6.252,0,0,1,.026-1.63A11.552,11.552,0,0,0,1.756,9.419C.517,11.076.181,12.566.809,13.613a3.165,3.165,0,0,0,2.9,1.249,8.434,8.434,0,0,0,1.251-.1,17.855,17.855,0,0,0,6.219-2.4,17.808,17.808,0,0,0,5.061-4.332C17.483,6.369,17.819,4.88,17.191,3.832Z" fill="#50e6ff"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,11 +1,6 @@
import * as ko from "knockout";
import * as ReactBindingHandler from "./ReactBindingHandler";
interface RestorePoint {
readonly element: JQuery;
readonly width: number;
}
export class BindingHandlersRegisterer {
public static registerBindingHandlers() {
ko.bindingHandlers.setTemplateReady = {
@@ -17,7 +12,7 @@ export class BindingHandlersRegisterer {
bindingContext?: ko.BindingContext
) {
const value = ko.unwrap(wrappedValueAccessor());
bindingContext.$data.isTemplateReady(value);
bindingContext?.$data.isTemplateReady(value);
}
} as ko.BindingHandler;

View File

@@ -111,7 +111,6 @@ export class Features {
public static readonly hostedDataExplorer = "hosteddataexplorerenabled";
public static readonly enableTtl = "enablettl";
public static readonly enableNotebooks = "enablenotebooks";
public static readonly enableGallery = "enablegallery";
public static readonly enableGalleryPublish = "enablegallerypublish";
public static readonly enableSpark = "enablespark";
public static readonly livyEndpoint = "livyendpoint";
@@ -444,6 +443,17 @@ export class KeyCodes {
public static Tab: number = 9;
}
// Normalized per: https://www.w3.org/TR/uievents-key/#named-key-attribute-values
export class NormalizedEventKey {
public static readonly Space = " ";
public static readonly Enter = "Enter";
public static readonly Escape = "Escape";
public static readonly UpArrow = "ArrowUp";
public static readonly DownArrow = "ArrowDown";
public static readonly LeftArrow = "ArrowLeft";
public static readonly RightArrow = "ArrowRight";
}
export class TryCosmosExperience {
public static extendUrl: string = "https://trycosmosdb.azure.com/api/resource/extendportal?userId={0}";
public static deleteUrl: string = "https://trycosmosdb.azure.com/api/resource/deleteportal?userId={0}";

View File

@@ -59,13 +59,13 @@ function _generateLogEntry(
level: Diagnostics.LogEntryLevel,
message: string,
area: string,
code: number
code?: number
): Diagnostics.LogEntry {
return {
timestamp: new Date().getUTCSeconds(),
level: level,
message: message,
area: area,
code: code
level,
message,
area,
code
};
}

View File

@@ -16,7 +16,6 @@ interface Config {
ARM_API_VERSION: string;
GRAPH_ENDPOINT: string;
GRAPH_API_VERSION: string;
AZURESAMPLESCOSMOSDBPAT: string;
ARCADIA_ENDPOINT: string;
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
BACKEND_ENDPOINT?: string;
@@ -44,8 +43,7 @@ let config: Config = {
ARCADIA_ENDPOINT: "https://workspaceartifacts.projectarcadia.net",
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: "dev.azuresynapse.net",
GITHUB_CLIENT_ID: "6cb2f63cf6f7b5cbdeca", // Registered OAuth app: https://github.com/settings/applications/1189306
JUNO_ENDPOINT: "https://tools.cosmos.azure.com",
AZURESAMPLESCOSMOSDBPAT: "99e38770e29b4a61d7c49f188780504efd35cc86" //[SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification=" // this PAT is a "no scopes" PAT with zero access to any projects, this is just used to get around the dev.github.com rate limit when accessing public samples repo.")]
JUNO_ENDPOINT: "https://tools.cosmos.azure.com"
};
// Injected for local develpment. These will be removed in the production bundle by webpack

View File

@@ -46,7 +46,7 @@ export interface LogEntry {
/**
* The message code.
*/
code: number;
code?: number;
/**
* Any additional data to be logged.
*/

View File

@@ -83,7 +83,6 @@ export interface Explorer {
extensionEndpoint: ko.Observable<string>;
armEndpoint: ko.Observable<string>;
isFeatureEnabled: (feature: string) => boolean;
isGalleryEnabled: ko.Computed<boolean>;
isGalleryPublishEnabled: ko.Computed<boolean>;
isGitHubPaneEnabled: ko.Observable<boolean>;
isPublishNotebookPaneEnabled: ko.Observable<boolean>;
@@ -230,7 +229,7 @@ export interface Explorer {
openNotebook(notebookContentItem: NotebookContentItem): Promise<boolean>; // True if it was opened, false otherwise
resetNotebookWorkspace(): void;
importAndOpen: (path: string) => Promise<boolean>;
importAndOpenFromGallery: (name: string, content: string) => Promise<boolean>;
importAndOpenContent: (name: string, content: string) => Promise<boolean>;
publishNotebook: (name: string, content: string) => void;
openNotebookTerminal: (kind: TerminalKind) => void;
openGallery: (notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean) => void;

View File

@@ -198,7 +198,7 @@ export class Heatmap {
let timeSelected: string = data.points[0].x;
timeSelected = timeSelected.replace(" ", "T");
timeSelected = `${timeSelected}Z`;
let xAxisIndex;
let xAxisIndex = 0;
for (let i = 0; i < this._chartData.xAxisPoints.length; i++) {
if (this._chartData.xAxisPoints[i] === timeSelected) {
xAxisIndex = i;
@@ -234,7 +234,8 @@ export function handleMessage(event: MessageEvent) {
return;
}
Plotly.purge(Heatmap.elementId);
document.getElementById(Heatmap.elementId).innerHTML = "";
document.getElementById(Heatmap.elementId)!.innerHTML = "";
const data = event.data.data;
const chartData: DataPayload = data.chartData;
const chartSettings: HeatmapCaptions = data.chartSettings;
@@ -259,8 +260,8 @@ export function handleMessage(event: MessageEvent) {
noDataMessageContent.classList.add("dark-theme");
}
document.getElementById(Heatmap.elementId).appendChild(chartTitleElement);
document.getElementById(Heatmap.elementId).appendChild(noDataMessageElement);
document.getElementById(Heatmap.elementId)!.appendChild(chartTitleElement);
document.getElementById(Heatmap.elementId)!.appendChild(noDataMessageElement);
}
}

View File

@@ -48,7 +48,6 @@ export const FeaturePanelComponent: React.FunctionComponent = () => {
{ key: "feature.dataexplorerexecutesproc", label: "Execute stored procedure", value: "true" },
{ key: "feature.hosteddataexplorerenabled", label: "Hosted Data Explorer (deprecated?)", value: "true" },
{ key: "feature.enablettl", label: "Enable TTL", value: "true" },
{ key: "feature.enablegallery", label: "Enable Notebook Gallery", value: "true" },
{ key: "feature.enablegallerypublish", label: "Enable Notebook Gallery Publishing", value: "true" },
{ key: "feature.canexceedmaximumvalue", label: "Can exceed max value", value: "true" },
{

View File

@@ -157,14 +157,14 @@ exports[`Feature panel renders all flags 1`] = `
/>
<StyledCheckboxBase
checked={false}
key="feature.enablegallery"
label="Enable Notebook Gallery"
key="feature.enablegallerypublish"
label="Enable Notebook Gallery Publishing"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enablegallerypublish"
label="Enable Notebook Gallery Publishing"
key="feature.canexceedmaximumvalue"
label="Can exceed max value"
onChange={[Function]}
/>
</Stack>
@@ -172,12 +172,6 @@ exports[`Feature panel renders all flags 1`] = `
className="checkboxRow"
horizontalAlign="space-between"
>
<StyledCheckboxBase
checked={false}
key="feature.canexceedmaximumvalue"
label="Can exceed max value"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enablefixedcollectionwithsharedthroughput"

View File

@@ -17,6 +17,7 @@ import {
import * as React from "react";
import { IGalleryItem } from "../../../../Juno/JunoClient";
import { FileSystemUtil } from "../../../Notebook/FileSystemUtil";
import CosmosDBLogo from "../../../../../images/CosmosDB-logo.svg";
export interface GalleryCardComponentProps {
data: IGalleryItem;
@@ -55,7 +56,11 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
return (
<Card aria-label="Notebook Card" tokens={GalleryCardComponent.cardTokens} onClick={this.props.onClick}>
<Card.Item>
<Persona text={this.props.data.author} secondaryText={dateString} />
<Persona
imageUrl={this.props.data.isSample && CosmosDBLogo}
text={this.props.data.author}
secondaryText={dateString}
/>
</Card.Item>
<Card.Item fill>
@@ -89,15 +94,9 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
</Card.Section>
<Card.Section horizontal styles={{ root: { alignItems: "flex-end" } }}>
<Text variant="tiny" styles={{ root: { color: "#ccc" } }}>
<Icon iconName="RedEye" styles={{ root: { verticalAlign: "middle" } }} /> {this.props.data.views}
</Text>
<Text variant="tiny" styles={{ root: { color: "#ccc" } }}>
<Icon iconName="Download" styles={{ root: { verticalAlign: "middle" } }} /> {this.props.data.downloads}
</Text>
<Text variant="tiny" styles={{ root: { color: "#ccc" } }}>
<Icon iconName="Heart" styles={{ root: { verticalAlign: "middle" } }} /> {this.props.data.favorites}
</Text>
{this.generateIconText("RedEye", this.props.data.views.toString())}
{this.generateIconText("Download", this.props.data.downloads.toString())}
{this.props.isFavorite !== undefined && this.generateIconText("Heart", this.props.data.favorites.toString())}
</Card.Section>
<Card.Item>
@@ -105,17 +104,18 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
</Card.Item>
<Card.Section horizontal styles={{ root: { marginTop: 0 } }}>
{this.generateIconButtonWithTooltip(
this.props.isFavorite ? "HeartFill" : "Heart",
this.props.isFavorite ? "Unlike" : "Like",
this.props.isFavorite ? this.onUnfavoriteClick : this.onFavoriteClick
)}
{this.props.isFavorite !== undefined &&
this.generateIconButtonWithTooltip(
this.props.isFavorite ? "HeartFill" : "Heart",
this.props.isFavorite ? "Unlike" : "Like",
this.props.isFavorite ? this.onUnfavoriteClick : this.onFavoriteClick
)}
{this.generateIconButtonWithTooltip("Download", "Download", this.onDownloadClick)}
{this.props.showDelete && (
<div style={{ width: "100%", textAlign: "right" }}>
{this.generateIconButtonWithTooltip("Delete", "Remove", this.props.onDeleteClick)}
{this.generateIconButtonWithTooltip("Delete", "Remove", this.onDeleteClick)}
</div>
)}
</Card.Section>
@@ -123,6 +123,14 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
);
}
private generateIconText = (iconName: string, text: string): JSX.Element => {
return (
<Text variant="tiny" styles={{ root: { color: "#ccc" } }}>
<Icon iconName={iconName} styles={{ root: { verticalAlign: "middle" } }} /> {text}
</Text>
);
};
/*
* Fluent UI doesn't support tooltips on IconButtons out of the box. In the meantime the recommendation is
* to do the following (from https://developer.microsoft.com/en-us/fluentui#/controls/web/button)

View File

@@ -14,6 +14,7 @@ exports[`GalleryCardComponent renders 1`] = `
>
<CardItem>
<StyledPersonaBase
imageUrl={false}
secondaryText="Invalid Date"
text="author"
/>
@@ -256,6 +257,7 @@ exports[`GalleryCardComponent renders 1`] = `
"iconName": "Delete",
}
}
onClick={[Function]}
title="Remove"
/>
</StyledTooltipHostBase>

View File

@@ -75,27 +75,10 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
private static readonly mostViewedText = "Most viewed";
private static readonly mostDownloadedText = "Most downloaded";
private static readonly mostFavoritedText = "Most favorited";
private static readonly mostFavoritedText = "Most liked";
private static readonly mostRecentText = "Most recent";
private static readonly sortingOptions: IDropdownOption[] = [
{
key: SortBy.MostViewed,
text: GalleryViewerComponent.mostViewedText
},
{
key: SortBy.MostDownloaded,
text: GalleryViewerComponent.mostDownloadedText
},
{
key: SortBy.MostFavorited,
text: GalleryViewerComponent.mostFavoritedText
},
{
key: SortBy.MostRecent,
text: GalleryViewerComponent.mostRecentText
}
];
private readonly sortingOptions: IDropdownOption[];
private sampleNotebooks: IGalleryItem[];
private publicNotebooks: IGalleryItem[];
@@ -118,8 +101,29 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
dialogProps: undefined
};
this.sortingOptions = [
{
key: SortBy.MostViewed,
text: GalleryViewerComponent.mostViewedText
},
{
key: SortBy.MostDownloaded,
text: GalleryViewerComponent.mostDownloadedText
},
{
key: SortBy.MostRecent,
text: GalleryViewerComponent.mostRecentText
}
];
if (this.props.container?.isGalleryPublishEnabled()) {
this.sortingOptions.push({
key: SortBy.MostFavorited,
text: GalleryViewerComponent.mostFavoritedText
});
}
this.loadTabContent(this.state.selectedTab, this.state.searchText, this.state.sortBy, false);
if (this.props.container) {
if (this.props.container?.isGalleryPublishEnabled()) {
this.loadFavoriteNotebooks(this.state.searchText, this.state.sortBy, false); // Need this to show correct favorite button state
}
}
@@ -131,16 +135,10 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
public render(): JSX.Element {
const tabs: GalleryTabInfo[] = [this.createTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks)];
if (this.props.container) {
if (this.props.container.isGalleryPublishEnabled()) {
tabs.push(this.createTab(GalleryTab.PublicGallery, this.state.publicNotebooks));
}
if (this.props.container?.isGalleryPublishEnabled()) {
tabs.push(this.createTab(GalleryTab.PublicGallery, this.state.publicNotebooks));
tabs.push(this.createTab(GalleryTab.Favorites, this.state.favoriteNotebooks));
if (this.props.container.isGalleryPublishEnabled()) {
tabs.push(this.createTab(GalleryTab.Published, this.state.publishedNotebooks));
}
tabs.push(this.createTab(GalleryTab.Published, this.state.publishedNotebooks));
}
const pivotProps: IPivotProps = {
@@ -189,11 +187,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
<Label>Sort by</Label>
</Stack.Item>
<Stack.Item styles={{ root: { minWidth: 200 } }}>
<Dropdown
options={GalleryViewerComponent.sortingOptions}
selectedKey={this.state.sortBy}
onChange={this.onDropdownChange}
/>
<Dropdown options={this.sortingOptions} selectedKey={this.state.sortBy} onChange={this.onDropdownChange} />
</Stack.Item>
</Stack>
@@ -405,7 +399,10 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
};
private onRenderCell = (data?: IGalleryItem): JSX.Element => {
const isFavorite = this.favoriteNotebooks?.find(item => item.id === data.id) !== undefined;
let isFavorite: boolean;
if (this.props.container?.isGalleryPublishEnabled()) {
isFavorite = this.favoriteNotebooks?.find(item => item.id === data.id) !== undefined;
}
const props: GalleryCardComponentProps = {
data,
isFavorite,
@@ -434,7 +431,8 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
[GalleryUtils.NotebookViewerParams.GalleryItemId]: data.id
});
window.open(`/notebookViewer.html?${params.toString()}`);
const location = new URL("./notebookViewer.html", window.location.href).href;
window.open(`${location}?${params.toString()}`);
}
};

View File

@@ -67,10 +67,6 @@ exports[`GalleryViewerComponent renders 1`] = `
"key": 1,
"text": "Most downloaded",
},
Object {
"key": 2,
"text": "Most favorited",
},
Object {
"key": 3,
"text": "Most recent",

View File

@@ -44,11 +44,15 @@ export class NotebookMetadataComponent extends React.Component<NotebookMetadataC
{FileSystemUtil.stripExtension(this.props.data.name, "ipynb")}
</Text>
<Text>
<IconButton
iconProps={{ iconName: this.props.isFavorite ? "HeartFill" : "Heart" }}
onClick={this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick}
/>
{this.props.data.favorites} likes
{this.props.isFavorite !== undefined && (
<>
<IconButton
iconProps={{ iconName: this.props.isFavorite ? "HeartFill" : "Heart" }}
onClick={this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick}
/>
{this.props.data.favorites} likes
</>
)}
</Text>
<PrimaryButton text={this.props.downloadButtonText} onClick={this.props.onDownloadClick} />
</Stack>

View File

@@ -129,7 +129,7 @@ export class NotebookViewerComponent extends React.Component<NotebookViewerCompo
<></>
)}
{this.notebookComponentBootstrapper.renderComponent(NotebookReadOnlyRenderer, { hideInputs: true })}
{this.notebookComponentBootstrapper.renderComponent(NotebookReadOnlyRenderer, { hideInputs: false })}
{this.state.dialogProps && <DialogComponent {...this.state.dialogProps} />}
</div>

View File

@@ -0,0 +1,16 @@
@import "../../../../less/Common/Constants.less";
.radioSwitchComponent {
cursor: pointer;
display: flex;
&>span:nth-child(n+2) {
margin-left: 10px;
}
.caption {
color: @BaseDark;
padding-left: @SmallSpace;
vertical-align: top;
}
}

View File

@@ -0,0 +1,51 @@
/**
* Horizontal switch component
*/
import * as React from "react";
import "./RadioSwitchComponent.less";
import { Icon } from "office-ui-fabric-react/lib/Icon";
import { NormalizedEventKey } from "../../../Common/Constants";
export interface Choice {
key: string;
onSelect: () => void;
label: string;
}
export interface RadioSwitchComponentProps {
choices: Choice[];
selectedKey: string;
onSelectionKeyChange?: (newValue: string) => void;
}
export class RadioSwitchComponent extends React.Component<RadioSwitchComponentProps> {
public render(): JSX.Element {
return (
<div className="radioSwitchComponent">
{this.props.choices.map((choice: Choice) => (
<span
tabIndex={0}
key={choice.key}
onClick={() => this.onSelect(choice)}
onKeyPress={event => this.onKeyPress(event, choice)}
>
<Icon iconName={this.props.selectedKey === choice.key ? "RadioBtnOn" : "RadioBtnOff"} />
<span className="caption">{choice.label}</span>
</span>
))}
</div>
);
}
private onSelect(choice: Choice): void {
this.props.onSelectionKeyChange && this.props.onSelectionKeyChange(choice.key);
choice.onSelect();
}
private onKeyPress(event: React.KeyboardEvent<HTMLSpanElement>, choice: Choice): void {
if (event.key === NormalizedEventKey.Enter || event.key === NormalizedEventKey.Space) {
this.onSelect(choice);
}
}
}

View File

@@ -0,0 +1,35 @@
/* Utilities for validation */
export const onValidateValueChange = (newValue: string, minValue?: number, maxValue?: number): number => {
let numericValue = parseInt(newValue);
if (!isNaN(numericValue) && isFinite(numericValue)) {
if (minValue !== undefined && numericValue < minValue) {
numericValue = minValue;
}
if (maxValue !== undefined && numericValue > maxValue) {
numericValue = maxValue;
}
return Math.floor(numericValue);
}
return undefined;
};
export const onIncrementValue = (newValue: string, step: number, max?: number): number => {
const numericValue = parseInt(newValue);
if (!isNaN(numericValue) && isFinite(numericValue)) {
const newValue = numericValue + step;
return max !== undefined ? Math.min(max, newValue) : newValue;
}
return undefined;
};
export const onDecrementValue = (newValue: string, step: number, min?: number): number => {
const numericValue = parseInt(newValue);
if (!isNaN(numericValue) && isFinite(numericValue)) {
const newValue = numericValue - step;
return min !== undefined ? Math.max(min, newValue) : newValue;
}
return undefined;
};

View File

@@ -0,0 +1,14 @@
@import "../../../../less/Common/Constants.less";
.widgetRendererContainer {
text-align: left;
.inputLabelContainer {
margin-bottom: 4px;
.inputLabel {
color: #393939;
font-weight: 600;
}
}
}

View File

@@ -0,0 +1,88 @@
import React from "react";
import { shallow } from "enzyme";
import { SmartUiComponent, Descriptor, InputType } from "./SmartUiComponent";
describe("SmartUiComponent", () => {
const exampleData: Descriptor = {
root: {
id: "root",
info: {
message: "Start at $24/mo per database",
link: {
href: "https://aka.ms/azure-cosmos-db-pricing",
text: "More Details"
}
},
children: [
{
id: "throughput",
input: {
label: "Throughput (input)",
dataFieldName: "throughput",
type: "number",
min: 400,
max: 500,
step: 10,
defaultValue: 400,
inputType: "spin"
}
},
{
id: "throughput2",
input: {
label: "Throughput (Slider)",
dataFieldName: "throughput2",
type: "number",
min: 400,
max: 500,
step: 10,
defaultValue: 400,
inputType: "slider"
}
},
{
id: "containerId",
input: {
label: "Container id",
dataFieldName: "containerId",
type: "string"
}
},
{
id: "analyticalStore",
input: {
label: "Analytical Store",
trueLabel: "Enabled",
falseLabel: "Disabled",
defaultValue: true,
dataFieldName: "analyticalStore",
type: "boolean"
}
},
{
id: "database",
input: {
label: "Database",
dataFieldName: "database",
type: "enum",
choices: [
{ label: "Database 1", key: "db1", value: "database1" },
{ label: "Database 2", key: "db2", value: "database2" },
{ label: "Database 3", key: "db3", value: "database3" }
],
defaultKey: "db2"
}
}
]
}
};
const exampleCallbacks = (newValues: Map<string, InputType>): void => {
console.log("New values:", newValues);
};
it("should render", () => {
const wrapper = shallow(<SmartUiComponent descriptor={exampleData} onChange={exampleCallbacks} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,335 @@
import * as React from "react";
import { Position } from "office-ui-fabric-react/lib/utilities/positioning";
import { Slider } from "office-ui-fabric-react/lib/Slider";
import { SpinButton } from "office-ui-fabric-react/lib/SpinButton";
import { Dropdown, IDropdownOption } from "office-ui-fabric-react/lib/Dropdown";
import { TextField } from "office-ui-fabric-react/lib/TextField";
import { Text } from "office-ui-fabric-react/lib/Text";
import { InputType } from "../../Tables/Constants";
import { RadioSwitchComponent } from "../RadioSwitchComponent/RadioSwitchComponent";
import { Stack, IStackTokens } from "office-ui-fabric-react/lib/Stack";
import { Link, MessageBar, MessageBarType } from "office-ui-fabric-react";
import * as InputUtils from "./InputUtils";
import "./SmartUiComponent.less";
/**
* Generic UX renderer
* It takes:
* - a JSON object as data
* - a Map of callbacks
* - a descriptor of the UX.
*/
export type InputTypeValue = "number" | "string" | "boolean" | "enum";
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export type EnumItem = { label: string; key: string; value: any };
export type InputType = number | string | boolean | EnumItem;
interface BaseInput {
label: string;
dataFieldName: string;
type: InputTypeValue;
placeholder?: string;
}
/**
* For now, this only supports integers
*/
export interface NumberInput extends BaseInput {
min?: number;
max?: number;
step: number;
defaultValue: number;
inputType: "spin" | "slider";
}
export interface BooleanInput extends BaseInput {
trueLabel: string;
falseLabel: string;
defaultValue: boolean;
}
export interface StringInput extends BaseInput {
defaultValue?: string;
}
export interface EnumInput extends BaseInput {
choices: EnumItem[];
defaultKey: string;
}
export interface Info {
message: string;
link?: {
href: string;
text: string;
};
}
export type AnyInput = NumberInput | BooleanInput | StringInput | EnumInput;
export interface Node {
id: string;
info?: Info;
input?: AnyInput;
children?: Node[];
}
export interface Descriptor {
root: Node;
}
/************************** Component implementation starts here ************************************* */
export interface SmartUiComponentProps {
descriptor: Descriptor;
onChange: (newValues: Map<string, InputType>) => void;
}
interface SmartUiComponentState {
currentValues: Map<string, InputType>;
errors: Map<string, string>;
}
export class SmartUiComponent extends React.Component<SmartUiComponentProps, SmartUiComponentState> {
private static readonly labelStyle = {
color: "#393939",
fontFamily: "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
fontSize: 12
};
constructor(props: SmartUiComponentProps) {
super(props);
this.state = {
currentValues: new Map(),
errors: new Map()
};
}
private renderInfo(info: Info): JSX.Element {
return (
<MessageBar>
{info.message}
<Link href={info.link.href} target="_blank">
{info.link.text}
</Link>
</MessageBar>
);
}
private onInputChange = (newValue: string | number | boolean, dataFieldName: string) => {
const { currentValues } = this.state;
currentValues.set(dataFieldName, newValue);
this.setState({ currentValues }, () => this.props.onChange(this.state.currentValues));
};
private renderStringInput(input: StringInput): JSX.Element {
return (
<div className="stringInputContainer">
<div>
<TextField
id={`${input.dataFieldName}-input`}
label={input.label}
type="text"
value={input.defaultValue}
placeholder={input.placeholder}
onChange={(_, newValue) => this.onInputChange(newValue, input.dataFieldName)}
styles={{
subComponentStyles: {
label: {
root: {
...SmartUiComponent.labelStyle,
fontWeight: 600
}
}
}
}}
/>
</div>
</div>
);
}
private clearError(dataFieldName: string): void {
const { errors } = this.state;
errors.delete(dataFieldName);
this.setState({ errors });
}
private onValidate = (value: string, min: number, max: number, dataFieldName: string): string => {
const newValue = InputUtils.onValidateValueChange(value, min, max);
if (newValue) {
this.onInputChange(newValue, dataFieldName);
this.clearError(dataFieldName);
return newValue.toString();
} else {
const { errors } = this.state;
errors.set(dataFieldName, `Invalid value ${value}: must be between ${min} and ${max}`);
this.setState({ errors });
}
return undefined;
};
private onIncrement = (value: string, step: number, max: number, dataFieldName: string): string => {
const newValue = InputUtils.onIncrementValue(value, step, max);
if (newValue) {
this.onInputChange(newValue, dataFieldName);
this.clearError(dataFieldName);
return newValue.toString();
}
return undefined;
};
private onDecrement = (value: string, step: number, min: number, dataFieldName: string): string => {
const newValue = InputUtils.onDecrementValue(value, step, min);
if (newValue) {
this.onInputChange(newValue, dataFieldName);
this.clearError(dataFieldName);
return newValue.toString();
}
return undefined;
};
private renderNumberInput(input: NumberInput): JSX.Element {
const { label, min, max, defaultValue, dataFieldName, step } = input;
const props = { label, min, max, ariaLabel: label, step };
if (input.inputType === "spin") {
return (
<div>
<SpinButton
{...props}
defaultValue={defaultValue.toString()}
onValidate={newValue => this.onValidate(newValue, min, max, dataFieldName)}
onIncrement={newValue => this.onIncrement(newValue, step, max, dataFieldName)}
onDecrement={newValue => this.onDecrement(newValue, step, min, dataFieldName)}
labelPosition={Position.top}
styles={{
label: {
...SmartUiComponent.labelStyle,
fontWeight: 600
}
}}
/>
{this.state.errors.has(dataFieldName) && (
<MessageBar messageBarType={MessageBarType.error}>Error: {this.state.errors.get(dataFieldName)}</MessageBar>
)}
</div>
);
} else if (input.inputType === "slider") {
return (
<Slider
// showValue={true}
// valueFormat={}
{...props}
defaultValue={defaultValue}
onChange={newValue => this.onInputChange(newValue, dataFieldName)}
styles={{
titleLabel: {
...SmartUiComponent.labelStyle,
fontWeight: 600
},
valueLabel: SmartUiComponent.labelStyle
}}
/>
);
} else {
return <>Unsupported number input type {input.inputType}</>;
}
}
private renderBooleanInput(input: BooleanInput): JSX.Element {
const { dataFieldName } = input;
return (
<div>
<div className="inputLabelContainer">
<Text variant="small" nowrap className="inputLabel">
{input.label}
</Text>
</div>
<RadioSwitchComponent
choices={[
{
label: input.falseLabel,
key: "false",
onSelect: () => this.onInputChange(false, dataFieldName)
},
{
label: input.trueLabel,
key: "true",
onSelect: () => this.onInputChange(true, dataFieldName)
}
]}
selectedKey={
(this.state.currentValues.has(dataFieldName)
? (this.state.currentValues.get(dataFieldName) as boolean)
: input.defaultValue)
? "true"
: "false"
}
/>
</div>
);
}
private renderEnumInput(input: EnumInput): JSX.Element {
const { label, defaultKey, dataFieldName, choices, placeholder } = input;
return (
<Dropdown
label={label}
selectedKey={
this.state.currentValues.has(dataFieldName)
? (this.state.currentValues.get(dataFieldName) as string)
: defaultKey
}
onChange={(_, item: IDropdownOption) => this.onInputChange(item.key.toString(), dataFieldName)}
placeholder={placeholder}
options={choices.map(c => ({
key: c.key,
text: c.value
}))}
styles={{
label: {
...SmartUiComponent.labelStyle,
fontWeight: 600
},
dropdown: SmartUiComponent.labelStyle
}}
/>
);
}
private renderInput(input: AnyInput): JSX.Element {
switch (input.type) {
case "string":
return this.renderStringInput(input as StringInput);
case "number":
return this.renderNumberInput(input as NumberInput);
case "boolean":
return this.renderBooleanInput(input as BooleanInput);
case "enum":
return this.renderEnumInput(input as EnumInput);
default:
throw new Error(`Unknown input type: ${input.type}`);
}
}
private renderNode(node: Node): JSX.Element {
const containerStackTokens: IStackTokens = { childrenGap: 10 };
return (
<Stack tokens={containerStackTokens} className="widgetRendererContainer">
{node.info && this.renderInfo(node.info)}
{node.input && this.renderInput(node.input)}
{node.children && node.children.map(child => <div key={child.id}>{this.renderNode(child)}</div>)}
</Stack>
);
}
render(): JSX.Element {
return <>{this.renderNode(this.props.descriptor.root)}</>;
}
}

View File

@@ -0,0 +1,240 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SmartUiComponent should render 1`] = `
<Fragment>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StyledMessageBarBase>
Start at $24/mo per database
<StyledLinkBase
href="https://aka.ms/azure-cosmos-db-pricing"
target="_blank"
>
More Details
</StyledLinkBase>
</StyledMessageBarBase>
<div
key="throughput"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<div>
<CustomizedSpinButton
ariaLabel="Throughput (input)"
decrementButtonIcon={
Object {
"iconName": "ChevronDownSmall",
}
}
defaultValue="400"
disabled={false}
incrementButtonIcon={
Object {
"iconName": "ChevronUpSmall",
}
}
label="Throughput (input)"
labelPosition={0}
max={500}
min={400}
onDecrement={[Function]}
onIncrement={[Function]}
onValidate={[Function]}
step={10}
styles={
Object {
"label": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
}
}
/>
</div>
</Stack>
</div>
<div
key="throughput2"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StyledSliderBase
ariaLabel="Throughput (Slider)"
defaultValue={400}
label="Throughput (Slider)"
max={500}
min={400}
onChange={[Function]}
step={10}
styles={
Object {
"titleLabel": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
"valueLabel": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
},
}
}
/>
</Stack>
</div>
<div
key="containerId"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<div
className="stringInputContainer"
>
<div>
<StyledTextFieldBase
id="containerId-input"
label="Container id"
onChange={[Function]}
styles={
Object {
"subComponentStyles": Object {
"label": Object {
"root": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
},
},
}
}
type="text"
/>
</div>
</div>
</Stack>
</div>
<div
key="analyticalStore"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<div>
<div
className="inputLabelContainer"
>
<Text
className="inputLabel"
nowrap={true}
variant="small"
>
Analytical Store
</Text>
</div>
<RadioSwitchComponent
choices={
Array [
Object {
"key": "false",
"label": "Disabled",
"onSelect": [Function],
},
Object {
"key": "true",
"label": "Enabled",
"onSelect": [Function],
},
]
}
selectedKey="true"
/>
</div>
</Stack>
</div>
<div
key="database"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StyledWithResponsiveMode
label="Database"
onChange={[Function]}
options={
Array [
Object {
"key": "db1",
"text": "database1",
},
Object {
"key": "db2",
"text": "database2",
},
Object {
"key": "db3",
"text": "database3",
},
]
}
selectedKey="db2"
styles={
Object {
"dropdown": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
},
"label": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
}
}
/>
</Stack>
</div>
</Stack>
</Fragment>
`;

View File

@@ -83,6 +83,7 @@ import { UploadFilePane } from "./Panes/UploadFilePane";
import { UploadItemsPane } from "./Panes/UploadItemsPane";
import { UploadItemsPaneAdapter } from "./Panes/UploadItemsPaneAdapter";
import { ReactAdapter } from "../Bindings/ReactBindingHandler";
import { toRawContentUri, fromContentUri } from "../Utils/GitHubUtils";
BindingHandlersRegisterer.registerBindingHandlers();
// Hold a reference to ComponentRegisterer to prevent transpiler to ignore import
@@ -199,7 +200,6 @@ export default class Explorer implements ViewModels.Explorer {
public publishNotebookPaneAdapter: ReactAdapter;
// features
public isGalleryEnabled: ko.Computed<boolean>;
public isGalleryPublishEnabled: ko.Computed<boolean>;
public isGitHubPaneEnabled: ko.Observable<boolean>;
public isPublishNotebookPaneEnabled: ko.Observable<boolean>;
@@ -244,7 +244,10 @@ export default class Explorer implements ViewModels.Explorer {
private _isInitializingSparkConnectionInfo: boolean;
private notebookBasePath: ko.Observable<string>;
private _arcadiaManager: ViewModels.ArcadiaResourceManager;
private _filePathToImportAndOpen: string;
private notebookToImport: {
name: string;
content: string;
};
// React adapters
private commandBarComponentAdapter: CommandBarComponentAdapter;
@@ -344,7 +347,7 @@ export default class Explorer implements ViewModels.Explorer {
await this.initNotebooks(this.databaseAccount());
const workspaces = await this._getArcadiaWorkspaces();
this.arcadiaWorkspaces(workspaces);
} else if (this._filePathToImportAndOpen) {
} else if (this.notebookToImport) {
// if notebooks is not enabled but the user is trying to do a quickstart setup with notebooks, open the SetupNotebooksPane
this._openSetupNotebooksPaneForQuickstart();
}
@@ -405,7 +408,6 @@ export default class Explorer implements ViewModels.Explorer {
this.shouldShowShareDialogContents = ko.observable<boolean>(false);
this.shouldShowDataAccessExpiryDialog = ko.observable<boolean>(false);
this.shouldShowContextSwitchPrompt = ko.observable<boolean>(false);
this.isGalleryEnabled = ko.computed<boolean>(() => this.isFeatureEnabled(Constants.Features.enableGallery));
this.isGalleryPublishEnabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableGalleryPublish)
);
@@ -2490,10 +2492,6 @@ export default class Explorer implements ViewModels.Explorer {
const parent = this.resourceTree.myNotebooksContentRoot;
if (parent && parent.children && this.isNotebookEnabled() && this.notebookManager?.notebookClient) {
if (this._filePathToImportAndOpen === path) {
this._filePathToImportAndOpen = null; // we don't want to try opening this path again
}
const existingItem = _.find(parent.children, node => node.name === name);
if (existingItem) {
return this.openNotebook(existingItem);
@@ -2504,24 +2502,27 @@ export default class Explorer implements ViewModels.Explorer {
return this.openNotebook(uploadedItem);
}
this._filePathToImportAndOpen = path; // we'll try opening this path later on
return Promise.resolve(false);
}
public async importAndOpenFromGallery(name: string, content: string): Promise<boolean> {
public async importAndOpenContent(name: string, content: string): Promise<boolean> {
const parent = this.resourceTree.myNotebooksContentRoot;
if (parent && parent.children && this.isNotebookEnabled() && this.notebookManager?.notebookClient) {
if (this.notebookToImport && this.notebookToImport.name === name && this.notebookToImport.content === content) {
this.notebookToImport = undefined; // we don't want to try opening this notebook again
}
const existingItem = _.find(parent.children, node => node.name === name);
if (existingItem) {
this.showOkModalDialog("Download failed", "Notebook with the same name already exists.");
return Promise.reject(false);
return this.openNotebook(existingItem);
}
const uploadedItem = await this.uploadFile(name, content, parent);
return this.openNotebook(uploadedItem);
}
this.notebookToImport = { name, content }; // we'll try opening this notebook later on
return Promise.resolve(false);
}
@@ -2927,8 +2928,8 @@ export default class Explorer implements ViewModels.Explorer {
}
await this.resourceTree.initialize();
if (this._filePathToImportAndOpen) {
this.importAndOpen(this._filePathToImportAndOpen);
if (this.notebookToImport) {
this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content);
}
};
@@ -3351,6 +3352,21 @@ export default class Explorer implements ViewModels.Explorer {
this._openSetupNotebooksPaneForQuickstart();
}
this.importAndOpen(path);
// We still use github urls like https://github.com/Azure-Samples/cosmos-notebooks/blob/master/CSharp_quickstarts/GettingStarted_CSharp.ipynb
// when launching a notebook quickstart from Portal. In future we should just use gallery id and use Juno to fetch instead of directly
// calling GitHub. For now convert this url to a raw url and download content.
const gitHubInfo = fromContentUri(path);
if (gitHubInfo) {
const rawUrl = toRawContentUri(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.branch, gitHubInfo.path);
const response = await fetch(rawUrl);
if (response.status === Constants.HttpStatusCodes.OK) {
this.notebookToImport = {
name: NotebookUtil.getName(path),
content: await response.text()
};
this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content);
}
}
}
}

View File

@@ -19,7 +19,6 @@ describe("CommandBarComponentButtonFactory tests", () => {
mockExplorer.isPreferredApiMongoDB = ko.computed<boolean>(() => false);
mockExplorer.isPreferredApiCassandra = ko.computed<boolean>(() => false);
mockExplorer.isSparkEnabled = ko.observable(true);
mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false);
mockExplorer.isGalleryPublishEnabled = ko.computed<boolean>(() => false);
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
@@ -82,7 +81,6 @@ describe("CommandBarComponentButtonFactory tests", () => {
mockExplorer.isPreferredApiTable = ko.computed(() => true);
mockExplorer.isPreferredApiCassandra = ko.computed<boolean>(() => false);
mockExplorer.isSparkEnabled = ko.observable(true);
mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false);
mockExplorer.isGalleryPublishEnabled = ko.computed<boolean>(() => false);
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
@@ -163,7 +161,6 @@ describe("CommandBarComponentButtonFactory tests", () => {
mockExplorer.isPreferredApiTable = ko.computed(() => true);
mockExplorer.isPreferredApiMongoDB = ko.computed<boolean>(() => false);
mockExplorer.isSparkEnabled = ko.observable(true);
mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false);
mockExplorer.isGalleryPublishEnabled = ko.computed<boolean>(() => false);
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
@@ -250,7 +247,6 @@ describe("CommandBarComponentButtonFactory tests", () => {
mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
mockExplorer.isNotebooksEnabledForAccount = ko.observable(false);
mockExplorer.isRunningOnNationalCloud = ko.observable(false);
mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false);
mockExplorer.isGalleryPublishEnabled = ko.computed<boolean>(() => false);
mockExplorer.notebookManager = new NotebookManager();
mockExplorer.notebookManager.gitHubOAuthService = new GitHubOAuthService(undefined);

View File

@@ -4,7 +4,7 @@ import { NotebookContentRecordProps, selectors } from "@nteract/core";
* A bunch of utilities to interact with nteract
*/
export default class NTeractUtil {
public static getCurrentCellType(content: NotebookContentRecordProps): "markdown" | "code" | "raw" {
public static getCurrentCellType(content: NotebookContentRecordProps): "markdown" | "code" | "raw" | undefined {
if (!content) {
return undefined;
}

View File

@@ -5,10 +5,10 @@ import { Notebook } from "../../../Common/Constants";
import { CellId } from "@nteract/commutable";
export interface CdbRecordProps {
databaseAccountName: string;
defaultExperience: string;
databaseAccountName: string | undefined;
defaultExperience: string | undefined;
kernelRestartDelayMs: number;
hoveredCellId: CellId;
hoveredCellId: CellId | undefined;
}
export type CdbRecord = Immutable.RecordOf<CdbRecordProps>;

View File

@@ -6,7 +6,6 @@ import { JunoClient } from "../../Juno/JunoClient";
import * as ViewModels from "../../Contracts/ViewModels";
import { GitHubOAuthService } from "../../GitHub/GitHubOAuthService";
import { GitHubClient } from "../../GitHub/GitHubClient";
import { config } from "../../Config";
import * as Logger from "../../Common/Logger";
import { HttpStatusCodes, Areas } from "../../Common/Constants";
import { GitHubReposPane } from "../Panes/GitHubReposPane";
@@ -54,7 +53,7 @@ export default class NotebookManager {
this.junoClient = new JunoClient(this.params.container.databaseAccount);
this.gitHubOAuthService = new GitHubOAuthService(this.junoClient);
this.gitHubClient = new GitHubClient(config.AZURESAMPLESCOSMOSDBPAT, this.onGitHubClientError);
this.gitHubClient = new GitHubClient(this.onGitHubClientError);
this.gitHubReposPane = new GitHubReposPane({
documentClientUtility: this.params.container.documentClientUtility,
id: "gitHubReposPane",
@@ -91,7 +90,7 @@ export default class NotebookManager {
}
this.gitHubOAuthService.getTokenObservable().subscribe(token => {
this.gitHubClient.setToken(token?.access_token ? token.access_token : config.AZURESAMPLESCOSMOSDBPAT);
this.gitHubClient.setToken(token?.access_token);
if (this.gitHubReposPane.visible()) {
this.gitHubReposPane.open();

View File

@@ -1,127 +0,0 @@
import { IGitHubRepo, IGitHubBranch } from "../../GitHub/GitHubClient";
export const SamplesRepo: IGitHubRepo = {
name: "cosmos-notebooks",
owner: "Azure-Samples",
private: false
};
export const SamplesBranch: IGitHubBranch = {
name: "master"
};
export const isSamplesCall = (owner: string, repo: string, branch?: string): boolean => {
return owner === SamplesRepo.owner && repo === SamplesRepo.name && (!branch || branch === SamplesBranch.name);
};
// GitHub API calls have a rate limit of 5000 requests per hour. So if we get high traffic on Data Explorer
// loading samples exceed that limit. Using this hard coded response for samples until we fix that.
export const SamplesContentsQueryResponse = {
repository: {
owner: {
login: "Azure-Samples"
},
name: "cosmos-notebooks",
isPrivate: false,
ref: {
name: "master",
target: {
history: {
nodes: [
{
oid: "cda7facb9e039b173f3376200c26c859896e7974",
message:
"Merge pull request #45 from Azure-Samples/users/deborahc/pythonSampleUpdates\n\nAdd bokeh version to notebook",
committer: {
date: "2020-05-28T11:28:01-07:00"
}
}
]
}
}
},
object: {
entries: [
{
name: ".github",
type: "tree",
object: {}
},
{
name: ".gitignore",
type: "blob",
object: {
oid: "3e759b75bf455ac809d0987d369aab89137b5689",
byteSize: 5582
}
},
{
name: "1. GettingStarted.ipynb",
type: "blob",
object: {
oid: "0732ff5366e4aefdc4c378c61cbd968664f0acec",
byteSize: 3933
}
},
{
name: "2. Visualization.ipynb",
type: "blob",
object: {
oid: "6b16b0740a77afdd38a95bc6c3ebd0f2f17d9465",
byteSize: 820317
}
},
{
name: "3. RequestUnits.ipynb",
type: "blob",
object: {
oid: "252b79a4adc81e9f2ffde453231b695d75e270e8",
byteSize: 9490
}
},
{
name: "4. Indexing.ipynb",
type: "blob",
object: {
oid: "e10dd67bd1c55c345226769e4f80e43659ef9cd5",
byteSize: 10394
}
},
{
name: "5. StoredProcedures.ipynb",
type: "blob",
object: {
oid: "949941949920de4d2d111149e2182e9657cc8134",
byteSize: 11818
}
},
{
name: "6. GlobalDistribution.ipynb",
type: "blob",
object: {
oid: "b91c31dacacbc9e35750d9054063dda4a5309f3b",
byteSize: 11375
}
},
{
name: "7. IoTAnomalyDetection.ipynb",
type: "blob",
object: {
oid: "82057ae52a67721a5966e2361317f5dfbd0ee595",
byteSize: 377939
}
},
{
name: "All_API_quickstarts",
type: "tree",
object: {}
},
{
name: "CSharp_quickstarts",
type: "tree",
object: {}
}
]
}
}
};

View File

@@ -96,7 +96,6 @@ export class ExplorerStub implements ViewModels.Explorer {
public setupNotebooksPane: SetupNotebooksPane;
public setupSparkClusterPane: ViewModels.ContextualPane;
public manageSparkClusterPane: ViewModels.ContextualPane;
public isGalleryEnabled: ko.Computed<boolean>;
public isGalleryPublishEnabled: ko.Computed<boolean>;
public isGitHubPaneEnabled: ko.Observable<boolean>;
public isPublishNotebookPaneEnabled: ko.Observable<boolean>;
@@ -333,7 +332,7 @@ export class ExplorerStub implements ViewModels.Explorer {
throw new Error("Not implemented");
}
public importAndOpenFromGallery(name: string, content: string): Promise<boolean> {
public importAndOpenContent(name: string, content: string): Promise<boolean> {
throw new Error("Not implemented");
}

View File

@@ -18,13 +18,11 @@ import FileIcon from "../../../images/notebook/file-cosmos.svg";
import { ArrayHashMap } from "../../Common/ArrayHashMap";
import { NotebookUtil } from "../Notebook/NotebookUtil";
import _ from "underscore";
import { StringUtils } from "../../Utils/StringUtils";
import { IPinnedRepo } from "../../Juno/JunoClient";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { Areas } from "../../Common/Constants";
import * as GitHubUtils from "../../Utils/GitHubUtils";
import { SamplesRepo, SamplesBranch } from "../Notebook/NotebookSamples";
import GalleryIcon from "../../../images/GalleryIcon.svg";
import { Callout, Text, Link, DirectionalHint, Stack, ICalloutProps, ILinkProps } from "office-ui-fabric-react";
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
@@ -37,7 +35,6 @@ export class ResourceTreeAdapter implements ReactAdapter {
public parameters: ko.Observable<number>;
public galleryContentRoot: NotebookContentItem;
public sampleNotebooksContentRoot: NotebookContentItem;
public myNotebooksContentRoot: NotebookContentItem;
public gitHubNotebooksContentRoot: NotebookContentItem;
@@ -95,26 +92,11 @@ export class ResourceTreeAdapter implements ReactAdapter {
public async initialize(): Promise<void[]> {
const refreshTasks: Promise<void>[] = [];
if (this.container.isGalleryEnabled()) {
this.galleryContentRoot = {
name: "Gallery",
path: "Gallery",
type: NotebookContentItemType.File
};
this.sampleNotebooksContentRoot = undefined;
} else {
this.galleryContentRoot = undefined;
this.sampleNotebooksContentRoot = {
name: "Sample Notebooks (View Only)",
path: GitHubUtils.toContentUri(SamplesRepo.owner, SamplesRepo.name, SamplesBranch.name, ""),
type: NotebookContentItemType.Directory
};
refreshTasks.push(
this.container.refreshContentItem(this.sampleNotebooksContentRoot).then(() => this.triggerRender())
);
}
this.galleryContentRoot = {
name: "Gallery",
path: "Gallery",
type: NotebookContentItemType.File
};
this.myNotebooksContentRoot = {
name: "My Notebooks",
@@ -361,10 +343,6 @@ export class ResourceTreeAdapter implements ReactAdapter {
notebooksTree.children.push(this.buildGalleryNotebooksTree());
}
if (this.sampleNotebooksContentRoot) {
notebooksTree.children.push(this.buildSampleNotebooksTree());
}
if (this.myNotebooksContentRoot) {
notebooksTree.children.push(this.buildMyNotebooksTree());
}
@@ -437,57 +415,6 @@ export class ResourceTreeAdapter implements ReactAdapter {
};
}
private buildSampleNotebooksTree(): TreeNode {
const sampleNotebooksTree: TreeNode = this.buildNotebookDirectoryNode(
this.sampleNotebooksContentRoot,
(item: NotebookContentItem) => {
const databaseAccountName: string = this.container.databaseAccount() && this.container.databaseAccount().name;
const defaultExperience: string = this.container.defaultExperience && this.container.defaultExperience();
const dataExplorerArea: string = Areas.ResourceTree;
const startKey: number = TelemetryProcessor.traceStart(Action.OpenSampleNotebook, {
databaseAccountName,
defaultExperience,
dataExplorerArea
});
this.container.importAndOpen(item.path).then(hasOpened => {
if (hasOpened) {
this.pushItemToMostRecent(item);
TelemetryProcessor.traceSuccess(
Action.OpenSampleNotebook,
{
databaseAccountName,
defaultExperience,
dataExplorerArea
},
startKey
);
} else {
TelemetryProcessor.traceFailure(
Action.OpenSampleNotebook,
{
databaseAccountName,
defaultExperience,
dataExplorerArea
},
startKey
);
}
});
},
false,
false
);
sampleNotebooksTree.isExpanded = true;
// Remove children starting with "."
sampleNotebooksTree.children = sampleNotebooksTree.children.filter(
node => !StringUtils.startsWith(node.label, ".")
);
return sampleNotebooksTree;
}
private buildMyNotebooksTree(): TreeNode {
const myNotebooksTree: TreeNode = this.buildNotebookDirectoryNode(
this.myNotebooksContentRoot,

View File

@@ -15,7 +15,7 @@ import * as GalleryUtils from "../Utils/GalleryUtils";
const onInit = async () => {
initializeIcons();
await initializeConfiguration();
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window);
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search);
const props: GalleryViewerComponentProps = {
junoClient: new JunoClient(),

View File

@@ -1,110 +0,0 @@
import { HttpStatusCodes } from "../Common/Constants";
import { GitHubClient, IGitHubFile } from "./GitHubClient";
import { SamplesRepo, SamplesBranch, SamplesContentsQueryResponse } from "../Explorer/Notebook/NotebookSamples";
const invalidTokenCallback = jest.fn();
// Use a dummy token to get around API rate limit (something which doesn't affect the API quota for AZURESAMPLESCOSMOSDBPAT in Config.ts)
const gitHubClient = new GitHubClient("cd1906b9534362fab6ce45d6db6c76b59e55bc50", invalidTokenCallback);
const validateGitHubFile = (file: IGitHubFile) => {
expect(file.branch).toEqual(SamplesBranch);
expect(file.commit).toBeDefined();
expect(file.name).toBeDefined();
expect(file.path).toBeDefined();
expect(file.repo).toEqual(SamplesRepo);
expect(file.type).toBeDefined();
switch (file.type) {
case "blob":
expect(file.sha).toBeDefined();
expect(file.size).toBeDefined();
break;
case "tree":
expect(file.sha).toBeUndefined();
expect(file.size).toBeUndefined();
break;
default:
throw new Error(`Unsupported github file type: ${file.type}`);
}
};
describe("GitHubClient", () => {
it("getRepoAsync returns valid repo", async () => {
const response = await gitHubClient.getRepoAsync(SamplesRepo.owner, SamplesRepo.name);
expect(response).toEqual({
status: HttpStatusCodes.OK,
data: SamplesRepo
});
});
it("getReposAsync returns repos for authenticated user", async () => {
const response = await gitHubClient.getReposAsync(1);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(response.data).toBeDefined();
expect(response.data.length).toBe(1);
expect(response.pageInfo).toBeDefined();
});
it("getBranchesAsync returns branches for a repo", async () => {
const response = await gitHubClient.getBranchesAsync(SamplesRepo.owner, SamplesRepo.name, 1);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(response.data).toEqual([SamplesBranch]);
expect(response.pageInfo).toBeDefined();
});
it("getContentsAsync returns files in the repo", async () => {
const response = await gitHubClient.getContentsAsync(SamplesRepo.owner, SamplesRepo.name, SamplesBranch.name);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(response.data).toBeDefined();
const data = response.data as IGitHubFile[];
expect(data.length).toBeGreaterThan(0);
data.forEach(content => validateGitHubFile(content));
});
it("getContentsAsync returns files in a dir", async () => {
const samplesDir = SamplesContentsQueryResponse.repository.object.entries.find(file => file.type === "tree");
const response = await gitHubClient.getContentsAsync(
SamplesRepo.owner,
SamplesRepo.name,
SamplesBranch.name,
samplesDir.name
);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(response.data).toBeDefined();
const data = response.data as IGitHubFile[];
expect(data.length).toBeGreaterThan(0);
data.forEach(content => validateGitHubFile(content));
});
it("getContentsAsync returns a file", async () => {
const samplesFile = SamplesContentsQueryResponse.repository.object.entries.find(file => file.type === "blob");
const response = await gitHubClient.getContentsAsync(
SamplesRepo.owner,
SamplesRepo.name,
SamplesBranch.name,
samplesFile.name
);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(response.data).toBeDefined();
const file = response.data as IGitHubFile;
expect(file.type).toBe("blob");
validateGitHubFile(file);
expect(file.content).toBeUndefined();
});
it("getBlobAsync returns file content", async () => {
const samplesFile = SamplesContentsQueryResponse.repository.object.entries.find(file => file.type === "blob");
const response = await gitHubClient.getBlobAsync(SamplesRepo.owner, SamplesRepo.name, samplesFile.object.oid);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(response.data).toBeDefined();
expect(typeof response.data).toBe("string");
});
});

View File

@@ -2,7 +2,6 @@ import { Octokit } from "@octokit/rest";
import { HttpStatusCodes } from "../Common/Constants";
import * as Logger from "../Common/Logger";
import UrlUtility from "../Common/UrlUtility";
import { isSamplesCall, SamplesContentsQueryResponse } from "../Explorer/Notebook/NotebookSamples";
import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil";
export interface IGitHubPageInfo {
@@ -225,8 +224,8 @@ export class GitHubClient {
private static readonly SelfErrorCode = 599;
private ocktokit: Octokit;
constructor(token: string, private errorCallback: (error: any) => void) {
this.initOctokit(token);
constructor(private errorCallback: (error: any) => void) {
this.initOctokit();
}
public setToken(token: string): void {
@@ -310,18 +309,13 @@ export class GitHubClient {
path?: string
): Promise<IGitHubResponse<IGitHubFile | IGitHubFile[]>> {
try {
let response: ContentsQueryResponse;
if (isSamplesCall(owner, repo, branch) && !path) {
response = SamplesContentsQueryResponse;
} else {
response = (await this.ocktokit.graphql(contentsQuery, {
owner,
repo,
ref: `refs/heads/${branch}`,
path: path || undefined,
objectExpression: `refs/heads/${branch}:${path || ""}`
} as ContentsQueryParams)) as ContentsQueryResponse;
}
const response = (await this.ocktokit.graphql(contentsQuery, {
owner,
repo,
ref: `refs/heads/${branch}`,
path: path || undefined,
objectExpression: `refs/heads/${branch}:${path || ""}`
} as ContentsQueryParams)) as ContentsQueryResponse;
let data: IGitHubFile | IGitHubFile[];
const entries = response.repository.object.entries;
@@ -495,7 +489,7 @@ export class GitHubClient {
return { status: response.status, data: <string>(<unknown>response.data) };
}
private async initOctokit(token: string) {
private async initOctokit(token?: string) {
this.ocktokit = new Octokit({
auth: token,
log: {

View File

@@ -5,7 +5,7 @@ import { GitHubClient, IGitHubCommit, IGitHubFile } from "./GitHubClient";
import { GitHubContentProvider } from "./GitHubContentProvider";
import * as GitHubUtils from "../Utils/GitHubUtils";
const gitHubClient = new GitHubClient("token", () => {});
const gitHubClient = new GitHubClient(() => {});
const gitHubContentProvider = new GitHubContentProvider({
gitHubClient,
promptForCommitMsg: () => Promise.resolve("commit msg")

View File

@@ -1,7 +1,9 @@
import ko from "knockout";
import { HttpStatusCodes } from "../Common/Constants";
import * as ViewModels from "../Contracts/ViewModels";
import { IPinnedRepo, JunoClient } from "./JunoClient";
import { IPinnedRepo, JunoClient, IGalleryItem } from "./JunoClient";
import { config } from "../Config";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
const sampleDatabaseAccount: ViewModels.DatabaseAccount = {
id: "id",
@@ -31,6 +33,23 @@ const samplePinnedRepos: IPinnedRepo[] = [
}
];
const sampleGalleryItems: IGalleryItem[] = [
{
id: "id",
name: "name",
description: "description",
gitSha: "gitSha",
tags: ["tag1"],
author: "author",
thumbnailUrl: "thumbnailUrl",
created: "created",
isSample: false,
downloads: 0,
favorites: 0,
views: 0
}
];
describe("Pinned repos", () => {
const junoClient = new JunoClient(ko.observable<ViewModels.DatabaseAccount>(sampleDatabaseAccount));
@@ -126,3 +145,231 @@ describe("GitHub", () => {
expect(fetchUrlParams.get("client_id")).toBeDefined();
});
});
describe("Gallery", () => {
const junoClient = new JunoClient(ko.observable<ViewModels.DatabaseAccount>(sampleDatabaseAccount));
afterEach(() => {
jest.resetAllMocks();
});
it("getSampleNotebooks", async () => {
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as any
});
const response = await junoClient.getSampleNotebooks();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/samples`, undefined);
});
it("getPublicNotebooks", async () => {
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as any
});
const response = await junoClient.getPublicNotebooks();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/public`, undefined);
});
it("getNotebook", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as any
});
const response = await junoClient.getNotebook(id);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/${id}`);
});
it("getNotebookContent", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
text: () => undefined as any
});
const response = await junoClient.getNotebookContent(id);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/${id}/content`);
});
it("increaseNotebookViews", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as any
});
const response = await junoClient.increaseNotebookViews(id);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/${id}/views`, {
method: "PATCH"
});
});
it("increaseNotebookDownloadCount", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as any
});
const response = await junoClient.increaseNotebookDownloadCount(id);
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(
`${config.JUNO_ENDPOINT}/api/notebooks/${sampleDatabaseAccount.name}/gallery/${id}/downloads`,
{
method: "PATCH",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
"content-type": "application/json"
}
}
);
});
it("favoriteNotebook", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as any
});
const response = await junoClient.favoriteNotebook(id);
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(
`${config.JUNO_ENDPOINT}/api/notebooks/${sampleDatabaseAccount.name}/gallery/${id}/favorite`,
{
method: "PATCH",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
"content-type": "application/json"
}
}
);
});
it("unfavoriteNotebook", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as any
});
const response = await junoClient.unfavoriteNotebook(id);
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/${id}/unfavorite`, {
method: "PATCH",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
"content-type": "application/json"
}
});
});
it("getFavoriteNotebooks", async () => {
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as any
});
const response = await junoClient.getFavoriteNotebooks();
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/favorites`, {
headers: {
[authorizationHeader.header]: authorizationHeader.token,
"content-type": "application/json"
}
});
});
it("getPublishedNotebooks", async () => {
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as any
});
const response = await junoClient.getPublishedNotebooks();
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/published`, {
headers: {
[authorizationHeader.header]: authorizationHeader.token,
"content-type": "application/json"
}
});
});
it("deleteNotebook", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as any
});
const response = await junoClient.deleteNotebook(id);
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/${id}`, {
method: "DELETE",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
"content-type": "application/json"
}
});
});
it("publishNotebook", async () => {
const name = "name";
const description = "description";
const tags = ["tag"];
const author = "author";
const thumbnailUrl = "thumbnailUrl";
const content = `{ "key": "value" }`;
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as any
});
const response = await junoClient.publishNotebook(name, description, tags, author, thumbnailUrl, content);
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/${sampleDatabaseAccount.name}/gallery`, {
method: "PUT",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
"content-type": "application/json"
},
body: JSON.stringify({
name,
description,
tags,
author,
thumbnailUrl,
content: JSON.parse(content)
})
});
});
});

View File

@@ -13,8 +13,8 @@ import * as GalleryUtils from "../Utils/GalleryUtils";
const onInit = async () => {
initializeIcons();
await initializeConfiguration();
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window);
const notebookViewerProps = GalleryUtils.getNotebookViewerProps(window);
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search);
const notebookViewerProps = GalleryUtils.getNotebookViewerProps(window.location.search);
const backNavigationText = galleryViewerProps.selectedTab && GalleryUtils.getTabTitle(galleryViewerProps.selectedTab);
const notebookUrl = decodeURIComponent(notebookViewerProps.notebookUrl);

View File

@@ -1,3 +1,3 @@
if (window.parent !== window) {
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = window.parent.__REACT_DEVTOOLS_GLOBAL_HOOK__;
(window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__ = (window.parent as any).__REACT_DEVTOOLS_GLOBAL_HOOK__;
}

View File

@@ -5,7 +5,7 @@ export class LocalStorageUtility {
return !!localStorage.getItem(StorageKey[key]);
}
public static getEntryString(key: StorageKey): string {
public static getEntryString(key: StorageKey): string | null {
return localStorage.getItem(StorageKey[key]);
}
@@ -39,7 +39,7 @@ export class SessionStorageUtility {
return !!sessionStorage.getItem(StorageKey[key]);
}
public static getEntryString(key: StorageKey): string {
public static getEntryString(key: StorageKey): string | null {
return sessionStorage.getItem(StorageKey[key]);
}

View File

@@ -1,9 +1,9 @@
export class StringUtility {
public static toNumber(num: string): number {
public static toNumber(num: string | null): number {
return Number(num);
}
public static toBoolean(valueStr: string): boolean {
public static toBoolean(valueStr: string | null): boolean {
return valueStr === "true";
}
}

View File

@@ -82,33 +82,35 @@ export default class TelemetryProcessor {
}
public static traceOpen(action: Action, data?: any, timestamp?: number): number {
const validTimestamp = timestamp || Date.now();
MessageHandler.sendMessage({
type: MessageTypes.TelemetryInfo,
data: {
action: Action[action],
actionModifier: ActionModifiers.Open,
timestamp: timestamp || Date.now(),
timestamp: validTimestamp,
data: JSON.stringify(data)
}
});
appInsights.startTrackEvent(Action[action]);
return timestamp;
return validTimestamp;
}
public static traceMark(action: Action, data?: any, timestamp?: number): number {
const validTimestamp = timestamp || Date.now();
MessageHandler.sendMessage({
type: MessageTypes.TelemetryInfo,
data: {
action: Action[action],
actionModifier: ActionModifiers.Mark,
timestamp: timestamp || Date.now(),
timestamp: validTimestamp,
data: JSON.stringify(data)
}
});
appInsights.startTrackEvent(Action[action]);
return timestamp;
return validTimestamp;
}
private static getData(data?: any): any {

View File

@@ -0,0 +1,116 @@
import * as GalleryUtils from "./GalleryUtils";
import { JunoClient, IGalleryItem } from "../Juno/JunoClient";
import { ExplorerStub } from "../Explorer/OpenActionsStubs";
import { HttpStatusCodes } from "../Common/Constants";
import { GalleryTab, SortBy } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
const galleryItem: IGalleryItem = {
id: "id",
name: "name",
description: "description",
gitSha: "gitSha",
tags: ["tag1"],
author: "author",
thumbnailUrl: "thumbnailUrl",
created: "created",
isSample: false,
downloads: 0,
favorites: 0,
views: 0
};
describe("GalleryUtils", () => {
afterEach(() => {
jest.resetAllMocks();
});
it("downloadItem shows dialog in standalone gallery", () => {
const setDialogProps = jest.fn().mockImplementation();
GalleryUtils.downloadItem({ setDialogProps }, undefined, undefined, galleryItem, undefined);
expect(setDialogProps).toBeCalled();
});
it("downloadItem shows dialog in data explorer", () => {
const setDialogProps = jest.fn().mockImplementation();
const container = new ExplorerStub();
container.showOkCancelModalDialog = jest.fn().mockImplementation();
GalleryUtils.downloadItem({ setDialogProps }, container, undefined, galleryItem, undefined);
expect(setDialogProps).not.toBeCalled();
expect(container.showOkCancelModalDialog).toBeCalled();
});
it("favoriteItem favorites item", async () => {
const container = new ExplorerStub();
const junoClient = new JunoClient();
junoClient.favoriteNotebook = jest
.fn()
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: galleryItem }));
const onComplete = jest.fn().mockImplementation();
await GalleryUtils.favoriteItem(container, junoClient, galleryItem, onComplete);
expect(junoClient.favoriteNotebook).toBeCalledWith(galleryItem.id);
expect(onComplete).toBeCalledWith(galleryItem);
});
it("unfavoriteItem unfavorites item", async () => {
const container = new ExplorerStub();
const junoClient = new JunoClient();
junoClient.unfavoriteNotebook = jest
.fn()
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: galleryItem }));
const onComplete = jest.fn().mockImplementation();
await GalleryUtils.unfavoriteItem(container, junoClient, galleryItem, onComplete);
expect(junoClient.unfavoriteNotebook).toBeCalledWith(galleryItem.id);
expect(onComplete).toBeCalledWith(galleryItem);
});
it("deleteItem shows dialog in data explorer", () => {
const container = new ExplorerStub();
container.showOkCancelModalDialog = jest.fn().mockImplementation();
GalleryUtils.deleteItem(container, undefined, galleryItem, undefined);
expect(container.showOkCancelModalDialog).toBeCalled();
});
it("getGalleryViewerProps gets gallery viewer props correctly", () => {
const selectedTab: GalleryTab = GalleryTab.OfficialSamples;
const sortBy: SortBy = SortBy.MostDownloaded;
const searchText = "my-complicated%20search%20query!!!";
const response = GalleryUtils.getGalleryViewerProps(
`?${GalleryUtils.GalleryViewerParams.SelectedTab}=${GalleryTab[selectedTab]}&${GalleryUtils.GalleryViewerParams.SortBy}=${SortBy[sortBy]}&${GalleryUtils.GalleryViewerParams.SearchText}=${searchText}`
);
expect(response).toEqual({
selectedTab,
sortBy,
searchText: decodeURIComponent(searchText)
});
});
it("getNotebookViewerProps gets notebook viewer props correctly", () => {
const notebookUrl = "https%3A%2F%2Fnotebook.url";
const galleryItemId = "1234-abcd-efgh";
const response = GalleryUtils.getNotebookViewerProps(
`?${GalleryUtils.NotebookViewerParams.NotebookUrl}=${notebookUrl}&${GalleryUtils.NotebookViewerParams.GalleryItemId}=${galleryItemId}`
);
expect(response).toEqual({
notebookUrl: decodeURIComponent(notebookUrl),
galleryItemId
});
});
it("getTabTitle returns correct title for official samples", () => {
expect(GalleryUtils.getTabTitle(GalleryTab.OfficialSamples)).toBe("Official samples");
});
});

View File

@@ -36,7 +36,7 @@ export interface GalleryViewerProps {
searchText: string;
}
export function showOkCancelModalDialog(
function showOkCancelModalDialog(
component: DialogEnabledComponent,
title: string,
msg: string,
@@ -91,7 +91,7 @@ export function downloadItem(
throw new Error(`Received HTTP ${response.status} when fetching ${data.name}`);
}
await container.importAndOpenFromGallery(data.name, response.data);
await container.importAndOpenContent(data.name, response.data);
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Info,
`Successfully downloaded ${name} to My Notebooks`
@@ -217,8 +217,8 @@ export function deleteItem(
}
}
export function getGalleryViewerProps(window: Window & typeof globalThis): GalleryViewerProps {
const params = new URLSearchParams(window.location.search);
export function getGalleryViewerProps(search: string): GalleryViewerProps {
const params = new URLSearchParams(search);
let selectedTab: GalleryTab;
if (params.has(GalleryViewerParams.SelectedTab)) {
selectedTab = GalleryTab[params.get(GalleryViewerParams.SelectedTab) as keyof typeof GalleryTab];
@@ -236,8 +236,8 @@ export function getGalleryViewerProps(window: Window & typeof globalThis): Galle
};
}
export function getNotebookViewerProps(window: Window & typeof globalThis): NotebookViewerProps {
const params = new URLSearchParams(window.location.search);
export function getNotebookViewerProps(search: string): NotebookViewerProps {
const params = new URLSearchParams(search);
return {
notebookUrl: params.get(NotebookViewerParams.NotebookUrl),
galleryItemId: params.get(NotebookViewerParams.GalleryItemId)

View File

@@ -58,3 +58,7 @@ export function fromContentUri(
export function toContentUri(owner: string, repo: string, branch: string, path: string): string {
return `github://${owner}/${repo}/${path}?ref=${branch}`;
}
export function toRawContentUri(owner: string, repo: string, branch: string, path: string): string {
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
}

View File

@@ -0,0 +1,45 @@
import { RepoListItem } from "../Explorer/Controls/GitHub/GitHubReposComponent";
import { IPinnedRepo } from "../Juno/JunoClient";
import { JunoUtils } from "./JunoUtils";
import { IGitHubRepo } from "../GitHub/GitHubClient";
const gitHubRepo: IGitHubRepo = {
name: "repo-name",
owner: "owner",
private: false
};
const repoListItem: RepoListItem = {
key: "key",
repo: {
name: "repo-name",
owner: "owner",
private: false
},
branches: [
{
name: "branch-name"
}
]
};
const pinnedRepo: IPinnedRepo = {
name: "repo-name",
owner: "owner",
private: false,
branches: [
{
name: "branch-name"
}
]
};
describe("JunoUtils", () => {
it("toPinnedRepo converts RepoListItem to IPinnedRepo", () => {
expect(JunoUtils.toPinnedRepo(repoListItem)).toEqual(pinnedRepo);
});
it("toGitHubRepo converts IPinnedRepo to IGitHubRepo", () => {
expect(JunoUtils.toGitHubRepo(pinnedRepo)).toEqual(gitHubRepo);
});
});

View File

@@ -0,0 +1,24 @@
import AuthHeadersUtil from "../Platform/Hosted/Authorization";
import * as UserUtils from "./UserUtils";
describe("UserUtils", () => {
it("getFullName works in regular data explorer (inside portal)", () => {
const user: AuthenticationContext.UserInfo = {
userName: "userName",
profile: {
name: "name"
}
};
AuthHeadersUtil.getCachedUser = jest.fn().mockReturnValue(user);
expect(UserUtils.getFullName()).toBe("name");
});
it("getFullName works in fullscreen data explorer (outside portal)", () => {
jest.mock("./AuthorizationUtils", () => {
(): { name: string } => ({ name: "name" });
});
expect(UserUtils.getFullName()).toBe("name");
});
});

View File

@@ -50,6 +50,7 @@ describe("Collection CRUD", () => {
// .find('div[class="treeComponent dataResourceTree"]')
// .should("contain", dbId);
} catch (error) {
await page.screenshot({path: 'failure.png'});
trackException(error);
throw error;
}

View File

@@ -8,11 +8,14 @@
},
"files": [
"./src/AuthType.ts",
"./src/Bindings/BindingHandlersRegisterer.ts",
"./src/Bindings/ReactBindingHandler.ts",
"./src/Common/ArrayHashMap.ts",
"./src/Common/Constants.ts",
"./src/Common/DeleteFeedback.ts",
"./src/Common/HashMap.ts",
"./src/Common/HeadersUtility.ts",
"./src/Common/Logger.ts",
"./src/Common/MessageHandler.ts",
"./src/Common/ObjectCache.ts",
"./src/Common/ThemeUtility.ts",
@@ -23,8 +26,10 @@
"./src/Contracts/Diagnostics.ts",
"./src/Contracts/ExplorerContracts.ts",
"./src/Contracts/Versions.ts",
"./src/Controls/Heatmap/Heatmap.ts",
"./src/Controls/Heatmap/HeatmapDatatypes.ts",
"./src/Definitions/plotly.js-cartesian-dist.d-min.ts",
"./src/Explorer/Controls/GitHub/GitHubStyleConstants.ts",
"./src/Explorer/Controls/Toolbar/IToolbarAction.ts",
"./src/Explorer/Controls/Toolbar/IToolbarDisplayable.ts",
"./src/Explorer/Controls/Toolbar/IToolbarDropDown.ts",
@@ -35,8 +40,11 @@
"./src/Explorer/Notebook/FileSystemUtil.ts",
"./src/Explorer/Notebook/NotebookComponent/actions.ts",
"./src/Explorer/Notebook/NotebookComponent/loadTransform.ts",
"./src/Explorer/Notebook/NotebookComponent/reducers.ts",
"./src/Explorer/Notebook/NotebookComponent/types.ts",
"./src/Explorer/Notebook/NotebookContentItem.ts",
"./src/Explorer/Notebook/NotebookUtil.ts",
"./src/Explorer/Notebook/NTeractUtil.ts",
"./src/Explorer/Panes/Tables/Validators/EntityPropertyNameValidator.ts",
"./src/Explorer/Panes/Tables/Validators/EntityPropertyValidationCommon.ts",
"./src/Explorer/Tables/Constants.ts",
@@ -44,17 +52,21 @@
"./src/GitHub/GitHubConnector.ts",
"./src/NotebookWorkspaceManager/NotebookWorkspaceResourceProviderMockClients.ts",
"./src/PlatformType.ts",
"./src/quickstart.ts",
"./src/ReactDevTools.ts",
"./src/ResourceProvider/IResourceProviderClient.ts",
"./src/setupTests.ts",
"./src/Shared/appInsights.ts",
"./src/Shared/ExplorerSettings.ts",
"./src/Shared/StorageUtility.ts",
"./src/Shared/StringUtility.ts",
"./src/Shared/Telemetry/TelemetryConstants.ts",
"./src/Shared/appInsights.ts",
"./src/Shared/Telemetry/TelemetryProcessor.ts",
"./src/Utils/GitHubUtils.ts",
"./src/Utils/MessageValidation.ts",
"./src/Utils/OfferUtils.ts",
"./src/Utils/StringUtils.ts",
"./src/quickstart.ts",
"./src/setupTests.ts",
"./src/workers/upload/definitions.ts"
],
"include": []
}
}