Compare commits

..

12 Commits

Author SHA1 Message Date
Steve Faulkner
ec07ff05a4 Bundle config.json with published nugets (#64)
Co-authored-by: Vignesh Rangaishenvi <virangai@microsoft.com>
Co-authored-by: Tanuj Mittal <tamitta@microsoft.com>
2020-06-30 13:49:14 -05:00
Tanuj Mittal
7512b3c1d5 Notebooks Gallery (#59)
* Initial commit

* Address PR comments

* Move notebook related stuff to NotebookManager and dynamically load it

* Add New gallery callout and other UI tweaks

* Update test snapshot
2020-06-30 11:47:21 -07:00
vchske
dd199e6565 Fixing errors in mongo document tab (#58)
* This fixes an issue where errors when editing documents in an API for MongoDB endpoint would not be presented in the UI.

* Changing null to undefined in several places

* Fixed style issue.
Unignored MongoProxyClient.ts from full lint

* More linter issues since the removal from lint ignore
2020-06-29 16:02:31 -07:00
Laurent Nguyen
8200cc521f Switch to Graph explorer gremlin queries to use id and pk inside single quoted strings (#57) 2020-06-26 16:52:54 +02:00
Laurent Nguyen
1d3b672a14 Fix focus to match portal (#56) 2020-06-26 16:52:28 +02:00
Steve Faulkner
e5fc6f2022 Runner Tweaks (#62)
Co-authored-by: Steve Faulkner <stfaul@microsoft.com>
2020-06-25 18:59:44 -05:00
Steve Faulkner
3bf42b23dd Initial Portal Runner (#51) 2020-06-24 14:07:01 -05:00
Steve Faulkner
d22cb598a9 Fix Typo (#54) 2020-06-24 13:35:30 -05:00
Steve Faulkner
269ea6a349 Add Additional Lint Rules (#55) 2020-06-23 10:45:51 -05:00
Laurent Nguyen
123902e7ee Allow multi-line input for query box in Graph (#41) 2020-06-23 09:35:16 +02:00
Steve Faulkner
bccebaade5 Update Webpack Plugins (#50) 2020-06-18 08:39:47 -05:00
Laurent Nguyen
9fedf63a77 Show splash screen for all accounts (#44) 2020-06-18 10:36:05 +02:00
109 changed files with 11507 additions and 2692 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
# These options are only needed when if running end to end tests locally
PORTAL_RUNNER_USERNAME=
PORTAL_RUNNER_PASSWORD=
PORTAL_RUNNER_SUBSCRIPTION=
PORTAL_RUNNER_RESOURCE_GROUP=
PORTAL_RUNNER_DATABASE_ACCOUNT=

View File

@@ -1,4 +1,5 @@
**/node_modules/
dist/
src/Api/Apis.ts
src/AuthType.ts
src/Bindings/BindingHandlersRegisterer.ts
@@ -25,7 +26,6 @@ src/Common/Logger.test.ts
src/Common/MessageHandler.test.ts
src/Common/MessageHandler.ts
src/Common/MongoProxyClient.test.ts
src/Common/MongoProxyClient.ts
src/Common/MongoUtility.ts
src/Common/NotificationsClientBase.ts
src/Common/ObjectCache.test.ts

View File

@@ -3,7 +3,7 @@ module.exports = {
browser: true,
es6: true
},
plugins: ["@typescript-eslint"],
plugins: ["@typescript-eslint", "no-null"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
globals: {
Atomics: "readonly",
@@ -37,6 +37,8 @@ module.exports = {
],
rules: {
curly: "error",
"@typescript-eslint/no-unused-vars": "error"
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-extraneous-class": "error",
"no-null/no-null": "error"
}
};

View File

@@ -1,6 +1,3 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: CI
on:
push:
@@ -71,6 +68,7 @@ jobs:
key: ${{ runner.os }}-build-cache
- run: npm run pack:prod
- run: cp -r ./Contracts ./dist/contracts
- run: cp -r ./configs ./dist/configs
- uses: actions/upload-artifact@v2
with:
name: dist
@@ -172,6 +170,7 @@ jobs:
uses: actions/download-artifact@v2
with:
name: dist
- run: cp ./configs/prod.json config.json
- run: nuget sources add -Name "ADO" -Source "$NUGET_SOURCE" -UserName "GitHub" -Password "$AZURE_DEVOPS_PAT"
- run: nuget pack -Version "2.0.0-github-${GITHUB_SHA}"
- run: nuget push -Source "$NUGET_SOURCE" -ApiKey Az *.nupkg
@@ -193,6 +192,7 @@ jobs:
uses: actions/download-artifact@v2
with:
name: dist
- run: cp ./configs/mpac.json config.json
- run: sed -i 's/Azure.Cosmos.DB.Data.Explorer/Azure.Cosmos.DB.Data.Explorer.MPAC/g' DataExplorer.nuspec
- run: nuget sources add -Name "ADO" -Source "$NUGET_SOURCE" -UserName "GitHub" -Password "$AZURE_DEVOPS_PAT"
- run: nuget pack -Version "2.0.0-github-${GITHUB_SHA}"

20
.github/workflows/runners.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
name: Runners
on:
schedule:
- cron: "*/10 * * * *"
jobs:
sqlcreatecollection:
runs-on: ubuntu-latest
name: "SQL | Create Collection"
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- run: npm ci
- run: npm run test:e2e
env:
PORTAL_RUNNER_APP_INSIGHTS_KEY: ${{ secrets.PORTAL_RUNNER_APP_INSIGHTS_KEY }}
PORTAL_RUNNER_USERNAME: ${{ secrets.PORTAL_RUNNER_USERNAME }}
PORTAL_RUNNER_PASSWORD: ${{ secrets.PORTAL_RUNNER_PASSWORD }}
PORTAL_RUNNER_SUBSCRIPTION: 69e02f2d-f059-4409-9eac-97e8a276ae2c
PORTAL_RUNNER_RESOURCE_GROUP: runners
PORTAL_RUNNER_DATABASE_ACCOUNT: portal-sql-runner

3
.gitignore vendored
View File

@@ -15,4 +15,5 @@ cypress/fixtures
notebookapp/*
Contracts/*
.DS_Store
.cache/
.cache/
.env

View File

@@ -70,7 +70,7 @@ Unit tests are located adjacent to the code under test and run with [Jest](https
`npm run test`
#### End to End Tests
#### End to End CI Tests
[Cypress](https://www.cypress.io/) is used for end to end tests and are contained in `cypress/`. Currently, it operates as sub project with its own typescript config and dependencies. It also only operates against the emulator. To run cypress tests:
@@ -80,6 +80,13 @@ Unit tests are located adjacent to the code under test and run with [Jest](https
4. Install dependencies: `npm install`
5. Run cypress headless(`npm run test`) or in interactive mode(`npm run test:debug`)
#### End to End Production Runners
Jest and Puppeteer are used for end to end production runners and are contained in `test/`. To run these tests locally:
1. Copy .env.example to .env and fill in all variables
2. Run `npm run test:e2e`
# Contributing
Please read the [contribution guidelines](./CONTRIBUTING.md).
Please read the [contribution guidelines](./CONTRIBUTING.md).

3
configs/mpac.json Normal file
View File

@@ -0,0 +1,3 @@
{
"JUNO_ENDPOINT": "https://tools-staging.cosmos.azure.com"
}

3
configs/prod.json Normal file
View File

@@ -0,0 +1,3 @@
{
"JUNO_ENDPOINT": "https://tools.cosmos.azure.com"
}

9
jest-puppeteer.config.js Normal file
View File

@@ -0,0 +1,9 @@
const isCI = require("is-ci");
module.exports = {
launch: {
headless: isCI,
slowMo: isCI ? null : 20,
defaultViewport: null
}
};

5
jest.config.e2e.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
preset: "jest-puppeteer",
testMatch: ["<rootDir>/test/**/*.spec.[jt]s?(x)"],
setupFiles: ["dotenv/config"]
};

View File

@@ -150,7 +150,7 @@ module.exports = {
// testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?|ts?)$",
// This option allows the use of a custom results processor
testResultsProcessor: "./trxProcessor.js",
// testResultsProcessor: "./trxProcessor.js",
// This option allows use of a custom test runner
// testRunner: "jasmine2",

View File

@@ -54,6 +54,8 @@
@SelectionColor: #3074B0;
@FocusColor: #00bcf2;
/******************************************************************************
METRICS
/******************************************************************************/
@@ -198,7 +200,7 @@
}
.focus() {
outline: 1px dashed @AccentMedium;
outline: 1px dashed @FocusColor;
}
/************************************************************************************************

View File

@@ -14,6 +14,10 @@ body {
font-family: @DataExplorerFont;
font-size: 12px;
height: 100%;
:focus {
.focus()
}
}
.float-right {
@@ -174,7 +178,7 @@ body {
&:active {
.active();
}
&:focus .urlTokenCopyTooltiptext, &:focus .urlTokenCopyTooltiptext {
.tooltipVisible();
}
@@ -362,7 +366,7 @@ body {
}
.splashLoaderContainer {
z-index: 5;
z-index: 5;
position: absolute;
left: 0;
top: 0;
@@ -1449,7 +1453,7 @@ p {
.throughputModeRadio {
vertical-align: text-bottom;
}
.nonFirstRadio {
margin-left: @LargeSpace;
}
@@ -1484,7 +1488,7 @@ p {
.largePartitionKeyDescription {
margin: @DefaultSpace 0px 0px;
}
}
}
.enableAnalyticalStorage {
@@ -2216,13 +2220,13 @@ a:link {
.documentsGridHeaderContainer table thead tr {
position: sticky;
top: 0;
top: 0;
th {
position: sticky;
top: 0;
top: 0;
background-color: #fff !important;
border-bottom: 1px solid #CCCCCC !important;
}
}
}
.documentsGridHeader {

9266
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,22 +37,25 @@
"@uifabric/react-cards": "0.109.53",
"@uifabric/styling": "7.11.2",
"abort-controller": "3.0.0",
"applicationinsights": "1.8.0",
"babel-polyfill": "6.26.0",
"bootstrap": "3.3.7",
"canvas": "2.6.0",
"clean-webpack-plugin": "0.1.19",
"copy-webpack-plugin": "4.5.4",
"copy-webpack-plugin": "6.0.2",
"crossroads": "0.12.2",
"css-element-queries": "1.1.1",
"datatables.net-colreorder-dt": "1.5.1",
"datatables.net-dt": "1.10.19",
"date-fns": "1.29.0",
"dayjs": "1.8.19",
"dotenv": "8.2.0",
"es6-object-assign": "1.1.0",
"es6-symbol": "3.1.3",
"eslint-plugin-jest": "23.13.2",
"hasher": "1.2.0",
"immutable": "4.0.0-rc.12",
"is-ci": "2.0.0",
"jquery": "3.4.0",
"jquery-typeahead": "2.10.6",
"jquery-ui-dist": "1.12.1",
@@ -123,8 +126,9 @@
"enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.15.1",
"enzyme-to-json": "3.4.3",
"eslint": "7.2.0",
"eslint": "7.3.1",
"eslint-cli": "1.1.1",
"eslint-plugin-no-null": "1.0.2",
"eslint-plugin-react": "7.20.0",
"expose-loader": "0.7.5",
"file-loader": "2.0.0",
@@ -133,8 +137,9 @@
"html-loader-jest": "0.2.1",
"html-webpack-plugin": "3.2.0",
"inline-css": "2.2.5",
"jest": "24.9.0",
"jest": "25.5.4",
"jest-canvas-mock": "2.1.0",
"jest-puppeteer": "4.4.0",
"jest-trx-results-processor": "0.0.7",
"less": "3.8.1",
"less-loader": "4.1.0",
@@ -142,17 +147,18 @@
"mini-css-extract-plugin": "0.4.3",
"monaco-editor-webpack-plugin": "1.7.0",
"prettier": "1.19.1",
"puppeteer": "4.0.0",
"raw-loader": "0.5.1",
"rimraf": "3.0.0",
"sinon": "3.2.1",
"style-loader": "0.23.0",
"terser-webpack-plugin": "2.3.5",
"terser-webpack-plugin": "3.0.5",
"ts-loader": "6.2.2",
"tslint": "5.11.0",
"tslint-microsoft-contrib": "6.0.0",
"typescript": "3.8.3",
"url-loader": "1.1.1",
"webpack": "4.41.2",
"webpack": "4.43.0",
"webpack-bundle-analyzer": "3.6.1",
"webpack-cli": "3.3.10",
"webpack-dev-server": "3.11.0",
@@ -168,8 +174,8 @@
"pack:fast": "node --max_old_space_size=10196 ./node_modules/webpack/bin/webpack.js --mode development --progress",
"copyToConsumers": "node copyToConsumers",
"test": "rimraf coverage && jest",
"test:e2e": "jest -c ./jest.config.e2e.js --detectOpenHandles",
"watch": "npm run start",
"integrationTest": "runIntegrationTests.cmd",
"build:ase": "gulp build:ase",
"compile": "tsc",
"compile:contracts": "tsc -p ./tsconfig.contracts.json",

View File

@@ -1,24 +0,0 @@
@echo off
@for /f "delims=" %%P in ('npm prefix -g') do set "NPM_PREFIX=%%P"
@echo npm prefix = %NPM_PREFIX%
@echo Compiling TypeScript Test Sources ...
call %NPM_PREFIX%\tsc -p ./test
if %errorlevel% neq 0 goto end
copy .\test\Integration\TestRunner.html .\test\out\test\Integration /y >nul 2>&1
@echo Copying files for test simulation against Emulator ...
rmdir "%ProgramFiles%\Azure Cosmos DB Emulator\Packages\DataExplorer\test" >nul 2>&1
mkdir "%ProgramFiles%\Azure Cosmos DB Emulator\Packages\DataExplorer\test" >nul 2>&1
xcopy .\node_modules\jasmine-core\lib .\test\out\lib /s /c /i /r /y >nul 2>&1
xcopy .\node_modules\jasmine-core\images .\test\out\lib\images /s /c /i /r /y >nul 2>&1
xcopy .\test\out "%ProgramFiles%\Azure Cosmos DB Emulator\Packages\DataExplorer\test" /s /c /i /r /y >nul 2>&1
@echo Initiating test runner ...
start https://localhost:8081/_explorer/test/test/Integration/TestRunner.html
@echo Done!
:end
@echo on

View File

@@ -112,6 +112,7 @@ export class Features {
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";
public static readonly notebookServerUrl = "notebookserverurl";

View File

@@ -6,7 +6,7 @@ import Q from "q";
import { ConflictDefinition, ItemDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import { DataAccessUtilityBase } from "./DataAccessUtilityBase";
import { Logger } from "./Logger";
import * as Logger from "./Logger";
import { MessageHandler } from "./MessageHandler";
import { MessageTypes } from "../Contracts/ExplorerContracts";
import { MinimalQueryIterator, nextPage } from "./IteratorUtilities";

View File

@@ -1,5 +1,5 @@
import { LogEntryLevel } from "../Contracts/Diagnostics";
import { Logger } from "./Logger";
import * as Logger from "./Logger";
import { MessageHandler } from "./MessageHandler";
import { MessageTypes } from "../Contracts/ExplorerContracts";

View File

@@ -4,85 +4,68 @@ import { appInsights } from "../Shared/appInsights";
import { SeverityLevel } from "@microsoft/applicationinsights-web";
// TODO: Move to a separate Diagnostics folder
export class Logger {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public static logInfo(message: string | Record<string, any>, area: string, code?: number): void {
let logMessage: string;
if (typeof message === "string") {
logMessage = message;
} else {
logMessage = JSON.stringify(message, Object.getOwnPropertyNames(message));
}
const entry: Diagnostics.LogEntry = Logger._generateLogEntry(
Diagnostics.LogEntryLevel.Verbose,
logMessage,
area,
code
);
return Logger._logEntry(entry);
}
public static logWarning(message: string, area: string, code?: number): void {
const entry: Diagnostics.LogEntry = Logger._generateLogEntry(
Diagnostics.LogEntryLevel.Warning,
message,
area,
code
);
return Logger._logEntry(entry);
}
public static logError(message: string | Error, area: string, code?: number): void {
let logMessage: string;
if (typeof message === "string") {
logMessage = message;
} else {
logMessage = JSON.stringify(message, Object.getOwnPropertyNames(message));
}
const entry: Diagnostics.LogEntry = Logger._generateLogEntry(
Diagnostics.LogEntryLevel.Error,
logMessage,
area,
code
);
return Logger._logEntry(entry);
}
private static _logEntry(entry: Diagnostics.LogEntry): void {
MessageHandler.sendMessage({
type: MessageTypes.LogInfo,
data: JSON.stringify(entry)
});
const severityLevel = ((level: Diagnostics.LogEntryLevel): SeverityLevel => {
switch (level) {
case Diagnostics.LogEntryLevel.Custom:
case Diagnostics.LogEntryLevel.Debug:
case Diagnostics.LogEntryLevel.Verbose:
return SeverityLevel.Verbose;
case Diagnostics.LogEntryLevel.Warning:
return SeverityLevel.Warning;
case Diagnostics.LogEntryLevel.Error:
return SeverityLevel.Error;
default:
return SeverityLevel.Information;
}
})(entry.level);
appInsights.trackTrace({ message: entry.message, severityLevel }, { area: entry.area });
}
private static _generateLogEntry(
level: Diagnostics.LogEntryLevel,
message: string,
area: string,
code: number
): Diagnostics.LogEntry {
return {
timestamp: new Date().getUTCSeconds(),
level: level,
message: message,
area: area,
code: code
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function logInfo(message: string | Record<string, any>, area: string, code?: number): void {
let logMessage: string;
if (typeof message === "string") {
logMessage = message;
} else {
logMessage = JSON.stringify(message, Object.getOwnPropertyNames(message));
}
const entry: Diagnostics.LogEntry = _generateLogEntry(Diagnostics.LogEntryLevel.Verbose, logMessage, area, code);
return _logEntry(entry);
}
export function logWarning(message: string, area: string, code?: number): void {
const entry: Diagnostics.LogEntry = _generateLogEntry(Diagnostics.LogEntryLevel.Warning, message, area, code);
return _logEntry(entry);
}
export function logError(message: string | Error, area: string, code?: number): void {
let logMessage: string;
if (typeof message === "string") {
logMessage = message;
} else {
logMessage = JSON.stringify(message, Object.getOwnPropertyNames(message));
}
const entry: Diagnostics.LogEntry = _generateLogEntry(Diagnostics.LogEntryLevel.Error, logMessage, area, code);
return _logEntry(entry);
}
function _logEntry(entry: Diagnostics.LogEntry): void {
MessageHandler.sendMessage({
type: MessageTypes.LogInfo,
data: JSON.stringify(entry)
});
const severityLevel = ((level: Diagnostics.LogEntryLevel): SeverityLevel => {
switch (level) {
case Diagnostics.LogEntryLevel.Custom:
case Diagnostics.LogEntryLevel.Debug:
case Diagnostics.LogEntryLevel.Verbose:
return SeverityLevel.Verbose;
case Diagnostics.LogEntryLevel.Warning:
return SeverityLevel.Warning;
case Diagnostics.LogEntryLevel.Error:
return SeverityLevel.Error;
default:
return SeverityLevel.Information;
}
})(entry.level);
appInsights.trackTrace({ message: entry.message, severityLevel }, { area: entry.area });
}
function _generateLogEntry(
level: Diagnostics.LogEntryLevel,
message: string,
area: string,
code: number
): Diagnostics.LogEntry {
return {
timestamp: new Date().getUTCSeconds(),
level: level,
message: message,
area: area,
code: code
};
}

View File

@@ -31,13 +31,13 @@ function authHeaders(): any {
}
}
export function queryIterator(databaseId: string, collection: Collection, query: string) {
let continuationToken: string = null;
export function queryIterator(databaseId: string, collection: Collection, query: string): any {
let continuationToken: string;
return {
fetchNext: () => {
return queryDocuments(databaseId, collection, false, query).then(response => {
continuationToken = response.continuationToken;
let headers = {} as any;
const headers = {} as any;
response.headers.forEach((value: any, key: any) => {
headers[key] = value;
});
@@ -114,14 +114,7 @@ export function queryDocuments(
headers: response.headers
};
}
const errorMessage = await response.text();
if (response.status === HttpStatusCodes.Forbidden) {
MessageHandler.sendMessage({
type: MessageTypes.ForbiddenError,
reason: errorMessage
});
}
throw new Error(errorMessage);
return errorHandling(response, "querying documents", params);
});
}
@@ -160,11 +153,11 @@ export function readDocument(
)
}
})
.then(async response => {
.then(response => {
if (response.ok) {
return response.json();
}
errorHandling(response);
return errorHandling(response, "reading document", params);
});
}
@@ -199,11 +192,11 @@ export function createDocument(
...authHeaders()
}
})
.then(async response => {
.then(response => {
if (response.ok) {
return response.json();
}
errorHandling(response);
return errorHandling(response, "creating document", params);
});
}
@@ -243,11 +236,11 @@ export function updateDocument(
[CosmosSDKConstants.HttpHeaders.PartitionKey]: JSON.stringify(documentId.partitionKeyHeader())
}
})
.then(async response => {
.then(response => {
if (response.ok) {
return response.json();
}
errorHandling(response);
return errorHandling(response, "updating document", params);
});
}
@@ -285,11 +278,11 @@ export function deleteDocument(
[CosmosSDKConstants.HttpHeaders.PartitionKey]: JSON.stringify(documentId.partitionKeyHeader())
}
})
.then(async response => {
.then(response => {
if (response.ok) {
return;
return undefined;
}
errorHandling(response);
return errorHandling(response, "deleting document", params);
});
}
@@ -340,15 +333,11 @@ export function createMongoCollectionWithProxy(
}
}
)
.then(async response => {
.then(response => {
if (response.ok) {
return;
return undefined;
}
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error creating collection: ${await response.json()}, Payload: ${params}`
);
errorHandling(response);
return errorHandling(response, "creating collection", params);
});
}
@@ -407,13 +396,16 @@ export function getEndpoint(databaseAccount: ViewModels.DatabaseAccount): string
return url;
}
async function errorHandling(response: any): Promise<any> {
async function errorHandling(response: any, action: string, params: any): Promise<any> {
const errorMessage = await response.text();
// Log the error where the user can see it
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error ${action}: ${errorMessage}, Payload: ${JSON.stringify(params)}`
);
if (response.status === HttpStatusCodes.Forbidden) {
MessageHandler.sendMessage({
type: MessageTypes.ForbiddenError,
reason: errorMessage
});
MessageHandler.sendMessage({ type: MessageTypes.ForbiddenError, reason: errorMessage });
return;
}
throw new Error(errorMessage);
}
@@ -462,14 +454,6 @@ export async function _createMongoCollectionWithARM(
rpPayloadToCreateCollection
);
} catch (response) {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error creating collection: ${JSON.stringify(response)}`
);
if (response.status === HttpStatusCodes.Forbidden) {
MessageHandler.sendMessage({ type: MessageTypes.ForbiddenError });
return;
}
throw new Error(`Error creating collection`);
return errorHandling(response, "creating collection", undefined);
}
}

View File

@@ -7,7 +7,7 @@ import { BackendDefaults, HttpStatusCodes, SavedQueries } from "./Constants";
import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import { CosmosClient } from "./CosmosClient";
import { ItemDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { Logger } from "./Logger";
import * as Logger from "./Logger";
import { NotificationConsoleUtils } from "../Utils/NotificationConsoleUtils";
import { QueryUtils } from "../Utils/QueryUtils";

View File

@@ -704,49 +704,6 @@ export interface MemoryUsageInfo {
totalKB: number;
}
export interface NotebookMetadata {
date: string;
description: string;
tags: string[];
author: string;
views: number;
likes: number;
downloads: number;
imageUrl: string;
}
export interface UserMetadata {
likedNotebooks: string[];
}
export interface GitHubInfoJunoResponse {
encoding: string;
encodedContent: string;
content: string;
target: string;
submoduleGitUrl: string;
name: string;
path: string;
sha: string;
size: number;
type: {
stringValue: string;
value: number;
};
downloadUrl: string;
url: string;
gitUrl: string;
htmlUrl: string;
metadata?: NotebookMetadata;
officialSamplesIndex?: number;
isLikedNotebook?: boolean;
}
export interface LikedNotebooksJunoResponse {
likedNotebooksContent: GitHubInfoJunoResponse[];
userMetadata: UserMetadata;
}
export interface resourceTokenConnectionStringProperties {
accountEndpoint: string;
collectionId: string;

View File

@@ -12,10 +12,8 @@ import { CommandButtonComponentProps } from "../Explorer/Controls/CommandButton/
import { ConsoleData } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import { ExecuteSprocParam } from "../Explorer/Panes/ExecuteSprocParamsPane";
import { GitHubClient } from "../GitHub/GitHubClient";
import { GitHubOAuthService } from "../GitHub/GitHubOAuthService";
import { IColumnSetting } from "../Explorer/Panes/Tables/TableColumnOptionsPane";
import { IContentProvider } from "@nteract/core";
import { JunoClient } from "../Juno/JunoClient";
import { JunoClient, IGalleryItem } from "../Juno/JunoClient";
import { Library } from "./DataModels";
import { MostRecentActivity } from "../Explorer/MostRecentActivity/MostRecentActivity";
import { NotebookContentItem } from "../Explorer/Notebook/NotebookContentItem";
@@ -27,6 +25,7 @@ import { StringInputPane } from "../Explorer/Panes/StringInputPane";
import { TextFieldProps } from "../Explorer/Controls/DialogReactComponent/DialogComponent";
import { UploadDetails } from "../workers/upload/definitions";
import { UploadItemsPaneAdapter } from "../Explorer/Panes/UploadItemsPaneAdapter";
import { ReactAdapter } from "../Bindings/ReactBindingHandler";
export interface ExplorerOptions {
documentClientUtility: DocumentClientUtilityBase;
@@ -85,7 +84,9 @@ export interface Explorer {
armEndpoint: ko.Observable<string>;
isFeatureEnabled: (feature: string) => boolean;
isGalleryEnabled: ko.Computed<boolean>;
isGalleryPublishEnabled: ko.Computed<boolean>;
isGitHubPaneEnabled: ko.Observable<boolean>;
isPublishNotebookPaneEnabled: ko.Observable<boolean>;
isRightPanelV2Enabled: ko.Computed<boolean>;
canExceedMaximumValue: ko.Computed<boolean>;
hasAutoPilotV2FeatureFlag: ko.Computed<boolean>;
@@ -153,6 +154,7 @@ export interface Explorer {
libraryManagePane: ContextualPane;
clusterLibraryPane: ContextualPane;
gitHubReposPane: ContextualPane;
publishNotebookPaneAdapter: ReactAdapter;
// Facade
logConsoleData(data: ConsoleData): void;
@@ -224,22 +226,17 @@ export interface Explorer {
arcadiaWorkspaces: ko.ObservableArray<ArcadiaWorkspaceItem>;
isNotebookTabActive: ko.Computed<boolean>;
memoryUsageInfo: ko.Observable<DataModels.MemoryUsageInfo>;
notebookManager?: any; // This is dynamically loaded
openNotebook(notebookContentItem: NotebookContentItem): Promise<boolean>; // True if it was opened, false otherwise
resetNotebookWorkspace(): void;
importAndOpen: (path: string) => Promise<boolean>;
importAndOpenFromGallery: (path: string, newName: string, content: any) => Promise<boolean>;
importAndOpenFromGallery: (name: string, content: string) => Promise<boolean>;
publishNotebook: (name: string, content: string) => void;
openNotebookTerminal: (kind: TerminalKind) => void;
openGallery: () => void;
openNotebookViewer: (
notebookUrl: string,
notebookMetadata: DataModels.NotebookMetadata,
onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>,
isLikedNotebook: boolean
) => void;
openGallery: (notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean) => void;
openNotebookViewer: (notebookUrl: string) => void;
notebookWorkspaceManager: NotebookWorkspaceManager;
sparkClusterManager: SparkClusterManager;
notebookContentProvider: IContentProvider;
gitHubOAuthService: GitHubOAuthService;
mostRecentActivity: MostRecentActivity;
initNotebooks: (databaseAccount: DataModels.DatabaseAccount) => Promise<void>;
deleteCluster(): void;
@@ -594,6 +591,16 @@ export interface GitHubReposPaneOptions extends PaneOptions {
junoClient: JunoClient;
}
export interface PublishNotebookPaneOptions extends PaneOptions {
junoClient: JunoClient;
}
export interface PublishNotebookPaneOpenOptions {
name: string;
author: string;
content: string;
}
export interface AddCollectionPaneOptions extends PaneOptions {
isPreferredApiTable: ko.Computed<boolean>;
databaseId?: string;
@@ -873,16 +880,16 @@ export interface TerminalTabOptions extends TabOptions {
export interface GalleryTabOptions extends TabOptions {
account: DatabaseAccount;
container: Explorer;
junoClient: JunoClient;
notebookUrl?: string;
galleryItem?: IGalleryItem;
isFavorite?: boolean;
}
export interface NotebookViewerTabOptions extends TabOptions {
account: DatabaseAccount;
container: Explorer;
notebookUrl: string;
notebookName: string;
notebookMetadata: DataModels.NotebookMetadata;
onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>;
isLikedNotebook: boolean;
}
export interface DocumentsTabOptions extends TabOptions {

View File

@@ -6,7 +6,7 @@ import {
IContextualMenuProps,
ContextualMenuItemType
} from "office-ui-fabric-react/lib/ContextualMenu";
import { Logger } from "../../../Common/Logger";
import * as Logger from "../../../Common/Logger";
export interface ArcadiaMenuPickerProps {
selectText?: string;

View File

@@ -15,15 +15,20 @@ import { ArcadiaMenuPickerProps } from "../Arcadia/ArcadiaMenuPicker";
* Options for this component
*/
export interface CommandButtonComponentProps {
/**
* font icon name for the button
*/
iconName?: string;
/**
* image source for the button icon
*/
iconSrc: string;
iconSrc?: string;
/**
* image alt for accessibility
*/
iconAlt: string;
iconAlt?: string;
/**
* Click handler for command button click

View File

@@ -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.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" },
{
key: "feature.enablefixedcollectionwithsharedthroughput",
@@ -133,7 +134,7 @@ export const FeaturePanelComponent: React.FunctionComponent = () => {
.find(f => f.key === "feature.notebookserverurl")
.reactState[1]("https://localhost:10001/12345/notebook/");
stringFeatures.find(f => f.key === "feature.notebookservertoken").reactState[1]("token");
stringFeatures.find(f => f.key === "feature.notebookbasepath").reactState[1](".");
stringFeatures.find(f => f.key === "feature.notebookbasepath").reactState[1]("./notebooks");
setPlatform(platformOptions.find(o => o.key === "Hosted"));
};

View File

@@ -163,8 +163,8 @@ exports[`Feature panel renders all flags 1`] = `
/>
<StyledCheckboxBase
checked={false}
key="feature.canexceedmaximumvalue"
label="Can exceed max value"
key="feature.enablegallerypublish"
label="Enable Notebook Gallery Publishing"
onChange={[Function]}
/>
</Stack>
@@ -172,6 +172,12 @@ 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

@@ -5,7 +5,7 @@ import * as Constants from "../../../Common/Constants";
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
import { RepoListItem } from "./GitHubReposComponent";
import { ChildrenMargin } from "./GitHubStyleConstants";
import { GitHubUtils } from "../../../Utils/GitHubUtils";
import * as GitHubUtils from "../../../Utils/GitHubUtils";
import { IGitHubRepo } from "../../../GitHub/GitHubClient";
import TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import UrlUtility from "../../../Common/UrlUtility";

View File

@@ -19,7 +19,7 @@ import {
} from "office-ui-fabric-react";
import * as React from "react";
import { IGitHubBranch, IGitHubPageInfo } from "../../../GitHub/GitHubClient";
import { GitHubUtils } from "../../../Utils/GitHubUtils";
import * as GitHubUtils from "../../../Utils/GitHubUtils";
import { RepoListItem } from "./GitHubReposComponent";
import {
BranchesDropdownCheckboxStyles,

View File

@@ -4,4 +4,21 @@
vertical-align: middle;
display: inline-block;
width: 100%;
}
textarea {
width: 100%;
line-height: 1;
font-size: 14px;
padding: 6px 12px;
background: #fff;
border: 1px solid #ccc;
border-radius: 2px 0 0 2px;
min-height: 25px;
resize: vertical;
&:focus {
border-color: #66afe9;
}
}
}

View File

@@ -0,0 +1,34 @@
import React from "react";
import { shallow } from "enzyme";
import { InputTypeaheadComponent, InputTypeaheadComponentProps } from "./InputTypeaheadComponent";
import "../../../../externals/jquery.typeahead.min.js";
describe("inputTypeahead", () => {
it("renders <input />", () => {
const props: InputTypeaheadComponentProps = {
choices: [
{ caption: "item1", value: "value1" },
{ caption: "item2", value: "value2" }
],
placeholder: "placeholder",
useTextarea: false
};
const wrapper = shallow(<InputTypeaheadComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders <textarea />", () => {
const props: InputTypeaheadComponentProps = {
choices: [
{ caption: "item1", value: "value1" },
{ caption: "item2", value: "value2" }
],
placeholder: "placeholder",
useTextarea: true
};
const wrapper = shallow(<InputTypeaheadComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -7,6 +7,7 @@
*
*/
import * as React from "react";
import "./InputTypeahead.less";
import { KeyCodes } from "../../../Common/Constants";
export interface Item {
@@ -17,7 +18,7 @@ export interface Item {
/**
* Parameters for this component
*/
interface InputTypeaheadComponentProps {
export interface InputTypeaheadComponentProps {
/**
* List of choices available in the dropdown.
*/
@@ -66,6 +67,11 @@ interface InputTypeaheadComponentProps {
* true: show (X) button that clears the text inside the textbox when typing
*/
showCancelButton?: boolean;
/**
* true: use <textarea /> instead of <input />
*/
useTextarea?: boolean;
}
interface OnClickItem {
@@ -135,14 +141,25 @@ export class InputTypeaheadComponent extends React.Component<
<div className="typeahead__container" ref={input => (this.containerElt = input)}>
<div className="typeahead__field">
<span className="typeahead__query">
<input
name="q"
type="search"
autoComplete="off"
aria-label="Input query"
ref={input => (this.inputElt = input)}
defaultValue={this.props.defaultValue}
/>
{this.props.useTextarea ? (
<textarea
rows={1}
name="q"
autoComplete="off"
aria-label="Input query"
ref={input => (this.inputElt = input)}
defaultValue={this.props.defaultValue}
/>
) : (
<input
name="q"
type="search"
autoComplete="off"
aria-label="Input query"
ref={input => (this.inputElt = input)}
defaultValue={this.props.defaultValue}
/>
)}
</span>
{this.props.showSearchButton && (
<span className="typeahead__button">

View File

@@ -0,0 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`inputTypeahead renders <input /> 1`] = `
<span
className="input-typeahead-container"
>
<div
className="input-typehead"
onKeyDown={[Function]}
>
<div
className="typeahead__container"
>
<div
className="typeahead__field"
>
<span
className="typeahead__query"
>
<input
aria-label="Input query"
autoComplete="off"
name="q"
type="search"
/>
</span>
</div>
</div>
</div>
</span>
`;
exports[`inputTypeahead renders <textarea /> 1`] = `
<span
className="input-typeahead-container"
>
<div
className="input-typehead"
onKeyDown={[Function]}
>
<div
className="typeahead__container"
>
<div
className="typeahead__field"
>
<span
className="typeahead__query"
>
<textarea
aria-label="Input query"
autoComplete="off"
name="q"
rows={1}
/>
</span>
</div>
</div>
</div>
</span>
`;

View File

@@ -4,7 +4,7 @@
import * as React from "react";
import * as DataModels from "../../../Contracts/DataModels";
import { Logger } from "../../../Common/Logger";
import * as Logger from "../../../Common/Logger";
import { NotificationConsoleUtils } from "../../../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
import { StringUtils } from "../../../Utils/StringUtils";

View File

@@ -1,15 +1,32 @@
import React from "react";
import { shallow } from "enzyme";
import React from "react";
import { GalleryCardComponent, GalleryCardComponentProps } from "./GalleryCardComponent";
describe("GalleryCardComponent", () => {
it("renders", () => {
const props: GalleryCardComponentProps = {
name: "mycard",
url: "url",
notebookMetadata: undefined,
// eslint-disable-next-line @typescript-eslint/no-empty-function
onClick: () => {}
data: {
id: "id",
name: "name",
description: "description",
author: "author",
thumbnailUrl: "thumbnailUrl",
created: "created",
gitSha: "gitSha",
tags: ["tag"],
isSample: false,
downloads: 0,
favorites: 0,
views: 0
},
isFavorite: false,
showDelete: true,
onClick: undefined,
onTagClick: undefined,
onFavoriteClick: undefined,
onUnfavoriteClick: undefined,
onDownloadClick: undefined,
onDeleteClick: undefined
};
const wrapper = shallow(<GalleryCardComponent {...props} />);

View File

@@ -1,65 +1,199 @@
import * as React from "react";
import * as DataModels from "../../../../Contracts/DataModels";
import { Card, ICardTokens, ICardSectionTokens } from "@uifabric/react-cards";
import { Icon, Image, Persona, Text } from "office-ui-fabric-react";
import { Card, ICardTokens } from "@uifabric/react-cards";
import {
siteTextStyles,
descriptionTextStyles,
helpfulTextStyles,
subtleHelpfulTextStyles,
subtleIconStyles
} from "./CardStyleConstants";
FontWeights,
Icon,
IconButton,
Image,
ImageFit,
Persona,
Text,
Link,
BaseButton,
Button,
LinkBase,
Separator,
TooltipHost
} from "office-ui-fabric-react";
import * as React from "react";
import { IGalleryItem } from "../../../../Juno/JunoClient";
import { FileSystemUtil } from "../../../Notebook/FileSystemUtil";
export interface GalleryCardComponentProps {
name: string;
url: string;
notebookMetadata: DataModels.NotebookMetadata;
data: IGalleryItem;
isFavorite: boolean;
showDelete: boolean;
onClick: () => void;
onTagClick: (tag: string) => void;
onFavoriteClick: () => void;
onUnfavoriteClick: () => void;
onDownloadClick: () => void;
onDeleteClick: () => void;
}
export class GalleryCardComponent extends React.Component<GalleryCardComponentProps> {
private cardTokens: ICardTokens = { childrenMargin: 12 };
private attendantsCardSectionTokens: ICardSectionTokens = { childrenGap: 6 };
public static readonly CARD_HEIGHT = 384;
public static readonly CARD_WIDTH = 256;
private static readonly cardImageHeight = 144;
private static readonly cardDescriptionMaxChars = 88;
private static readonly cardTokens: ICardTokens = {
width: GalleryCardComponent.CARD_WIDTH,
height: GalleryCardComponent.CARD_HEIGHT,
childrenGap: 8,
childrenMargin: 10
};
public render(): JSX.Element {
return this.props.notebookMetadata != null ? (
<Card aria-label="Notebook Card" onClick={this.props.onClick} tokens={this.cardTokens}>
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric"
};
const dateString = new Date(this.props.data.created).toLocaleString("default", options);
return (
<Card aria-label="Notebook Card" tokens={GalleryCardComponent.cardTokens} onClick={this.props.onClick}>
<Card.Item>
<Persona text={this.props.notebookMetadata.author} secondaryText={this.props.notebookMetadata.date} />
<Persona text={this.props.data.author} secondaryText={dateString} />
</Card.Item>
<Card.Item fill>
<Image src={this.props.notebookMetadata.imageUrl} width="100%" alt="Notebook display image" />
<Image
src={
this.props.data.thumbnailUrl ||
`https://placehold.it/${GalleryCardComponent.CARD_WIDTH}x${GalleryCardComponent.cardImageHeight}`
}
width={GalleryCardComponent.CARD_WIDTH}
height={GalleryCardComponent.cardImageHeight}
imageFit={ImageFit.cover}
alt="Notebook cover image"
/>
</Card.Item>
<Card.Section>
<Text variant="small" styles={siteTextStyles}>
{this.props.notebookMetadata.tags.join(", ")}
<Text variant="small" nowrap>
{this.props.data.tags?.map((tag, index, array) => (
<span key={tag}>
<Link onClick={(event): void => this.onTagClick(event, tag)}>{tag}</Link>
{index === array.length - 1 ? <></> : ", "}
</span>
))}
</Text>
<Text styles={descriptionTextStyles}>{this.props.name}</Text>
<Text variant="small" styles={helpfulTextStyles}>
{this.props.notebookMetadata.description}
<Text styles={{ root: { fontWeight: FontWeights.semibold } }} nowrap>
{FileSystemUtil.stripExtension(this.props.data.name, "ipynb")}
</Text>
<Text variant="small" styles={{ root: { height: 36 } }}>
{this.props.data.description.substr(0, GalleryCardComponent.cardDescriptionMaxChars)}
</Text>
</Card.Section>
<Card.Section horizontal tokens={this.attendantsCardSectionTokens}>
<Icon iconName="RedEye" styles={subtleIconStyles} />
<Text variant="small" styles={subtleHelpfulTextStyles}>
{this.props.notebookMetadata.views}
<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>
<Icon iconName="Download" styles={subtleIconStyles} />
<Text variant="small" styles={subtleHelpfulTextStyles}>
{this.props.notebookMetadata.downloads}
<Text variant="tiny" styles={{ root: { color: "#ccc" } }}>
<Icon iconName="Download" styles={{ root: { verticalAlign: "middle" } }} /> {this.props.data.downloads}
</Text>
<Icon iconName="Heart" styles={subtleIconStyles} />
<Text variant="small" styles={subtleHelpfulTextStyles}>
{this.props.notebookMetadata.likes}
<Text variant="tiny" styles={{ root: { color: "#ccc" } }}>
<Icon iconName="Heart" styles={{ root: { verticalAlign: "middle" } }} /> {this.props.data.favorites}
</Text>
</Card.Section>
</Card>
) : (
<Card aria-label="Notebook Card" onClick={this.props.onClick} tokens={this.cardTokens}>
<Card.Section>
<Text styles={descriptionTextStyles}>{this.props.name}</Text>
<Card.Item>
<Separator styles={{ root: { padding: 0, height: 1 } }} />
</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.generateIconButtonWithTooltip("Download", "Download", this.onDownloadClick)}
{this.props.showDelete && (
<div style={{ width: "100%", textAlign: "right" }}>
{this.generateIconButtonWithTooltip("Delete", "Remove", this.props.onDeleteClick)}
</div>
)}
</Card.Section>
</Card>
);
}
/*
* 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)
*/
private generateIconButtonWithTooltip = (
iconName: string,
title: string,
onClick: (
event: React.MouseEvent<
HTMLAnchorElement | HTMLButtonElement | HTMLDivElement | BaseButton | Button | HTMLSpanElement,
MouseEvent
>
) => void
): JSX.Element => {
return (
<TooltipHost
content={title}
id={`TooltipHost-IconButton-${iconName}`}
calloutProps={{ gapSpace: 0 }}
styles={{ root: { display: "inline-block" } }}
>
<IconButton iconProps={{ iconName }} title={title} ariaLabel={title} onClick={onClick} />
</TooltipHost>
);
};
private onTagClick = (
event: React.MouseEvent<HTMLElement | HTMLAnchorElement | HTMLButtonElement | LinkBase, MouseEvent>,
tag: string
): void => {
event.stopPropagation();
this.props.onTagClick(tag);
};
private onFavoriteClick = (
event: React.MouseEvent<
HTMLAnchorElement | HTMLButtonElement | HTMLDivElement | BaseButton | Button | HTMLSpanElement,
MouseEvent
>
): void => {
event.stopPropagation();
this.props.onFavoriteClick();
};
private onUnfavoriteClick = (
event: React.MouseEvent<
HTMLAnchorElement | HTMLButtonElement | HTMLDivElement | BaseButton | Button | HTMLSpanElement,
MouseEvent
>
): void => {
event.stopPropagation();
this.props.onUnfavoriteClick();
};
private onDownloadClick = (
event: React.MouseEvent<
HTMLAnchorElement | HTMLButtonElement | HTMLDivElement | BaseButton | Button | HTMLSpanElement,
MouseEvent
>
): void => {
event.stopPropagation();
this.props.onDownloadClick();
};
private onDeleteClick = (
event: React.MouseEvent<
HTMLAnchorElement | HTMLButtonElement | HTMLDivElement | BaseButton | Button | HTMLSpanElement,
MouseEvent
>
): void => {
event.stopPropagation();
this.props.onDeleteClick();
};
}

View File

@@ -3,26 +3,263 @@
exports[`GalleryCardComponent renders 1`] = `
<Card
aria-label="Notebook Card"
onClick={[Function]}
tokens={
Object {
"childrenMargin": 12,
"childrenGap": 8,
"childrenMargin": 10,
"height": 384,
"width": 256,
}
}
>
<CardItem>
<StyledPersonaBase
secondaryText="Invalid Date"
text="author"
/>
</CardItem>
<CardItem
fill={true}
>
<StyledImageBase
alt="Notebook cover image"
height={144}
imageFit={2}
src="thumbnailUrl"
width={256}
/>
</CardItem>
<CardSection>
<Text
nowrap={true}
variant="small"
>
<span
key="tag"
>
<StyledLinkBase
onClick={[Function]}
>
tag
</StyledLinkBase>
</span>
</Text>
<Text
nowrap={true}
styles={
Object {
"root": Object {
"color": "#333333",
"fontWeight": 600,
},
}
}
>
mycard
name
</Text>
<Text
styles={
Object {
"root": Object {
"height": 36,
},
}
}
variant="small"
>
description
</Text>
</CardSection>
<CardSection
horizontal={true}
styles={
Object {
"root": Object {
"alignItems": "flex-end",
},
}
}
>
<Text
styles={
Object {
"root": Object {
"color": "#ccc",
},
}
}
variant="tiny"
>
<StyledIconBase
iconName="RedEye"
styles={
Object {
"root": Object {
"verticalAlign": "middle",
},
}
}
/>
0
</Text>
<Text
styles={
Object {
"root": Object {
"color": "#ccc",
},
}
}
variant="tiny"
>
<StyledIconBase
iconName="Download"
styles={
Object {
"root": Object {
"verticalAlign": "middle",
},
}
}
/>
0
</Text>
<Text
styles={
Object {
"root": Object {
"color": "#ccc",
},
}
}
variant="tiny"
>
<StyledIconBase
iconName="Heart"
styles={
Object {
"root": Object {
"verticalAlign": "middle",
},
}
}
/>
0
</Text>
</CardSection>
<CardItem>
<Styled
styles={
Object {
"root": Object {
"height": 1,
"padding": 0,
},
}
}
/>
</CardItem>
<CardSection
horizontal={true}
styles={
Object {
"root": Object {
"marginTop": 0,
},
}
}
>
<StyledTooltipHostBase
calloutProps={
Object {
"gapSpace": 0,
}
}
content="Like"
id="TooltipHost-IconButton-Heart"
styles={
Object {
"root": Object {
"display": "inline-block",
},
}
}
>
<CustomizedIconButton
ariaLabel="Like"
iconProps={
Object {
"iconName": "Heart",
}
}
onClick={[Function]}
title="Like"
/>
</StyledTooltipHostBase>
<StyledTooltipHostBase
calloutProps={
Object {
"gapSpace": 0,
}
}
content="Download"
id="TooltipHost-IconButton-Download"
styles={
Object {
"root": Object {
"display": "inline-block",
},
}
}
>
<CustomizedIconButton
ariaLabel="Download"
iconProps={
Object {
"iconName": "Download",
}
}
onClick={[Function]}
title="Download"
/>
</StyledTooltipHostBase>
<div
style={
Object {
"textAlign": "right",
"width": "100%",
}
}
>
<StyledTooltipHostBase
calloutProps={
Object {
"gapSpace": 0,
}
}
content="Remove"
id="TooltipHost-IconButton-Delete"
styles={
Object {
"root": Object {
"display": "inline-block",
},
}
}
>
<CustomizedIconButton
ariaLabel="Remove"
iconProps={
Object {
"iconName": "Delete",
}
}
title="Remove"
/>
</StyledTooltipHostBase>
</div>
</CardSection>
</Card>
`;

View File

@@ -1,62 +1,17 @@
import React from "react";
import { shallow } from "enzyme";
import {
GalleryViewerContainerComponent,
GalleryViewerContainerComponentProps,
FullWidthTabs,
FullWidthTabsProps,
GalleryCardsComponent,
GalleryCardsComponentProps,
GalleryViewerComponent,
GalleryViewerComponentProps
} from "./GalleryViewerComponent";
import React from "react";
import { GalleryViewerComponent, GalleryViewerComponentProps, GalleryTab, SortBy } from "./GalleryViewerComponent";
describe("GalleryCardsComponent", () => {
it("renders", () => {
// TODO Mock this
const props: GalleryCardsComponentProps = {
data: [],
userMetadata: undefined,
onNotebookMetadataChange: () => Promise.resolve(),
onClick: () => Promise.resolve()
};
const wrapper = shallow(<GalleryCardsComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
describe("FullWidthTabs", () => {
it("renders", () => {
const props: FullWidthTabsProps = {
officialSamplesContent: [],
likedNotebooksContent: [],
userMetadata: undefined,
onClick: () => Promise.resolve()
};
const wrapper = shallow(<FullWidthTabs {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
describe("GalleryViewerContainerComponent", () => {
it("renders", () => {
const props: GalleryViewerContainerComponentProps = {
container: undefined
};
const wrapper = shallow(<GalleryViewerContainerComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
describe("GalleryCardComponent", () => {
describe("GalleryViewerComponent", () => {
it("renders", () => {
const props: GalleryViewerComponentProps = {
container: undefined,
officialSamplesData: [],
likedNotebookData: undefined
junoClient: undefined,
selectedTab: GalleryTab.OfficialSamples,
sortBy: SortBy.MostViewed,
searchText: undefined,
onSelectedTabChange: undefined,
onSortByChange: undefined,
onSearchTextChange: undefined
};
const wrapper = shallow(<GalleryViewerComponent {...props} />);

View File

@@ -1,361 +1,513 @@
/**
* Gallery Viewer
*/
import {
Dropdown,
FocusZone,
IDropdownOption,
IPageSpecification,
IPivotItemProps,
IPivotProps,
IRectangle,
Label,
List,
Pivot,
PivotItem,
SearchBox,
Stack
} from "office-ui-fabric-react";
import * as React from "react";
import * as DataModels from "../../../Contracts/DataModels";
import * as Logger from "../../../Common/Logger";
import * as ViewModels from "../../../Contracts/ViewModels";
import { GalleryCardComponent } from "./Cards/GalleryCardComponent";
import { Stack, IStackTokens } from "office-ui-fabric-react";
import { JunoUtils } from "../../../Utils/JunoUtils";
import { CosmosClient } from "../../../Common/CosmosClient";
import { config } from "../../../Config";
import path from "path";
import { SessionStorageUtility, StorageKey } from "../../../Shared/StorageUtility";
import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient";
import * as GalleryUtils from "../../../Utils/GalleryUtils";
import { NotificationConsoleUtils } from "../../../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
import * as TabComponent from "../Tabs/TabComponent";
import { DialogComponent, DialogProps } from "../DialogReactComponent/DialogComponent";
import { GalleryCardComponent, GalleryCardComponentProps } from "./Cards/GalleryCardComponent";
import "./GalleryViewerComponent.less";
import { HttpStatusCodes } from "../../../Common/Constants";
export interface GalleryCardsComponentProps {
data: DataModels.GitHubInfoJunoResponse[];
userMetadata: DataModels.UserMetadata;
onNotebookMetadataChange: (
officialSamplesIndex: number,
notebookMetadata: DataModels.NotebookMetadata
) => Promise<void>;
onClick: (
url: string,
notebookMetadata: DataModels.NotebookMetadata,
onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>,
isLikedNotebook: boolean
) => Promise<void>;
export interface GalleryViewerComponentProps {
container?: ViewModels.Explorer;
junoClient: JunoClient;
selectedTab: GalleryTab;
sortBy: SortBy;
searchText: string;
onSelectedTabChange: (newTab: GalleryTab) => void;
onSortByChange: (sortBy: SortBy) => void;
onSearchTextChange: (searchText: string) => void;
}
export class GalleryCardsComponent extends React.Component<GalleryCardsComponentProps> {
private sectionStackTokens: IStackTokens = { childrenGap: 30 };
export enum GalleryTab {
OfficialSamples,
PublicGallery,
Favorites,
Published
}
export enum SortBy {
MostViewed,
MostDownloaded,
MostFavorited,
MostRecent
}
interface GalleryViewerComponentState {
sampleNotebooks: IGalleryItem[];
publicNotebooks: IGalleryItem[];
favoriteNotebooks: IGalleryItem[];
publishedNotebooks: IGalleryItem[];
selectedTab: GalleryTab;
sortBy: SortBy;
searchText: string;
dialogProps: DialogProps;
}
interface GalleryTabInfo {
tab: GalleryTab;
content: JSX.Element;
}
export class GalleryViewerComponent extends React.Component<GalleryViewerComponentProps, GalleryViewerComponentState>
implements GalleryUtils.DialogEnabledComponent {
public static readonly OfficialSamplesTitle = "Official samples";
public static readonly PublicGalleryTitle = "Public gallery";
public static readonly FavoritesTitle = "Liked";
public static readonly PublishedTitle = "Your published work";
private static readonly mostViewedText = "Most viewed";
private static readonly mostDownloadedText = "Most downloaded";
private static readonly mostFavoritedText = "Most favorited";
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 sampleNotebooks: IGalleryItem[];
private publicNotebooks: IGalleryItem[];
private favoriteNotebooks: IGalleryItem[];
private publishedNotebooks: IGalleryItem[];
private columnCount: number;
private rowCount: number;
constructor(props: GalleryViewerComponentProps) {
super(props);
this.state = {
sampleNotebooks: undefined,
publicNotebooks: undefined,
favoriteNotebooks: undefined,
publishedNotebooks: undefined,
selectedTab: props.selectedTab,
sortBy: props.sortBy,
searchText: props.searchText,
dialogProps: undefined
};
this.loadTabContent(this.state.selectedTab, this.state.searchText, this.state.sortBy, false);
if (this.props.container) {
this.loadFavoriteNotebooks(this.state.searchText, this.state.sortBy, false); // Need this to show correct favorite button state
}
}
setDialogProps = (dialogProps: DialogProps): void => {
this.setState({ dialogProps });
};
public render(): JSX.Element {
return (
<Stack horizontal wrap tokens={this.sectionStackTokens}>
{this.props.data.map((githubInfo: DataModels.GitHubInfoJunoResponse) => {
const name = githubInfo.name;
const url = githubInfo.downloadUrl;
const notebookMetadata = githubInfo.metadata || {
date: "2008-12-01",
description: "Great notebook",
tags: ["favorite", "sample"],
author: "Laurent Nguyen",
views: 432,
likes: 123,
downloads: 56,
imageUrl:
"https://media.magazine.ferrari.com/images/2019/02/27/170304506-c1bcf028-b513-45f6-9f27-0cadac619c3d.jpg"
};
const officialSamplesIndex = githubInfo.officialSamplesIndex;
const isLikedNotebook = githubInfo.isLikedNotebook;
const updateTabsStatePerNotebook = this.props.onNotebookMetadataChange
? (notebookMetadata: DataModels.NotebookMetadata): Promise<void> =>
this.props.onNotebookMetadataChange(officialSamplesIndex, notebookMetadata)
: undefined;
const tabs: GalleryTabInfo[] = [this.createTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks)];
return (
name !== ".gitignore" &&
url && (
<GalleryCardComponent
key={url}
name={name}
url={url}
notebookMetadata={notebookMetadata}
onClick={(): Promise<void> =>
this.props.onClick(url, notebookMetadata, updateTabsStatePerNotebook, isLikedNotebook)
}
/>
)
);
})}
if (this.props.container) {
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));
}
}
const pivotProps: IPivotProps = {
onLinkClick: this.onPivotChange,
selectedKey: GalleryTab[this.state.selectedTab]
};
const pivotItems = tabs.map(tab => {
const pivotItemProps: IPivotItemProps = {
itemKey: GalleryTab[tab.tab],
style: { marginTop: 20 },
headerText: GalleryUtils.getTabTitle(tab.tab)
};
return (
<PivotItem key={pivotItemProps.itemKey} {...pivotItemProps}>
{tab.content}
</PivotItem>
);
});
return (
<div className="galleryContainer">
<Pivot {...pivotProps}>{pivotItems}</Pivot>
{this.state.dialogProps && <DialogComponent {...this.state.dialogProps} />}
</div>
);
}
private createTab(tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo {
return {
tab,
content: this.createTabContent(data)
};
}
private createTabContent(data: IGalleryItem[]): JSX.Element {
return (
<Stack tokens={{ childrenGap: 20 }}>
<Stack horizontal tokens={{ childrenGap: 20 }}>
<Stack.Item grow>
<SearchBox value={this.state.searchText} placeholder="Search" onChange={this.onSearchBoxChange} />
</Stack.Item>
<Stack.Item>
<Label>Sort by</Label>
</Stack.Item>
<Stack.Item styles={{ root: { minWidth: 200 } }}>
<Dropdown
options={GalleryViewerComponent.sortingOptions}
selectedKey={this.state.sortBy}
onChange={this.onDropdownChange}
/>
</Stack.Item>
</Stack>
{data && this.createCardsTabContent(data)}
</Stack>
);
}
}
export interface FullWidthTabsProps {
officialSamplesContent: DataModels.GitHubInfoJunoResponse[];
likedNotebooksContent: DataModels.GitHubInfoJunoResponse[];
userMetadata: DataModels.UserMetadata;
onClick: (
url: string,
notebookMetadata: DataModels.NotebookMetadata,
onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>,
isLikedNotebook: boolean
) => Promise<void>;
}
interface FullWidthTabsState {
activeTabIndex: number;
officialSamplesContent: DataModels.GitHubInfoJunoResponse[];
likedNotebooksContent: DataModels.GitHubInfoJunoResponse[];
userMetadata: DataModels.UserMetadata;
}
export class FullWidthTabs extends React.Component<FullWidthTabsProps, FullWidthTabsState> {
private authorizationToken = CosmosClient.authorizationToken();
private appTabs: TabComponent.Tab[];
constructor(props: FullWidthTabsProps) {
super(props);
this.state = {
activeTabIndex: 0,
officialSamplesContent: this.props.officialSamplesContent,
likedNotebooksContent: this.props.likedNotebooksContent,
userMetadata: this.props.userMetadata
};
this.appTabs = [
{
title: "Official Samples",
content: {
className: "",
render: (): JSX.Element => (
<GalleryCardsComponent
data={this.state.officialSamplesContent}
onClick={this.props.onClick}
userMetadata={this.state.userMetadata}
onNotebookMetadataChange={this.updateTabsState}
/>
)
},
isVisible: (): boolean => true
},
{
title: "Liked Notebooks",
content: {
className: "",
render: (): JSX.Element => (
<GalleryCardsComponent
data={this.state.likedNotebooksContent}
onClick={this.props.onClick}
userMetadata={this.state.userMetadata}
onNotebookMetadataChange={this.updateTabsState}
/>
)
},
isVisible: (): boolean => true
}
];
private createCardsTabContent(data: IGalleryItem[]): JSX.Element {
return (
<FocusZone>
<List
items={data}
getPageSpecification={this.getPageSpecification}
renderedWindowsAhead={3}
onRenderCell={this.onRenderCell}
/>
</FocusZone>
);
}
public updateTabsState = async (
officialSamplesIndex: number,
notebookMetadata: DataModels.NotebookMetadata
): Promise<void> => {
let currentLikedNotebooksContent = [...this.state.likedNotebooksContent];
let currentUserMetadata = { ...this.state.userMetadata };
let currentLikedNotebooks = [...currentUserMetadata.likedNotebooks];
private loadTabContent(tab: GalleryTab, searchText: string, sortBy: SortBy, offline: boolean): void {
switch (tab) {
case GalleryTab.OfficialSamples:
this.loadSampleNotebooks(searchText, sortBy, offline);
break;
const currentOfficialSamplesContent = [...this.state.officialSamplesContent];
const currentOfficialSamplesObject = { ...currentOfficialSamplesContent[officialSamplesIndex] };
const metadata = { ...currentOfficialSamplesObject.metadata };
const metadataLikesUpdates = metadata.likes - notebookMetadata.likes;
case GalleryTab.PublicGallery:
this.loadPublicNotebooks(searchText, sortBy, offline);
break;
metadata.views = notebookMetadata.views;
metadata.downloads = notebookMetadata.downloads;
metadata.likes = notebookMetadata.likes;
currentOfficialSamplesObject.metadata = metadata;
case GalleryTab.Favorites:
this.loadFavoriteNotebooks(searchText, sortBy, offline);
break;
// Notebook has been liked. Add To likedNotebooksContent, update isLikedNotebook flag
if (metadataLikesUpdates < 0) {
currentOfficialSamplesObject.isLikedNotebook = true;
currentLikedNotebooksContent = currentLikedNotebooksContent.concat(currentOfficialSamplesObject);
currentLikedNotebooks = currentLikedNotebooks.concat(currentOfficialSamplesObject.path);
currentUserMetadata = { likedNotebooks: currentLikedNotebooks };
} else if (metadataLikesUpdates > 0) {
// Notebook has been unliked. Remove from likedNotebooksContent after matching the path, update isLikedNotebook flag
case GalleryTab.Published:
this.loadPublishedNotebooks(searchText, sortBy, offline);
break;
currentOfficialSamplesObject.isLikedNotebook = false;
const likedNotebookIndex = currentLikedNotebooks.findIndex((path: string) => {
return path === currentOfficialSamplesObject.path;
});
currentLikedNotebooksContent.splice(likedNotebookIndex, 1);
currentLikedNotebooks.splice(likedNotebookIndex, 1);
currentUserMetadata = { likedNotebooks: currentLikedNotebooks };
default:
throw new Error(`Unknown tab ${tab}`);
}
}
currentOfficialSamplesContent[officialSamplesIndex] = currentOfficialSamplesObject;
private async loadSampleNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
if (!offline) {
try {
const response = await this.props.junoClient.getSampleNotebooks();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when loading sample notebooks`);
}
this.sampleNotebooks = response.data;
} catch (error) {
const message = `Failed to load sample notebooks: ${error}`;
Logger.logError(message, "GalleryViewerComponent/loadSampleNotebooks");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
}
}
this.setState({
activeTabIndex: 0,
userMetadata: currentUserMetadata,
likedNotebooksContent: currentLikedNotebooksContent,
officialSamplesContent: currentOfficialSamplesContent
sampleNotebooks: this.sampleNotebooks && [...this.sort(sortBy, this.search(searchText, this.sampleNotebooks))]
});
}
private async loadPublicNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
if (!offline) {
try {
const response = await this.props.junoClient.getPublicNotebooks();
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");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
}
}
this.setState({
publicNotebooks: this.publicNotebooks && [...this.sort(sortBy, this.search(searchText, this.publicNotebooks))]
});
}
private async loadFavoriteNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
if (!offline) {
try {
const response = await this.props.junoClient.getFavoriteNotebooks();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when loading favorite notebooks`);
}
this.favoriteNotebooks = response.data;
} catch (error) {
const message = `Failed to load favorite notebooks: ${error}`;
Logger.logError(message, "GalleryViewerComponent/loadFavoriteNotebooks");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
}
}
this.setState({
favoriteNotebooks: this.favoriteNotebooks && [
...this.sort(sortBy, this.search(searchText, this.favoriteNotebooks))
]
});
JunoUtils.updateNotebookMetadata(this.authorizationToken, notebookMetadata).then(
async () => {
if (metadataLikesUpdates !== 0) {
JunoUtils.updateUserMetadata(this.authorizationToken, currentUserMetadata);
// TODO: update state here?
// Refresh favorite button state
if (this.state.selectedTab !== GalleryTab.Favorites) {
this.refreshSelectedTab();
}
}
private async loadPublishedNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
if (!offline) {
try {
const response = await this.props.junoClient.getPublishedNotebooks();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when loading published notebooks`);
}
},
error => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error updating notebook metadata: ${JSON.stringify(error)}`
);
// TODO add telemetry
this.publishedNotebooks = response.data;
} catch (error) {
const message = `Failed to load published notebooks: ${error}`;
Logger.logError(message, "GalleryViewerComponent/loadPublishedNotebooks");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
}
}
this.setState({
publishedNotebooks: this.publishedNotebooks && [
...this.sort(sortBy, this.search(searchText, this.publishedNotebooks))
]
});
}
private search(searchText: string, data: IGalleryItem[]): IGalleryItem[] {
if (searchText) {
return data?.filter(item => this.isGalleryItemPresent(searchText, item));
}
return data;
}
private isGalleryItemPresent(searchText: string, item: IGalleryItem): boolean {
const toSearch = searchText.trim().toUpperCase();
const searchData: string[] = [
item.author.toUpperCase(),
item.description.toUpperCase(),
item.name.toUpperCase(),
...item.tags?.map(tag => tag.toUpperCase())
];
for (const data of searchData) {
if (data?.indexOf(toSearch) !== -1) {
return true;
}
}
return false;
}
private sort(sortBy: SortBy, data: IGalleryItem[]): IGalleryItem[] {
return data?.sort((a, b) => {
switch (sortBy) {
case SortBy.MostViewed:
return b.views - a.views;
case SortBy.MostDownloaded:
return b.downloads - a.downloads;
case SortBy.MostFavorited:
return b.favorites - a.favorites;
case SortBy.MostRecent:
return Date.parse(b.created) - Date.parse(a.created);
default:
throw new Error(`Unknown sorting condition ${sortBy}`);
}
});
}
private refreshSelectedTab(item?: IGalleryItem): void {
if (item) {
this.updateGalleryItem(item);
}
this.loadTabContent(this.state.selectedTab, this.state.searchText, this.state.sortBy, true);
}
private updateGalleryItem(updatedItem: IGalleryItem): void {
this.replaceGalleryItem(updatedItem, this.sampleNotebooks);
this.replaceGalleryItem(updatedItem, this.publicNotebooks);
this.replaceGalleryItem(updatedItem, this.favoriteNotebooks);
this.replaceGalleryItem(updatedItem, this.publishedNotebooks);
}
private replaceGalleryItem(item: IGalleryItem, items?: IGalleryItem[]): void {
const index = items?.findIndex(value => value.id === item.id);
if (index !== -1) {
items?.splice(index, 1, item);
}
}
private getPageSpecification = (itemIndex?: number, visibleRect?: IRectangle): IPageSpecification => {
this.columnCount = Math.floor(visibleRect.width / GalleryCardComponent.CARD_WIDTH);
this.rowCount = Math.floor(visibleRect.height / GalleryCardComponent.CARD_HEIGHT);
return {
height: visibleRect.height,
itemCount: this.columnCount * this.rowCount
};
};
private onRenderCell = (data?: IGalleryItem): JSX.Element => {
const isFavorite = this.favoriteNotebooks?.find(item => item.id === data.id) !== undefined;
const props: GalleryCardComponentProps = {
data,
isFavorite,
showDelete: this.state.selectedTab === GalleryTab.Published,
onClick: () => this.openNotebook(data, isFavorite),
onTagClick: this.loadTaggedItems,
onFavoriteClick: () => this.favoriteItem(data),
onUnfavoriteClick: () => this.unfavoriteItem(data),
onDownloadClick: () => this.downloadItem(data),
onDeleteClick: () => this.deleteItem(data)
};
return (
<div style={{ float: "left", padding: 10 }}>
<GalleryCardComponent {...props} />
</div>
);
};
private onTabIndexChange = (activeTabIndex: number): void => this.setState({ activeTabIndex });
public render(): JSX.Element {
return (
<TabComponent.TabComponent
tabs={this.appTabs}
onTabIndexChange={this.onTabIndexChange.bind(this)}
currentTabIndex={this.state.activeTabIndex}
hideHeader={false}
/>
);
}
}
export interface GalleryViewerContainerComponentProps {
container: ViewModels.Explorer;
}
interface GalleryViewerContainerComponentState {
officialSamplesData: DataModels.GitHubInfoJunoResponse[];
likedNotebooksData: DataModels.LikedNotebooksJunoResponse;
}
export class GalleryViewerContainerComponent extends React.Component<
GalleryViewerContainerComponentProps,
GalleryViewerContainerComponentState
> {
constructor(props: GalleryViewerContainerComponentProps) {
super(props);
this.state = {
officialSamplesData: undefined,
likedNotebooksData: undefined
};
}
componentDidMount(): void {
const authToken = CosmosClient.authorizationToken();
JunoUtils.getOfficialSampleNotebooks(authToken).then(
(data1: DataModels.GitHubInfoJunoResponse[]) => {
const officialSamplesData = data1;
JunoUtils.getLikedNotebooks(authToken).then(
(data2: DataModels.LikedNotebooksJunoResponse) => {
const likedNotebooksData = data2;
officialSamplesData.map((value: DataModels.GitHubInfoJunoResponse, index: number) => {
value.officialSamplesIndex = index;
value.isLikedNotebook = likedNotebooksData.userMetadata.likedNotebooks.includes(value.path);
});
likedNotebooksData.likedNotebooksContent.map((value: DataModels.GitHubInfoJunoResponse) => {
value.isLikedNotebook = true;
value.officialSamplesIndex = officialSamplesData.findIndex(
(officialSample: DataModels.GitHubInfoJunoResponse) => {
return officialSample.path === value.path;
}
);
});
this.setState({
officialSamplesData: officialSamplesData,
likedNotebooksData: likedNotebooksData
});
},
error => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error fetching liked notebooks: ${JSON.stringify(error)}`
);
// TODO Add telemetry
}
);
},
error => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error fetching sample notebooks: ${JSON.stringify(error)}`
);
// TODO Add telemetry
}
);
}
public render(): JSX.Element {
return this.state.officialSamplesData && this.state.likedNotebooksData ? (
<GalleryViewerComponent
container={this.props.container}
officialSamplesData={this.state.officialSamplesData}
likedNotebookData={this.state.likedNotebooksData}
/>
) : (
<></>
);
}
}
export interface GalleryViewerComponentProps {
container: ViewModels.Explorer;
officialSamplesData: DataModels.GitHubInfoJunoResponse[];
likedNotebookData: DataModels.LikedNotebooksJunoResponse;
}
export class GalleryViewerComponent extends React.Component<GalleryViewerComponentProps> {
public render(): JSX.Element {
return this.props.container ? (
<div className="galleryContainer">
<FullWidthTabs
officialSamplesContent={this.props.officialSamplesData}
likedNotebooksContent={this.props.likedNotebookData.likedNotebooksContent}
userMetadata={this.props.likedNotebookData.userMetadata}
onClick={this.openNotebookViewer}
/>
</div>
) : (
<div className="galleryContainer">
<GalleryCardsComponent
data={this.props.officialSamplesData}
onClick={this.openNotebookViewer}
userMetadata={undefined}
onNotebookMetadataChange={undefined}
/>
</div>
);
}
public getOfficialSamplesData(): DataModels.GitHubInfoJunoResponse[] {
return this.props.officialSamplesData;
}
public getLikedNotebookData(): DataModels.LikedNotebooksJunoResponse {
return this.props.likedNotebookData;
}
public openNotebookViewer = async (
url: string,
notebookMetadata: DataModels.NotebookMetadata,
onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>,
isLikedNotebook: boolean
): Promise<void> => {
if (!this.props.container) {
SessionStorageUtility.setEntryString(
StorageKey.NotebookMetadata,
notebookMetadata ? JSON.stringify(notebookMetadata) : null
);
SessionStorageUtility.setEntryString(StorageKey.NotebookName, path.basename(url));
window.open(`${config.hostedExplorerURL}notebookViewer.html?notebookurl=${url}`, "_blank");
private openNotebook = (data: IGalleryItem, isFavorite: boolean): void => {
if (this.props.container && this.props.junoClient) {
this.props.container.openGallery(this.props.junoClient.getNotebookContentUrl(data.id), data, isFavorite);
} else {
this.props.container.openNotebookViewer(url, notebookMetadata, onNotebookMetadataChange, isLikedNotebook);
const params = new URLSearchParams({
[GalleryUtils.NotebookViewerParams.NotebookUrl]: this.props.junoClient.getNotebookContentUrl(data.id),
[GalleryUtils.NotebookViewerParams.GalleryItemId]: data.id
});
window.open(`/notebookViewer.html?${params.toString()}`);
}
};
private loadTaggedItems = (tag: string): void => {
const searchText = tag;
this.setState({
searchText
});
this.loadTabContent(this.state.selectedTab, searchText, this.state.sortBy, true);
this.props.onSearchTextChange && this.props.onSearchTextChange(searchText);
};
private favoriteItem = async (data: IGalleryItem): Promise<void> => {
GalleryUtils.favoriteItem(this.props.container, this.props.junoClient, data, (item: IGalleryItem) => {
if (this.favoriteNotebooks) {
this.favoriteNotebooks.push(item);
} else {
this.favoriteNotebooks = [item];
}
this.refreshSelectedTab(item);
});
};
private unfavoriteItem = async (data: IGalleryItem): Promise<void> => {
GalleryUtils.unfavoriteItem(this.props.container, this.props.junoClient, data, (item: IGalleryItem) => {
this.favoriteNotebooks = this.favoriteNotebooks?.filter(value => value.id !== item.id);
this.refreshSelectedTab(item);
});
};
private downloadItem = async (data: IGalleryItem): Promise<void> => {
GalleryUtils.downloadItem(this, this.props.container, this.props.junoClient, data, item =>
this.refreshSelectedTab(item)
);
};
private deleteItem = async (data: IGalleryItem): Promise<void> => {
GalleryUtils.deleteItem(this.props.container, this.props.junoClient, data, item => {
this.publishedNotebooks = this.publishedNotebooks.filter(notebook => item.id !== notebook.id);
this.refreshSelectedTab(item);
});
};
private onPivotChange = (item: PivotItem): void => {
const selectedTab = GalleryTab[item.props.itemKey as keyof typeof GalleryTab];
const searchText: string = undefined;
this.setState({
selectedTab,
searchText
});
this.loadTabContent(selectedTab, searchText, this.state.sortBy, false);
this.props.onSelectedTabChange && this.props.onSelectedTabChange(selectedTab);
};
private onSearchBoxChange = (event?: React.ChangeEvent<HTMLInputElement>, newValue?: string): void => {
const searchText = newValue;
this.setState({
searchText
});
this.loadTabContent(this.state.selectedTab, searchText, this.state.sortBy, true);
this.props.onSearchTextChange && this.props.onSearchTextChange(searchText);
};
private onDropdownChange = (event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption): void => {
const sortBy = option.key as SortBy;
this.setState({
sortBy
});
this.loadTabContent(this.state.selectedTab, this.state.searchText, sortBy, true);
this.props.onSortByChange && this.props.onSortByChange(sortBy);
};
}

View File

@@ -1,54 +1,88 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FullWidthTabs renders 1`] = `
<TabComponent
currentTabIndex={0}
hideHeader={false}
onTabIndexChange={[Function]}
tabs={
Array [
Object {
"content": Object {
"className": "",
"render": [Function],
},
"isVisible": [Function],
"title": "Official Samples",
},
Object {
"content": Object {
"className": "",
"render": [Function],
},
"isVisible": [Function],
"title": "Liked Notebooks",
},
]
}
/>
`;
exports[`GalleryCardComponent renders 1`] = `
exports[`GalleryViewerComponent renders 1`] = `
<div
className="galleryContainer"
>
<GalleryCardsComponent
data={Array []}
onClick={[Function]}
/>
<StyledPivotBase
onLinkClick={[Function]}
selectedKey="OfficialSamples"
>
<PivotItem
headerText="Official samples"
itemKey="OfficialSamples"
key="OfficialSamples"
style={
Object {
"marginTop": 20,
}
}
>
<Stack
tokens={
Object {
"childrenGap": 20,
}
}
>
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 20,
}
}
>
<StackItem
grow={true}
>
<StyledSearchBoxBase
onChange={[Function]}
placeholder="Search"
/>
</StackItem>
<StackItem>
<StyledLabelBase>
Sort by
</StyledLabelBase>
</StackItem>
<StackItem
styles={
Object {
"root": Object {
"minWidth": 200,
},
}
}
>
<StyledWithResponsiveMode
onChange={[Function]}
options={
Array [
Object {
"key": 0,
"text": "Most viewed",
},
Object {
"key": 1,
"text": "Most downloaded",
},
Object {
"key": 2,
"text": "Most favorited",
},
Object {
"key": 3,
"text": "Most recent",
},
]
}
selectedKey={0}
/>
</StackItem>
</Stack>
</Stack>
</PivotItem>
</StyledPivotBase>
</div>
`;
exports[`GalleryCardsComponent renders 1`] = `
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 30,
}
}
wrap={true}
/>
`;
exports[`GalleryViewerContainerComponent renders 1`] = `<Fragment />`;

View File

@@ -1,16 +1,30 @@
import React from "react";
import { shallow } from "enzyme";
import { NotebookMetadataComponentProps, NotebookMetadataComponent } from "./NotebookMetadataComponent";
import React from "react";
import { NotebookMetadataComponent, NotebookMetadataComponentProps } from "./NotebookMetadataComponent";
describe("NotebookMetadataComponent", () => {
it("renders un-liked notebook", () => {
const props: NotebookMetadataComponentProps = {
notebookName: "My notebook",
container: undefined,
notebookMetadata: undefined,
notebookContent: {},
onNotebookMetadataChange: () => Promise.resolve(),
isLikedNotebook: false
data: {
id: "id",
name: "name",
description: "description",
author: "author",
thumbnailUrl: "thumbnailUrl",
created: "created",
gitSha: "gitSha",
tags: ["tag"],
isSample: false,
downloads: 0,
favorites: 0,
views: 0
},
isFavorite: false,
downloadButtonText: "Download",
onTagClick: undefined,
onDownloadClick: undefined,
onFavoriteClick: undefined,
onUnfavoriteClick: undefined
};
const wrapper = shallow(<NotebookMetadataComponent {...props} />);
@@ -19,12 +33,26 @@ describe("NotebookMetadataComponent", () => {
it("renders liked notebook", () => {
const props: NotebookMetadataComponentProps = {
notebookName: "My notebook",
container: undefined,
notebookMetadata: undefined,
notebookContent: {},
onNotebookMetadataChange: () => Promise.resolve(),
isLikedNotebook: true
data: {
id: "id",
name: "name",
description: "description",
author: "author",
thumbnailUrl: "thumbnailUrl",
created: "created",
gitSha: "gitSha",
tags: ["tag"],
isSample: false,
downloads: 0,
favorites: 0,
views: 0
},
isFavorite: true,
downloadButtonText: "Download",
onTagClick: undefined,
onDownloadClick: undefined,
onFavoriteClick: undefined,
onUnfavoriteClick: undefined
};
const wrapper = shallow(<NotebookMetadataComponent {...props} />);

View File

@@ -1,189 +1,85 @@
/**
* Wrapper around Notebook metadata
*/
import * as React from "react";
import * as ViewModels from "../../../Contracts/ViewModels";
import { NotebookMetadata } from "../../../Contracts/DataModels";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import { Icon, Persona, Text, IconButton } from "office-ui-fabric-react";
import {
siteTextStyles,
subtleIconStyles,
iconStyles,
iconButtonStyles,
mainHelpfulTextStyles,
subtleHelpfulTextStyles,
helpfulTextStyles
} from "../NotebookGallery/Cards/CardStyleConstants";
FontWeights,
Icon,
IconButton,
Link,
Persona,
PersonaSize,
PrimaryButton,
Stack,
Text
} from "office-ui-fabric-react";
import * as React from "react";
import { IGalleryItem } from "../../../Juno/JunoClient";
import { FileSystemUtil } from "../../Notebook/FileSystemUtil";
import "./NotebookViewerComponent.less";
initializeIcons();
export interface NotebookMetadataComponentProps {
notebookName: string;
container: ViewModels.Explorer;
notebookMetadata: NotebookMetadata;
notebookContent: any;
onNotebookMetadataChange: (newNotebookMetadata: NotebookMetadata) => Promise<void>;
isLikedNotebook: boolean;
data: IGalleryItem;
isFavorite: boolean;
downloadButtonText: string;
onTagClick: (tag: string) => void;
onFavoriteClick: () => void;
onUnfavoriteClick: () => void;
onDownloadClick: () => void;
}
interface NotebookMetadatComponentState {
liked: boolean;
notebookMetadata: NotebookMetadata;
}
export class NotebookMetadataComponent extends React.Component<
NotebookMetadataComponentProps,
NotebookMetadatComponentState
> {
constructor(props: NotebookMetadataComponentProps) {
super(props);
this.state = {
liked: this.props.isLikedNotebook,
notebookMetadata: this.props.notebookMetadata
};
}
private onDownloadClick = (newNotebookName: string) => {
this.props.container
.importAndOpenFromGallery(this.props.notebookName, newNotebookName, JSON.stringify(this.props.notebookContent))
.then(() => {
if (this.props.notebookMetadata) {
if (this.props.onNotebookMetadataChange) {
const notebookMetadata = { ...this.state.notebookMetadata };
notebookMetadata.downloads += 1;
this.props.onNotebookMetadataChange(notebookMetadata).then(() => {
this.setState({ notebookMetadata: notebookMetadata });
});
}
}
});
};
componentDidMount() {
if (this.props.onNotebookMetadataChange) {
const notebookMetadata = { ...this.state.notebookMetadata };
if (this.props.notebookMetadata) {
notebookMetadata.views += 1;
this.props.onNotebookMetadataChange(notebookMetadata).then(() => {
this.setState({ notebookMetadata: notebookMetadata });
});
}
}
}
private onLike = (): void => {
if (this.props.onNotebookMetadataChange) {
const notebookMetadata = { ...this.state.notebookMetadata };
let liked: boolean;
if (this.state.liked) {
liked = false;
notebookMetadata.likes -= 1;
} else {
liked = true;
notebookMetadata.likes += 1;
}
this.props.onNotebookMetadataChange(notebookMetadata).then(() => {
this.setState({ liked: liked, notebookMetadata: notebookMetadata });
});
}
};
private onDownload = (): void => {
const promptForNotebookName = () => {
return new Promise<string>((resolve, reject) => {
let newNotebookName = this.props.notebookName;
this.props.container.showOkCancelTextFieldModalDialog(
"Save notebook as",
undefined,
"Ok",
() => resolve(newNotebookName),
"Cancel",
() => reject(new Error("New notebook name dialog canceled")),
{
label: "New notebook name:",
autoAdjustHeight: true,
multiline: true,
rows: 3,
defaultValue: this.props.notebookName,
onChange: (_, newValue: string) => {
newNotebookName = newValue;
}
}
);
});
};
promptForNotebookName().then((newNotebookName: string) => {
this.onDownloadClick(newNotebookName);
});
};
export class NotebookMetadataComponent extends React.Component<NotebookMetadataComponentProps> {
public render(): JSX.Element {
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric"
};
const dateString = new Date(this.props.data.created).toLocaleString("default", options);
return (
<div className="notebookViewerMetadataContainer">
<h3 className="title">{this.props.notebookName}</h3>
<Stack tokens={{ childrenGap: 10 }}>
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 30 }}>
<Text variant="xxLarge" nowrap>
{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
</Text>
<PrimaryButton text={this.props.downloadButtonText} onClick={this.props.onDownloadClick} />
</Stack>
{this.props.notebookMetadata && (
<div className="decoration">
{this.props.container ? (
<IconButton
iconProps={{ iconName: this.state.liked ? "HeartFill" : "Heart" }}
styles={iconButtonStyles}
onClick={this.onLike}
/>
) : (
<Icon iconName="Heart" styles={iconStyles} />
)}
<Text variant="large" styles={mainHelpfulTextStyles}>
{this.state.notebookMetadata.likes} likes
</Text>
</div>
)}
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 10 }}>
<Persona text={this.props.data.author} size={PersonaSize.size32} />
<Text>{dateString}</Text>
<Text>
<Icon iconName="RedEye" /> {this.props.data.views}
</Text>
<Text>
<Icon iconName="Download" />
{this.props.data.downloads}
</Text>
</Stack>
{this.props.container && (
<button aria-label="downloadButton" className="downloadButton" onClick={this.onDownload}>
Download Notebook
</button>
)}
<Text nowrap>
{this.props.data.tags?.map((tag, index, array) => (
<span key={tag}>
<Link onClick={(): void => this.props.onTagClick(tag)}>{tag}</Link>
{index === array.length - 1 ? <></> : ", "}
</span>
))}
</Text>
{this.props.notebookMetadata && (
<>
<div>
<Persona
className="persona"
text={this.props.notebookMetadata.author}
secondaryText={this.props.notebookMetadata.date}
/>
</div>
<div>
<div className="extras">
<Icon iconName="RedEye" styles={subtleIconStyles} />
<Text variant="small" styles={subtleHelpfulTextStyles}>
{this.state.notebookMetadata.views}
</Text>
<Icon iconName="Download" styles={subtleIconStyles} />
<Text variant="small" styles={subtleHelpfulTextStyles}>
{this.state.notebookMetadata.downloads}
</Text>
</div>
<Text variant="small" styles={siteTextStyles}>
{this.props.notebookMetadata.tags.join(", ")}
</Text>
</div>
<div>
<Text variant="small" styles={helpfulTextStyles}>
<b>Description:</b>
<p>{this.props.notebookMetadata.description}</p>
</Text>
</div>
</>
)}
</div>
<Text variant="large" styles={{ root: { fontWeight: FontWeights.semibold } }}>
Description
</Text>
<Text>{this.props.data.description}</Text>
</Stack>
);
}
}

View File

@@ -1,7 +1,7 @@
@import "../../../../less/Common/Constants";
.notebookViewerContainer {
padding: @DefaultSpace;
padding: 30px;
height: 100%;
width: 100%;
overflow-y: auto;

View File

@@ -1,36 +1,44 @@
/**
* Wrapper around Notebook Viewer Read only content
*/
import { Notebook } from "@nteract/commutable";
import { createContentRef } from "@nteract/core";
import { Icon, Link } from "office-ui-fabric-react";
import * as React from "react";
import { contents } from "rx-jupyter";
import * as Logger from "../../../Common/Logger";
import * as ViewModels from "../../../Contracts/ViewModels";
import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient";
import * as GalleryUtils from "../../../Utils/GalleryUtils";
import { NotificationConsoleUtils } from "../../../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
import { NotebookClientV2 } from "../../Notebook/NotebookClientV2";
import { NotebookComponentBootstrapper } from "../../Notebook/NotebookComponent/NotebookComponentBootstrapper";
import { createContentRef } from "@nteract/core";
import NotebookReadOnlyRenderer from "../../Notebook/NotebookRenderer/NotebookReadOnlyRenderer";
import { contents } from "rx-jupyter";
import { NotebookMetadata } from "../../../Contracts/DataModels";
import { DialogComponent, DialogProps } from "../DialogReactComponent/DialogComponent";
import { NotebookMetadataComponent } from "./NotebookMetadataComponent";
import "./NotebookViewerComponent.less";
export interface NotebookViewerComponentProps {
notebookName: string;
notebookUrl: string;
container?: ViewModels.Explorer;
notebookMetadata: NotebookMetadata;
onNotebookMetadataChange?: (newNotebookMetadata: NotebookMetadata) => Promise<void>;
isLikedNotebook?: boolean;
hideInputs?: boolean;
junoClient?: JunoClient;
notebookUrl: string;
galleryItem?: IGalleryItem;
isFavorite?: boolean;
backNavigationText: string;
onBackClick: () => void;
onTagClick: (tag: string) => void;
}
interface NotebookViewerComponentState {
content: any;
content: Notebook;
galleryItem?: IGalleryItem;
isFavorite?: boolean;
dialogProps: DialogProps;
}
export class NotebookViewerComponent extends React.Component<
NotebookViewerComponentProps,
NotebookViewerComponentState
> {
export class NotebookViewerComponent extends React.Component<NotebookViewerComponentProps, NotebookViewerComponentState>
implements GalleryUtils.DialogEnabledComponent {
private clientManager: NotebookClientV2;
private notebookComponentBootstrapper: NotebookComponentBootstrapper;
@@ -52,40 +60,118 @@ export class NotebookViewerComponent extends React.Component<
contentRef: createContentRef()
});
this.state = { content: undefined };
this.state = {
content: undefined,
galleryItem: props.galleryItem,
isFavorite: props.isFavorite,
dialogProps: undefined
};
this.loadNotebookContent();
}
private async getJsonNotebookContent(): Promise<any> {
const response: Response = await fetch(this.props.notebookUrl);
if (response.ok) {
return await response.json();
} else {
return undefined;
setDialogProps = (dialogProps: DialogProps): void => {
this.setState({ dialogProps });
};
private async loadNotebookContent(): Promise<void> {
try {
const response = await fetch(this.props.notebookUrl);
if (!response.ok) {
throw new Error(`Received HTTP ${response.status} while fetching ${this.props.notebookUrl}`);
}
const notebook: Notebook = await response.json();
this.notebookComponentBootstrapper.setContent("json", notebook);
this.setState({ content: notebook });
if (this.props.galleryItem) {
const response = await this.props.junoClient.increaseNotebookViews(this.props.galleryItem.id);
if (!response.data) {
throw new Error(`Received HTTP ${response.status} while increasing notebook views`);
}
this.setState({ galleryItem: response.data });
}
} catch (error) {
const message = `Failed to load notebook content: ${error}`;
Logger.logError(message, "NotebookViewerComponent/loadNotebookContent");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
}
}
componentDidMount() {
this.getJsonNotebookContent().then((jsonContent: any) => {
this.notebookComponentBootstrapper.setContent("json", jsonContent);
this.setState({ content: jsonContent });
});
}
public render(): JSX.Element {
return (
<div className="notebookViewerContainer">
<NotebookMetadataComponent
notebookMetadata={this.props.notebookMetadata}
notebookName={this.props.notebookName}
container={this.props.container}
notebookContent={this.state.content}
onNotebookMetadataChange={this.props.onNotebookMetadataChange}
isLikedNotebook={this.props.isLikedNotebook}
/>
{this.notebookComponentBootstrapper.renderComponent(NotebookReadOnlyRenderer, {
hideInputs: this.props.hideInputs
})}
{this.props.backNavigationText ? (
<Link onClick={this.props.onBackClick}>
<Icon iconName="Back" /> {this.props.backNavigationText}
</Link>
) : (
<></>
)}
{this.state.galleryItem ? (
<div style={{ margin: 10 }}>
<NotebookMetadataComponent
data={this.state.galleryItem}
isFavorite={this.state.isFavorite}
downloadButtonText={
this.props.container ? "Download to my notebooks" : "Edit/Run in Cosmos DB data explorer"
}
onTagClick={this.props.onTagClick}
onFavoriteClick={this.favoriteItem}
onUnfavoriteClick={this.unfavoriteItem}
onDownloadClick={this.downloadItem}
/>
</div>
) : (
<></>
)}
{this.notebookComponentBootstrapper.renderComponent(NotebookReadOnlyRenderer, { hideInputs: true })}
{this.state.dialogProps && <DialogComponent {...this.state.dialogProps} />}
</div>
);
}
public static getDerivedStateFromProps(
props: NotebookViewerComponentProps,
state: NotebookViewerComponentState
): Partial<NotebookViewerComponentState> {
let galleryItem = props.galleryItem;
let isFavorite = props.isFavorite;
if (state.galleryItem !== undefined) {
galleryItem = state.galleryItem;
}
if (state.isFavorite !== undefined) {
isFavorite = state.isFavorite;
}
return {
galleryItem,
isFavorite
};
}
private favoriteItem = async (): Promise<void> => {
GalleryUtils.favoriteItem(this.props.container, this.props.junoClient, this.state.galleryItem, item =>
this.setState({ galleryItem: item, isFavorite: true })
);
};
private unfavoriteItem = async (): Promise<void> => {
GalleryUtils.unfavoriteItem(this.props.container, this.props.junoClient, this.state.galleryItem, item =>
this.setState({ galleryItem: item, isFavorite: false })
);
};
private downloadItem = async (): Promise<void> => {
GalleryUtils.downloadItem(this, this.props.container, this.props.junoClient, this.state.galleryItem, item =>
this.setState({ galleryItem: item })
);
};
}

View File

@@ -1,25 +1,199 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NotebookMetadataComponent renders liked notebook 1`] = `
<div
className="notebookViewerMetadataContainer"
<Stack
tokens={
Object {
"childrenGap": 10,
}
}
>
<h3
className="title"
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 30,
}
}
verticalAlign="center"
>
My notebook
</h3>
</div>
<Text
nowrap={true}
variant="xxLarge"
>
name
</Text>
<Text>
<CustomizedIconButton
iconProps={
Object {
"iconName": "HeartFill",
}
}
/>
0
likes
</Text>
<CustomizedPrimaryButton
text="Download"
/>
</Stack>
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 10,
}
}
verticalAlign="center"
>
<StyledPersonaBase
size={11}
text="author"
/>
<Text>
Invalid Date
</Text>
<Text>
<StyledIconBase
iconName="RedEye"
/>
0
</Text>
<Text>
<StyledIconBase
iconName="Download"
/>
0
</Text>
</Stack>
<Text
nowrap={true}
>
<span
key="tag"
>
<StyledLinkBase
onClick={[Function]}
>
tag
</StyledLinkBase>
</span>
</Text>
<Text
styles={
Object {
"root": Object {
"fontWeight": 600,
},
}
}
variant="large"
>
Description
</Text>
<Text>
description
</Text>
</Stack>
`;
exports[`NotebookMetadataComponent renders un-liked notebook 1`] = `
<div
className="notebookViewerMetadataContainer"
<Stack
tokens={
Object {
"childrenGap": 10,
}
}
>
<h3
className="title"
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 30,
}
}
verticalAlign="center"
>
My notebook
</h3>
</div>
<Text
nowrap={true}
variant="xxLarge"
>
name
</Text>
<Text>
<CustomizedIconButton
iconProps={
Object {
"iconName": "Heart",
}
}
/>
0
likes
</Text>
<CustomizedPrimaryButton
text="Download"
/>
</Stack>
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 10,
}
}
verticalAlign="center"
>
<StyledPersonaBase
size={11}
text="author"
/>
<Text>
Invalid Date
</Text>
<Text>
<StyledIconBase
iconName="RedEye"
/>
0
</Text>
<Text>
<StyledIconBase
iconName="Download"
/>
0
</Text>
</Stack>
<Text
nowrap={true}
>
<span
key="tag"
>
<StyledLinkBase
onClick={[Function]}
>
tag
</StyledLinkBase>
</span>
</Text>
<Text
styles={
Object {
"root": Object {
"fontWeight": 600,
},
}
}
variant="large"
>
Description
</Text>
<Text>
description
</Text>
</Stack>
`;

View File

@@ -6,10 +6,9 @@
*/
import * as React from "react";
import * as ReactDOM from "react-dom";
import * as Constants from "../../../Common/Constants";
import AnimateHeight from "react-animate-height";
import { IconButton } from "office-ui-fabric-react/lib/Button";
import { IconButton, IButtonStyles } from "office-ui-fabric-react/lib/Button";
import {
DirectionalHint,
IContextualMenuItemProps,
@@ -227,6 +226,10 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
private renderContextMenuButton(node: TreeNode): JSX.Element {
const menuItemLabel = "More";
const buttonStyles: Partial<IButtonStyles> = {
rootFocused: { outline: `1px dashed ${Constants.StyleConstants.FocusColor}` }
};
return (
<div ref={this.contextMenuRef} onContextMenu={this.onRightClick} onKeyPress={this.onMoreButtonKeyPress}>
<IconButton
@@ -264,6 +267,7 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
onRenderIcon: (props: any) => <img src={menuItem.iconSrc} alt="" />
}))
}}
styles={buttonStyles}
/>
</div>
);

View File

@@ -191,6 +191,13 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
}
}
name="More"
styles={
Object {
"rootFocused": Object {
"outline": "1px dashed undefined",
},
}
}
/>
</div>
</div>
@@ -314,6 +321,13 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
}
}
name="More"
styles={
Object {
"rootFocused": Object {
"outline": "1px dashed undefined",
},
}
}
/>
</div>
</div>

View File

@@ -49,19 +49,15 @@ import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane";
import { ExplorerMetrics } from "../Common/Constants";
import { ExplorerSettings } from "../Shared/ExplorerSettings";
import { FileSystemUtil } from "./Notebook/FileSystemUtil";
import { GitHubOAuthService } from "../GitHub/GitHubOAuthService";
import { GitHubReposPane } from "./Panes/GitHubReposPane";
import { handleOpenAction } from "./OpenActions";
import { IContentProvider } from "@nteract/core";
import { isInvalidParentFrameOrigin } from "../Utils/MessageValidation";
import { JunoClient } from "../Juno/JunoClient";
import { IGalleryItem } from "../Juno/JunoClient";
import { LibraryManagePane } from "./Panes/LibraryManagePane";
import { LoadQueryPane } from "./Panes/LoadQueryPane";
import { Logger } from "../Common/Logger";
import * as Logger from "../Common/Logger";
import { ManageSparkClusterPane } from "./Panes/ManageSparkClusterPane";
import { MessageHandler } from "../Common/MessageHandler";
import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem";
import { NotebookContentProvider } from "./Notebook/NotebookComponent/NotebookContentProvider";
import { NotebookUtil } from "./Notebook/NotebookUtil";
import { NotebookWorkspaceManager } from "../NotebookWorkspaceManager/NotebookWorkspaceManager";
import { NotificationConsoleComponentAdapter } from "./Menus/NotificationConsole/NotificationConsoleComponentAdapter";
@@ -86,6 +82,7 @@ import { TableColumnOptionsPane } from "./Panes/Tables/TableColumnOptionsPane";
import { UploadFilePane } from "./Panes/UploadFilePane";
import { UploadItemsPane } from "./Panes/UploadItemsPane";
import { UploadItemsPaneAdapter } from "./Panes/UploadItemsPaneAdapter";
import { ReactAdapter } from "../Bindings/ReactBindingHandler";
BindingHandlersRegisterer.registerBindingHandlers();
// Hold a reference to ComponentRegisterer to prevent transpiler to ignore import
@@ -199,10 +196,13 @@ export default class Explorer implements ViewModels.Explorer {
public libraryManagePane: ViewModels.ContextualPane;
public clusterLibraryPane: ViewModels.ContextualPane;
public gitHubReposPane: ViewModels.ContextualPane;
public publishNotebookPaneAdapter: ReactAdapter;
// features
public isGalleryEnabled: ko.Computed<boolean>;
public isGalleryPublishEnabled: ko.Computed<boolean>;
public isGitHubPaneEnabled: ko.Observable<boolean>;
public isPublishNotebookPaneEnabled: ko.Observable<boolean>;
public isHostedDataExplorerEnabled: ko.Computed<boolean>;
public isRightPanelV2Enabled: ko.Computed<boolean>;
public canExceedMaximumValue: ko.Computed<boolean>;
@@ -223,11 +223,7 @@ export default class Explorer implements ViewModels.Explorer {
// Notebooks
public isNotebookEnabled: ko.Observable<boolean>;
public isNotebooksEnabledForAccount: ko.Observable<boolean>;
private notebookClient: ViewModels.INotebookContainerClient;
private notebookContentClient: ViewModels.INotebookContentClient;
public notebookServerInfo: ko.Observable<DataModels.NotebookWorkspaceConnectionInfo>;
public notebookContentProvider: IContentProvider;
public gitHubOAuthService: GitHubOAuthService;
public notebookWorkspaceManager: ViewModels.NotebookWorkspaceManager;
public sparkClusterManager: ViewModels.SparkClusterManager;
public sparkClusterConnectionInfo: ko.Observable<DataModels.SparkClusterConnectionInfo>;
@@ -239,6 +235,7 @@ export default class Explorer implements ViewModels.Explorer {
public isSynapseLinkUpdating: ko.Observable<boolean>;
public isNotebookTabActive: ko.Computed<boolean>;
public memoryUsageInfo: ko.Observable<DataModels.MemoryUsageInfo>;
public notebookManager?: any; // This is dynamically loaded
private _panes: ViewModels.ContextualPane[] = [];
private _importExplorerConfigComplete: boolean = false;
@@ -409,7 +406,11 @@ export default class Explorer implements ViewModels.Explorer {
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)
);
this.isGitHubPaneEnabled = ko.observable<boolean>(false);
this.isPublishNotebookPaneEnabled = ko.observable<boolean>(false);
this.canExceedMaximumValue = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.canExceedMaximumValue)
@@ -937,127 +938,33 @@ export default class Explorer implements ViewModels.Explorer {
startKey
);
const junoClient = new JunoClient(this.databaseAccount);
this.isNotebookEnabled = ko.observable(false);
this.isNotebookEnabled.subscribe(async (isEnabled: boolean) => {
this.refreshCommandBarButtons();
this.isNotebookEnabled.subscribe(async () => {
if (!this.notebookManager) {
const notebookManagerModule = await import(
/* webpackChunkName: "NotebookManager" */ "./Notebook/NotebookManager"
);
this.notebookManager = new notebookManagerModule.default();
this.notebookManager.initialize({
container: this,
dialogProps: this._dialogProps,
notebookBasePath: this.notebookBasePath,
resourceTree: this.resourceTree,
refreshCommandBarButtons: () => this.refreshCommandBarButtons(),
refreshNotebookList: () => this.refreshNotebookList()
});
this.gitHubOAuthService = new GitHubOAuthService(junoClient);
const GitHubClientModule = await import(/* webpackChunkName: "GitHubClient" */ "../GitHub/GitHubClient");
const gitHubClient = new GitHubClientModule.GitHubClient(config.AZURESAMPLESCOSMOSDBPAT, error => {
Logger.logError(error, "Explorer/GitHubClient errorCallback");
if (error.status === Constants.HttpStatusCodes.Unauthorized) {
this.gitHubOAuthService?.resetToken();
this.showOkCancelModalDialog(
undefined,
"Cosmos DB cannot access your Github account anymore. Please connect to GitHub again.",
"Connect to GitHub",
() => this.gitHubReposPane?.open(),
"Cancel",
undefined
);
}
});
this.gitHubReposPane = new GitHubReposPane({
documentClientUtility: this.documentClientUtility,
id: "gitHubReposPane",
visible: ko.observable<boolean>(false),
container: this,
junoClient,
gitHubClient
});
this.isGitHubPaneEnabled(true);
this.gitHubOAuthService.getTokenObservable().subscribe(token => {
gitHubClient.setToken(token?.access_token ? token.access_token : config.AZURESAMPLESCOSMOSDBPAT);
if (this.gitHubReposPane?.visible()) {
this.gitHubReposPane.open();
}
this.refreshCommandBarButtons();
this.refreshNotebookList();
});
if (this.isGalleryEnabled()) {
this.galleryTab = await import(/* webpackChunkName: "GalleryTab" */ "./Tabs/GalleryTab");
this.notebookViewerTab = await import(/* webpackChunkName: "NotebookViewerTab" */ "./Tabs/NotebookViewerTab");
this.gitHubReposPane = this.notebookManager.gitHubReposPane;
this.isGitHubPaneEnabled(true);
}
const promptForCommitMsg = (title: string, primaryButtonLabel: string) => {
return new Promise<string>((resolve, reject) => {
let commitMsg: string = "Committed from Azure Cosmos DB Notebooks";
this.showOkCancelTextFieldModalDialog(
title || "Commit",
undefined,
primaryButtonLabel || "Commit",
() => {
TelemetryProcessor.trace(Action.NotebooksGitHubCommit, ActionModifiers.Mark, {
databaseAccountName: this.databaseAccount() && this.databaseAccount().name,
defaultExperience: this.defaultExperience && this.defaultExperience(),
dataExplorerArea: Constants.Areas.Notebook
});
resolve(commitMsg);
},
"Cancel",
() => reject(new Error("Commit dialog canceled")),
{
label: "Commit message",
autoAdjustHeight: true,
multiline: true,
defaultValue: commitMsg,
rows: 3,
onChange: (_, newValue: string) => {
commitMsg = newValue;
this._dialogProps().primaryButtonDisabled = !commitMsg;
this._dialogProps.valueHasMutated();
}
},
!commitMsg
);
});
};
const GitHubContentProviderModule = await import(
/* webpackChunkName: "rx-jupyter" */ "../GitHub/GitHubContentProvider"
);
const RXJupyterModule = await import(/* webpackChunkName: "rx-jupyter" */ "rx-jupyter");
this.notebookContentProvider = new NotebookContentProvider(
new GitHubContentProviderModule.GitHubContentProvider({ gitHubClient, promptForCommitMsg }),
RXJupyterModule.contents.JupyterContentProvider
);
const NotebookContainerClientModule = await import(
/* webpackChunkName: "NotebookContainerClient" */ "./Notebook/NotebookContainerClient"
);
this.notebookClient = new NotebookContainerClientModule.NotebookContainerClient(
this.notebookServerInfo,
() => this.initNotebooks(this.databaseAccount()),
(update: DataModels.MemoryUsageInfo) => this.memoryUsageInfo(update)
);
const NotebookContentClientModule = await import(
/* webpackChunkName: "NotebookContentClient" */ "./Notebook/NotebookContentClient"
);
this.notebookContentClient = new NotebookContentClientModule.NotebookContentClient(
this.notebookServerInfo,
this.notebookBasePath,
this.notebookContentProvider
);
this.refreshCommandBarButtons();
this.refreshNotebookList();
});
this.isSparkEnabled = ko.observable(false);
this.isSparkEnabled.subscribe((isEnabled: boolean) => this.refreshCommandBarButtons());
this.resourceTree = new ResourceTreeAdapter(this, junoClient);
this.resourceTree = new ResourceTreeAdapter(this);
this.resourceTreeForResourceToken = new ResourceTreeAdapterForResourceToken(this);
this.notebookServerInfo = ko.observable<DataModels.NotebookWorkspaceConnectionInfo>({
notebookServerEndpoint: undefined,
@@ -1887,7 +1794,7 @@ export default class Explorer implements ViewModels.Explorer {
}
public resetNotebookWorkspace() {
if (!this.isNotebookEnabled() || !this.notebookClient) {
if (!this.isNotebookEnabled() || !this.notebookManager?.notebookClient) {
const error = "Attempt to reset notebook workspace, but notebook is not enabled";
Logger.logError(error, "Explorer/resetNotebookWorkspace");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
@@ -1994,7 +1901,7 @@ export default class Explorer implements ViewModels.Explorer {
this._closeModalDialog();
const id = NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, "Resetting notebook workspace");
try {
await this.notebookClient.resetWorkspace();
await this.notebookManager?.notebookClient.resetWorkspace();
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, "Successfully reset notebook workspace");
TelemetryProcessor.traceSuccess(Action.ResetNotebookWorkspace);
} catch (error) {
@@ -2563,17 +2470,17 @@ export default class Explorer implements ViewModels.Explorer {
}
private uploadFile(name: string, content: string, parent: NotebookContentItem): Promise<NotebookContentItem> {
if (!this.isNotebookEnabled() || !this.notebookContentClient) {
if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) {
const error = "Attempt to upload notebook, but notebook is not enabled";
Logger.logError(error, "Explorer/uploadFile");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
throw new Error(error);
}
const promise = this.notebookContentClient.uploadFileAsync(name, content, parent);
const promise = this.notebookManager?.notebookContentClient.uploadFileAsync(name, content, parent);
promise
.then(() => this.resourceTree.triggerRender())
.catch(reason => this.showOkModalDialog("Unable to upload file", reason));
.catch((reason: any) => this.showOkModalDialog("Unable to upload file", reason));
return promise;
}
@@ -2582,7 +2489,7 @@ export default class Explorer implements ViewModels.Explorer {
const item = NotebookUtil.createNotebookContentItem(name, path, "file");
const parent = this.resourceTree.myNotebooksContentRoot;
if (parent && parent.children && this.isNotebookEnabled() && this.notebookClient) {
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
}
@@ -2601,15 +2508,10 @@ export default class Explorer implements ViewModels.Explorer {
return Promise.resolve(false);
}
public async importAndOpenFromGallery(path: string, newName: string, content: any): Promise<boolean> {
const name = newName;
public async importAndOpenFromGallery(name: string, content: string): Promise<boolean> {
const parent = this.resourceTree.myNotebooksContentRoot;
if (parent && this.isNotebookEnabled() && this.notebookClient) {
if (this._filePathToImportAndOpen === path) {
this._filePathToImportAndOpen = undefined; // we don't want to try opening this path again
}
if (parent && parent.children && this.isNotebookEnabled() && this.notebookManager?.notebookClient) {
const existingItem = _.find(parent.children, node => node.name === name);
if (existingItem) {
this.showOkModalDialog("Download failed", "Notebook with the same name already exists.");
@@ -2620,10 +2522,17 @@ 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 publishNotebook(name: string, content: string): void {
if (this.notebookManager) {
this.notebookManager.openPublishNotebookPane(name, content);
this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter;
this.isPublishNotebookPaneEnabled(true);
}
}
public showOkModalDialog(title: string, msg: string): void {
this._dialogProps({
isModal: true,
@@ -2756,7 +2665,7 @@ export default class Explorer implements ViewModels.Explorer {
}
public renameNotebook(notebookFile: NotebookContentItem): Q.Promise<NotebookContentItem> {
if (!this.isNotebookEnabled() || !this.notebookContentClient) {
if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) {
const error = "Attempt to rename notebook, but notebook is not enabled";
Logger.logError(error, "Explorer/renameNotebook");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
@@ -2784,7 +2693,7 @@ export default class Explorer implements ViewModels.Explorer {
paneTitle: "Rename Notebook",
submitButtonLabel: "Rename",
defaultInput: FileSystemUtil.stripExtension(notebookFile.name, "ipynb"),
onSubmit: (input: string) => this.notebookContentClient.renameNotebook(notebookFile, input)
onSubmit: (input: string) => this.notebookManager?.notebookContentClient.renameNotebook(notebookFile, input)
})
.then(newNotebookFile => {
this.openedTabs()
@@ -2806,7 +2715,7 @@ export default class Explorer implements ViewModels.Explorer {
}
public onCreateDirectory(parent: NotebookContentItem): Q.Promise<NotebookContentItem> {
if (!this.isNotebookEnabled() || !this.notebookContentClient) {
if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) {
const error = "Attempt to create notebook directory, but notebook is not enabled";
Logger.logError(error, "Explorer/onCreateDirectory");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
@@ -2821,32 +2730,32 @@ export default class Explorer implements ViewModels.Explorer {
paneTitle: "Create new directory",
submitButtonLabel: "Create",
defaultInput: "",
onSubmit: (input: string) => this.notebookContentClient.createDirectory(parent, input)
onSubmit: (input: string) => this.notebookManager?.notebookContentClient.createDirectory(parent, input)
});
result.then(() => this.resourceTree.triggerRender());
return result;
}
public readFile(notebookFile: NotebookContentItem): Promise<string> {
if (!this.isNotebookEnabled() || !this.notebookContentClient) {
if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) {
const error = "Attempt to read file, but notebook is not enabled";
Logger.logError(error, "Explorer/downloadFile");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
throw new Error(error);
}
return this.notebookContentClient.readFileContent(notebookFile.path);
return this.notebookManager?.notebookContentClient.readFileContent(notebookFile.path);
}
public downloadFile(notebookFile: NotebookContentItem): Promise<void> {
if (!this.isNotebookEnabled() || !this.notebookContentClient) {
if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) {
const error = "Attempt to download file, but notebook is not enabled";
Logger.logError(error, "Explorer/downloadFile");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
throw new Error(error);
}
return this.notebookContentClient.readFileContent(notebookFile.path).then(
return this.notebookManager?.notebookContentClient.readFileContent(notebookFile.path).then(
(content: string) => {
const blob = new Blob([content], { type: "octet/stream" });
if (navigator.msSaveBlob) {
@@ -2866,7 +2775,7 @@ export default class Explorer implements ViewModels.Explorer {
downloadLink.remove();
}
},
error => {
(error: any) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Could not download notebook ${JSON.stringify(error)}`
@@ -3013,7 +2922,7 @@ export default class Explorer implements ViewModels.Explorer {
}
private refreshNotebookList = async (): Promise<void> => {
if (!this.isNotebookEnabled() || !this.notebookContentClient) {
if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) {
return;
}
@@ -3024,7 +2933,7 @@ export default class Explorer implements ViewModels.Explorer {
};
public deleteNotebookFile(item: NotebookContentItem): Promise<void> {
if (!this.isNotebookEnabled() || !this.notebookContentClient) {
if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) {
const error = "Attempt to delete notebook file, but notebook is not enabled";
Logger.logError(error, "Explorer/deleteNotebookFile");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
@@ -3055,11 +2964,11 @@ export default class Explorer implements ViewModels.Explorer {
return Promise.reject();
}
return this.notebookContentClient.deleteContentItem(item).then(
return this.notebookManager?.notebookContentClient.deleteContentItem(item).then(
() => {
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully deleted: ${item.path}`);
},
reason => {
(reason: any) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Failed to delete "${item.path}": ${JSON.stringify(reason)}`
@@ -3072,7 +2981,7 @@ export default class Explorer implements ViewModels.Explorer {
* This creates a new notebook file, then opens the notebook
*/
public onNewNotebookClicked(parent?: NotebookContentItem): void {
if (!this.isNotebookEnabled() || !this.notebookContentClient) {
if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) {
const error = "Attempt to create new notebook, but notebook is not enabled";
Logger.logError(error, "Explorer/onNewNotebookClicked");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
@@ -3092,7 +3001,7 @@ export default class Explorer implements ViewModels.Explorer {
dataExplorerArea: Constants.Areas.Notebook
});
this.notebookContentClient
this.notebookManager?.notebookContentClient
.createNewNotebookFile(parent)
.then((newFile: NotebookContentItem) => {
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully created: ${newFile.name}`);
@@ -3108,7 +3017,7 @@ export default class Explorer implements ViewModels.Explorer {
return this.openNotebook(newFile);
})
.then(() => this.resourceTree.triggerRender())
.catch(reason => {
.catch((reason: any) => {
const error = `Failed to create a new notebook: ${reason}`;
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
TelemetryProcessor.traceFailure(
@@ -3158,14 +3067,14 @@ export default class Explorer implements ViewModels.Explorer {
}
public refreshContentItem(item: NotebookContentItem): Promise<void> {
if (!this.isNotebookEnabled() || !this.notebookContentClient) {
if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) {
const error = "Attempt to refresh notebook list, but notebook is not enabled";
Logger.logError(error, "Explorer/refreshContentItem");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
return Promise.reject(new Error(error));
}
return this.notebookContentClient.updateItemChildren(item);
return this.notebookManager?.notebookContentClient.updateItemChildren(item);
}
public getNotebookBasePath(): string {
@@ -3234,7 +3143,7 @@ export default class Explorer implements ViewModels.Explorer {
newTab.onTabClick();
}
public openGallery() {
public async openGallery(notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean) {
let title: string;
let hashLocation: string;
@@ -3249,25 +3158,34 @@ export default class Explorer implements ViewModels.Explorer {
if (openedTabs[i].hashLocation() == hashLocation) {
openedTabs[i].onTabClick();
openedTabs[i].onActivate();
(openedTabs[i] as any).updateGalleryParams(notebookUrl, galleryItem, isFavorite);
return;
}
}
if (!this.galleryTab) {
this.galleryTab = await import(/* webpackChunkName: "GalleryTab" */ "./Tabs/GalleryTab");
}
const newTab = new this.galleryTab.default({
// GalleryTabOptions
account: CosmosClient.databaseAccount(),
container: this,
junoClient: this.notebookManager?.junoClient,
notebookUrl,
galleryItem,
isFavorite,
// TabOptions
tabKind: ViewModels.CollectionTabKind.Gallery,
node: null,
title: title,
tabPath: title,
documentClientUtility: null,
collection: null,
selfLink: null,
hashLocation: hashLocation,
isActive: ko.observable(false),
hashLocation: hashLocation,
onUpdateTabsButtons: this.onUpdateTabsButtons,
isTabsContentExpanded: ko.observable(true),
onLoadStartKey: null,
onUpdateTabsButtons: this.onUpdateTabsButtons,
container: this,
openedTabs: this.openedTabs()
});
@@ -3277,16 +3195,14 @@ export default class Explorer implements ViewModels.Explorer {
newTab.onTabClick();
}
public openNotebookViewer(
notebookUrl: string,
notebookMetadata: DataModels.NotebookMetadata,
onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>,
isLikedNotebook: boolean
) {
const notebookName = path.basename(notebookUrl);
const title = notebookName;
public async openNotebookViewer(notebookUrl: string) {
const title = path.basename(notebookUrl);
const hashLocation = notebookUrl;
if (!this.notebookViewerTab) {
this.notebookViewerTab = await import(/* webpackChunkName: "NotebookViewerTab" */ "./Tabs/NotebookViewerTab");
}
const notebookViewerTabModule = this.notebookViewerTab;
let isNotebookViewerOpen = (tab: ViewModels.Tab) => {
@@ -3322,11 +3238,7 @@ export default class Explorer implements ViewModels.Explorer {
onUpdateTabsButtons: this.onUpdateTabsButtons,
container: this,
openedTabs: this.openedTabs(),
notebookUrl: notebookUrl,
notebookName: notebookName,
notebookMetadata: notebookMetadata,
onNotebookMetadataChange: onNotebookMetadataChange,
isLikedNotebook: isLikedNotebook
notebookUrl
});
this.openedTabs.push(newTab);

View File

@@ -11,7 +11,6 @@ import * as ViewModels from "../../../Contracts/ViewModels";
import * as DataModels from "../../../Contracts/DataModels";
import * as StorageUtility from "../../../Shared/StorageUtility";
import GraphTab from "../../Tabs/GraphTab";
import DocumentClientUtilityBase from "../../../Common/DocumentClientUtilityBase";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
describe("Check whether query result is vertex array", () => {
@@ -59,10 +58,74 @@ describe("Check whether query result is edge-vertex array", () => {
describe("Create proper pkid pair", () => {
it("should enclose string pk with quotes", () => {
expect(GraphExplorer.generatePkIdPair("test", "id")).toEqual('["test", "id"]');
expect(GraphExplorer.generatePkIdPair("test", "id")).toEqual("['test', 'id']");
});
it("should not enclose non-string pk with quotes", () => {
expect(GraphExplorer.generatePkIdPair(2, "id")).toEqual('[2, "id"]');
expect(GraphExplorer.generatePkIdPair(2, "id")).toEqual("[2, 'id']");
});
});
describe("getPkIdFromDocumentId", () => {
const createFakeDoc = (override: any) => ({
_rid: "_rid",
_self: "_self",
_etag: "_etag",
_ts: 1234,
...override
});
it("should create pkid pair from non-partitioned graph", () => {
const doc = createFakeDoc({ id: "id" });
expect(GraphExplorer.getPkIdFromDocumentId(doc, undefined)).toEqual("'id'");
expect(GraphExplorer.getPkIdFromDocumentId(doc, "_partitiongKey")).toEqual("'id'");
});
it("should create pkid pair from partitioned graph (pk as string)", () => {
const doc = createFakeDoc({ id: "id", mypk: "pkvalue" });
expect(GraphExplorer.getPkIdFromDocumentId(doc, "mypk")).toEqual("['pkvalue', 'id']");
});
it("should create pkid pair from partitioned graph (pk as valid array value)", () => {
const doc = createFakeDoc({ id: "id", mypk: [{ id: "someid", _value: "pkvalue" }] });
expect(GraphExplorer.getPkIdFromDocumentId(doc, "mypk")).toEqual("['pkvalue', 'id']");
});
it("should error if id is not a string", () => {
const doc = createFakeDoc({ id: { foo: 1 } });
try {
GraphExplorer.getPkIdFromDocumentId(doc, undefined);
expect(true).toBe(false);
} catch (e) {
expect(true).toBe(true);
}
});
it("should error if pk not string nor non-empty array", () => {
let doc = createFakeDoc({ mypk: { foo: 1 } });
try {
GraphExplorer.getPkIdFromDocumentId(doc, "mypk");
} catch (e) {
expect(true).toBe(true);
}
doc = createFakeDoc({ mypk: [] });
try {
GraphExplorer.getPkIdFromDocumentId(doc, "mypk");
expect(true).toBe(false);
} catch (e) {
expect(true).toBe(true);
}
// Array must be [{ id: string, _value: string }]
doc = createFakeDoc({ mypk: [{ foo: 1 }] });
try {
GraphExplorer.getPkIdFromDocumentId(doc, "mypk");
expect(true).toBe(false);
} catch (e) {
expect(true).toBe(true);
}
});
});
@@ -253,11 +316,11 @@ describe("GraphExplorer", () => {
};
const createFetchOutEQuery = (vertexId: string, limit: number): string => {
return `g.V("${vertexId}").outE().limit(${limit}).as('e').inV().as('v').select('e', 'v')`;
return `g.V('${vertexId}').outE().limit(${limit}).as('e').inV().as('v').select('e', 'v')`;
};
const createFetchInEQuery = (vertexId: string, limit: number): string => {
return `g.V("${vertexId}").inE().limit(${limit}).as('e').outV().as('v').select('e', 'v')`;
return `g.V('${vertexId}').inE().limit(${limit}).as('e').outV().as('v').select('e', 'v')`;
};
const isVisible = (selector: string): boolean => {
@@ -293,7 +356,7 @@ describe("GraphExplorer", () => {
describe("Load Graph button", () => {
beforeEach(async done => {
const backendResponses: BackendResponses = {};
backendResponses["g.V()"] = backendResponses['g.V("1")'] = {
backendResponses["g.V()"] = backendResponses["g.V('1')"] = {
response: [{ id: "1", type: "vertex" }],
isLast: false
};
@@ -341,7 +404,7 @@ describe("GraphExplorer", () => {
describe("Execute Gremlin Query button", () => {
beforeEach(done => {
const backendResponses: BackendResponses = {};
backendResponses["g.V()"] = backendResponses['g.V("2")'] = {
backendResponses["g.V()"] = backendResponses["g.V('2')"] = {
response: [{ id: "2", type: "vertex" }],
isLast: false
};
@@ -411,7 +474,7 @@ describe("GraphExplorer", () => {
beforeEach(done => {
const backendResponses: BackendResponses = {};
// TODO Make this less dependent on spaces, order and quotes
backendResponses["g.V()"] = backendResponses[`g.V("${node1Id}","${node2Id}")`] = {
backendResponses["g.V()"] = backendResponses[`g.V('${node1Id}','${node2Id}')`] = {
response: [
{
id: node1Id,
@@ -667,7 +730,7 @@ describe("GraphExplorer", () => {
describe("when isGraphAutoVizDisabled setting is true (autoviz disabled)", () => {
beforeEach(done => {
const backendResponses: BackendResponses = {};
backendResponses["g.V()"] = backendResponses['g.V("3")'] = {
backendResponses["g.V()"] = backendResponses["g.V('3')"] = {
response: [{ id: "3", type: "vertex" }],
isLast: true
};

View File

@@ -327,8 +327,8 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
* @param id
*/
public static generatePkIdPair(pk: PartitionKeyValueType, id: string) {
const pkStr = typeof pk === "string" ? `"${pk}"` : `${pk}`;
return `[${pkStr}, "${GraphUtil.escapeDoubleQuotes(id)}"]`;
const pkStr = typeof pk === "string" ? `'${pk}'` : `${pk}`;
return `[${pkStr}, '${GraphUtil.escapeSingleQuotes(id)}']`;
}
public updateVertexProperties(editedProperties: EditedProperties): Q.Promise<GremlinClient.GremlinRequestResult> {
@@ -1335,7 +1335,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
const pk = v.properties[this.props.collectionPartitionKeyProperty][0].value;
return GraphExplorer.generatePkIdPair(pk, v.id);
} else {
return `"${GraphUtil.escapeDoubleQuotes(v.id)}"`;
return `'${GraphUtil.escapeSingleQuotes(v.id)}'`;
}
}
@@ -1361,15 +1361,34 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
/**
* If collection is not partitioned, return 'id'.
* If collection is partitioned, return pk-id pair.
* public for testing purposes
* @param vertex
* @return id
*/
private getPkIdFromDocumentId(d: DataModels.DocumentId): string {
if (this.props.collectionPartitionKeyProperty && d.hasOwnProperty(this.props.collectionPartitionKeyProperty)) {
const pk = (d as any)[this.props.collectionPartitionKeyProperty];
return GraphExplorer.generatePkIdPair(pk, d.id);
public static getPkIdFromDocumentId(d: DataModels.DocumentId, collectionPartitionKeyProperty: string): string {
let { id } = d;
if (typeof id !== "string") {
const error = `Vertex id is not a string: ${JSON.stringify(id)}.`;
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
throw new Error(error);
}
if (collectionPartitionKeyProperty && d.hasOwnProperty(collectionPartitionKeyProperty)) {
let pk = (d as any)[collectionPartitionKeyProperty];
if (typeof pk !== "string") {
if (Array.isArray(pk) && pk.length > 0) {
// pk is [{ id: 'id', _value: 'value' }]
pk = pk[0]["_value"];
} else {
const error = `Vertex pk is not a string nor a non-empty array: ${JSON.stringify(pk)}.`;
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
throw new Error(error);
}
}
return GraphExplorer.generatePkIdPair(pk, id);
} else {
return `"${GraphUtil.escapeDoubleQuotes(d.id)}"`;
return `'${GraphUtil.escapeSingleQuotes(id)}'`;
}
}
@@ -1769,7 +1788,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
const documents = results.documents || [];
return documents.map(
(item: DataModels.DocumentId) => {
return this.getPkIdFromDocumentId(item);
return GraphExplorer.getPkIdFromDocumentId(item, this.props.collectionPartitionKeyProperty);
},
(reason: any) => {
// Failure

View File

@@ -1,7 +1,7 @@
import * as sinon from "sinon";
import { GremlinClient, GremlinClientParameters } from "./GremlinClient";
import { NotificationConsoleUtils } from "../../../Utils/NotificationConsoleUtils";
import { Logger } from "../../../Common/Logger";
import * as Logger from "../../../Common/Logger";
describe("Gremlin Client", () => {
const emptyParams: GremlinClientParameters = {

View File

@@ -7,7 +7,7 @@ import { GremlinSimpleClient, Result } from "./GremlinSimpleClient";
import { NotificationConsoleUtils } from "../../../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
import { HashMap } from "../../../Common/HashMap";
import { Logger } from "../../../Common/Logger";
import * as Logger from "../../../Common/Logger";
export interface GremlinClientParameters {
endpoint: string;

View File

@@ -40,6 +40,7 @@ export class QueryContainerComponent extends React.Component<
submitFct={(inputValue: string, selection: InputTypeaheadComponent.Item) =>
this.onSubmit(inputValue, selection)
}
useTextarea={true}
/>
{this.renderQueryInputButton()}
</div>

View File

@@ -3,6 +3,7 @@ import * as ViewModels from "../../../Contracts/ViewModels";
import { CommandBarComponentButtonFactory } from "./CommandBarComponentButtonFactory";
import { ExplorerStub } from "../../OpenActionsStubs";
import { GitHubOAuthService } from "../../../GitHub/GitHubOAuthService";
import NotebookManager from "../../Notebook/NotebookManager";
describe("CommandBarComponentButtonFactory tests", () => {
let mockExplorer: ViewModels.Explorer;
@@ -19,6 +20,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
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;
});
@@ -81,6 +83,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
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;
});
@@ -161,6 +164,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
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;
});
@@ -247,7 +251,9 @@ describe("CommandBarComponentButtonFactory tests", () => {
mockExplorer.isNotebooksEnabledForAccount = ko.observable(false);
mockExplorer.isRunningOnNationalCloud = ko.observable(false);
mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false);
mockExplorer.gitHubOAuthService = new GitHubOAuthService(undefined);
mockExplorer.isGalleryPublishEnabled = ko.computed<boolean>(() => false);
mockExplorer.notebookManager = new NotebookManager();
mockExplorer.notebookManager.gitHubOAuthService = new GitHubOAuthService(undefined);
});
beforeEach(() => {
@@ -268,7 +274,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
it("Notebooks is enabled and GitHubOAuthService is logged in - manage github settings button should be visible", () => {
mockExplorer.isNotebookEnabled = ko.observable(true);
mockExplorer.gitHubOAuthService.isLoggedIn = jest.fn().mockReturnValue(true);
mockExplorer.notebookManager.gitHubOAuthService.isLoggedIn = jest.fn().mockReturnValue(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const manageGitHubSettingsBtn = buttons.find(

View File

@@ -26,7 +26,6 @@ import EnableNotebooksIcon from "../../../../images/notebook/Notebook-enable.svg
import NewNotebookIcon from "../../../../images/notebook/Notebook-new.svg";
import ResetWorkspaceIcon from "../../../../images/notebook/Notebook-reset-workspace.svg";
import LibraryManageIcon from "../../../../images/notebook/Spark-library-manage.svg";
import GalleryIcon from "../../../../images/GalleryIcon.svg";
import GitHubIcon from "../../../../images/github.svg";
import SynapseIcon from "../../../../images/synapse-link.svg";
import { config, Platform } from "../../../Config";
@@ -64,7 +63,7 @@ export class CommandBarComponentButtonFactory {
];
buttons.push(newNotebookButton);
if (container.gitHubOAuthService) {
if (container.notebookManager?.gitHubOAuthService) {
buttons.push(CommandBarComponentButtonFactory.createManageGitHubAccountButton(container));
}
}
@@ -87,10 +86,6 @@ export class CommandBarComponentButtonFactory {
buttons.push(CommandBarComponentButtonFactory.createOpenTerminalButton(container));
buttons.push(CommandBarComponentButtonFactory.createNotebookWorkspaceResetButton(container));
if (container.isGalleryEnabled()) {
buttons.push(CommandBarComponentButtonFactory.createGalleryButton(container));
}
}
// TODO: Should be replaced with the create arcadia spark pool button
@@ -575,19 +570,6 @@ export class CommandBarComponentButtonFactory {
};
}
private static createGalleryButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "View Gallery";
return {
iconSrc: GalleryIcon,
iconAlt: label,
onCommandClick: () => container.openGallery(),
commandButtonLabel: label,
hasPopup: false,
disabled: false,
ariaLabel: label
};
}
private static createOpenMongoTerminalButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Open Mongo Shell";
const tooltip =
@@ -654,7 +636,7 @@ export class CommandBarComponentButtonFactory {
}
private static createManageGitHubAccountButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
let connectedToGitHub: boolean = container.gitHubOAuthService.isLoggedIn();
let connectedToGitHub: boolean = container.notebookManager?.gitHubOAuthService.isLoggedIn();
const label = connectedToGitHub ? "Manage GitHub settings" : "Connect to GitHub";
return {
iconSrc: GitHubIcon,

View File

@@ -36,11 +36,12 @@ export class CommandBarUtil {
const result: ICommandBarItemProps = {
iconProps: {
iconType: IconType.image,
style: {
width: StyleConstants.CommandBarIconWidth // 16
width: StyleConstants.CommandBarIconWidth, // 16
alignSelf: btn.iconName ? "baseline" : undefined
},
imageProps: { src: btn.iconSrc, alt: btn.iconAlt }
imageProps: btn.iconSrc ? { src: btn.iconSrc, alt: btn.iconAlt } : undefined,
iconName: btn.iconName
},
onClick: btn.onCommandClick,
key: `${btn.commandButtonLabel}${index}`,

View File

@@ -17,7 +17,7 @@ import {
} from "@nteract/core";
import * as Immutable from "immutable";
import { Provider } from "react-redux";
import { CellType, CellId } from "@nteract/commutable";
import { CellType, CellId, toJS } from "@nteract/commutable";
import { Store, AnyAction } from "redux";
import "./NotebookComponent.less";
@@ -71,6 +71,28 @@ export class NotebookComponentBootstrapper {
);
}
public getContent(): { name: string; content: string } {
const record = this.getStore()
.getState()
.core.entities.contents.byRef.get(this.contentRef);
let content: string;
switch (record.model.type) {
case "notebook":
content = JSON.stringify(toJS(record.model.notebook));
break;
case "file":
content = record.model.text;
break;
default:
throw new Error(`Unsupported model type ${record.model.type}`);
}
return {
name: NotebookUtil.getName(record.filepath),
content
};
}
public setContent(name: string, content: any): void {
this.getStore().dispatch(
actions.fetchContentFulfilled({

View File

@@ -2,7 +2,7 @@ import { ServerConfig, IContentProvider, FileType, IContent, IGetParams } from "
import { Observable } from "rxjs";
import { AjaxResponse } from "rxjs/ajax";
import { GitHubContentProvider } from "../../../GitHub/GitHubContentProvider";
import { GitHubUtils } from "../../../Utils/GitHubUtils";
import * as GitHubUtils from "../../../Utils/GitHubUtils";
export class NotebookContentProvider implements IContentProvider {
constructor(private gitHubContentProvider: GitHubContentProvider, private jupyterContentProvider: IContentProvider) {}

View File

@@ -6,7 +6,7 @@ import * as ViewModels from "../../Contracts/ViewModels";
import * as Constants from "../../Common/Constants";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
import { Logger } from "../../Common/Logger";
import * as Logger from "../../Common/Logger";
export class NotebookContainerClient implements ViewModels.INotebookContainerClient {
private reconnectingNotificationId: string;

View File

@@ -0,0 +1,168 @@
/*
* Contains all notebook related stuff meant to be dynamically loaded by explorer
*/
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";
import ko from "knockout";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { IContentProvider } from "@nteract/core";
import { NotebookContentProvider } from "./NotebookComponent/NotebookContentProvider";
import { GitHubContentProvider } from "../../GitHub/GitHubContentProvider";
import { contents } from "rx-jupyter";
import { NotebookContainerClient } from "./NotebookContainerClient";
import { MemoryUsageInfo } from "../../Contracts/DataModels";
import { NotebookContentClient } from "./NotebookContentClient";
import { DialogProps } from "../Controls/DialogReactComponent/DialogComponent";
import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter";
import { PublishNotebookPaneAdapter } from "../Panes/PublishNotebookPaneAdapter";
import { getFullName } from "../../Utils/UserUtils";
export interface NotebookManagerOptions {
container: ViewModels.Explorer;
notebookBasePath: ko.Observable<string>;
dialogProps: ko.Observable<DialogProps>;
resourceTree: ResourceTreeAdapter;
refreshCommandBarButtons: () => void;
refreshNotebookList: () => void;
}
export default class NotebookManager {
private params: NotebookManagerOptions;
public junoClient: JunoClient;
public notebookContentProvider: IContentProvider;
public notebookClient: ViewModels.INotebookContainerClient;
public notebookContentClient: ViewModels.INotebookContentClient;
private gitHubContentProvider: GitHubContentProvider;
public gitHubOAuthService: GitHubOAuthService;
private gitHubClient: GitHubClient;
public gitHubReposPane: ViewModels.ContextualPane;
public publishNotebookPaneAdapter: PublishNotebookPaneAdapter;
public initialize(params: NotebookManagerOptions): void {
this.params = params;
this.junoClient = new JunoClient(this.params.container.databaseAccount);
this.gitHubOAuthService = new GitHubOAuthService(this.junoClient);
this.gitHubClient = new GitHubClient(config.AZURESAMPLESCOSMOSDBPAT, this.onGitHubClientError);
this.gitHubReposPane = new GitHubReposPane({
documentClientUtility: this.params.container.documentClientUtility,
id: "gitHubReposPane",
visible: ko.observable<boolean>(false),
container: this.params.container,
junoClient: this.junoClient,
gitHubClient: this.gitHubClient
});
this.gitHubContentProvider = new GitHubContentProvider({
gitHubClient: this.gitHubClient,
promptForCommitMsg: this.promptForCommitMsg
});
this.notebookContentProvider = new NotebookContentProvider(
this.gitHubContentProvider,
contents.JupyterContentProvider
);
this.notebookClient = new NotebookContainerClient(
this.params.container.notebookServerInfo,
() => this.params.container.initNotebooks(this.params.container.databaseAccount()),
(update: MemoryUsageInfo) => this.params.container.memoryUsageInfo(update)
);
this.notebookContentClient = new NotebookContentClient(
this.params.container.notebookServerInfo,
this.params.notebookBasePath,
this.notebookContentProvider
);
if (this.params.container.isGalleryPublishEnabled()) {
this.publishNotebookPaneAdapter = new PublishNotebookPaneAdapter(this.params.container, this.junoClient);
}
this.gitHubOAuthService.getTokenObservable().subscribe(token => {
this.gitHubClient.setToken(token?.access_token ? token.access_token : config.AZURESAMPLESCOSMOSDBPAT);
if (this.gitHubReposPane.visible()) {
this.gitHubReposPane.open();
}
this.params.refreshCommandBarButtons();
this.params.refreshNotebookList();
});
this.junoClient.subscribeToPinnedRepos(pinnedRepos => {
this.params.resourceTree.initializeGitHubRepos(pinnedRepos);
this.params.resourceTree.triggerRender();
});
this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope);
}
public openPublishNotebookPane(name: string, content: string): void {
this.publishNotebookPaneAdapter.open(name, getFullName(), content);
}
// Octokit's error handler uses any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private onGitHubClientError = (error: any): void => {
Logger.logError(error, "NotebookManager/onGitHubClientError");
if (error.status === HttpStatusCodes.Unauthorized) {
this.gitHubOAuthService.resetToken();
this.params.container.showOkCancelModalDialog(
undefined,
"Cosmos DB cannot access your Github account anymore. Please connect to GitHub again.",
"Connect to GitHub",
() => this.gitHubReposPane.open(),
"Cancel",
undefined
);
}
};
private promptForCommitMsg = (title: string, primaryButtonLabel: string) => {
return new Promise<string>((resolve, reject) => {
let commitMsg = "Committed from Azure Cosmos DB Notebooks";
this.params.container.showOkCancelTextFieldModalDialog(
title || "Commit",
undefined,
primaryButtonLabel || "Commit",
() => {
TelemetryProcessor.trace(Action.NotebooksGitHubCommit, ActionModifiers.Mark, {
databaseAccountName:
this.params.container.databaseAccount() && this.params.container.databaseAccount().name,
defaultExperience: this.params.container.defaultExperience && this.params.container.defaultExperience(),
dataExplorerArea: Areas.Notebook
});
resolve(commitMsg);
},
"Cancel",
() => reject(new Error("Commit dialog canceled")),
{
label: "Commit message",
autoAdjustHeight: true,
multiline: true,
defaultValue: commitMsg,
rows: 3,
onChange: (_, newValue: string) => {
commitMsg = newValue;
this.params.dialogProps().primaryButtonDisabled = !commitMsg;
this.params.dialogProps.valueHasMutated();
}
},
!commitMsg
);
});
};
}

View File

@@ -1,5 +1,5 @@
import { NotebookUtil } from "./NotebookUtil";
import { GitHubUtils } from "../../Utils/GitHubUtils";
import * as GitHubUtils from "../../Utils/GitHubUtils";
const fileName = "file";
const notebookName = "file.ipynb";

View File

@@ -2,7 +2,7 @@ import path from "path";
import { ImmutableNotebook } from "@nteract/commutable";
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
import { StringUtils } from "../../Utils/StringUtils";
import { GitHubUtils } from "../../Utils/GitHubUtils";
import * as GitHubUtils from "../../Utils/GitHubUtils";
// Must match rx-jupyter' FileType
export type FileType = "directory" | "file" | "notebook";

View File

@@ -6,8 +6,6 @@ import Q from "q";
import { ArcadiaWorkspaceItem } from "./Controls/Arcadia/ArcadiaMenuPicker";
import { CassandraTableKey, CassandraTableKeys, TableDataClient } from "../../src/Explorer/Tables/TableDataClient";
import { ConsoleData } from "../../src/Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import { GitHubOAuthService } from "../GitHub/GitHubOAuthService";
import { IContentProvider } from "@nteract/core";
import { MostRecentActivity } from "./MostRecentActivity/MostRecentActivity";
import { NotebookContentItem } from "./Notebook/NotebookContentItem";
import { PlatformType } from "../../src/PlatformType";
@@ -22,6 +20,8 @@ import { UploadFilePane } from "./Panes/UploadFilePane";
import { UploadItemsPaneAdapter } from "./Panes/UploadItemsPaneAdapter";
import { Versions } from "../../src/Contracts/ExplorerContracts";
import { CollectionCreationDefaults } from "../Shared/Constants";
import { IGalleryItem } from "../Juno/JunoClient";
import { ReactAdapter } from "../Bindings/ReactBindingHandler";
export class ExplorerStub implements ViewModels.Explorer {
public flight: ko.Observable<string>;
@@ -97,7 +97,9 @@ export class ExplorerStub implements ViewModels.Explorer {
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>;
public isRightPanelV2Enabled: ko.Computed<boolean>;
public canExceedMaximumValue: ko.Computed<boolean>;
public isHostedDataExplorerEnabled: ko.Computed<boolean>;
@@ -111,19 +113,19 @@ export class ExplorerStub implements ViewModels.Explorer {
public arcadiaToken: ko.Observable<string>;
public notebookWorkspaceManager: ViewModels.NotebookWorkspaceManager;
public sparkClusterManager: ViewModels.SparkClusterManager;
public notebookContentProvider: IContentProvider;
public gitHubOAuthService: GitHubOAuthService;
public notebookServerInfo: ko.Observable<DataModels.NotebookWorkspaceConnectionInfo>;
public sparkClusterConnectionInfo: ko.Observable<DataModels.SparkClusterConnectionInfo>;
public libraryManagePane: ViewModels.ContextualPane;
public clusterLibraryPane: ViewModels.ContextualPane;
public gitHubReposPane: ViewModels.ContextualPane;
public publishNotebookPaneAdapter: ReactAdapter;
public arcadiaWorkspaces: ko.ObservableArray<ArcadiaWorkspaceItem>;
public hasStorageAnalyticsAfecFeature: ko.Observable<boolean>;
public isSynapseLinkUpdating: ko.Observable<boolean>;
public isNotebookTabActive: ko.Computed<boolean>;
public memoryUsageInfo: ko.Observable<DataModels.MemoryUsageInfo>;
public openGallery: () => void;
public notebookManager?: any;
public openGallery: (notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean) => void;
public openNotebookViewer: (notebookUrl: string) => void;
public resourceTokenDatabaseId: ko.Observable<string>;
public resourceTokenCollectionId: ko.Observable<string>;
@@ -331,7 +333,11 @@ export class ExplorerStub implements ViewModels.Explorer {
throw new Error("Not implemented");
}
public importAndOpenFromGallery(path: string, newName: string, content: any): Promise<boolean> {
public importAndOpenFromGallery(name: string, content: string): Promise<boolean> {
throw new Error("Not implemented");
}
public publishNotebook(name: string, content: string): void {
throw new Error("Not implemented");
}

View File

@@ -3,7 +3,7 @@ import * as ViewModels from "../../Contracts/ViewModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { Areas } from "../../Common/Constants";
import { ContextualPaneBase } from "./ContextualPaneBase";
import { Logger } from "../../Common/Logger";
import * as Logger from "../../Common/Logger";
import { QueriesGridComponentAdapter } from "../Controls/QueriesGridReactComponent/QueriesGridComponentAdapter";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";

View File

@@ -10,7 +10,7 @@ import { ContextualPaneBase } from "./ContextualPaneBase";
import { ClusterLibraryGridAdapter } from "../Controls/LibraryManagement/ClusterLibraryGridAdapter";
import { ClusterLibraryGridProps, ClusterLibraryItem } from "../Controls/LibraryManagement/ClusterLibraryGrid";
import { Library, SparkCluster, SparkClusterLibrary } from "../../Contracts/DataModels";
import { Logger } from "../../Common/Logger";
import * as Logger from "../../Common/Logger";
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
export class ClusterLibraryPane extends ContextualPaneBase {

View File

@@ -1,12 +1,12 @@
import _ from "underscore";
import { Areas, HttpStatusCodes } from "../../Common/Constants";
import { Logger } from "../../Common/Logger";
import * as Logger from "../../Common/Logger";
import * as ViewModels from "../../Contracts/ViewModels";
import { GitHubClient, IGitHubPageInfo, IGitHubRepo } from "../../GitHub/GitHubClient";
import { IPinnedRepo, JunoClient } from "../../Juno/JunoClient";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { GitHubUtils } from "../../Utils/GitHubUtils";
import * as GitHubUtils from "../../Utils/GitHubUtils";
import { JunoUtils } from "../../Utils/JunoUtils";
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
import { AuthorizeAccessComponent } from "../Controls/GitHub/AuthorizeAccessComponent";
@@ -132,12 +132,13 @@ export class GitHubReposPane extends ContextualPaneBase {
private getOAuthScope(): string {
return (
this.container.gitHubOAuthService?.getTokenObservable()()?.scope || AuthorizeAccessComponent.Scopes.Public.key
this.container.notebookManager?.gitHubOAuthService.getTokenObservable()()?.scope ||
AuthorizeAccessComponent.Scopes.Public.key
);
}
private setup(forceShowConnectToGitHub = false): void {
forceShowConnectToGitHub || !this.container.gitHubOAuthService.isLoggedIn()
forceShowConnectToGitHub || !this.container.notebookManager?.gitHubOAuthService.isLoggedIn()
? this.setupForConnectToGitHub()
: this.setupForManageRepos();
}
@@ -294,7 +295,7 @@ export class GitHubReposPane extends ContextualPaneBase {
try {
const response = await this.junoClient.getPinnedRepos(
this.container.gitHubOAuthService?.getTokenObservable()()?.scope
this.container.notebookManager?.gitHubOAuthService.getTokenObservable()()?.scope
);
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when fetching pinned repos`);
@@ -350,7 +351,7 @@ export class GitHubReposPane extends ContextualPaneBase {
dataExplorerArea: Areas.Notebook,
scopesSelected: scope
});
this.container.gitHubOAuthService.startOAuth(scope);
this.container.notebookManager?.gitHubOAuthService.startOAuth(scope);
}
private triggerRender(): void {

View File

@@ -15,7 +15,7 @@ import {
LibraryManageGridProps
} from "../Controls/LibraryManagement/LibraryManage";
import { Library } from "../../Contracts/DataModels";
import { Logger } from "../../Common/Logger";
import * as Logger from "../../Common/Logger";
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
export class LibraryManagePane extends ContextualPaneBase {

View File

@@ -4,7 +4,7 @@ import * as Constants from "../../Common/Constants";
import * as ViewModels from "../../Contracts/ViewModels";
import { ContextualPaneBase } from "./ContextualPaneBase";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import { Logger } from "../../Common/Logger";
import * as Logger from "../../Common/Logger";
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
export class LoadQueryPane extends ContextualPaneBase implements ViewModels.LoadQueryPane {

View File

@@ -0,0 +1,164 @@
import ko from "knockout";
import { ITextFieldProps, Stack, Text, TextField } from "office-ui-fabric-react";
import * as React from "react";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import * as Logger from "../../Common/Logger";
import * as ViewModels from "../../Contracts/ViewModels";
import { JunoClient } from "../../Juno/JunoClient";
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import { FileSystemUtil } from "../Notebook/FileSystemUtil";
import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRightPaneComponent";
export class PublishNotebookPaneAdapter implements ReactAdapter {
parameters: ko.Observable<number>;
private isOpened: boolean;
private isExecuting: boolean;
private formError: string;
private formErrorDetail: string;
private name: string;
private author: string;
private content: string;
private description: string;
private tags: string;
private thumbnailUrl: string;
constructor(private container: ViewModels.Explorer, private junoClient: JunoClient) {
this.parameters = ko.observable(Date.now());
this.reset();
this.triggerRender();
}
public renderComponent(): JSX.Element {
if (!this.isOpened) {
return undefined;
}
const props: GenericRightPaneProps = {
container: this.container,
content: this.createContent(),
formError: this.formError,
formErrorDetail: this.formErrorDetail,
id: "publishnotebookpane",
isExecuting: this.isExecuting,
title: "Publish to gallery",
submitButtonText: "Publish",
onClose: () => this.close(),
onSubmit: () => this.submit()
};
return <GenericRightPaneComponent {...props} />;
}
public triggerRender(): void {
window.requestAnimationFrame(() => this.parameters(Date.now()));
}
public open(name: string, author: string, content: string): void {
this.name = name;
this.author = author;
this.content = content;
this.isOpened = true;
this.triggerRender();
}
public close(): void {
this.reset();
this.triggerRender();
}
public async submit(): Promise<void> {
const notificationId = NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.InProgress,
`Publishing ${this.name} to gallery`
);
this.isExecuting = true;
this.triggerRender();
try {
if (!this.name || !this.description || !this.author) {
throw new Error("Name, description, and author are required");
}
const response = await this.junoClient.publishNotebook(
this.name,
this.description,
this.tags?.split(","),
this.author,
this.thumbnailUrl,
this.content
);
if (!response.data) {
throw new Error(`Received HTTP ${response.status} when publishing ${name} to gallery`);
}
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Published ${name} to gallery`);
} catch (error) {
this.formError = `Failed to publish ${this.name} to gallery`;
this.formErrorDetail = `${error}`;
const message = `${this.formError}: ${this.formErrorDetail}`;
Logger.logError(message, "PublishNotebookPaneAdapter/submit");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
return;
} finally {
NotificationConsoleUtils.clearInProgressMessageWithId(notificationId);
this.isExecuting = false;
this.triggerRender();
}
this.close();
}
private createContent = (): JSX.Element => {
const descriptionPara1 =
"This notebook has your data. Please make sure you delete any sensitive data/output before publishing.";
const descriptionPara2 = `Would you like to publish and share ${FileSystemUtil.stripExtension(
this.name,
"ipynb"
)} to the gallery?`;
const descriptionProps: ITextFieldProps = {
label: "Description",
ariaLabel: "Description",
multiline: true,
rows: 3,
required: true,
onChange: (event, newValue) => (this.description = newValue)
};
const tagsProps: ITextFieldProps = {
label: "Tags",
ariaLabel: "Tags",
placeholder: "Optional tag 1, Optional tag 2",
onChange: (event, newValue) => (this.tags = newValue)
};
const thumbnailProps: ITextFieldProps = {
label: "Cover image url",
ariaLabel: "Cover image url",
onChange: (event, newValue) => (this.thumbnailUrl = newValue)
};
return (
<div className="panelContent">
<Stack className="paneMainContent" tokens={{ childrenGap: 20 }}>
<Text>{descriptionPara1}</Text>
<Text>{descriptionPara2}</Text>
<TextField {...descriptionProps} />
<TextField {...tagsProps} />
<TextField {...thumbnailProps} />
</Stack>
</div>
);
};
private reset = (): void => {
this.isOpened = false;
this.isExecuting = false;
this.formError = undefined;
this.formErrorDetail = undefined;
this.name = undefined;
this.author = undefined;
this.content = undefined;
};
}

View File

@@ -8,7 +8,7 @@ import * as Constants from "../../Common/Constants";
import * as Entities from "./Entities";
import EnvironmentUtility from "../../Common/EnvironmentUtility";
import * as HeadersUtility from "../../Common/HeadersUtility";
import { Logger } from "../../Common/Logger";
import * as Logger from "../../Common/Logger";
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
import * as TableConstants from "./Constants";
import * as TableEntityProcessor from "./TableEntityProcessor";

View File

@@ -1,45 +1,151 @@
import * as ko from "knockout";
import * as ViewModels from "../../Contracts/ViewModels";
import TabsBase from "./TabsBase";
import * as React from "react";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import { GalleryViewerContainerComponent } from "../Controls/NotebookGallery/GalleryViewerComponent";
import * as ViewModels from "../../Contracts/ViewModels";
import { IGalleryItem, JunoClient } from "../../Juno/JunoClient";
import * as GalleryUtils from "../../Utils/GalleryUtils";
import {
GalleryTab as GalleryViewerTab,
GalleryViewerComponent,
GalleryViewerComponentProps,
SortBy
} from "../Controls/NotebookGallery/GalleryViewerComponent";
import {
NotebookViewerComponent,
NotebookViewerComponentProps
} from "../Controls/NotebookViewer/NotebookViewerComponent";
import TabsBase from "./TabsBase";
/**
* Notebook gallery tab
*/
interface GalleryComponentAdapterProps {
container: ViewModels.Explorer;
junoClient: JunoClient;
notebookUrl: string;
galleryItem: IGalleryItem;
isFavorite: boolean;
selectedTab: GalleryViewerTab;
sortBy: SortBy;
searchText: string;
}
interface GalleryComponentAdapterState {
notebookUrl: string;
galleryItem: IGalleryItem;
isFavorite: boolean;
selectedTab: GalleryViewerTab;
sortBy: SortBy;
searchText: string;
}
class GalleryComponentAdapter implements ReactAdapter {
public parameters: ko.Computed<boolean>;
constructor(private getContainer: () => ViewModels.Explorer) {}
public parameters: ko.Observable<number>;
private state: GalleryComponentAdapterState;
constructor(private props: GalleryComponentAdapterProps) {
this.parameters = ko.observable<number>(Date.now());
this.state = {
notebookUrl: props.notebookUrl,
galleryItem: props.galleryItem,
isFavorite: props.isFavorite,
selectedTab: props.selectedTab,
sortBy: props.sortBy,
searchText: props.searchText
};
}
public renderComponent(): JSX.Element {
return this.parameters() ? <GalleryViewerContainerComponent container={this.getContainer()} /> : <></>;
if (this.state.notebookUrl) {
const props: NotebookViewerComponentProps = {
container: this.props.container,
junoClient: this.props.junoClient,
notebookUrl: this.state.notebookUrl,
galleryItem: this.state.galleryItem,
isFavorite: this.state.isFavorite,
backNavigationText: GalleryUtils.getTabTitle(this.state.selectedTab),
onBackClick: this.onBackClick,
onTagClick: this.loadTaggedItems
};
return <NotebookViewerComponent {...props} />;
}
const props: GalleryViewerComponentProps = {
container: this.props.container,
junoClient: this.props.junoClient,
selectedTab: this.state.selectedTab,
sortBy: this.state.sortBy,
searchText: this.state.searchText,
onSelectedTabChange: this.onSelectedTabChange,
onSortByChange: this.onSortByChange,
onSearchTextChange: this.onSearchTextChange
};
return <GalleryViewerComponent {...props} />;
}
public setState(state: Partial<GalleryComponentAdapterState>): void {
this.state = Object.assign(this.state, state);
window.requestAnimationFrame(() => this.parameters(Date.now()));
}
private onBackClick = (): void => {
this.props.container.openGallery();
};
private loadTaggedItems = (tag: string): void => {
this.setState({
notebookUrl: undefined,
searchText: tag
});
};
private onSelectedTabChange = (selectedTab: GalleryViewerTab): void => {
this.state.selectedTab = selectedTab;
};
private onSortByChange = (sortBy: SortBy): void => {
this.state.sortBy = sortBy;
};
private onSearchTextChange = (searchText: string): void => {
this.state.searchText = searchText;
};
}
export default class GalleryTab extends TabsBase implements ViewModels.Tab {
private container: ViewModels.Explorer;
private galleryComponentAdapterProps: GalleryComponentAdapterProps;
private galleryComponentAdapter: GalleryComponentAdapter;
constructor(options: ViewModels.GalleryTabOptions) {
super(options);
this.container = options.container;
this.galleryComponentAdapter = new GalleryComponentAdapter(() => this.getContainer());
this.galleryComponentAdapter.parameters = ko.computed<boolean>(() => {
return this.isTemplateReady() && this.container.isNotebookEnabled();
});
this.container = options.container;
this.galleryComponentAdapterProps = {
container: options.container,
junoClient: options.junoClient,
notebookUrl: options.notebookUrl,
galleryItem: options.galleryItem,
isFavorite: options.isFavorite,
selectedTab: GalleryViewerTab.OfficialSamples,
sortBy: SortBy.MostViewed,
searchText: undefined
};
this.galleryComponentAdapter = new GalleryComponentAdapter(this.galleryComponentAdapterProps);
}
protected getContainer(): ViewModels.Explorer {
return this.container;
}
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
return [];
}
protected buildCommandBarOptions(): void {
this.updateNavbarWithTabsButtons();
public updateGalleryParams(notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean): void {
this.galleryComponentAdapter.setState({
notebookUrl,
galleryItem,
isFavorite
});
}
}

View File

@@ -18,7 +18,7 @@ import {
updateDocument
} from "../../Common/MongoProxyClient";
import { extractPartitionKey } from "@azure/cosmos";
import { Logger } from "../../Common/Logger";
import * as Logger from "../../Common/Logger";
import { PartitionKeyDefinition } from "@azure/cosmos";
export default class MongoDocumentsTab extends DocumentsTab implements ViewModels.DocumentsTab {
@@ -177,10 +177,9 @@ export default class MongoDocumentsTab extends DocumentsTab implements ViewModel
);
},
reason => {
this.isExecutionError(true);
const message = ErrorParserUtility.parse(reason)[0].message;
window.alert(message);
this.isExecutionError(true);
console.error(reason);
TelemetryProcessor.traceFailure(
Action.UpdateDocument,
{

View File

@@ -45,7 +45,7 @@ export default class NotebookTabV2 extends TabsBase implements ViewModels.Tab {
connectionInfo: this.container.notebookServerInfo(),
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
contentProvider: this.container.notebookContentProvider
contentProvider: this.container.notebookManager?.notebookContentProvider
});
}
@@ -112,6 +112,7 @@ export default class NotebookTabV2 extends TabsBase implements ViewModels.Tab {
const availableKernels = NotebookTabV2.clientManager.getAvailableKernelSpecs();
const saveLabel = "Save";
const publishLabel = "Publish to gallery";
const workspaceLabel = "No Workspace";
const kernelLabel = "No Kernel";
const runLabel = "Run";
@@ -142,7 +143,27 @@ export default class NotebookTabV2 extends TabsBase implements ViewModels.Tab {
commandButtonLabel: saveLabel,
hasPopup: false,
disabled: false,
ariaLabel: saveLabel
ariaLabel: saveLabel,
children: this.container.isGalleryPublishEnabled()
? [
{
iconName: "Save",
onCommandClick: () => this.notebookComponentAdapter.notebookSave(),
commandButtonLabel: saveLabel,
hasPopup: false,
disabled: false,
ariaLabel: saveLabel
},
{
iconName: "PublishContent",
onCommandClick: () => this.publishToGallery(),
commandButtonLabel: publishLabel,
hasPopup: false,
disabled: false,
ariaLabel: publishLabel
}
]
: undefined
},
{
iconSrc: null,
@@ -425,6 +446,11 @@ export default class NotebookTabV2 extends TabsBase implements ViewModels.Tab {
);
}
private publishToGallery = () => {
const notebookContent = this.notebookComponentAdapter.getContent();
this.container.publishNotebook(notebookContent.name, notebookContent.content);
};
private traceTelemetry(actionType: number) {
TelemetryProcessor.trace(actionType, ActionModifiers.Mark, {
databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name,

View File

@@ -1,10 +1,12 @@
import * as ko from "knockout";
import * as ViewModels from "../../Contracts/ViewModels";
import * as DataModels from "../../Contracts/DataModels";
import TabsBase from "./TabsBase";
import * as React from "react";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import { NotebookViewerComponent } from "../Controls/NotebookViewer/NotebookViewerComponent";
import * as ViewModels from "../../Contracts/ViewModels";
import {
NotebookViewerComponent,
NotebookViewerComponentProps
} from "../Controls/NotebookViewer/NotebookViewerComponent";
import TabsBase from "./TabsBase";
/**
* Notebook Viewer tab
@@ -12,48 +14,32 @@ import { NotebookViewerComponent } from "../Controls/NotebookViewer/NotebookView
class NotebookViewerComponentAdapter implements ReactAdapter {
// parameters: true: show, false: hide
public parameters: ko.Computed<boolean>;
constructor(
private notebookUrl: string,
private notebookName: string,
private container: ViewModels.Explorer,
private notebookMetadata: DataModels.NotebookMetadata,
private onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>,
private isLikedNotebook: boolean
) {}
constructor(private notebookUrl: string) {}
public renderComponent(): JSX.Element {
return this.parameters() ? (
<NotebookViewerComponent
notebookUrl={this.notebookUrl}
notebookMetadata={this.notebookMetadata}
notebookName={this.notebookName}
container={this.container}
onNotebookMetadataChange={this.onNotebookMetadataChange}
isLikedNotebook={this.isLikedNotebook}
/>
) : (
<></>
);
const props: NotebookViewerComponentProps = {
notebookUrl: this.notebookUrl,
backNavigationText: undefined,
onBackClick: undefined,
onTagClick: undefined
};
return this.parameters() ? <NotebookViewerComponent {...props} /> : <></>;
}
}
export default class NotebookViewerTab extends TabsBase implements ViewModels.Tab {
private container: ViewModels.Explorer;
public notebookViewerComponentAdapter: NotebookViewerComponentAdapter;
public notebookUrl: string;
public notebookViewerComponentAdapter: NotebookViewerComponentAdapter;
constructor(options: ViewModels.NotebookViewerTabOptions) {
super(options);
this.container = options.container;
this.notebookUrl = options.notebookUrl;
this.notebookViewerComponentAdapter = new NotebookViewerComponentAdapter(
options.notebookUrl,
options.notebookName,
options.container,
options.notebookMetadata,
options.onNotebookMetadataChange,
options.isLikedNotebook
);
this.notebookViewerComponentAdapter = new NotebookViewerComponentAdapter(options.notebookUrl);
this.notebookViewerComponentAdapter.parameters = ko.computed<boolean>(() => {
if (this.isTemplateReady() && this.container.isNotebookEnabled()) {

View File

@@ -8,7 +8,7 @@ import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
import TabsBase from "./TabsBase";
import { HashMap } from "../../Common/HashMap";
import * as HeadersUtility from "../../Common/HeadersUtility";
import { Logger } from "../../Common/Logger";
import * as Logger from "../../Common/Logger";
import { Splitter, SplitterBounds, SplitterDirection } from "../../Common/Splitter";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import ExecuteQueryIcon from "../../../images/ExecuteQuery.svg";

View File

@@ -5,7 +5,7 @@ import UploadWorker from "worker-loader!../../workers/upload";
import { AuthType } from "../../AuthType";
import * as Constants from "../../Common/Constants";
import { CosmosClient } from "../../Common/CosmosClient";
import { Logger } from "../../Common/Logger";
import * as Logger from "../../Common/Logger";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { PlatformType } from "../../PlatformType";
@@ -1409,7 +1409,7 @@ export default class Collection implements ViewModels.Collection {
*/
public getLabel(): string {
if (this.container.isPreferredApiTable()) {
return "Entitites";
return "Entities";
} else if (this.container.isPreferredApiCassandra()) {
return "Rows";
} else if (this.container.isPreferredApiGraph()) {

View File

@@ -10,7 +10,7 @@ import Collection from "./Collection";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import { Logger } from "../../Common/Logger";
import * as Logger from "../../Common/Logger";
export default class Database implements ViewModels.Database {
public nodeKind: string;

View File

@@ -19,12 +19,15 @@ import { ArrayHashMap } from "../../Common/ArrayHashMap";
import { NotebookUtil } from "../Notebook/NotebookUtil";
import _ from "underscore";
import { StringUtils } from "../../Utils/StringUtils";
import { JunoClient, IPinnedRepo } from "../../Juno/JunoClient";
import { IPinnedRepo } from "../../Juno/JunoClient";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { Areas } from "../../Common/Constants";
import { GitHubUtils } from "../../Utils/GitHubUtils";
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";
export class ResourceTreeAdapter implements ReactAdapter {
private static readonly DataTitle = "DATA";
@@ -33,17 +36,16 @@ export class ResourceTreeAdapter implements ReactAdapter {
public parameters: ko.Observable<number>;
public galleryContentRoot: NotebookContentItem;
public sampleNotebooksContentRoot: NotebookContentItem;
public myNotebooksContentRoot: NotebookContentItem;
public gitHubNotebooksContentRoot: NotebookContentItem;
private pinnedReposSubscription: ko.Subscription;
private koSubsDatabaseIdMap: ArrayHashMap<ko.Subscription>; // database id -> ko subs
private koSubsCollectionIdMap: ArrayHashMap<ko.Subscription>; // collection id -> ko subs
private databaseCollectionIdMap: ArrayHashMap<string>; // database id -> collection ids
public constructor(private container: ViewModels.Explorer, private junoClient: JunoClient) {
public constructor(private container: ViewModels.Explorer) {
this.parameters = ko.observable(Date.now());
this.container.selectedNode.subscribe((newValue: any) => this.triggerRender());
@@ -72,14 +74,18 @@ export class ResourceTreeAdapter implements ReactAdapter {
if (this.container.isNotebookEnabled()) {
return (
<AccordionComponent>
<AccordionItemComponent title={ResourceTreeAdapter.DataTitle} isExpanded={!this.gitHubNotebooksContentRoot}>
<TreeComponent className="dataResourceTree" rootNode={dataRootNode} />
</AccordionItemComponent>
<AccordionItemComponent title={ResourceTreeAdapter.NotebooksTitle}>
<TreeComponent className="notebookResourceTree" rootNode={notebooksRootNode} />
</AccordionItemComponent>
</AccordionComponent>
<>
<AccordionComponent>
<AccordionItemComponent title={ResourceTreeAdapter.DataTitle} isExpanded={!this.gitHubNotebooksContentRoot}>
<TreeComponent className="dataResourceTree" rootNode={dataRootNode} />
</AccordionItemComponent>
<AccordionItemComponent title={ResourceTreeAdapter.NotebooksTitle}>
<TreeComponent className="notebookResourceTree" rootNode={notebooksRootNode} />
</AccordionItemComponent>
</AccordionComponent>
{this.galleryContentRoot && this.buildGalleryCallout()}
</>
);
} else {
return <TreeComponent className="dataResourceTree" rootNode={dataRootNode} />;
@@ -89,14 +95,26 @@ export class ResourceTreeAdapter implements ReactAdapter {
public async initialize(): Promise<void[]> {
const refreshTasks: Promise<void>[] = [];
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())
);
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.myNotebooksContentRoot = {
name: "My Notebooks",
@@ -111,14 +129,12 @@ export class ResourceTreeAdapter implements ReactAdapter {
);
}
if (this.container.gitHubOAuthService?.isLoggedIn()) {
if (this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
this.gitHubNotebooksContentRoot = {
name: "GitHub repos",
path: ResourceTreeAdapter.PseudoDirPath,
type: NotebookContentItemType.Directory
};
refreshTasks.push(this.refreshGitHubReposAndTriggerRender(this.gitHubNotebooksContentRoot));
} else {
this.gitHubNotebooksContentRoot = undefined;
}
@@ -126,10 +142,10 @@ export class ResourceTreeAdapter implements ReactAdapter {
return Promise.all(refreshTasks);
}
private async refreshGitHubReposAndTriggerRender(item: NotebookContentItem): Promise<void> {
const updateGitHubReposAndRender = (pinnedRepos: IPinnedRepo[]) => {
item.children = [];
pinnedRepos.forEach(pinnedRepo => {
public initializeGitHubRepos(pinnedRepos: IPinnedRepo[]): void {
if (this.gitHubNotebooksContentRoot) {
this.gitHubNotebooksContentRoot.children = [];
pinnedRepos?.forEach(pinnedRepo => {
const repoFullName = GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name);
const repoTreeItem: NotebookContentItem = {
name: repoFullName,
@@ -146,20 +162,11 @@ export class ResourceTreeAdapter implements ReactAdapter {
});
});
item.children.push(repoTreeItem);
this.gitHubNotebooksContentRoot.children.push(repoTreeItem);
});
this.triggerRender();
};
if (this.pinnedReposSubscription) {
this.pinnedReposSubscription.dispose();
}
this.pinnedReposSubscription = this.junoClient.subscribeToPinnedRepos(pinnedRepos =>
updateGitHubReposAndRender(pinnedRepos)
);
await this.junoClient.getPinnedRepos(this.container.gitHubOAuthService?.getTokenObservable()()?.scope);
}
private buildDataTree(): TreeNode {
@@ -347,10 +354,13 @@ export class ResourceTreeAdapter implements ReactAdapter {
let notebooksTree: TreeNode = {
label: undefined,
isExpanded: true,
isLeavesParentsSeparate: true,
children: []
};
if (this.galleryContentRoot) {
notebooksTree.children.push(this.buildGalleryNotebooksTree());
}
if (this.sampleNotebooksContentRoot) {
notebooksTree.children.push(this.buildSampleNotebooksTree());
}
@@ -368,6 +378,65 @@ export class ResourceTreeAdapter implements ReactAdapter {
return notebooksTree;
}
private buildGalleryCallout(): JSX.Element {
if (
LocalStorageUtility.hasItem(StorageKey.GalleryCalloutDismissed) &&
LocalStorageUtility.getEntryBoolean(StorageKey.GalleryCalloutDismissed)
) {
return undefined;
}
const calloutProps: ICalloutProps = {
calloutMaxWidth: 350,
ariaLabel: "New gallery",
role: "alertdialog",
gapSpace: 0,
target: ".galleryHeader",
directionalHint: DirectionalHint.leftTopEdge,
onDismiss: () => {
LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true);
this.triggerRender();
},
setInitialFocus: true
};
const openGalleryProps: ILinkProps = {
onClick: () => {
LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true);
this.container.openGallery();
this.triggerRender();
}
};
return (
<Callout {...calloutProps}>
<Stack tokens={{ childrenGap: 10, padding: 20 }}>
<Text variant="xLarge" block>
New gallery
</Text>
<Text block>
Sample notebooks are now combined in gallery. View and try out samples provided by Microsoft and other
contributors.
</Text>
<Link {...openGalleryProps}>Open gallery</Link>
</Stack>
</Callout>
);
}
private buildGalleryNotebooksTree(): TreeNode {
return {
label: "Gallery",
iconSrc: GalleryIcon,
className: "notebookHeader galleryHeader",
onClick: () => this.container.openGallery(),
isSelected: () => {
const activeTab = this.container.findActiveTab();
return activeTab && activeTab.tabKind === ViewModels.CollectionTabKind.Gallery;
}
};
}
private buildSampleNotebooksTree(): TreeNode {
const sampleNotebooksTree: TreeNode = this.buildNotebookDirectoryNode(
this.sampleNotebooksContentRoot,
@@ -467,7 +536,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
defaultExperience: this.container.defaultExperience && this.container.defaultExperience(),
dataExplorerArea: Areas.Notebook
});
this.container.gitHubOAuthService.logout();
this.container.notebookManager?.gitHubOAuthService.logout();
}
}
];

View File

@@ -1,19 +1,33 @@
import * as ReactDOM from "react-dom";
import "bootstrap/dist/css/bootstrap.css";
import { CosmosClient } from "../Common/CosmosClient";
import { GalleryViewerComponent } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
import { JunoUtils } from "../Utils/JunoUtils";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { initializeConfiguration } from "../Config";
import {
GalleryTab,
GalleryViewerComponent,
GalleryViewerComponentProps,
SortBy
} from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
import { JunoClient } from "../Juno/JunoClient";
import * as GalleryUtils from "../Utils/GalleryUtils";
const onInit = async () => {
initializeIcons();
const officialSamplesData = await JunoUtils.getOfficialSampleNotebooks(CosmosClient.authorizationToken());
const galleryViewerComponent = new GalleryViewerComponent({
officialSamplesData: officialSamplesData,
likedNotebookData: undefined,
container: undefined
});
ReactDOM.render(galleryViewerComponent.render(), document.getElementById("galleryContent"));
await initializeConfiguration();
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window);
const props: GalleryViewerComponentProps = {
junoClient: new JunoClient(),
selectedTab: galleryViewerProps.selectedTab || GalleryTab.OfficialSamples,
sortBy: galleryViewerProps.sortBy || SortBy.MostViewed,
searchText: galleryViewerProps.searchText,
onSelectedTabChange: undefined,
onSortByChange: undefined,
onSearchTextChange: undefined
};
ReactDOM.render(<GalleryViewerComponent {...props} />, document.getElementById("galleryContent"));
};
// Entry point

View File

@@ -8,6 +8,6 @@
</head>
<body>
<div class="galleryComponentContainer" id="galleryContent"></div>
<div class="galleryContent" id="galleryContent"></div>
</body>
</html>

View File

@@ -1,6 +1,6 @@
import { Octokit } from "@octokit/rest";
import { HttpStatusCodes } from "../Common/Constants";
import { Logger } from "../Common/Logger";
import * as Logger from "../Common/Logger";
import UrlUtility from "../Common/UrlUtility";
import { isSamplesCall, SamplesContentsQueryResponse } from "../Explorer/Notebook/NotebookSamples";
import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil";

View File

@@ -3,7 +3,7 @@ import { fixture } from "@nteract/fixtures";
import { HttpStatusCodes } from "../Common/Constants";
import { GitHubClient, IGitHubCommit, IGitHubFile } from "./GitHubClient";
import { GitHubContentProvider } from "./GitHubContentProvider";
import { GitHubUtils } from "../Utils/GitHubUtils";
import * as GitHubUtils from "../Utils/GitHubUtils";
const gitHubClient = new GitHubClient("token", () => {});
const gitHubContentProvider = new GitHubContentProvider({

View File

@@ -3,10 +3,10 @@ import { FileType, IContent, IContentProvider, IEmptyContent, IGetParams, Server
import { from, Observable, of } from "rxjs";
import { AjaxResponse } from "rxjs/ajax";
import { HttpStatusCodes } from "../Common/Constants";
import { Logger } from "../Common/Logger";
import * as Logger from "../Common/Logger";
import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil";
import { GitHubClient, IGitHubFile, IGitHubResponse, IGitHubCommit, IGitHubBranch } from "./GitHubClient";
import { GitHubUtils } from "../Utils/GitHubUtils";
import * as GitHubUtils from "../Utils/GitHubUtils";
import UrlUtility from "../Common/UrlUtility";
export interface GitHubContentProviderParams {

View File

@@ -5,6 +5,7 @@ import { JunoClient } from "../Juno/JunoClient";
import { GitHubConnector, IGitHubConnectorParams } from "./GitHubConnector";
import { GitHubOAuthService } from "./GitHubOAuthService";
import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import NotebookManager from "../Explorer/Notebook/NotebookManager";
const sampleDatabaseAccount: ViewModels.DatabaseAccount = {
id: "id",
@@ -32,10 +33,12 @@ describe("GitHubOAuthService", () => {
originalDataExplorer = window.dataExplorer;
window.dataExplorer = {
...originalDataExplorer,
gitHubOAuthService,
logConsoleData: (data): void =>
data.type === ConsoleDataType.Error ? console.error(data.message) : console.log(data.message)
} as ViewModels.Explorer;
window.dataExplorer.notebookManager = new NotebookManager();
window.dataExplorer.notebookManager.junoClient = junoClient;
window.dataExplorer.notebookManager.gitHubOAuthService = gitHubOAuthService;
});
afterEach(() => {

View File

@@ -1,6 +1,6 @@
import ko from "knockout";
import { HttpStatusCodes } from "../Common/Constants";
import { Logger } from "../Common/Logger";
import * as Logger from "../Common/Logger";
import { config } from "../Config";
import { AuthorizeAccessComponent } from "../Explorer/Controls/GitHub/AuthorizeAccessComponent";
import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
@@ -17,7 +17,7 @@ window.addEventListener("message", (event: MessageEvent) => {
const msg = event.data;
if (msg.type === GitHubConnectorMsgType) {
const params = msg.data as IGitHubConnectorParams;
window.dataExplorer.gitHubOAuthService.finishOAuth(params);
window.dataExplorer.notebookManager?.gitHubOAuthService.finishOAuth(params);
}
});

View File

@@ -23,7 +23,7 @@ import { DialogProps } from "./Explorer/Controls/DialogReactComponent/DialogComp
import { DirectoryListProps } from "./Explorer/Controls/Directory/DirectoryListComponent";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import { LocalStorageUtility, StorageKey, SessionStorageUtility } from "./Shared/StorageUtility";
import { Logger } from "./Common/Logger";
import * as Logger from "./Common/Logger";
import { MeControlComponentProps } from "./Explorer/Menus/NavBar/MeControlComponent";
import { MeControlComponentAdapter } from "./Explorer/Menus/NavBar/MeControlComponentAdapter";
import { MessageTypes } from "./Contracts/ExplorerContracts";

View File

@@ -2,10 +2,10 @@ import ko from "knockout";
import { HttpStatusCodes } from "../Common/Constants";
import { config } from "../Config";
import * as ViewModels from "../Contracts/ViewModels";
import { AuthorizeAccessComponent } from "../Explorer/Controls/GitHub/AuthorizeAccessComponent";
import { IGitHubResponse } from "../GitHub/GitHubClient";
import { IGitHubOAuthToken } from "../GitHub/GitHubOAuthService";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
import { AuthorizeAccessComponent } from "../Explorer/Controls/GitHub/AuthorizeAccessComponent";
export interface IJunoResponse<T> {
status: number;
@@ -23,10 +23,39 @@ export interface IPinnedBranch {
name: string;
}
export interface IGalleryItem {
id: string;
name: string;
description: string;
gitSha: string;
tags: string[];
author: string;
thumbnailUrl: string;
created: string;
isSample: boolean;
downloads: number;
favorites: number;
views: number;
}
export interface IUserGallery {
favorites: string[];
published: string[];
}
interface IPublishNotebookRequest {
name: string;
description: string;
tags: string[];
author: string;
thumbnailUrl: string;
content: any;
}
export class JunoClient {
private cachedPinnedRepos: ko.Observable<IPinnedRepo[]>;
constructor(public databaseAccount: ko.Observable<ViewModels.DatabaseAccount>) {
constructor(private databaseAccount?: ko.Observable<ViewModels.DatabaseAccount>) {
this.cachedPinnedRepos = ko.observable<IPinnedRepo[]>([]);
}
@@ -35,8 +64,8 @@ export class JunoClient {
}
public async getPinnedRepos(scope: string): Promise<IJunoResponse<IPinnedRepo[]>> {
const response = await window.fetch(`${this.getJunoGitHubUrl()}/pinnedrepos`, {
headers: this.getHeaders()
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/pinnedrepos`, {
headers: JunoClient.getHeaders()
});
let pinnedRepos: IPinnedRepo[];
@@ -58,10 +87,10 @@ export class JunoClient {
}
public async updatePinnedRepos(repos: IPinnedRepo[]): Promise<IJunoResponse<undefined>> {
const response = await window.fetch(`${this.getJunoGitHubUrl()}/pinnedrepos`, {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/pinnedrepos`, {
method: "PUT",
body: JSON.stringify(repos),
headers: this.getHeaders()
headers: JunoClient.getHeaders()
});
if (response.status === HttpStatusCodes.OK) {
@@ -75,9 +104,9 @@ export class JunoClient {
}
public async deleteGitHubInfo(): Promise<IJunoResponse<undefined>> {
const response = await window.fetch(this.getJunoGitHubUrl(), {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github`, {
method: "DELETE",
headers: this.getHeaders()
headers: JunoClient.getHeaders()
});
return {
@@ -90,8 +119,8 @@ export class JunoClient {
const githubParams = JunoClient.getGitHubClientParams();
githubParams.append("code", code);
const response = await window.fetch(`${this.getJunoGitHubUrl()}/token?${githubParams.toString()}`, {
headers: this.getHeaders()
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/token?${githubParams.toString()}`, {
headers: JunoClient.getHeaders()
});
let data: IGitHubOAuthToken;
@@ -114,9 +143,9 @@ export class JunoClient {
const githubParams = JunoClient.getGitHubClientParams();
githubParams.append("access_token", token);
const response = await window.fetch(`${this.getJunoGitHubUrl()}/token?${githubParams.toString()}`, {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/token?${githubParams.toString()}`, {
method: "DELETE",
headers: this.getHeaders()
headers: JunoClient.getHeaders()
});
return {
@@ -125,11 +154,201 @@ export class JunoClient {
};
}
private getJunoGitHubUrl(): string {
return `${config.JUNO_ENDPOINT}/api/notebooks/${this.databaseAccount().name}/github`;
public async getSampleNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/samples`);
}
private getHeaders(): HeadersInit {
public async getPublicNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/public`);
}
public async getNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(this.getNotebookInfoUrl(id));
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async getNotebookContent(id: string): Promise<IJunoResponse<string>> {
const response = await window.fetch(this.getNotebookContentUrl(id));
let data: string;
if (response.status === HttpStatusCodes.OK) {
data = await response.text();
}
return {
status: response.status,
data
};
}
public async increaseNotebookViews(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}/views`, {
method: "PATCH"
});
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async increaseNotebookDownloadCount(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery/${id}/downloads`, {
method: "PATCH",
headers: JunoClient.getHeaders()
});
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async favoriteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery/${id}/favorite`, {
method: "PATCH",
headers: JunoClient.getHeaders()
});
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async unfavoriteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}/unfavorite`, {
method: "PATCH",
headers: JunoClient.getHeaders()
});
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async getFavoriteNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
return await this.getNotebooks(`${this.getNotebooksUrl()}/gallery/favorites`, {
headers: JunoClient.getHeaders()
});
}
public async getPublishedNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
return await this.getNotebooks(`${this.getNotebooksUrl()}/gallery/published`, {
headers: JunoClient.getHeaders()
});
}
public async deleteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}`, {
method: "DELETE",
headers: JunoClient.getHeaders()
});
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async publishNotebook(
name: string,
description: string,
tags: string[],
author: string,
thumbnailUrl: string,
content: string
): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery`, {
method: "PUT",
headers: JunoClient.getHeaders(),
body: JSON.stringify({
name,
description,
tags,
author,
thumbnailUrl,
content: JSON.parse(content)
} as IPublishNotebookRequest)
});
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public getNotebookContentUrl(id: string): string {
return `${this.getNotebooksUrl()}/gallery/${id}/content`;
}
public getNotebookInfoUrl(id: string): string {
return `${this.getNotebooksUrl()}/gallery/${id}`;
}
private async getNotebooks(input: RequestInfo, init?: RequestInit): Promise<IJunoResponse<IGalleryItem[]>> {
const response = await window.fetch(input, init);
let data: IGalleryItem[];
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
private getNotebooksUrl(): string {
return `${config.JUNO_ENDPOINT}/api/notebooks`;
}
private getNotebooksAccountUrl(): string {
return `${config.JUNO_ENDPOINT}/api/notebooks/${this.databaseAccount().name}`;
}
private static getHeaders(): HeadersInit {
const authorizationHeader = getAuthorizationHeader();
return {
[authorizationHeader.header]: authorizationHeader.token,
@@ -137,7 +356,7 @@ export class JunoClient {
};
}
public static getGitHubClientParams(): URLSearchParams {
private static getGitHubClientParams(): URLSearchParams {
const githubParams = new URLSearchParams({
client_id: config.GITHUB_CLIENT_ID
});

View File

@@ -6,7 +6,6 @@ import "../less/forms.less";
import "../less/menus.less";
import "../less/infobox.less";
import "../less/messagebox.less";
import "./Explorer/Controls/InputTypeahead/InputTypeahead.less";
import "./Explorer/Controls/ErrorDisplayComponent/ErrorDisplayComponent.less";
import "./Explorer/Menus/NotificationConsole/NotificationConsole.less";
import "./Explorer/Menus/CommandBar/CommandBarComponent.less";

View File

@@ -1,42 +1,44 @@
import "bootstrap/dist/css/bootstrap.css";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import React from "react";
import * as ReactDOM from "react-dom";
import "bootstrap/dist/css/bootstrap.css";
import { NotebookMetadata } from "../Contracts/DataModels";
import { NotebookViewerComponent } from "../Explorer/Controls/NotebookViewer/NotebookViewerComponent";
import { SessionStorageUtility, StorageKey } from "../Shared/StorageUtility";
const getNotebookUrl = (): string => {
const regex: RegExp = new RegExp("[?&]notebookurl=([^&#]*)|&|#|$");
const results: RegExpExecArray | null = regex.exec(window.location.href);
if (!results || !results[1]) {
return "";
}
return decodeURIComponent(results[1]);
};
import { initializeConfiguration } from "../Config";
import {
NotebookViewerComponent,
NotebookViewerComponentProps
} from "../Explorer/Controls/NotebookViewer/NotebookViewerComponent";
import { IGalleryItem, JunoClient } from "../Juno/JunoClient";
import * as GalleryUtils from "../Utils/GalleryUtils";
const onInit = async () => {
var notebookMetadata: NotebookMetadata;
const notebookMetadataString = SessionStorageUtility.getEntryString(StorageKey.NotebookMetadata);
const notebookName = SessionStorageUtility.getEntryString(StorageKey.NotebookName);
initializeIcons();
await initializeConfiguration();
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window);
const notebookViewerProps = GalleryUtils.getNotebookViewerProps(window);
const backNavigationText = galleryViewerProps.selectedTab && GalleryUtils.getTabTitle(galleryViewerProps.selectedTab);
if (notebookMetadataString == "null" || notebookMetadataString != null) {
notebookMetadata = (await JSON.parse(notebookMetadataString)) as NotebookMetadata;
SessionStorageUtility.removeEntry(StorageKey.NotebookMetadata);
SessionStorageUtility.removeEntry(StorageKey.NotebookName);
const notebookUrl = decodeURIComponent(notebookViewerProps.notebookUrl);
render(notebookUrl, backNavigationText);
const galleryItemId = notebookViewerProps.galleryItemId;
if (galleryItemId) {
const junoClient = new JunoClient();
const notebook = await junoClient.getNotebook(galleryItemId);
render(notebookUrl, backNavigationText, notebook.data);
}
};
const urlParams = new URLSearchParams(window.location.search);
const render = (notebookUrl: string, backNavigationText: string, galleryItem?: IGalleryItem) => {
const props: NotebookViewerComponentProps = {
junoClient: galleryItem ? new JunoClient() : undefined,
notebookUrl,
galleryItem,
backNavigationText,
onBackClick: undefined,
onTagClick: undefined
};
const notebookViewerComponent = (
<NotebookViewerComponent
notebookMetadata={notebookMetadata}
notebookName={notebookName}
notebookUrl={getNotebookUrl()}
hideInputs={urlParams.get("hideinputs") === "true"}
/>
);
ReactDOM.render(notebookViewerComponent, document.getElementById("notebookContent"));
ReactDOM.render(<NotebookViewerComponent {...props} />, document.getElementById("notebookContent"));
};
// Entry point

View File

@@ -1,7 +1,7 @@
import * as ViewModels from "../Contracts/ViewModels";
import { ArmApiVersions } from "../Common/Constants";
import { IResourceProviderClient, IResourceProviderClientFactory } from "../ResourceProvider/IResourceProviderClient";
import { Logger } from "../Common/Logger";
import * as Logger from "../Common/Logger";
import {
NotebookWorkspace,
NotebookWorkspaceConnectionInfo,

View File

@@ -1,6 +1,6 @@
import AuthHeadersUtil from "./Authorization";
import * as Constants from "../../Common/Constants";
import { Logger } from "../../Common/Logger";
import * as Logger from "../../Common/Logger";
import { Tenant, Subscription, DatabaseAccount, AccountKeys } from "../../Contracts/DataModels";
import { config } from "../../Config";

View File

@@ -9,7 +9,7 @@ import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
import { CosmosClient } from "../../Common/CosmosClient";
import { Logger } from "../../Common/Logger";
import * as Logger from "../../Common/Logger";
import { config } from "../../Config";
export default class AuthHeadersUtil {

View File

@@ -71,6 +71,5 @@ export enum StorageKey {
TenantId,
MostRecentActivity,
SetPartitionKeyUndefined,
NotebookMetadata,
NotebookName
GalleryCalloutDismissed
}

View File

@@ -7,7 +7,7 @@ import {
} from "../Contracts/DataModels";
import { ArmApiVersions, ArmResourceTypes } from "../Common/Constants";
import { IResourceProviderClient, IResourceProviderClientFactory } from "../ResourceProvider/IResourceProviderClient";
import { Logger } from "../Common/Logger";
import * as Logger from "../Common/Logger";
import { ResourceProviderClientFactory } from "../ResourceProvider/ResourceProviderClientFactory";
import { config } from "../Config";

View File

@@ -1,7 +1,7 @@
import * as ViewModels from "../Contracts/ViewModels";
import { ArmApiVersions } from "../Common/Constants";
import { IResourceProviderClient, IResourceProviderClientFactory } from "../ResourceProvider/IResourceProviderClient";
import { Logger } from "../Common/Logger";
import * as Logger from "../Common/Logger";
import { ResourceProviderClientFactory } from "../ResourceProvider/ResourceProviderClientFactory";
import {
SparkCluster,

View File

@@ -2,7 +2,7 @@ import * as Constants from "../Common/Constants";
import * as ViewModels from "../Contracts/ViewModels";
import AuthHeadersUtil from "../Platform/Hosted/Authorization";
import { AuthType } from "../AuthType";
import { Logger } from "../Common/Logger";
import * as Logger from "../Common/Logger";
import { PlatformType } from "../PlatformType";
import { CosmosClient } from "../Common/CosmosClient";
import { config } from "../Config";

260
src/Utils/GalleryUtils.ts Normal file
View File

@@ -0,0 +1,260 @@
import { LinkProps, DialogProps } from "../Explorer/Controls/DialogReactComponent/DialogComponent";
import { IGalleryItem, JunoClient } from "../Juno/JunoClient";
import * as ViewModels from "../Contracts/ViewModels";
import { NotificationConsoleUtils } from "./NotificationConsoleUtils";
import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import * as Logger from "../Common/Logger";
import {
GalleryTab,
SortBy,
GalleryViewerComponent
} from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
export interface DialogEnabledComponent {
setDialogProps: (dialogProps: DialogProps) => void;
}
export enum NotebookViewerParams {
NotebookUrl = "notebookUrl",
GalleryItemId = "galleryItemId"
}
export interface NotebookViewerProps {
notebookUrl: string;
galleryItemId: string;
}
export enum GalleryViewerParams {
SelectedTab = "tab",
SortBy = "sort",
SearchText = "q"
}
export interface GalleryViewerProps {
selectedTab: GalleryTab;
sortBy: SortBy;
searchText: string;
}
export function showOkCancelModalDialog(
component: DialogEnabledComponent,
title: string,
msg: string,
linkProps: LinkProps,
okLabel: string,
onOk: () => void,
cancelLabel: string,
onCancel: () => void
): void {
component.setDialogProps({
linkProps,
isModal: true,
visible: true,
title,
subText: msg,
primaryButtonText: okLabel,
secondaryButtonText: cancelLabel,
onPrimaryButtonClick: () => {
component.setDialogProps(undefined);
onOk && onOk();
},
onSecondaryButtonClick: () => {
component.setDialogProps(undefined);
onCancel && onCancel();
}
});
}
export function downloadItem(
component: DialogEnabledComponent,
container: ViewModels.Explorer,
junoClient: JunoClient,
data: IGalleryItem,
onComplete: (item: IGalleryItem) => void
): void {
const name = data.name;
if (container) {
container.showOkCancelModalDialog(
"Download to My Notebooks",
`Download ${name} from gallery as a copy to your notebooks to run and/or edit the notebook.`,
"Download",
async () => {
const notificationId = NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.InProgress,
`Downloading ${name} to My Notebooks`
);
try {
const response = await junoClient.getNotebookContent(data.id);
if (!response.data) {
throw new Error(`Received HTTP ${response.status} when fetching ${data.name}`);
}
await container.importAndOpenFromGallery(data.name, response.data);
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Info,
`Successfully downloaded ${name} to My Notebooks`
);
const increaseDownloadResponse = await junoClient.increaseNotebookDownloadCount(data.id);
if (increaseDownloadResponse.data) {
onComplete(increaseDownloadResponse.data);
}
} catch (error) {
const message = `Failed to download ${data.name}: ${error}`;
Logger.logError(message, "GalleryUtils/downloadItem");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
}
NotificationConsoleUtils.clearInProgressMessageWithId(notificationId);
},
"Cancel",
undefined
);
} else {
showOkCancelModalDialog(
component,
"Edit/Run notebook in Cosmos DB data explorer",
`In order to edit/run ${name} in Cosmos DB data explorer, a Cosmos DB account will be needed. If you do not have a Cosmos DB account yet, please create one.`,
{
linkText: "Learn more about Cosmos DB",
linkUrl: "https://azure.microsoft.com/en-us/services/cosmos-db"
},
"Open data explorer",
() => {
window.open("https://cosmos.azure.com");
},
"Create Cosmos DB account",
() => {
window.open("https://ms.portal.azure.com/#create/Microsoft.DocumentDB");
}
);
}
}
export async function favoriteItem(
container: ViewModels.Explorer,
junoClient: JunoClient,
data: IGalleryItem,
onComplete: (item: IGalleryItem) => void
): Promise<void> {
if (container) {
try {
const response = await junoClient.favoriteNotebook(data.id);
if (!response.data) {
throw new Error(`Received HTTP ${response.status} when favoriting ${data.name}`);
}
onComplete(response.data);
} catch (error) {
const message = `Failed to favorite ${data.name}: ${error}`;
Logger.logError(message, "GalleryUtils/favoriteItem");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
}
}
}
export async function unfavoriteItem(
container: ViewModels.Explorer,
junoClient: JunoClient,
data: IGalleryItem,
onComplete: (item: IGalleryItem) => void
): Promise<void> {
if (container) {
try {
const response = await junoClient.unfavoriteNotebook(data.id);
if (!response.data) {
throw new Error(`Received HTTP ${response.status} when unfavoriting ${data.name}`);
}
onComplete(response.data);
} catch (error) {
const message = `Failed to unfavorite ${data.name}: ${error}`;
Logger.logError(message, "GalleryUtils/unfavoriteItem");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
}
}
}
export function deleteItem(
container: ViewModels.Explorer,
junoClient: JunoClient,
data: IGalleryItem,
onComplete: (item: IGalleryItem) => void
): void {
if (container) {
container.showOkCancelModalDialog(
"Remove published notebook",
`Would you like to remove ${data.name} from the gallery?`,
"Remove",
async () => {
const name = data.name;
const notificationId = NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.InProgress,
`Removing ${name} from gallery`
);
try {
const response = await junoClient.deleteNotebook(data.id);
if (!response.data) {
throw new Error(`Received HTTP ${response.status} while removing ${name}`);
}
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully removed ${name} from gallery`);
onComplete(response.data);
} catch (error) {
const message = `Failed to remove ${name} from gallery: ${error}`;
Logger.logError(message, "GalleryUtils/deleteItem");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
}
NotificationConsoleUtils.clearInProgressMessageWithId(notificationId);
},
"Cancel",
undefined
);
}
}
export function getGalleryViewerProps(window: Window & typeof globalThis): GalleryViewerProps {
const params = new URLSearchParams(window.location.search);
let selectedTab: GalleryTab;
if (params.has(GalleryViewerParams.SelectedTab)) {
selectedTab = GalleryTab[params.get(GalleryViewerParams.SelectedTab) as keyof typeof GalleryTab];
}
let sortBy: SortBy;
if (params.has(GalleryViewerParams.SortBy)) {
sortBy = SortBy[params.get(GalleryViewerParams.SortBy) as keyof typeof SortBy];
}
return {
selectedTab,
sortBy,
searchText: params.get(GalleryViewerParams.SearchText)
};
}
export function getNotebookViewerProps(window: Window & typeof globalThis): NotebookViewerProps {
const params = new URLSearchParams(window.location.search);
return {
notebookUrl: params.get(NotebookViewerParams.NotebookUrl),
galleryItemId: params.get(NotebookViewerParams.GalleryItemId)
};
}
export function getTabTitle(tab: GalleryTab): string {
switch (tab) {
case GalleryTab.OfficialSamples:
return GalleryViewerComponent.OfficialSamplesTitle;
case GalleryTab.PublicGallery:
return GalleryViewerComponent.PublicGalleryTitle;
case GalleryTab.Favorites:
return GalleryViewerComponent.FavoritesTitle;
case GalleryTab.Published:
return GalleryViewerComponent.PublishedTitle;
default:
throw new Error(`Unknown tab ${tab}`);
}
}

View File

@@ -1,4 +1,4 @@
import { GitHubUtils } from "./GitHubUtils";
import * as GitHubUtils from "./GitHubUtils";
const owner = "owner-1";
const repo = "repo-1";

Some files were not shown because too many files have changed in this diff Show More