Compare commits

...

27 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
Laurent Nguyen
4abfcc5e25 Fix graph tab height issue (bottom part too low and occluded by notification bar) (#48) 2020-06-17 12:17:22 -05:00
Laurent Nguyen
3d9256abc6 Add contribution guidelines (#40) 2020-06-17 08:48:39 +02:00
Steve Faulkner
7f1355b1a4 Linting Updates (#47) 2020-06-16 09:21:44 -05:00
Tanuj Mittal
3eff440680 Remove unused Telemetry Actions and update comment (#18) 2020-06-15 10:35:02 -07:00
Steve Faulkner
4da0887e5e End to End CI Test for Mongo and SQL (#33)
Co-authored-by: Ashwin Kumar M <v-asmuth@microsoft.com>
Co-authored-by: Steve Faulkner <stfaul@microsoft.com>
2020-06-15 11:25:59 -05:00
Steve Faulkner
b783445130 Add Compile + Lint + Format to CI (#45) 2020-06-15 10:08:54 -05:00
Laurent Nguyen
73f2c612ed Remove old resource tree and cleanup (#28)
* Remove old resource tree and its various components

* Fix stored procedure, udf, trigger not always deleting when context menu option chosen

* Reformat and fix eslint warnings

* Remove CommandButtonOptions
2020-06-15 12:16:52 +02:00
Laurent Nguyen
d70e30c4fc Add data explorer launcher (#23)
* Initial migration from ADO

* Bug fixes

* Fix bugs. Make active area smaller and require shift + ctrl + dbl click

* Add missing features

* Switch from HashMap to Map as it is already polyfilled
2020-06-15 10:50:55 +02:00
Steve Faulkner
f8f1df4183 Update @azure/cosmos to 3.7.1 (#43) 2020-06-12 17:57:03 -05:00
Steve Faulkner
d427fc729e Update webpack-dev-server to latest (#32) 2020-06-12 17:34:52 -05:00
Steve Faulkner
9c36782661 Copy Contracts into dist/ (#39) 2020-06-12 12:33:00 -05:00
Laurent Nguyen
d32bd8851e Remove NotebookTab (#35) 2020-06-12 09:26:39 +02:00
Laurent Nguyen
23b2d8100f Remove obsolete feature flags (and reformat) (#27)
* Reformat

* Remove unused feature flags: graph, cacheOptimizations, settingsPane, throughputOverview, enableNteract
2020-06-11 12:39:58 +02:00
Steve Faulkner
1662d20e8a Fix Auth Header + Firefox Bug in Emulator (#22) 2020-06-10 16:08:05 -05:00
victor-meng
582ac865ff Migrate UploadItemPane to react (#17)
* Create GenericPaneComponent and use it to migrate UploadItemsPane to React

* Add helper functions for building each panel section

* Address comments and some styling changes

* Unsubscribe to isNotificationConsoleExpanded when component unmounts
2020-06-10 00:15:32 -07:00
167 changed files with 13269 additions and 6181 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/ **/node_modules/
dist/
src/Api/Apis.ts src/Api/Apis.ts
src/AuthType.ts src/AuthType.ts
src/Bindings/BindingHandlersRegisterer.ts src/Bindings/BindingHandlersRegisterer.ts
@@ -25,7 +26,6 @@ src/Common/Logger.test.ts
src/Common/MessageHandler.test.ts src/Common/MessageHandler.test.ts
src/Common/MessageHandler.ts src/Common/MessageHandler.ts
src/Common/MongoProxyClient.test.ts src/Common/MongoProxyClient.test.ts
src/Common/MongoProxyClient.ts
src/Common/MongoUtility.ts src/Common/MongoUtility.ts
src/Common/NotificationsClientBase.ts src/Common/NotificationsClientBase.ts
src/Common/ObjectCache.test.ts src/Common/ObjectCache.test.ts
@@ -202,7 +202,6 @@ src/Explorer/Tabs/GraphTab.ts
src/Explorer/Tabs/MongoDocumentsTab.ts src/Explorer/Tabs/MongoDocumentsTab.ts
src/Explorer/Tabs/MongoQueryTab.ts src/Explorer/Tabs/MongoQueryTab.ts
src/Explorer/Tabs/MongoShellTab.ts src/Explorer/Tabs/MongoShellTab.ts
src/Explorer/Tabs/NotebookTab.ts
src/Explorer/Tabs/NotebookV2Tab.ts src/Explorer/Tabs/NotebookV2Tab.ts
src/Explorer/Tabs/QueryTab.test.ts src/Explorer/Tabs/QueryTab.test.ts
src/Explorer/Tabs/QueryTab.ts src/Explorer/Tabs/QueryTab.ts
@@ -216,7 +215,6 @@ src/Explorer/Tabs/TabComponents.ts
src/Explorer/Tabs/TabsBase.ts src/Explorer/Tabs/TabsBase.ts
src/Explorer/Tabs/TriggerTab.ts src/Explorer/Tabs/TriggerTab.ts
src/Explorer/Tabs/UserDefinedFunctionTab.ts src/Explorer/Tabs/UserDefinedFunctionTab.ts
src/Explorer/Tabs/__mocks__/NotebookTab.ts
src/Explorer/Tree/AccessibleVerticalList.ts src/Explorer/Tree/AccessibleVerticalList.ts
src/Explorer/Tree/Collection.test.ts src/Explorer/Tree/Collection.test.ts
src/Explorer/Tree/Collection.ts src/Explorer/Tree/Collection.ts

View File

@@ -3,12 +3,8 @@ module.exports = {
browser: true, browser: true,
es6: true es6: true
}, },
plugins: ["@typescript-eslint"], plugins: ["@typescript-eslint", "no-null"],
extends: [ extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
globals: { globals: {
Atomics: "readonly", Atomics: "readonly",
SharedArrayBuffer: "readonly" SharedArrayBuffer: "readonly"
@@ -40,6 +36,9 @@ module.exports = {
} }
], ],
rules: { rules: {
curly: "error" curly: "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 name: CI
on: on:
push: push:
@@ -8,7 +5,41 @@ on:
pull_request: pull_request:
branches: [master] branches: [master]
jobs: jobs:
test: compile:
runs-on: ubuntu-latest
name: "Compile TypeScript"
steps:
- uses: actions/checkout@v2
- name: Use Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.x
- run: npm ci
- run: npm run compile
- run: npm run compile:strict
format:
runs-on: ubuntu-latest
name: "Check Format"
steps:
- uses: actions/checkout@v2
- name: Use Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.x
- run: npm ci
- run: npm run format:check
lint:
runs-on: ubuntu-latest
name: "Lint"
steps:
- uses: actions/checkout@v2
- name: Use Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.x
- run: npm ci
- run: npm run lint
unittest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: "Unit Tests" name: "Unit Tests"
steps: steps:
@@ -36,13 +67,14 @@ jobs:
path: .cache path: .cache
key: ${{ runner.os }}-build-cache key: ${{ runner.os }}-build-cache
- run: npm run pack:prod - run: npm run pack:prod
- run: npm run copyToConsumers - run: cp -r ./Contracts ./dist/contracts
- run: cp -r ./configs ./dist/configs
- uses: actions/upload-artifact@v2 - uses: actions/upload-artifact@v2
with: with:
name: dist name: dist
path: dist/ path: dist/
endtoend: endtoendemulator:
name: "End to End Tests" name: "End To End Tests | Emulator | SQL"
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@@ -67,9 +99,65 @@ jobs:
EMULATOR_ENDPOINT: https://0.0.0.0:8081/ EMULATOR_ENDPOINT: https://0.0.0.0:8081/
NODE_TLS_REJECT_UNAUTHORIZED: 0 NODE_TLS_REJECT_UNAUTHORIZED: 0
CYPRESS_CACHE_FOLDER: ~/.cache/Cypress CYPRESS_CACHE_FOLDER: ~/.cache/Cypress
endtoendsql:
name: "End To End Tests | SQL"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Restore Cypress Binary Cache
uses: actions/cache@v2
with:
path: ~/.cache/Cypress
key: ${{ runner.os }}-cypress-binary-cache
- run: npm ci
- name: End to End Tests
run: |
npm start &
cd cypress
npm ci
node cleanup.js
npm run wait-for-server
npx cypress run --browser chrome --headless --spec "./integration/dataexplorer/SQL/*"
shell: bash
env:
NODE_TLS_REJECT_UNAUTHORIZED: 0
CYPRESS_CACHE_FOLDER: ~/.cache/Cypress
CYPRESS_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_SQL }}
endtoendmongo:
name: "End To End Tests | Mongo"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Restore Cypress Binary Cache
uses: actions/cache@v2
with:
path: ~/.cache/Cypress
key: ${{ runner.os }}-cypress-binary-cache
- name: End to End Tests
run: |
npm ci
npm start &
cd cypress
npm ci
node cleanup.js
npm run wait-for-server
npx cypress run --browser chrome --headless --spec "./integration/dataexplorer/MONGO/*"
shell: bash
env:
NODE_TLS_REJECT_UNAUTHORIZED: 0
CYPRESS_CACHE_FOLDER: ~/.cache/Cypress
CYPRESS_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_MONGO }}
nuget: nuget:
name: Publish Nuget name: Publish Nuget
needs: [build, test, endtoend] needs: [lint, format, compile, build, unittest, endtoendemulator, endtoendsql, endtoendmongo]
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
NUGET_SOURCE: ${{ secrets.NUGET_SOURCE }} NUGET_SOURCE: ${{ secrets.NUGET_SOURCE }}
@@ -82,6 +170,7 @@ jobs:
uses: actions/download-artifact@v2 uses: actions/download-artifact@v2
with: with:
name: dist 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 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 pack -Version "2.0.0-github-${GITHUB_SHA}"
- run: nuget push -Source "$NUGET_SOURCE" -ApiKey Az *.nupkg - run: nuget push -Source "$NUGET_SOURCE" -ApiKey Az *.nupkg
@@ -90,7 +179,7 @@ jobs:
path: "*.nupkg" path: "*.nupkg"
nugetmpac: nugetmpac:
name: Publish Nuget MPAC name: Publish Nuget MPAC
needs: [build, test, endtoend] needs: [lint, format, compile, build, unittest, endtoendemulator, endtoendsql, endtoendmongo]
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
NUGET_SOURCE: ${{ secrets.NUGET_SOURCE }} NUGET_SOURCE: ${{ secrets.NUGET_SOURCE }}
@@ -103,6 +192,7 @@ jobs:
uses: actions/download-artifact@v2 uses: actions/download-artifact@v2
with: with:
name: dist 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: 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 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 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/* notebookapp/*
Contracts/* Contracts/*
.DS_Store .DS_Store
.cache/ .cache/
.env

51
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,51 @@
# Contribution guidelines to Data Explorer
This project welcomes contributions and suggestions. Most contributions require you to agree to a
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
the rights to use your contribution. For details, visit https://cla.microsoft.com.
When you submit a pull request, a CLA-bot will automatically determine whether you need to provide
a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions
provided by the bot. You will only need to do this once across all repos using our CLA.
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
## Microsoft Open Source Code of Conduct
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
Resources:
- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/)
- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/)
- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns
## Browser support
Please make sure to support all modern browsers as well as Internet Explorer 11.
For IE support, polyfill is preferred over new usage of lodash or underscore. We already polyfill almost everything by importing babel-polyfill at the top of entry points.
## Coding guidelines, conventions and recommendations
### Typescript
* Follow this [typescript style guide](https://github.com/excelmicro/typescript) which is based on [airbnb's style guide](https://github.com/airbnb/javascript).
* Conventions speficic to this project:
* Use double-quotes for string
* Don't use null, use undefined
* Pascal case for private static readonly fields
* Camel case for classnames in markup
* Don't use class unless necessary
* Code related to notebooks should be dynamically imported so that it is loaded from a separate bundle only if the account is notebook-enabled. There are already top-level notebook components which are dynamically imported and their dependencies can be statically imported from these files.
### React
* Prefer using React class components over function components and hooks unless you have a simple component and require no nested functions:
* Nested functions may be harder to test independently
* Switching from function component to class component later mayb be painful
## Testing
Any PR should not decrease testing coverage.
## Recommended Tools and VS Code extensions
* [Bookmarks](https://github.com/alefragnani/vscode-bookmarks)
* [Bracket pair colorizer](https://github.com/CoenraadS/Bracket-Pair-Colorizer-2)
* [GitHub Pull Requests and Issues](https://github.com/Microsoft/vscode-pull-request-github)

View File

@@ -70,7 +70,7 @@ Unit tests are located adjacent to the code under test and run with [Jest](https
`npm run test` `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: [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,16 +80,13 @@ Unit tests are located adjacent to the code under test and run with [Jest](https
4. Install dependencies: `npm install` 4. Install dependencies: `npm install`
5. Run cypress headless(`npm run test`) or in interactive mode(`npm run test:debug`) 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 # Contributing
This project welcomes contributions and suggestions. Most contributions require you to agree to a Please read the [contribution guidelines](./CONTRIBUTING.md).
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
the rights to use your contribution. For details, visit https://cla.microsoft.com.
When you submit a pull request, a CLA-bot will automatically determine whether you need to provide
a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions
provided by the bot. You will only need to do this once across all repos using our CLA.
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.

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"
}

5
cypress/.gitignore vendored
View File

@@ -1 +1,4 @@
cypress.env.json cypress.env.json
cypress/report
cypress/screenshots
cypress/videos

51
cypress/cleanup.js Normal file
View File

@@ -0,0 +1,51 @@
// Cleans up old databases from previous test runs
const { CosmosClient } = require("@azure/cosmos");
// TODO: Add support for other API connection strings
const mongoRegex = RegExp("mongodb://.*:(.*)@(.*).mongo.cosmos.azure.com");
async function cleanup() {
const connectionString = process.env.CYPRESS_CONNECTION_STRING;
if (!connectionString) {
throw new Error("Connection string not provided");
}
let client;
switch (true) {
case connectionString.includes("mongodb://"): {
const [, key, accountName] = connectionString.match(mongoRegex);
client = new CosmosClient({
key,
endpoint: `https://${accountName}.documents.azure.com:443/`
});
break;
}
// TODO: Add support for other API connection strings
default:
client = new CosmosClient(connectionString);
break;
}
const response = await client.databases.readAll().fetchAll();
return Promise.all(
response.resources.map(async db => {
const dbTimestamp = new Date(db._ts * 1000);
const twentyMinutesAgo = new Date(Date.now() - 1000 * 60 * 20);
if (dbTimestamp < twentyMinutesAgo) {
await client.database(db.id).delete();
console.log(`DELETED: ${db.id} | Timestamp: ${dbTimestamp}`);
} else {
console.log(`SKIPPED: ${db.id} | Timestamp: ${dbTimestamp}`);
}
})
);
}
cleanup()
.then(() => {
process.exit(0);
})
.catch(error => {
console.error(error);
process.exit(1);
});

View File

@@ -16,7 +16,7 @@ let crypt = require("crypto");
context("Mongo API Test - createDatabase", () => { context("Mongo API Test - createDatabase", () => {
beforeEach(() => { beforeEach(() => {
connectionString.loginUsingConnectionString(connectionString.constants.mongo); connectionString.loginUsingConnectionString();
}); });
it("Create a new collection in Mongo API", () => { it("Create a new collection in Mongo API", () => {
@@ -63,7 +63,7 @@ context("Mongo API Test - createDatabase", () => {
.type(sharedKey); .type(sharedKey);
cy.wrap($body) cy.wrap($body)
.find('input[data-test="addCollection-createCollection"]') .find("#submitBtnAddCollection")
.click(); .click();
cy.wait(10000); cy.wait(10000);

View File

@@ -16,10 +16,10 @@ let crypt = require("crypto");
context("Mongo API Test", () => { context("Mongo API Test", () => {
beforeEach(() => { beforeEach(() => {
connectionString.loginUsingConnectionString(connectionString.constants.mongo); connectionString.loginUsingConnectionString();
}); });
it("Create a new collection in Mongo API - Autopilot", () => { it.skip("Create a new collection in Mongo API - Autopilot", () => {
const dbId = `TestDatabase${crypt.randomBytes(8).toString("hex")}`; const dbId = `TestDatabase${crypt.randomBytes(8).toString("hex")}`;
const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`; const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`;
const sharedKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`; const sharedKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`;

View File

@@ -4,10 +4,10 @@ let crypt = require("crypto");
context("Mongo API Test", () => { context("Mongo API Test", () => {
beforeEach(() => { beforeEach(() => {
connectionString.loginUsingConnectionString(connectionString.constants.mongo); connectionString.loginUsingConnectionString();
}); });
it("Create a new collection in existing database in Mongo API", () => { it.skip("Create a new collection in existing database in Mongo API", () => {
const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`; const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`;
const sharedKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`; const sharedKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`;

View File

@@ -2,9 +2,9 @@ const connectionString = require("../../../utilities/connectionString");
let crypt = require("crypto"); let crypt = require("crypto");
context("Mongo API Test", () => { context.skip("Mongo API Test", () => {
beforeEach(() => { beforeEach(() => {
connectionString.loginUsingConnectionString(connectionString.constants.mongo); connectionString.loginUsingConnectionString();
}); });
it("Create a new collection in Mongo API - Provision database throughput", () => { it("Create a new collection in Mongo API - Provision database throughput", () => {

View File

@@ -16,13 +16,14 @@ let crypt = require("crypto");
context("SQL API Test", () => { context("SQL API Test", () => {
beforeEach(() => { beforeEach(() => {
connectionString.loginUsingConnectionString(connectionString.constants.sql); connectionString.loginUsingConnectionString();
}); });
it("Create a new container in SQL API", () => { it("Create a new container in SQL API", () => {
const dbId = `TestDatabase${crypt.randomBytes(8).toString("hex")}`; const dbId = `TestDatabase${crypt.randomBytes(8).toString("hex")}`;
const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`; const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`;
const sharedKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`; const sharedKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`;
connectionString.loginUsingConnectionString();
cy.get("iframe").then($element => { cy.get("iframe").then($element => {
const $body = $element.contents().find("body"); const $body = $element.contents().find("body");
@@ -63,7 +64,7 @@ context("SQL API Test", () => {
.type(sharedKey); .type(sharedKey);
cy.wrap($body) cy.wrap($body)
.find('input[data-test="addCollection-createCollection"]') .find("#submitBtnAddCollection")
.click(); .click();
cy.wait(10000); cy.wait(10000);

View File

@@ -273,77 +273,12 @@
"any-observable": "^0.3.0" "any-observable": "^0.3.0"
} }
}, },
"@types/blob-util": { "@types/sinonjs__fake-timers": {
"version": "1.3.3", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@types/blob-util/-/blob-util-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz",
"integrity": "sha512-4ahcL/QDnpjWA2Qs16ZMQif7HjGP2cw3AGjHabybjw7Vm1EKu+cfQN1D78BaZbS1WJNa1opSMF5HNMztx7lR0w==", "integrity": "sha512-yYezQwGWty8ziyYLdZjwxyMb0CZR49h8JALHGrxjQHWlqGgc8kLdHEgWrgL0uZ29DMvEVBDnHU2Wg36zKSIUtA==",
"dev": true "dev": true
}, },
"@types/bluebird": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.29.tgz",
"integrity": "sha512-kmVtnxTuUuhCET669irqQmPAez4KFnFVKvpleVRyfC3g+SHD1hIkFZcWLim9BVcwUBLO59o8VZE4yGCmTif8Yw==",
"dev": true
},
"@types/chai": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.7.tgz",
"integrity": "sha512-luq8meHGYwvky0O7u0eQZdA7B4Wd9owUCqvbw2m3XCrCU8mplYOujMBbvyS547AxJkC+pGnd0Cm15eNxEUNU8g==",
"dev": true
},
"@types/chai-jquery": {
"version": "1.1.40",
"resolved": "https://registry.npmjs.org/@types/chai-jquery/-/chai-jquery-1.1.40.tgz",
"integrity": "sha512-mCNEZ3GKP7T7kftKeIs7QmfZZQM7hslGSpYzKbOlR2a2HCFf9ph4nlMRA9UnuOETeOQYJVhJQK7MwGqNZVyUtQ==",
"dev": true,
"requires": {
"@types/chai": "*",
"@types/jquery": "*"
}
},
"@types/jquery": {
"version": "3.3.31",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.3.31.tgz",
"integrity": "sha512-Lz4BAJihoFw5nRzKvg4nawXPzutkv7wmfQ5121avptaSIXlDNJCUuxZxX/G+9EVidZGuO0UBlk+YjKbwRKJigg==",
"dev": true,
"requires": {
"@types/sizzle": "*"
}
},
"@types/lodash": {
"version": "4.14.149",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz",
"integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==",
"dev": true
},
"@types/minimatch": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
"integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==",
"dev": true
},
"@types/mocha": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz",
"integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==",
"dev": true
},
"@types/sinon": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-7.5.1.tgz",
"integrity": "sha512-EZQUP3hSZQyTQRfiLqelC9NMWd1kqLcmQE0dMiklxBkgi84T+cHOhnKpgk4NnOWpGX863yE6+IaGnOXUNFqDnQ==",
"dev": true
},
"@types/sinon-chai": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@types/sinon-chai/-/sinon-chai-3.2.3.tgz",
"integrity": "sha512-TOUFS6vqS0PVL1I8NGVSNcFaNJtFoyZPXZ5zur+qlhDfOmQECZZM4H4kKgca6O8L+QceX/ymODZASfUfn+y4yQ==",
"dev": true,
"requires": {
"@types/chai": "*",
"@types/sinon": "*"
}
},
"@types/sizzle": { "@types/sizzle": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz", "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz",
@@ -827,24 +762,15 @@
} }
}, },
"cypress": { "cypress": {
"version": "4.5.0", "version": "4.8.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-4.5.0.tgz", "resolved": "https://registry.npmjs.org/cypress/-/cypress-4.8.0.tgz",
"integrity": "sha512-2A4g5FW5d2fHzq8HKUGAMVTnW6P8nlWYQALiCoGN4bqBLvgwhYM/oG9oKc2CS6LnvgHFiKivKzpm9sfk3uU3zQ==", "integrity": "sha512-Lsff8lF8pq6k/ioNua783tCsxGSLp6gqGXecdIfqCkqjYiOA53XKuEf1CaQJLUVs1dHSf49eDUp/sb620oJjVQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"@cypress/listr-verbose-renderer": "0.4.1", "@cypress/listr-verbose-renderer": "0.4.1",
"@cypress/request": "2.88.5", "@cypress/request": "2.88.5",
"@cypress/xvfb": "1.2.4", "@cypress/xvfb": "1.2.4",
"@types/blob-util": "1.3.3", "@types/sinonjs__fake-timers": "6.0.1",
"@types/bluebird": "3.5.29",
"@types/chai": "4.2.7",
"@types/chai-jquery": "1.1.40",
"@types/jquery": "3.3.31",
"@types/lodash": "4.14.149",
"@types/minimatch": "3.0.3",
"@types/mocha": "5.2.7",
"@types/sinon": "7.5.1",
"@types/sinon-chai": "3.2.3",
"@types/sizzle": "2.3.2", "@types/sizzle": "2.3.2",
"arch": "2.1.1", "arch": "2.1.1",
"bluebird": "3.7.2", "bluebird": "3.7.2",
@@ -878,14 +804,6 @@
"untildify": "4.0.0", "untildify": "4.0.0",
"url": "0.11.0", "url": "0.11.0",
"yauzl": "2.10.0" "yauzl": "2.10.0"
},
"dependencies": {
"minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"dev": true
}
} }
}, },
"dashdash": { "dashdash": {
@@ -1081,12 +999,6 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"dev": true
},
"mkdirp": { "mkdirp": {
"version": "0.5.5", "version": "0.5.5",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
@@ -1859,6 +1771,12 @@
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
} }
}, },
"minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"dev": true
},
"mkdirp": { "mkdirp": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",

View File

@@ -5,11 +5,13 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "cypress run", "test": "cypress run",
"wait-for-server": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/",
"test:sql": "cypress run --browser chrome --headless --spec \"./integration/dataexplorer/SQL/*\"",
"test:ci": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/ https-get://0.0.0.0:8081/_explorer/index.html && cypress run --browser chrome --headless", "test:ci": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/ https-get://0.0.0.0:8081/_explorer/index.html && cypress run --browser chrome --headless",
"test:debug": "cypress open" "test:debug": "cypress open"
}, },
"devDependencies": { "devDependencies": {
"cypress": "^4.5.0", "cypress": "^4.8.0",
"mocha": "^7.0.1", "mocha": "^7.0.1",
"mochawesome": "^4.1.0", "mochawesome": "^4.1.0",
"mochawesome-merge": "^4.0.1", "mochawesome-merge": "^4.0.1",

View File

@@ -1,56 +1,41 @@
module.exports = { module.exports = {
loginUsingConnectionString: function (api) { loginUsingConnectionString: function() {
const prodUrl = Cypress.env("TEST_ENDPOINT") || "https://localhost:1234/hostedExplorer.html";
const timeout = 15000;
cy.visit(prodUrl);
cy.get('iframe[id="explorerMenu"]').should("be.visible");
const prodUrl = "https://cosmos.azure.com/"; cy.get("iframe").then($element => {
const timeout = 15000; const $body = $element.contents().find("body");
cy.visit(prodUrl);
cy.get('iframe[id="explorerMenu"]').should("be.visible");
cy.get("iframe").then($element => {
const $body = $element.contents().find("body");
cy.wrap($body)
.find("#connectExplorer")
.should("exist")
.find("div[class='connectExplorer']")
.should("exist")
.find("p[class='welcomeText']")
.should("exist");
cy.wrap($body.find("div > p.switchConnectTypeText"))
.should("exist")
.last()
.click({ force: true });
const secret = Cypress.env('connectionString')[api];
cy.wrap($body) cy.wrap($body)
.find("input[class='inputToken']") .find("#connectExplorer")
.should("exist") .should("exist")
.type(secret, { .find("div[class='connectExplorer']")
force: true .should("exist")
}); .find("p[class='welcomeText']")
.should("exist");
cy.wrap($body.find("input[value='Connect']"), { timeout })
.first()
.click({ force: true });
cy.wait(15000);
cy.wrap($body)
.find(".connectExplorer > p:nth-child(3)")
.should("be.visible");
}); cy.wrap($body.find("div > p.switchConnectTypeText"))
}, .should("exist")
constants:{ .last()
sql: "sql", .click({ force: true });
mongo: "mongo",
table: "table",
graph: "graph",
cassandra: "cassandra"
}
} const secret = Cypress.env("CONNECTION_STRING");
cy.wrap($body)
.find("input[class='inputToken']")
.should("exist")
.type(secret, {
force: true
});
cy.wrap($body.find("input[value='Connect']"), { timeout })
.first()
.click({ force: true });
cy.wait(15000);
});
}
};

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?)$", // testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?|ts?)$",
// This option allows the use of a custom results processor // 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 // This option allows use of a custom test runner
// testRunner: "jasmine2", // testRunner: "jasmine2",

View File

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

View File

@@ -14,6 +14,10 @@ body {
font-family: @DataExplorerFont; font-family: @DataExplorerFont;
font-size: 12px; font-size: 12px;
height: 100%; height: 100%;
:focus {
.focus()
}
} }
.float-right { .float-right {
@@ -174,7 +178,7 @@ body {
&:active { &:active {
.active(); .active();
} }
&:focus .urlTokenCopyTooltiptext, &:focus .urlTokenCopyTooltiptext { &:focus .urlTokenCopyTooltiptext, &:focus .urlTokenCopyTooltiptext {
.tooltipVisible(); .tooltipVisible();
} }
@@ -362,7 +366,7 @@ body {
} }
.splashLoaderContainer { .splashLoaderContainer {
z-index: 5; z-index: 5;
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
@@ -570,6 +574,12 @@ body {
} }
} }
.fileImportButton {
height: 24px;
border: @ButtonBorderWidth solid transparent;
vertical-align: top;
}
.fileUploadSummaryContainer { .fileUploadSummaryContainer {
margin-top: 40px; margin-top: 40px;
@@ -1016,6 +1026,18 @@ menuQuickStart {
background: #262626; background: #262626;
} }
.panelContent {
display: flex;
flex-direction: column;
flex: 1;
}
.panelContentWrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.contextual-pane { .contextual-pane {
top: 0px; top: 0px;
right: 0 !important; right: 0 !important;
@@ -1232,23 +1254,25 @@ menuQuickStart {
padding: 2px 30px; padding: 2px 30px;
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
&:active {
border-color: #0072c6;
background-color: #0072c6;
}
} }
.btncreatecoll1:hover { .leftpanel-okbut .genericPaneSubmitBtn {
background: @AccentMediumHigh; border: 1px solid @AccentMediumHigh;
background-color: @AccentMediumHigh;
color: #fff; color: #fff;
border-color: @AccentMediumHigh;
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
} height: 24px;
.btncreatecoll1:active { &:active {
border: 1px solid #0072c6; border-color: #0072c6;
background-color: #0072c6; background-color: #0072c6;
color: white; }
padding: 2px 30px;
cursor: pointer;
font-size: 12px;
} }
.btncreatecoll1-off { .btncreatecoll1-off {
@@ -1361,6 +1385,15 @@ p {
color: #000; color: #000;
} }
.headerline .closePaneBtn {
float: right;
cursor: pointer;
width: 16px;
height: 100%;
margin-right: 4px;
color: #000;
}
.closeImg { .closeImg {
float: right; float: right;
cursor: pointer; cursor: pointer;
@@ -1420,7 +1453,7 @@ p {
.throughputModeRadio { .throughputModeRadio {
vertical-align: text-bottom; vertical-align: text-bottom;
} }
.nonFirstRadio { .nonFirstRadio {
margin-left: @LargeSpace; margin-left: @LargeSpace;
} }
@@ -1455,7 +1488,7 @@ p {
.largePartitionKeyDescription { .largePartitionKeyDescription {
margin: @DefaultSpace 0px 0px; margin: @DefaultSpace 0px 0px;
} }
} }
.enableAnalyticalStorage { .enableAnalyticalStorage {
@@ -1710,6 +1743,13 @@ input::-webkit-calendar-picker-indicator {
margin: (2 * @MediumSpace) 0px; margin: (2 * @MediumSpace) 0px;
} }
.contextual-pane .panelMainContent {
padding-left: 34px;
padding-right: 34px;
color: @BaseDark;
margin: (2 * @MediumSpace) 0px;
}
.contextual-pane .paneFooter { .contextual-pane .paneFooter {
width: 100%; width: 100%;
height: 60px; height: 60px;
@@ -2180,13 +2220,13 @@ a:link {
.documentsGridHeaderContainer table thead tr { .documentsGridHeaderContainer table thead tr {
position: sticky; position: sticky;
top: 0; top: 0;
th { th {
position: sticky; position: sticky;
top: 0; top: 0;
background-color: #fff !important; background-color: #fff !important;
border-bottom: 1px solid #CCCCCC !important; border-bottom: 1px solid #CCCCCC !important;
} }
} }
.documentsGridHeader { .documentsGridHeader {

9831
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "Cosmos Explorer", "description": "Cosmos Explorer",
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@azure/cosmos": "3.6.3", "@azure/cosmos": "3.7.1",
"@azure/cosmos-language-service": "0.0.4", "@azure/cosmos-language-service": "0.0.4",
"@jupyterlab/services": "4.2.0", "@jupyterlab/services": "4.2.0",
"@jupyterlab/terminal": "1.2.1", "@jupyterlab/terminal": "1.2.1",
@@ -37,22 +37,25 @@
"@uifabric/react-cards": "0.109.53", "@uifabric/react-cards": "0.109.53",
"@uifabric/styling": "7.11.2", "@uifabric/styling": "7.11.2",
"abort-controller": "3.0.0", "abort-controller": "3.0.0",
"applicationinsights": "1.8.0",
"babel-polyfill": "6.26.0", "babel-polyfill": "6.26.0",
"bootstrap": "3.3.7", "bootstrap": "3.3.7",
"canvas": "2.6.0", "canvas": "2.6.0",
"clean-webpack-plugin": "0.1.19", "clean-webpack-plugin": "0.1.19",
"copy-webpack-plugin": "4.5.4", "copy-webpack-plugin": "6.0.2",
"crossroads": "0.12.2", "crossroads": "0.12.2",
"css-element-queries": "1.1.1", "css-element-queries": "1.1.1",
"datatables.net-colreorder-dt": "1.5.1", "datatables.net-colreorder-dt": "1.5.1",
"datatables.net-dt": "1.10.19", "datatables.net-dt": "1.10.19",
"date-fns": "1.29.0", "date-fns": "1.29.0",
"dayjs": "1.8.19", "dayjs": "1.8.19",
"dotenv": "8.2.0",
"es6-object-assign": "1.1.0", "es6-object-assign": "1.1.0",
"es6-symbol": "3.1.3", "es6-symbol": "3.1.3",
"eslint-plugin-jest": "23.8.2", "eslint-plugin-jest": "23.13.2",
"hasher": "1.2.0", "hasher": "1.2.0",
"immutable": "4.0.0-rc.12", "immutable": "4.0.0-rc.12",
"is-ci": "2.0.0",
"jquery": "3.4.0", "jquery": "3.4.0",
"jquery-typeahead": "2.10.6", "jquery-typeahead": "2.10.6",
"jquery-ui-dist": "1.12.1", "jquery-ui-dist": "1.12.1",
@@ -110,8 +113,8 @@
"@types/text-encoding": "0.0.33", "@types/text-encoding": "0.0.33",
"@types/underscore": "1.7.36", "@types/underscore": "1.7.36",
"@types/webfontloader": "1.6.29", "@types/webfontloader": "1.6.29",
"@typescript-eslint/eslint-plugin": "2.25.0", "@typescript-eslint/eslint-plugin": "3.2.0",
"@typescript-eslint/parser": "2.25.0", "@typescript-eslint/parser": "3.2.0",
"adal-angular": "1.0.15", "adal-angular": "1.0.15",
"babel-jest": "24.9.0", "babel-jest": "24.9.0",
"babel-loader": "8.1.0", "babel-loader": "8.1.0",
@@ -123,9 +126,10 @@
"enzyme": "3.10.0", "enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.15.1", "enzyme-adapter-react-16": "1.15.1",
"enzyme-to-json": "3.4.3", "enzyme-to-json": "3.4.3",
"eslint": "6.8.0", "eslint": "7.3.1",
"eslint-cli": "1.1.1", "eslint-cli": "1.1.1",
"eslint-plugin-react": "7.19.0", "eslint-plugin-no-null": "1.0.2",
"eslint-plugin-react": "7.20.0",
"expose-loader": "0.7.5", "expose-loader": "0.7.5",
"file-loader": "2.0.0", "file-loader": "2.0.0",
"fs-extra": "7.0.0", "fs-extra": "7.0.0",
@@ -133,8 +137,9 @@
"html-loader-jest": "0.2.1", "html-loader-jest": "0.2.1",
"html-webpack-plugin": "3.2.0", "html-webpack-plugin": "3.2.0",
"inline-css": "2.2.5", "inline-css": "2.2.5",
"jest": "24.9.0", "jest": "25.5.4",
"jest-canvas-mock": "2.1.0", "jest-canvas-mock": "2.1.0",
"jest-puppeteer": "4.4.0",
"jest-trx-results-processor": "0.0.7", "jest-trx-results-processor": "0.0.7",
"less": "3.8.1", "less": "3.8.1",
"less-loader": "4.1.0", "less-loader": "4.1.0",
@@ -142,20 +147,21 @@
"mini-css-extract-plugin": "0.4.3", "mini-css-extract-plugin": "0.4.3",
"monaco-editor-webpack-plugin": "1.7.0", "monaco-editor-webpack-plugin": "1.7.0",
"prettier": "1.19.1", "prettier": "1.19.1",
"puppeteer": "4.0.0",
"raw-loader": "0.5.1", "raw-loader": "0.5.1",
"rimraf": "3.0.0", "rimraf": "3.0.0",
"sinon": "3.2.1", "sinon": "3.2.1",
"style-loader": "0.23.0", "style-loader": "0.23.0",
"terser-webpack-plugin": "2.3.5", "terser-webpack-plugin": "3.0.5",
"ts-loader": "6.2.2", "ts-loader": "6.2.2",
"tslint": "5.11.0", "tslint": "5.11.0",
"tslint-microsoft-contrib": "6.0.0", "tslint-microsoft-contrib": "6.0.0",
"typescript": "3.8.3", "typescript": "3.8.3",
"url-loader": "1.1.1", "url-loader": "1.1.1",
"webpack": "4.41.2", "webpack": "4.43.0",
"webpack-bundle-analyzer": "3.6.1", "webpack-bundle-analyzer": "3.6.1",
"webpack-cli": "3.3.10", "webpack-cli": "3.3.10",
"webpack-dev-server": "3.9.0", "webpack-dev-server": "3.11.0",
"worker-loader": "2.0.0" "worker-loader": "2.0.0"
}, },
"scripts": { "scripts": {
@@ -168,8 +174,8 @@
"pack:fast": "node --max_old_space_size=10196 ./node_modules/webpack/bin/webpack.js --mode development --progress", "pack:fast": "node --max_old_space_size=10196 ./node_modules/webpack/bin/webpack.js --mode development --progress",
"copyToConsumers": "node copyToConsumers", "copyToConsumers": "node copyToConsumers",
"test": "rimraf coverage && jest", "test": "rimraf coverage && jest",
"test:e2e": "jest -c ./jest.config.e2e.js --detectOpenHandles",
"watch": "npm run start", "watch": "npm run start",
"integrationTest": "runIntegrationTests.cmd",
"build:ase": "gulp build:ase", "build:ase": "gulp build:ase",
"compile": "tsc", "compile": "tsc",
"compile:contracts": "tsc -p ./tsconfig.contracts.json", "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

@@ -104,29 +104,25 @@ export class CapabilityNames {
} }
export class Features { export class Features {
public static readonly graphs = "graphs";
public static readonly cosmosdb = "cosmosdb"; public static readonly cosmosdb = "cosmosdb";
public static readonly enableChangeFeedPolicy = "enablechangefeedpolicy"; public static readonly enableChangeFeedPolicy = "enablechangefeedpolicy";
public static readonly enableRupm = "enablerupm"; public static readonly enableRupm = "enablerupm";
public static readonly cacheOptimizations = "dataexplorercacheoptimizations";
public static readonly executeSproc = "dataexplorerexecutesproc"; public static readonly executeSproc = "dataexplorerexecutesproc";
public static readonly hostedDataExplorer = "hosteddataexplorerenabled"; public static readonly hostedDataExplorer = "hosteddataexplorerenabled";
public static readonly enableTtl = "enablettl"; public static readonly enableTtl = "enablettl";
public static readonly enableNotebooks = "enablenotebooks"; public static readonly enableNotebooks = "enablenotebooks";
public static readonly enableGallery = "enablegallery"; public static readonly enableGallery = "enablegallery";
public static readonly enableGalleryPublish = "enablegallerypublish";
public static readonly enableSpark = "enablespark"; public static readonly enableSpark = "enablespark";
public static readonly livyEndpoint = "livyendpoint"; public static readonly livyEndpoint = "livyendpoint";
public static readonly settingsPane = "dataexplorersettingspane";
public static readonly throughputOverview = "throughputOverview";
public static readonly enableNteract = "enablenteract";
public static readonly notebookServerUrl = "notebookserverurl"; public static readonly notebookServerUrl = "notebookserverurl";
public static readonly notebookServerToken = "notebookservertoken"; public static readonly notebookServerToken = "notebookservertoken";
public static readonly notebookBasePath = "notebookbasepath"; public static readonly notebookBasePath = "notebookbasepath";
public static readonly enableLegacyResourceTree = "enablelegacyresourcetree";
public static readonly canExceedMaximumValue = "canexceedmaximumvalue"; public static readonly canExceedMaximumValue = "canexceedmaximumvalue";
public static readonly enableFixedCollectionWithSharedThroughput = "enablefixedcollectionwithsharedthroughput"; public static readonly enableFixedCollectionWithSharedThroughput = "enablefixedcollectionwithsharedthroughput";
public static readonly enableAutoPilotV2 = "enableautopilotv2"; public static readonly enableAutoPilotV2 = "enableautopilotv2";
public static readonly ttl90Days = "ttl90days"; public static readonly ttl90Days = "ttl90days";
public static readonly enableRightPanelV2 = "enablerightpanelv2";
} }
export class AfecFeatures { export class AfecFeatures {

View File

@@ -21,13 +21,15 @@ const _global = typeof self === "undefined" ? window : self;
export const tokenProvider = async (requestInfo: RequestInfo) => { export const tokenProvider = async (requestInfo: RequestInfo) => {
const { verb, resourceId, resourceType, headers } = requestInfo; const { verb, resourceId, resourceType, headers } = requestInfo;
if (config.platform === Platform.Emulator) { if (config.platform === Platform.Emulator) {
// TODO Remove any. SDK expects a return value for tokenProvider, but we are mutating the header object instead. // TODO This SDK method mutates the headers object. Find a better one or fix the SDK.
return setAuthorizationTokenHeaderUsingMasterKey(verb, resourceId, resourceType, headers, EmulatorMasterKey) as any; await setAuthorizationTokenHeaderUsingMasterKey(verb, resourceId, resourceType, headers, EmulatorMasterKey);
return decodeURIComponent(headers.authorization);
} }
if (_masterKey) { if (_masterKey) {
// TODO Remove any. SDK expects a return value for tokenProvider, but we are mutating the header object instead. // TODO This SDK method mutates the headers object. Find a better one or fix the SDK.
return setAuthorizationTokenHeaderUsingMasterKey(verb, resourceId, resourceType, headers, _masterKey) as any; await setAuthorizationTokenHeaderUsingMasterKey(verb, resourceId, resourceType, headers, EmulatorMasterKey);
return decodeURIComponent(headers.authorization);
} }
if (_resourceToken) { if (_resourceToken) {
@@ -47,7 +49,9 @@ export const requestPlugin: Cosmos.Plugin<any> = async (requestContext, next) =>
export const endpoint = () => { export const endpoint = () => {
if (config.platform === Platform.Emulator) { if (config.platform === Platform.Emulator) {
return config.EMULATOR_ENDPOINT || window.parent.location.origin; // In worker scope, _global(self).parent does not exist
const location = _global.parent ? _global.parent.location : _global.location;
return config.EMULATOR_ENDPOINT || location.origin;
} }
return _endpoint || (_databaseAccount && _databaseAccount.properties && _databaseAccount.properties.documentEndpoint); return _endpoint || (_databaseAccount && _databaseAccount.properties && _databaseAccount.properties.documentEndpoint);
}; };

View File

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

View File

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

View File

@@ -4,84 +4,68 @@ import { appInsights } from "../Shared/appInsights";
import { SeverityLevel } from "@microsoft/applicationinsights-web"; import { SeverityLevel } from "@microsoft/applicationinsights-web";
// TODO: Move to a separate Diagnostics folder // 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 { export function logInfo(message: string | Record<string, any>, area: string, code?: number): void {
let logMessage: string; let logMessage: string;
if (typeof message === "string") { if (typeof message === "string") {
logMessage = message; logMessage = message;
} else { } else {
logMessage = JSON.stringify(message, Object.getOwnPropertyNames(message)); 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
};
} }
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) { export function queryIterator(databaseId: string, collection: Collection, query: string): any {
let continuationToken: string = null; let continuationToken: string;
return { return {
fetchNext: () => { fetchNext: () => {
return queryDocuments(databaseId, collection, false, query).then(response => { return queryDocuments(databaseId, collection, false, query).then(response => {
continuationToken = response.continuationToken; continuationToken = response.continuationToken;
let headers = {} as any; const headers = {} as any;
response.headers.forEach((value: any, key: any) => { response.headers.forEach((value: any, key: any) => {
headers[key] = value; headers[key] = value;
}); });
@@ -114,14 +114,7 @@ export function queryDocuments(
headers: response.headers headers: response.headers
}; };
} }
const errorMessage = await response.text(); return errorHandling(response, "querying documents", params);
if (response.status === HttpStatusCodes.Forbidden) {
MessageHandler.sendMessage({
type: MessageTypes.ForbiddenError,
reason: errorMessage
});
}
throw new Error(errorMessage);
}); });
} }
@@ -160,11 +153,11 @@ export function readDocument(
) )
} }
}) })
.then(async response => { .then(response => {
if (response.ok) { if (response.ok) {
return response.json(); return response.json();
} }
errorHandling(response); return errorHandling(response, "reading document", params);
}); });
} }
@@ -199,11 +192,11 @@ export function createDocument(
...authHeaders() ...authHeaders()
} }
}) })
.then(async response => { .then(response => {
if (response.ok) { if (response.ok) {
return response.json(); 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()) [CosmosSDKConstants.HttpHeaders.PartitionKey]: JSON.stringify(documentId.partitionKeyHeader())
} }
}) })
.then(async response => { .then(response => {
if (response.ok) { if (response.ok) {
return response.json(); 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()) [CosmosSDKConstants.HttpHeaders.PartitionKey]: JSON.stringify(documentId.partitionKeyHeader())
} }
}) })
.then(async response => { .then(response => {
if (response.ok) { 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) { if (response.ok) {
return; return undefined;
} }
NotificationConsoleUtils.logConsoleMessage( return errorHandling(response, "creating collection", params);
ConsoleDataType.Error,
`Error creating collection: ${await response.json()}, Payload: ${params}`
);
errorHandling(response);
}); });
} }
@@ -407,13 +396,16 @@ export function getEndpoint(databaseAccount: ViewModels.DatabaseAccount): string
return url; 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(); 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) { if (response.status === HttpStatusCodes.Forbidden) {
MessageHandler.sendMessage({ MessageHandler.sendMessage({ type: MessageTypes.ForbiddenError, reason: errorMessage });
type: MessageTypes.ForbiddenError, return;
reason: errorMessage
});
} }
throw new Error(errorMessage); throw new Error(errorMessage);
} }
@@ -462,14 +454,6 @@ export async function _createMongoCollectionWithARM(
rpPayloadToCreateCollection rpPayloadToCreateCollection
); );
} catch (response) { } catch (response) {
NotificationConsoleUtils.logConsoleMessage( return errorHandling(response, "creating collection", undefined);
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`);
} }
} }

View File

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

View File

@@ -704,49 +704,6 @@ export interface MemoryUsageInfo {
totalKB: number; 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 { export interface resourceTokenConnectionStringProperties {
accountEndpoint: string; accountEndpoint: string;
collectionId: string; collectionId: string;

View File

@@ -9,14 +9,11 @@ import { AccessibleVerticalList } from "../Explorer/Tree/AccessibleVerticalList"
import { ArcadiaWorkspaceItem } from "../Explorer/Controls/Arcadia/ArcadiaMenuPicker"; import { ArcadiaWorkspaceItem } from "../Explorer/Controls/Arcadia/ArcadiaMenuPicker";
import { CassandraTableKey, CassandraTableKeys, TableDataClient } from "../Explorer/Tables/TableDataClient"; import { CassandraTableKey, CassandraTableKeys, TableDataClient } from "../Explorer/Tables/TableDataClient";
import { CommandButtonComponentProps } from "../Explorer/Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../Explorer/Controls/CommandButton/CommandButtonComponent";
import { CommandButtonOptions } from "../Explorer/Controls/CommandButton/CommandButton";
import { ConsoleData } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleData } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import { ExecuteSprocParam } from "../Explorer/Panes/ExecuteSprocParamsPane"; import { ExecuteSprocParam } from "../Explorer/Panes/ExecuteSprocParamsPane";
import { GitHubClient } from "../GitHub/GitHubClient"; import { GitHubClient } from "../GitHub/GitHubClient";
import { GitHubOAuthService } from "../GitHub/GitHubOAuthService";
import { IColumnSetting } from "../Explorer/Panes/Tables/TableColumnOptionsPane"; import { IColumnSetting } from "../Explorer/Panes/Tables/TableColumnOptionsPane";
import { IContentProvider } from "@nteract/core"; import { JunoClient, IGalleryItem } from "../Juno/JunoClient";
import { JunoClient } from "../Juno/JunoClient";
import { Library } from "./DataModels"; import { Library } from "./DataModels";
import { MostRecentActivity } from "../Explorer/MostRecentActivity/MostRecentActivity"; import { MostRecentActivity } from "../Explorer/MostRecentActivity/MostRecentActivity";
import { NotebookContentItem } from "../Explorer/Notebook/NotebookContentItem"; import { NotebookContentItem } from "../Explorer/Notebook/NotebookContentItem";
@@ -27,6 +24,8 @@ import { Splitter } from "../Common/Splitter";
import { StringInputPane } from "../Explorer/Panes/StringInputPane"; import { StringInputPane } from "../Explorer/Panes/StringInputPane";
import { TextFieldProps } from "../Explorer/Controls/DialogReactComponent/DialogComponent"; import { TextFieldProps } from "../Explorer/Controls/DialogReactComponent/DialogComponent";
import { UploadDetails } from "../workers/upload/definitions"; import { UploadDetails } from "../workers/upload/definitions";
import { UploadItemsPaneAdapter } from "../Explorer/Panes/UploadItemsPaneAdapter";
import { ReactAdapter } from "../Bindings/ReactBindingHandler";
export interface ExplorerOptions { export interface ExplorerOptions {
documentClientUtility: DocumentClientUtilityBase; documentClientUtility: DocumentClientUtilityBase;
@@ -85,8 +84,10 @@ export interface Explorer {
armEndpoint: ko.Observable<string>; armEndpoint: ko.Observable<string>;
isFeatureEnabled: (feature: string) => boolean; isFeatureEnabled: (feature: string) => boolean;
isGalleryEnabled: ko.Computed<boolean>; isGalleryEnabled: ko.Computed<boolean>;
isGalleryPublishEnabled: ko.Computed<boolean>;
isGitHubPaneEnabled: ko.Observable<boolean>; isGitHubPaneEnabled: ko.Observable<boolean>;
isGraphsEnabled: ko.Computed<boolean>; isPublishNotebookPaneEnabled: ko.Observable<boolean>;
isRightPanelV2Enabled: ko.Computed<boolean>;
canExceedMaximumValue: ko.Computed<boolean>; canExceedMaximumValue: ko.Computed<boolean>;
hasAutoPilotV2FeatureFlag: ko.Computed<boolean>; hasAutoPilotV2FeatureFlag: ko.Computed<boolean>;
isHostedDataExplorerEnabled: ko.Computed<boolean>; isHostedDataExplorerEnabled: ko.Computed<boolean>;
@@ -141,6 +142,7 @@ export interface Explorer {
executeSprocParamsPane: ExecuteSprocParamsPane; executeSprocParamsPane: ExecuteSprocParamsPane;
renewAdHocAccessPane: RenewAdHocAccessPane; renewAdHocAccessPane: RenewAdHocAccessPane;
uploadItemsPane: UploadItemsPane; uploadItemsPane: UploadItemsPane;
uploadItemsPaneAdapter: UploadItemsPaneAdapter;
loadQueryPane: LoadQueryPane; loadQueryPane: LoadQueryPane;
saveQueryPane: ContextualPane; saveQueryPane: ContextualPane;
browseQueriesPane: BrowseQueriesPane; browseQueriesPane: BrowseQueriesPane;
@@ -152,6 +154,7 @@ export interface Explorer {
libraryManagePane: ContextualPane; libraryManagePane: ContextualPane;
clusterLibraryPane: ContextualPane; clusterLibraryPane: ContextualPane;
gitHubReposPane: ContextualPane; gitHubReposPane: ContextualPane;
publishNotebookPaneAdapter: ReactAdapter;
// Facade // Facade
logConsoleData(data: ConsoleData): void; logConsoleData(data: ConsoleData): void;
@@ -223,22 +226,17 @@ export interface Explorer {
arcadiaWorkspaces: ko.ObservableArray<ArcadiaWorkspaceItem>; arcadiaWorkspaces: ko.ObservableArray<ArcadiaWorkspaceItem>;
isNotebookTabActive: ko.Computed<boolean>; isNotebookTabActive: ko.Computed<boolean>;
memoryUsageInfo: ko.Observable<DataModels.MemoryUsageInfo>; memoryUsageInfo: ko.Observable<DataModels.MemoryUsageInfo>;
notebookManager?: any; // This is dynamically loaded
openNotebook(notebookContentItem: NotebookContentItem): Promise<boolean>; // True if it was opened, false otherwise openNotebook(notebookContentItem: NotebookContentItem): Promise<boolean>; // True if it was opened, false otherwise
resetNotebookWorkspace(): void; resetNotebookWorkspace(): void;
importAndOpen: (path: string) => Promise<boolean>; 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; openNotebookTerminal: (kind: TerminalKind) => void;
openGallery: () => void; openGallery: (notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean) => void;
openNotebookViewer: ( openNotebookViewer: (notebookUrl: string) => void;
notebookUrl: string,
notebookMetadata: DataModels.NotebookMetadata,
onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>,
isLikedNotebook: boolean
) => void;
notebookWorkspaceManager: NotebookWorkspaceManager; notebookWorkspaceManager: NotebookWorkspaceManager;
sparkClusterManager: SparkClusterManager; sparkClusterManager: SparkClusterManager;
notebookContentProvider: IContentProvider;
gitHubOAuthService: GitHubOAuthService;
mostRecentActivity: MostRecentActivity; mostRecentActivity: MostRecentActivity;
initNotebooks: (databaseAccount: DataModels.DatabaseAccount) => Promise<void>; initNotebooks: (databaseAccount: DataModels.DatabaseAccount) => Promise<void>;
deleteCluster(): void; deleteCluster(): void;
@@ -337,17 +335,6 @@ export interface Button {
isSelected?: ko.Computed<boolean>; isSelected?: ko.Computed<boolean>;
} }
export interface CommandButton {
disabled: ko.Subscribable<boolean>;
visible: ko.Subscribable<boolean>;
iconSrc: string;
commandButtonLabel: string | ko.Observable<string>;
tooltipText: string | ko.Observable<string>;
children: ko.ObservableArray<CommandButtonOptions>;
commandClickCallback: () => void;
}
export interface NotificationConsole { export interface NotificationConsole {
filteredConsoleData: ko.ObservableArray<ConsoleData>; filteredConsoleData: ko.ObservableArray<ConsoleData>;
isConsoleExpanded: ko.Observable<boolean>; isConsoleExpanded: ko.Observable<boolean>;
@@ -371,7 +358,6 @@ export interface TreeNode {
id: ko.Observable<string>; id: ko.Observable<string>;
database?: Database; database?: Database;
collection?: Collection; collection?: Collection;
contextMenu?: ContextMenu;
onNewQueryClick?(source: any, event: MouseEvent): void; onNewQueryClick?(source: any, event: MouseEvent): void;
onNewStoredProcedureClick?(source: Collection, event: MouseEvent): void; onNewStoredProcedureClick?(source: Collection, event: MouseEvent): void;
@@ -536,7 +522,7 @@ export interface StoredProcedure extends TreeNode {
id: ko.Observable<string>; id: ko.Observable<string>;
body: ko.Observable<string>; body: ko.Observable<string>;
delete(source: TreeNode, event: MouseEvent | KeyboardEvent): void; delete(): void;
open: () => void; open: () => void;
select(): void; select(): void;
execute(params: string[], partitionKeyValue?: string): void; execute(params: string[], partitionKeyValue?: string): void;
@@ -550,7 +536,7 @@ export interface UserDefinedFunction extends TreeNode {
id: ko.Observable<string>; id: ko.Observable<string>;
body: ko.Observable<string>; body: ko.Observable<string>;
delete(source: TreeNode, event: MouseEvent | KeyboardEvent): void; delete(): void;
open: () => void; open: () => void;
select(): void; select(): void;
} }
@@ -565,7 +551,7 @@ export interface Trigger extends TreeNode {
triggerType: ko.Observable<string>; triggerType: ko.Observable<string>;
triggerOperation: ko.Observable<string>; triggerOperation: ko.Observable<string>;
delete(source: TreeNode, event: MouseEvent | KeyboardEvent): void; delete(): void;
open: () => void; open: () => void;
select(): void; select(): void;
} }
@@ -605,6 +591,16 @@ export interface GitHubReposPaneOptions extends PaneOptions {
junoClient: JunoClient; junoClient: JunoClient;
} }
export interface PublishNotebookPaneOptions extends PaneOptions {
junoClient: JunoClient;
}
export interface PublishNotebookPaneOpenOptions {
name: string;
author: string;
content: string;
}
export interface AddCollectionPaneOptions extends PaneOptions { export interface AddCollectionPaneOptions extends PaneOptions {
isPreferredApiTable: ko.Computed<boolean>; isPreferredApiTable: ko.Computed<boolean>;
databaseId?: string; databaseId?: string;
@@ -884,16 +880,16 @@ export interface TerminalTabOptions extends TabOptions {
export interface GalleryTabOptions extends TabOptions { export interface GalleryTabOptions extends TabOptions {
account: DatabaseAccount; account: DatabaseAccount;
container: Explorer; container: Explorer;
junoClient: JunoClient;
notebookUrl?: string;
galleryItem?: IGalleryItem;
isFavorite?: boolean;
} }
export interface NotebookViewerTabOptions extends TabOptions { export interface NotebookViewerTabOptions extends TabOptions {
account: DatabaseAccount; account: DatabaseAccount;
container: Explorer; container: Explorer;
notebookUrl: string; notebookUrl: string;
notebookName: string;
notebookMetadata: DataModels.NotebookMetadata;
onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>;
isLikedNotebook: boolean;
} }
export interface DocumentsTabOptions extends TabOptions { export interface DocumentsTabOptions extends TabOptions {
@@ -1174,7 +1170,6 @@ export interface TriggerTab extends ScriptTab {
} }
export interface GraphTab extends Tab {} export interface GraphTab extends Tab {}
export interface NotebookTab extends Tab {}
export interface EditorPosition { export interface EditorPosition {
line: number; line: number;
column: number; column: number;
@@ -1218,7 +1213,7 @@ export enum CollectionTabKind {
MongoShell = 10, MongoShell = 10,
DatabaseSettings = 11, DatabaseSettings = 11,
Conflicts = 12, Conflicts = 12,
Notebook = 13, Notebook = 13 /* Deprecated */,
Terminal = 14, Terminal = 14,
NotebookV2 = 15, NotebookV2 = 15,
SparkMasterTab = 16, SparkMasterTab = 16,
@@ -1232,17 +1227,6 @@ export enum TerminalKind {
Cassandra = 2 Cassandra = 2
} }
export interface ContextMenu {
container: Explorer;
visible: ko.Observable<boolean>;
elementId: string;
options: ko.ObservableArray<CommandButtonOptions>;
tabIndex: ko.Observable<number>;
show(source: any, event: MouseEvent | KeyboardEvent): void;
hide(source: any, event: MouseEvent | KeyboardEvent): void;
}
export interface DataExplorerInputsFrame { export interface DataExplorerInputsFrame {
databaseAccount: any; databaseAccount: any;
subscriptionId: string; subscriptionId: string;

View File

@@ -4,10 +4,6 @@ import * as ko from "knockout";
import "./ComponentRegisterer"; import "./ComponentRegisterer";
describe("Component Registerer", () => { describe("Component Registerer", () => {
it("should register command-button component", () => {
expect(ko.components.isRegistered("command-button")).toBe(true);
});
it("should register input-typeahead component", () => { it("should register input-typeahead component", () => {
expect(ko.components.isRegistered("input-typeahead")).toBe(true); expect(ko.components.isRegistered("input-typeahead")).toBe(true);
}); });
@@ -64,10 +60,6 @@ describe("Component Registerer", () => {
expect(ko.components.isRegistered("graph-tab")).toBe(true); expect(ko.components.isRegistered("graph-tab")).toBe(true);
}); });
it("should register notebook-tab component", () => {
expect(ko.components.isRegistered("notebook-tab")).toBe(true);
});
it("should register notebookv2-tab component", () => { it("should register notebookv2-tab component", () => {
expect(ko.components.isRegistered("notebookv2-tab")).toBe(true); expect(ko.components.isRegistered("notebookv2-tab")).toBe(true);
}); });
@@ -84,30 +76,6 @@ describe("Component Registerer", () => {
expect(ko.components.isRegistered("mongo-shell-tab")).toBe(true); expect(ko.components.isRegistered("mongo-shell-tab")).toBe(true);
}); });
it("should register resource-tree component", () => {
expect(ko.components.isRegistered("resource-tree")).toBe(true);
});
it("should register database-node component", () => {
expect(ko.components.isRegistered("database-node")).toBe(true);
});
it("should register collection-node component", () => {
expect(ko.components.isRegistered("collection-node")).toBe(true);
});
it("should register stored-procedure-node component", () => {
expect(ko.components.isRegistered("stored-procedure-node")).toBe(true);
});
it("should register trigger-node component", () => {
expect(ko.components.isRegistered("trigger-node")).toBe(true);
});
it("should register user-defined-function-node component", () => {
expect(ko.components.isRegistered("user-defined-function-node")).toBe(true);
});
it("should registeradd-collection-pane component", () => { it("should registeradd-collection-pane component", () => {
expect(ko.components.isRegistered("add-collection-pane")).toBe(true); expect(ko.components.isRegistered("add-collection-pane")).toBe(true);
}); });
@@ -156,10 +124,6 @@ describe("Component Registerer", () => {
expect(ko.components.isRegistered("manage-spark-cluster-pane")).toBe(true); expect(ko.components.isRegistered("manage-spark-cluster-pane")).toBe(true);
}); });
it("should register collection-node-context-menu component", () => {
expect(ko.components.isRegistered("collection-node-context-menu")).toBe(true);
});
it("should register dynamic-list component", () => { it("should register dynamic-list component", () => {
expect(ko.components.isRegistered("dynamic-list")).toBe(true); expect(ko.components.isRegistered("dynamic-list")).toBe(true);
}); });

View File

@@ -1,9 +1,7 @@
import * as ko from "knockout"; import * as ko from "knockout";
import * as PaneComponents from "./Panes/PaneComponents"; import * as PaneComponents from "./Panes/PaneComponents";
import * as TabComponents from "./Tabs/TabComponents"; import * as TabComponents from "./Tabs/TabComponents";
import * as TreeComponents from "./Tree/TreeComponents";
import { CollapsiblePanelComponent } from "./Controls/CollapsiblePanel/CollapsiblePanelComponent"; import { CollapsiblePanelComponent } from "./Controls/CollapsiblePanel/CollapsiblePanelComponent";
import { CommandButtonComponent } from "./Controls/CommandButton/CommandButton";
import { DiffEditorComponent } from "./Controls/DiffEditor/DiffEditorComponent"; import { DiffEditorComponent } from "./Controls/DiffEditor/DiffEditorComponent";
import { DynamicListComponent } from "./Controls/DynamicList/DynamicListComponent"; import { DynamicListComponent } from "./Controls/DynamicList/DynamicListComponent";
import { EditorComponent } from "./Controls/Editor/EditorComponent"; import { EditorComponent } from "./Controls/Editor/EditorComponent";
@@ -16,7 +14,6 @@ import { ThroughputInputComponent } from "./Controls/ThroughputInput/ThroughputI
import { ThroughputInputComponentAutoPilotV3 } from "./Controls/ThroughputInput/ThroughputInputComponentAutoPilotV3"; import { ThroughputInputComponentAutoPilotV3 } from "./Controls/ThroughputInput/ThroughputInputComponentAutoPilotV3";
import { ToolbarComponent } from "./Controls/Toolbar/Toolbar"; import { ToolbarComponent } from "./Controls/Toolbar/Toolbar";
ko.components.register("command-button", CommandButtonComponent);
ko.components.register("toolbar", new ToolbarComponent()); ko.components.register("toolbar", new ToolbarComponent());
ko.components.register("input-typeahead", new InputTypeaheadComponent()); ko.components.register("input-typeahead", new InputTypeaheadComponent());
ko.components.register("new-vertex-form", NewVertexComponent); ko.components.register("new-vertex-form", NewVertexComponent);
@@ -42,7 +39,6 @@ ko.components.register("tables-query-tab", new TabComponents.QueryTablesTab());
ko.components.register("graph-tab", new TabComponents.GraphTab()); ko.components.register("graph-tab", new TabComponents.GraphTab());
ko.components.register("mongo-shell-tab", new TabComponents.MongoShellTab()); ko.components.register("mongo-shell-tab", new TabComponents.MongoShellTab());
ko.components.register("conflicts-tab", new TabComponents.ConflictsTab()); ko.components.register("conflicts-tab", new TabComponents.ConflictsTab());
ko.components.register("notebook-tab", new TabComponents.NotebookTab());
ko.components.register("notebookv2-tab", new TabComponents.NotebookV2Tab()); ko.components.register("notebookv2-tab", new TabComponents.NotebookV2Tab());
ko.components.register("terminal-tab", new TabComponents.TerminalTab()); ko.components.register("terminal-tab", new TabComponents.TerminalTab());
ko.components.register("spark-master-tab", new TabComponents.SparkMasterTab()); ko.components.register("spark-master-tab", new TabComponents.SparkMasterTab());
@@ -52,14 +48,6 @@ ko.components.register("notebook-viewer-tab", new TabComponents.NotebookViewerTa
// Database Tabs // Database Tabs
ko.components.register("database-settings-tab", new TabComponents.DatabaseSettingsTab()); ko.components.register("database-settings-tab", new TabComponents.DatabaseSettingsTab());
// Resource Tree nodes
ko.components.register("resource-tree", new TreeComponents.ResourceTree());
ko.components.register("database-node", new TreeComponents.DatabaseTreeNode());
ko.components.register("collection-node", new TreeComponents.CollectionTreeNode());
ko.components.register("stored-procedure-node", new TreeComponents.StoredProcedureTreeNode());
ko.components.register("trigger-node", new TreeComponents.TriggerTreeNode());
ko.components.register("user-defined-function-node", new TreeComponents.UserDefinedFunctionTreeNode());
// Panes // Panes
ko.components.register("add-database-pane", new PaneComponents.AddDatabasePaneComponent()); ko.components.register("add-database-pane", new PaneComponents.AddDatabasePaneComponent());
ko.components.register("add-collection-pane", new PaneComponents.AddCollectionPaneComponent()); ko.components.register("add-collection-pane", new PaneComponents.AddCollectionPaneComponent());
@@ -93,6 +81,3 @@ ko.components.register("manage-spark-cluster-pane", new PaneComponents.ManageSpa
ko.components.register("library-manage-pane", new PaneComponents.LibraryManagePaneComponent()); ko.components.register("library-manage-pane", new PaneComponents.LibraryManagePaneComponent());
ko.components.register("cluster-library-pane", new PaneComponents.ClusterLibraryPaneComponent()); ko.components.register("cluster-library-pane", new PaneComponents.ClusterLibraryPaneComponent());
ko.components.register("github-repos-pane", new PaneComponents.GitHubReposPaneComponent()); ko.components.register("github-repos-pane", new PaneComponents.GitHubReposPaneComponent());
// Menus
ko.components.register("collection-node-context-menu", new TreeComponents.CollectionTreeNodeContextMenu());

View File

@@ -1,6 +1,5 @@
import * as ko from "knockout"; import * as ko from "knockout";
import * as ViewModels from "../Contracts/ViewModels"; import * as ViewModels from "../Contracts/ViewModels";
import { CommandButtonOptions } from "./Controls/CommandButton/CommandButton";
import { TreeNodeMenuItem } from "./Controls/TreeComponent/TreeComponent"; import { TreeNodeMenuItem } from "./Controls/TreeComponent/TreeComponent";
import AddCollectionIcon from "../../images/AddCollection.svg"; import AddCollectionIcon from "../../images/AddCollection.svg";
import AddSqlQueryIcon from "../../images/AddSqlQuery_16x16.svg"; import AddSqlQueryIcon from "../../images/AddSqlQuery_16x16.svg";
@@ -115,7 +114,10 @@ export class ResourceTreeContextMenuButtonFactory {
return items; return items;
} }
public static createStoreProcedureContextMenuItems(container: ViewModels.Explorer): TreeNodeMenuItem[] { public static createStoreProcedureContextMenuItems(
container: ViewModels.Explorer,
storedProcedure: ViewModels.StoredProcedure
): TreeNodeMenuItem[] {
if (container.isPreferredApiCassandra()) { if (container.isPreferredApiCassandra()) {
return []; return [];
} }
@@ -123,16 +125,16 @@ export class ResourceTreeContextMenuButtonFactory {
return [ return [
{ {
iconSrc: DeleteSprocIcon, iconSrc: DeleteSprocIcon,
onClick: () => { onClick: () => storedProcedure.delete(),
const selectedStoreProcedure: ViewModels.StoredProcedure = container.findSelectedStoredProcedure();
selectedStoreProcedure && selectedStoreProcedure.delete(selectedStoreProcedure, null);
},
label: "Delete Store Procedure" label: "Delete Store Procedure"
} }
]; ];
} }
public static createTriggerContextMenuItems(container: ViewModels.Explorer): TreeNodeMenuItem[] { public static createTriggerContextMenuItems(
container: ViewModels.Explorer,
trigger: ViewModels.Trigger
): TreeNodeMenuItem[] {
if (container.isPreferredApiCassandra()) { if (container.isPreferredApiCassandra()) {
return []; return [];
} }
@@ -140,16 +142,16 @@ export class ResourceTreeContextMenuButtonFactory {
return [ return [
{ {
iconSrc: DeleteTriggerIcon, iconSrc: DeleteTriggerIcon,
onClick: () => { onClick: () => trigger.delete(),
const selectedTrigger: ViewModels.Trigger = container.findSelectedTrigger();
selectedTrigger && selectedTrigger.delete(selectedTrigger, null);
},
label: "Delete Trigger" label: "Delete Trigger"
} }
]; ];
} }
public static createUserDefinedFunctionContextMenuItems(container: ViewModels.Explorer): TreeNodeMenuItem[] { public static createUserDefinedFunctionContextMenuItems(
container: ViewModels.Explorer,
userDefinedFunction: ViewModels.UserDefinedFunction
): TreeNodeMenuItem[] {
if (container.isPreferredApiCassandra()) { if (container.isPreferredApiCassandra()) {
return []; return [];
} }
@@ -157,266 +159,9 @@ export class ResourceTreeContextMenuButtonFactory {
return [ return [
{ {
iconSrc: DeleteUDFIcon, iconSrc: DeleteUDFIcon,
onClick: () => { onClick: () => userDefinedFunction.delete(),
const selectedUDF: ViewModels.UserDefinedFunction = container.findSelectedUDF();
selectedUDF && selectedUDF.delete(selectedUDF, null);
},
label: "Delete User Defined Function" label: "Delete User Defined Function"
} }
]; ];
} }
} }
/**
* Current resource tree (in KO)
* TODO: Remove when switching to new resource tree
*/
export class ContextMenuButtonFactory {
public static createDatabaseContextMenuButton(
container: ViewModels.Explorer,
btnParams: DatabaseContextMenuButtonParams
): CommandButtonOptions[] {
const addCollectionId = `${btnParams.databaseId}-${container.addCollectionText()}`;
const deleteDatabaseId = `${btnParams.databaseId}-${container.deleteDatabaseText()}`;
const newCollectionButtonOptions: CommandButtonOptions = {
iconSrc: AddCollectionIcon,
id: addCollectionId,
onCommandClick: () => {
if (container.isPreferredApiCassandra()) {
container.cassandraAddCollectionPane.open();
} else {
container.addCollectionPane.open(container.selectedDatabaseId());
}
const selectedDatabase: ViewModels.Database = container.findSelectedDatabase();
selectedDatabase && selectedDatabase.contextMenu.hide(selectedDatabase, null);
},
commandButtonLabel: container.addCollectionText(),
hasPopup: true
};
const deleteDatabaseButtonOptions: CommandButtonOptions = {
iconSrc: DeleteDatabaseIcon,
id: deleteDatabaseId,
onCommandClick: () => {
const database: ViewModels.Database = container.findSelectedDatabase();
database.onDeleteDatabaseContextMenuClick(database, null);
},
commandButtonLabel: container.deleteDatabaseText(),
hasPopup: true,
disabled: ko.computed<boolean>(() => container.isNoneSelected()),
visible: ko.computed<boolean>(() => !container.isNoneSelected())
};
return [newCollectionButtonOptions, deleteDatabaseButtonOptions];
}
public static createCollectionContextMenuButton(
container: ViewModels.Explorer,
btnParams: CollectionContextMenuButtonParams
): CommandButtonOptions[] {
const newSqlQueryId = `${btnParams.databaseId}-${btnParams.collectionId}-newSqlQuery`;
const newSqlQueryForGraphId = `${btnParams.databaseId}-${btnParams.collectionId}-newSqlQueryForGraph`;
const newQueryForMongoId = `${btnParams.databaseId}-${btnParams.collectionId}-newQuery`;
const newShellForMongoId = `${btnParams.databaseId}-${btnParams.collectionId}-newShell`;
const newStoredProcedureId = `${btnParams.databaseId}-${btnParams.collectionId}-newStoredProcedure`;
const udfId = `${btnParams.databaseId}-${btnParams.collectionId}-udf`;
const newTriggerId = `${btnParams.databaseId}-${btnParams.collectionId}-newTrigger`;
const deleteCollectionId = `${btnParams.databaseId}-${btnParams.collectionId}-${container.deleteCollectionText()}`;
const newSQLQueryButtonOptions: CommandButtonOptions = {
iconSrc: AddSqlQueryIcon,
id: newSqlQueryId,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onNewQueryClick(selectedCollection, null);
},
commandButtonLabel: "New SQL Query",
hasPopup: true,
disabled: ko.computed<boolean>(
() => container.isDatabaseNodeOrNoneSelected() && container.isPreferredApiDocumentDB()
),
visible: ko.computed<boolean>(
() => !container.isDatabaseNodeOrNoneSelected() && container.isPreferredApiDocumentDB()
)
//TODO: Merge with add query logic below, same goes for CommandBarButtonFactory
};
const newSQLQueryButtonOptionsForGraph: CommandButtonOptions = {
iconSrc: AddSqlQueryIcon,
id: newSqlQueryForGraphId,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onNewQueryClick(selectedCollection, null);
},
commandButtonLabel: "New SQL Query",
hasPopup: true,
disabled: ko.computed<boolean>(() => container.isDatabaseNodeOrNoneSelected() && container.isPreferredApiGraph()),
visible: ko.computed<boolean>(() => !container.isDatabaseNodeOrNoneSelected() && container.isPreferredApiGraph())
};
const newMongoQueryButtonOptions: CommandButtonOptions = {
iconSrc: AddSqlQueryIcon,
id: newQueryForMongoId,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onNewMongoQueryClick(selectedCollection, null);
},
commandButtonLabel: "New Query",
hasPopup: true,
disabled: ko.computed<boolean>(
() => container.isDatabaseNodeOrNoneSelected() && container.isPreferredApiMongoDB()
),
visible: ko.computed<boolean>(
() => !container.isDatabaseNodeOrNoneSelected() && container.isPreferredApiMongoDB()
)
};
const newMongoShellButtonOptions: CommandButtonOptions = {
iconSrc: HostedTerminalIcon,
id: newShellForMongoId,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onNewMongoShellClick();
},
commandButtonLabel: "New Shell",
hasPopup: true,
disabled: ko.computed<boolean>(
() => container.isDatabaseNodeOrNoneSelected() && container.isPreferredApiMongoDB()
),
visible: ko.computed<boolean>(
() => !container.isDatabaseNodeOrNoneSelected() && container.isPreferredApiMongoDB()
)
};
const newStoredProcedureButtonOptions: CommandButtonOptions = {
iconSrc: AddStoredProcedureIcon,
id: newStoredProcedureId,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onNewStoredProcedureClick(selectedCollection, null);
},
commandButtonLabel: "New Stored Procedure",
hasPopup: true,
disabled: ko.computed<boolean>(() => container.isDatabaseNodeOrNoneSelected()),
visible: ko.computed<boolean>(
() => !container.isDatabaseNodeOrNoneSelected() && !container.isPreferredApiCassandra()
)
};
const newUserDefinedFunctionButtonOptions: CommandButtonOptions = {
iconSrc: AddUdfIcon,
id: udfId,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onNewUserDefinedFunctionClick(selectedCollection, null);
},
commandButtonLabel: "New UDF",
hasPopup: true,
disabled: ko.computed<boolean>(() => container.isDatabaseNodeOrNoneSelected()),
visible: ko.computed<boolean>(
() => !container.isDatabaseNodeOrNoneSelected() && !container.isPreferredApiCassandra()
)
};
const newTriggerButtonOptions: CommandButtonOptions = {
iconSrc: AddTriggerIcon,
id: newTriggerId,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onNewTriggerClick(selectedCollection, null);
},
commandButtonLabel: "New Trigger",
hasPopup: true,
disabled: ko.computed<boolean>(() => container.isDatabaseNodeOrNoneSelected()),
visible: ko.computed<boolean>(
() => !container.isDatabaseNodeOrNoneSelected() && !container.isPreferredApiCassandra()
)
};
const deleteCollectionButtonOptions: CommandButtonOptions = {
iconSrc: DeleteCollectionIcon,
id: deleteCollectionId,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onDeleteCollectionContextMenuClick(selectedCollection, null);
},
commandButtonLabel: container.deleteCollectionText(),
hasPopup: true,
disabled: ko.computed<boolean>(() => container.isDatabaseNodeOrNoneSelected()),
visible: ko.computed<boolean>(() => !container.isDatabaseNodeOrNoneSelected())
//TODO: Change to isCollectionNodeorNoneSelected and same in CommandBarButtonFactory
};
return [
newSQLQueryButtonOptions,
newSQLQueryButtonOptionsForGraph,
newMongoQueryButtonOptions,
newMongoShellButtonOptions,
newStoredProcedureButtonOptions,
newUserDefinedFunctionButtonOptions,
newTriggerButtonOptions,
deleteCollectionButtonOptions
];
}
public static createStoreProcedureContextMenuButton(container: ViewModels.Explorer): CommandButtonOptions[] {
const deleteStoredProcedureId = "Context Menu - Delete Stored Procedure";
const deleteStoreProcedureButtonOptions: CommandButtonOptions = {
iconSrc: DeleteSprocIcon,
id: deleteStoredProcedureId,
onCommandClick: () => {
const selectedStoreProcedure: ViewModels.StoredProcedure = container.findSelectedStoredProcedure();
selectedStoreProcedure && selectedStoreProcedure.delete(selectedStoreProcedure, null);
},
commandButtonLabel: "Delete Stored Procedure",
hasPopup: false,
disabled: ko.computed<boolean>(() => container.isDatabaseNodeOrNoneSelected()),
visible: ko.computed<boolean>(
() => !container.isDatabaseNodeOrNoneSelected() && !container.isPreferredApiCassandra()
)
};
return [deleteStoreProcedureButtonOptions];
}
public static createTriggerContextMenuButton(container: ViewModels.Explorer): CommandButtonOptions[] {
const deleteTriggerId = "Context Menu - Delete Trigger";
const deleteTriggerButtonOptions: CommandButtonOptions = {
iconSrc: DeleteTriggerIcon,
id: deleteTriggerId,
onCommandClick: () => {
const selectedTrigger: ViewModels.Trigger = container.findSelectedTrigger();
selectedTrigger && selectedTrigger.delete(selectedTrigger, null);
},
commandButtonLabel: "Delete Trigger",
hasPopup: false,
disabled: ko.computed<boolean>(() => container.isDatabaseNodeOrNoneSelected()),
visible: ko.computed<boolean>(
() => !container.isDatabaseNodeOrNoneSelected() && !container.isPreferredApiCassandra()
)
};
return [deleteTriggerButtonOptions];
}
public static createUserDefinedFunctionContextMenuButton(container: ViewModels.Explorer): CommandButtonOptions[] {
const deleteUserDefinedFunctionId = "Context Menu - Delete User Defined Function";
const deleteUserDefinedFunctionButtonOptions: CommandButtonOptions = {
iconSrc: DeleteUDFIcon,
id: deleteUserDefinedFunctionId,
onCommandClick: () => {
const selectedUDF: ViewModels.UserDefinedFunction = container.findSelectedUDF();
selectedUDF && selectedUDF.delete(selectedUDF, null);
},
commandButtonLabel: "Delete User Defined Function",
hasPopup: false,
disabled: ko.computed<boolean>(() => container.isDatabaseNodeOrNoneSelected()),
visible: ko.computed<boolean>(
() => !container.isDatabaseNodeOrNoneSelected() && !container.isPreferredApiCassandra()
)
};
return [deleteUserDefinedFunctionButtonOptions];
}
}

View File

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

View File

@@ -1,200 +0,0 @@
@import "../../../../less/Common/Constants";
@ButtonIconSize: 18px;
.commandBar {
padding-left: @DefaultSpace;
border-bottom: @ButtonBorderWidth solid @BaseMedium;
display: flex;
overflow: hidden;
height: @topcommandbarheight;
.staticCommands {
list-style: none;
margin: 0px;
padding: 0px;
display: flex;
flex: 0 0 auto;
}
.overflowCommands {
display:flex;
flex: 1 0 auto;
.visibleCommands {
display: inline-flex;
list-style: none;
margin: 0px;
padding: 0px;
}
.partialSplitterContainer {
padding: @SmallSpace @DefaultSpace @SmallSpace @SmallSpace;
.flex-display();
}
}
.commandExpand {
border: none;
padding: 0px;
direction: rtl;
&:hover {
.hover();
cursor: pointer;
& > .commandDropdownContainer {
display: block !important; // TODO: Remove after reusing KO mouseover and mouseout event handlers
}
}
&:focus {
.focus();
}
.commandDropdownLauncher {
direction: ltr;
padding-top: @SmallSpace;
.commandIcon {
vertical-align: text-top;
}
.commandBarEllipses {
font-weight: bold;
font-size: 20px;
}
}
}
.hiddenCommandsContainer > .commandDropdownLauncher {
padding: 0px @DefaultSpace;
}
.commandDropdownContainer {
display: none;
z-index: 1000;
direction: ltr;
position: absolute;
width: fit-content;
padding: 0px;
background-color: @BaseLight;
box-shadow: 1px 2px 6px @BaseMediumHigh, -2px 2px 6px @BaseMediumHigh;
.commandDropdown {
display: flex;
flex-direction: column;
padding: 0px;
margin: 0px;
}
}
.feedbackButton {
margin-right: @LargeSpace;
white-space: nowrap;
}
}
command-button,
.commandButtonReact {
display: inline-flex;
.commandButtonComponent {
width: 100%;
color: @BaseHigh;
background-color: transparent;
text-decoration: none;
border: @ButtonBorderWidth solid transparent;
.flex-display();
&:hover:not(.commandDisabled) {
cursor: pointer;
.hover();
}
&:active:not(.commandDisabled) {
border: @ButtonBorderWidth dashed @AccentMedium;
.active();
}
&:focus:not(.commandDisabled) {
border: @ButtonBorderWidth dashed @AccentMedium;
}
.commandContent {
padding: @DefaultSpace @DefaultSpace @DefaultSpace;
flex: 0 0 auto;
.commandIcon {
margin: 0 @SmallSpace 0 0;
vertical-align: text-top;
width: @ButtonIconSize;
height: @ButtonIconSize;
}
.commandLabel {
padding: 0px;
}
}
.commandContent .hasHiddenItems {
padding-right: @SmallSpace;
}
}
.commandButtonComponent.commandDisabled {
color: @BaseMediumHigh;
opacity: 0.5;
}
.commandExpand {
padding-top: @SmallSpace;
padding-bottom: @SmallSpace;
&:hover {
.hover();
& > .commandDropdownContainer {
display: block !important; // TODO: Remove after reusing KO mouseover and mouseout event handlers
}
}
&:focus {
.focus();
}
.commandDropdownLauncher {
cursor: pointer;
display: inline-flex;
.commandButtonComponent {
padding: 0px;
}
}
.expandDropdown {
padding: @SmallSpace;
img {
vertical-align: top;
}
}
.partialSplitter {
margin: @SmallSpace 0px 6px;
}
}
.commandButtonComponent[tabindex]:focus {
outline: none;
}
.selectedButton {
background-color: @AccentLow;
outline: none
}
}
.partialSplitter {
border-left: @ButtonBorderWidth solid @BaseMediumHigh;
}
.commandDropdown .commandButtonComponent {
padding-left: 0px;
}

View File

@@ -1,139 +0,0 @@
import * as ko from "knockout";
import { CommandButtonComponent, CommandButtonOptions } from "./CommandButton";
const mockLabel = "Some Label";
const id = "Some id";
function buildComponent(buttonOptions: any) {
document.body.innerHTML = CommandButtonComponent.template as any;
const vm = new CommandButtonComponent.viewModel(buttonOptions);
ko.applyBindings(vm);
}
describe("Command Button Component", () => {
function buildButtonOptions(
onClick: () => void,
id?: string,
label?: string,
disabled?: ko.Observable<boolean>,
visible?: ko.Observable<boolean>,
tooltipText?: string
): { buttonProps: CommandButtonOptions } {
return {
buttonProps: {
iconSrc: "images/AddCollection.svg",
id: id,
commandButtonLabel: label || mockLabel,
disabled: disabled,
visible: visible,
tooltipText: tooltipText,
hasPopup: false,
onCommandClick: onClick
}
};
}
function buildSplitterButtonOptions(
onClick: () => void,
id?: string,
label?: string,
disabled?: ko.Observable<boolean>,
visible?: ko.Observable<boolean>,
tooltipText?: string
): { buttonProps: CommandButtonOptions } {
const child: CommandButtonOptions = {
iconSrc: "images/settings_15x15.svg",
id: id,
commandButtonLabel: label || mockLabel,
disabled: disabled,
visible: visible,
tooltipText: tooltipText,
hasPopup: false,
onCommandClick: onClick
};
return {
buttonProps: {
iconSrc: "images/AddCollection.svg",
id: id,
commandButtonLabel: label || mockLabel,
disabled: disabled,
visible: visible,
tooltipText: tooltipText,
hasPopup: false,
onCommandClick: onClick,
children: [child]
}
};
}
afterEach(() => {
ko.cleanNode(document);
document.body.innerHTML = "";
});
describe("Rendering", () => {
it("should display button label", () => {
const buttonOptions = buildButtonOptions(() => {
/** do nothing **/
}, mockLabel);
buildComponent(buttonOptions);
expect(document.getElementsByClassName("commandButtonComponent").item(0).textContent).toContain(mockLabel);
});
it("should display button icon", () => {
const buttonOptions = buildButtonOptions(() => {
/** do nothing **/
});
buildComponent(buttonOptions);
expect(
document
.getElementsByTagName("img")
.item(0)
.getAttribute("src")
).toBeDefined();
});
});
describe("Behavior", () => {
let clickSpy: jasmine.Spy;
beforeEach(() => {
clickSpy = jasmine.createSpy("Command button click spy");
});
it("should trigger the click handler when the command button is clicked", () => {
const buttonOptions = buildButtonOptions(() => clickSpy());
buildComponent(buttonOptions);
document
.getElementsByClassName("commandButtonComponent")
.item(0)
.dispatchEvent(new Event("click"));
expect(clickSpy).toHaveBeenCalled();
});
it("should not trigger the click handler when command button is disabled", () => {
const buttonOptions = buildButtonOptions(() => clickSpy(), id, mockLabel, ko.observable(true));
buildComponent(buttonOptions);
document
.getElementsByClassName("commandButtonComponent")
.item(0)
.dispatchEvent(new Event("click"));
expect(clickSpy).not.toHaveBeenCalled();
});
it("should not have a dropdown if it has no child", () => {
const buttonOptions = buildButtonOptions(() => clickSpy(), id, mockLabel, ko.observable(true));
buildComponent(buttonOptions);
const dropdownSize = document.getElementsByClassName("commandExpand").length;
expect(dropdownSize).toBe(0);
});
it("should have a dropdown if it has a child", () => {
const buttonOptions = buildSplitterButtonOptions(() => clickSpy(), id, mockLabel, ko.observable(true));
buildComponent(buttonOptions);
const dropdownSize = document.getElementsByClassName("commandExpand").length;
expect(dropdownSize).toBe(1);
});
});
});

View File

@@ -1,191 +0,0 @@
/**
* How to use this component:
*
* In your html markup, use:
* <command-button params="{
* iconSrc: '/icon/example/src/',
* onCommandClick: () => { doSomething },
* commandButtonLabel: 'Some Label'
* disabled: true/false
* }">
* </command-button>
*
*/
import * as ko from "knockout";
import * as ViewModels from "../../../Contracts/ViewModels";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import { WaitsForTemplateViewModel } from "../../WaitsForTemplateViewModel";
import { KeyCodes } from "../../../Common/Constants";
import TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import template from "./command-button.html";
/**
* Options for this component
*/
export interface CommandButtonOptions {
/**
* image source for the button icon
*/
iconSrc: string;
/**
* Id for the button icon
*/
id: string;
/**
* Click handler for command button click
*/
onCommandClick: () => void;
/**
* Label for the button
*/
commandButtonLabel: string | ko.Observable<string>;
/**
* True if this button opens a tab or pane, false otherwise.
*/
hasPopup: boolean;
/**
* Enabled/disabled state of command button
*/
disabled?: ko.Subscribable<boolean>;
/**
* Visibility/Invisibility of the button
*/
visible?: ko.Subscribable<boolean>;
/**
* Whether or not the button should have the 'selectedButton' styling
*/
isSelected?: ko.Observable<boolean>;
/**
* Text to displayed in the tooltip on hover
*/
tooltipText?: string | ko.Observable<string>;
/**
* Callback triggered when the template is bound to the component
*/
onTemplateReady?: () => void;
/**
* tabindex for the command button
*/
tabIndex?: ko.Observable<number>;
/**
* Childrens command buttons to hide in the dropdown
*/
children?: CommandButtonOptions[];
}
export class CommandButtonViewModel extends WaitsForTemplateViewModel implements ViewModels.CommandButton {
public commandClickCallback: () => void;
public commandButtonId: string;
public disabled: ko.Subscribable<boolean>;
public visible: ko.Subscribable<boolean>;
public isSelected: ko.Observable<boolean>;
public iconSrc: string;
public commandButtonLabel: ko.Observable<string>;
public tooltipText: ko.Observable<string>;
public tabIndex: ko.Observable<number>;
public isTemplateReady: ko.Observable<boolean>;
public hasPopup: boolean;
public children: ko.ObservableArray<CommandButtonOptions>;
public constructor(options: { buttonProps: CommandButtonOptions }) {
super();
const props = options.buttonProps;
const commandButtonLabel = props.commandButtonLabel;
const tooltipText = props.tooltipText;
this.commandButtonLabel =
typeof commandButtonLabel === "string" ? ko.observable<string>(commandButtonLabel) : commandButtonLabel;
this.commandButtonId = props.id;
this.disabled = props.disabled || ko.observable(false);
this.visible = props.visible || ko.observable(true);
this.isSelected = props.isSelected || ko.observable(false);
this.iconSrc = props.iconSrc;
this.tabIndex = props.tabIndex || ko.observable(0);
this.hasPopup = props.hasPopup;
this.children = ko.observableArray(props.children);
super.onTemplateReady((isTemplateReady: boolean) => {
if (isTemplateReady && props.onTemplateReady) {
props.onTemplateReady();
}
});
if (tooltipText && typeof tooltipText === "string") {
this.tooltipText = ko.observable<string>(tooltipText);
} else if (tooltipText && typeof tooltipText === "function") {
this.tooltipText = tooltipText;
} else {
this.tooltipText = this.commandButtonLabel;
}
this.commandClickCallback = () => {
if (this.disabled()) {
return;
}
const el = document.querySelector(".commandDropdownContainer") as HTMLElement;
if (el) {
el.style.display = "none";
}
props.onCommandClick();
TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, {
commandButtonClicked: this.commandButtonLabel
});
};
}
public onKeyPress(source: any, event: KeyboardEvent): boolean {
if (event.keyCode === KeyCodes.Space || event.keyCode === KeyCodes.Enter) {
this.commandClickCallback && this.commandClickCallback();
event.stopPropagation();
return false;
}
return true;
}
public onLauncherKeyDown(source: any, event: KeyboardEvent): boolean {
// TODO: Convert JQuery code into Knockout
if (event.keyCode === KeyCodes.DownArrow) {
$(event.target)
.parent()
.siblings()
.children(".commandExpand")
.children(".commandDropdownContainer")
.hide();
$(event.target)
.children(".commandDropdownContainer")
.show()
.focus();
event.stopPropagation();
return false;
}
if (event.keyCode === KeyCodes.UpArrow) {
$(event.target)
.children(".commandDropdownContainer")
.hide();
event.stopPropagation();
return false;
}
return true;
}
}
/**
* Helper class for ko component registration
*/
export const CommandButtonComponent = {
viewModel: CommandButtonViewModel,
template
};

View File

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

View File

@@ -1,40 +0,0 @@
<span
class="commandButtonComponent"
role="menuitem"
tabindex="0"
data-bind="setTemplateReady: true,
css: {
commandDisabled: disabled,
selectedButton: isSelected
},
event: {
keypress: onKeyPress
},
attr: {
title: tooltipText,
id: commandButtonId,
tabindex: tabIndex ,
'aria-disabled': disabled,
'aria-haspopup': hasPopup
},
click: commandClickCallback,
visible: visible"
>
<div class="commandContent" data-bind="css: { hasHiddenItems: children().length > 0 }">
<img class="commandIcon" data-bind="attr: {src: iconSrc, alt: commandButtonLabel}" />
<span class="commandLabel" data-bind="text: commandButtonLabel"></span>
</div>
</span>
<!-- ko if: children().length > 0 -->
<div class="commandExpand" tabindex="0" data-bind="visible: visible, event: { keydown: onLauncherKeyDown }">
<div class="commandDropdownLauncher">
<span class="partialSplitter"></span>
<span class="expandDropdown"> <img src="/QueryBuilder/CollapseChevronDown_16x.png" /> </span>
</div>
<div class="commandDropdownContainer">
<div class="commandDropdown" data-bind="foreach: children">
<command-button params="{buttonProps: $data}"></command-button>
</div>
</div>
</div>
<!-- /ko -->

View File

@@ -0,0 +1,20 @@
.featurePanelComponentContainer {
width: 800px;
.urlContainer {
padding: 10px;
margin-bottom: 10px;
white-space: nowrap;
overflow: auto;
}
.options {
padding: 10px;
overflow: auto;
height: 100%;
}
.checkboxRow {
width: 390px;
}
}

View File

@@ -0,0 +1,11 @@
import React from "react";
import { shallow } from "enzyme";
import { FeaturePanelComponent } from "./FeaturePanelComponent";
describe("Feature panel", () => {
it("renders all flags", () => {
const wrapper = shallow(<FeaturePanelComponent />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,253 @@
import * as React from "react";
import { Stack } from "office-ui-fabric-react/lib/Stack";
import { Dropdown, IDropdownOption, IDropdownStyles } from "office-ui-fabric-react/lib/Dropdown";
import { Checkbox } from "office-ui-fabric-react/lib/Checkbox";
import { TextField, ITextFieldStyles } from "office-ui-fabric-react/lib/TextField";
import { DefaultButton } from "office-ui-fabric-react";
import "./FeaturePanelComponent.less";
export const FeaturePanelComponent: React.FunctionComponent = () => {
// Initial conditions
const originalParams = new URLSearchParams(window.location.search);
const urlParams = new Map(); // Params with lowercase keys
originalParams.forEach((value: string, key: string) => urlParams.set(key.toLocaleLowerCase(), value));
const baseUrlOptions = [
{ key: "https://localhost:1234/explorer.html", text: "localhost:1234" },
{ key: "https://cosmos.azure.com/explorer.html", text: "cosmos.azure.com" },
{ key: "https://portal.azure.com", text: "portal" }
];
const platformOptions = [
{ key: "Hosted", text: "Hosted" },
{ key: "Portal", text: "Portal" },
{ key: "Emulator", text: "Emulator" },
{ key: "", text: "None" }
];
// React hooks to keep state
const [baseUrl, setBaseUrl] = React.useState<IDropdownOption>(
baseUrlOptions.find(o => o.key === window.location.origin + window.location.pathname) || baseUrlOptions[0]
);
const [platform, setPlatform] = React.useState<IDropdownOption>(
urlParams.has("platform")
? platformOptions.find(o => o.key === urlParams.get("platform")) || platformOptions[0]
: platformOptions[0]
);
const booleanFeatures: {
key: string;
label: string;
value: string;
disabled?: () => boolean;
reactState?: [boolean, React.Dispatch<React.SetStateAction<boolean>>];
onChange?: (_?: React.FormEvent<HTMLElement | HTMLInputElement>, checked?: boolean) => void;
}[] = [
{ key: "feature.enablechangefeedpolicy", label: "Enable change feed policy", value: "true" },
{ key: "feature.enablerupm", label: "Enable RUPM", value: "true" },
{ key: "feature.dataexplorerexecutesproc", label: "Execute stored procedure", value: "true" },
{ key: "feature.hosteddataexplorerenabled", label: "Hosted Data Explorer (deprecated?)", value: "true" },
{ key: "feature.enablettl", label: "Enable TTL", value: "true" },
{ key: "feature.enablegallery", label: "Enable Notebook Gallery", value: "true" },
{ key: "feature.enablegallerypublish", label: "Enable Notebook Gallery Publishing", value: "true" },
{ key: "feature.canexceedmaximumvalue", label: "Can exceed max value", value: "true" },
{
key: "feature.enablefixedcollectionwithsharedthroughput",
label: "Enable fixed collection with shared throughput",
value: "true"
},
{ key: "feature.ttl90days", label: "TTL 90 days", value: "true" },
{ key: "feature.enablenotebooks", label: "Enable notebooks", value: "true" },
{
key: "feature.customportal",
label: "Force Production portal (portal only)",
value: "false",
disabled: (): boolean => baseUrl.key !== "https://portal.azure.com"
},
{ key: "feature.enablespark", label: "Enable Synapse", value: "true" },
{ key: "feature.enableautopilotv2", label: "Enable Auto-pilot V2", value: "true" }
];
const stringFeatures: {
key: string;
label: string;
placeholder: string;
disabled?: () => boolean;
reactState?: [string, React.Dispatch<React.SetStateAction<string>>];
onChange?: (_: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string) => void;
}[] = [
{ key: "feature.notebookserverurl", label: "Notebook server URL", placeholder: "https://notebookserver" },
{ key: "feature.notebookservertoken", label: "Notebook server token", placeholder: "" },
{ key: "feature.notebookbasepath", label: "Notebook base path", placeholder: "" },
{ key: "key", label: "Auth key", placeholder: "" },
{
key: "dataExplorerSource",
label: "Data Explorer Source (portal only)",
placeholder: "https://localhost:1234/explorer.html",
disabled: (): boolean => baseUrl.key !== "https://portal.azure.com"
},
{ key: "feature.livyendpoint", label: "Livy endpoint", placeholder: "" }
];
booleanFeatures.forEach(
f => (f.reactState = React.useState<boolean>(urlParams.has(f.key) ? urlParams.get(f.key) === "true" : false))
);
stringFeatures.forEach(
f => (f.reactState = React.useState<string>(urlParams.has(f.key) ? urlParams.get(f.key) : undefined))
);
const buildUrl = (): string => {
const fragments = (platform.key === "" ? [] : [`platform=${platform.key}`])
.concat(booleanFeatures.map(f => (f.reactState[0] ? `${f.key}=${f.value}` : "")))
.concat(stringFeatures.map(f => (f.reactState[0] ? `${f.key}=${encodeURIComponent(f.reactState[0])}` : "")))
.filter(v => v && v.length > 0);
const paramString = fragments.length < 1 ? "" : `?${fragments.join("&")}`;
return `${baseUrl.key}${paramString}`;
};
const onChangeBaseUrl = (event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption): void => {
setBaseUrl(option);
};
const onChangePlatform = (event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption): void => {
setPlatform(option);
};
booleanFeatures.forEach(
f =>
(f.onChange = (ev?: React.FormEvent<HTMLElement | HTMLInputElement>, checked?: boolean): void => {
f.reactState[1](checked);
})
);
stringFeatures.forEach(
f =>
(f.onChange = (event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string): void => {
f.reactState[1](newValue);
})
);
const onNotebookShortcut = (): void => {
booleanFeatures.find(f => f.key === "feature.enablenotebooks").reactState[1](true);
stringFeatures
.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]("./notebooks");
setPlatform(platformOptions.find(o => o.key === "Hosted"));
};
const onPortalLocalDEShortcut = (): void => {
setBaseUrl(baseUrlOptions.find(o => o.key === "https://portal.azure.com"));
setPlatform(platformOptions.find(o => o.key === "Portal"));
stringFeatures.find(f => f.key === "dataExplorerSource").reactState[1]("https://localhost:1234/explorer.html");
};
const onReset = (): void => {
booleanFeatures.forEach(f => f.reactState[1](false));
stringFeatures.forEach(f => f.reactState[1](""));
};
const stackTokens = { childrenGap: 10 };
const dropdownStyles: Partial<IDropdownStyles> = { dropdown: { width: 200 } };
const textFieldStyles: Partial<ITextFieldStyles> = { fieldGroup: { width: 300 } };
// Show in 2 columns to keep it compact
let halfSize = Math.ceil(booleanFeatures.length / 2);
const leftBooleanFeatures = booleanFeatures.slice(0, halfSize);
const rightBooleanFeatures = booleanFeatures.slice(halfSize, booleanFeatures.length);
halfSize = Math.ceil(stringFeatures.length / 2);
const leftStringFeatures = stringFeatures.slice(0, halfSize);
const rightStringFeatures = stringFeatures.slice(halfSize, stringFeatures.length);
const anchorOptions = {
href: buildUrl(),
target: "_blank",
rel: "noopener"
};
return (
<div className="featurePanelComponentContainer">
<div className="urlContainer">
<a {...anchorOptions}>{buildUrl()}</a>
</div>
<Stack className="options" tokens={stackTokens}>
<Stack horizontal horizontalAlign="space-between" tokens={stackTokens}>
<DefaultButton onClick={onNotebookShortcut}>Notebooks on localhost</DefaultButton>
<DefaultButton onClick={onPortalLocalDEShortcut}>Portal points to local DE</DefaultButton>
<DefaultButton onClick={onReset}>Reset</DefaultButton>
</Stack>
<Stack horizontal horizontalAlign="start" tokens={stackTokens}>
<Dropdown
selectedKey={baseUrl.key}
options={baseUrlOptions}
onChange={onChangeBaseUrl}
label="Base Url"
styles={dropdownStyles}
/>
<Dropdown
label="Platform"
selectedKey={platform.key}
onChange={onChangePlatform}
options={platformOptions}
styles={dropdownStyles}
/>
</Stack>
<Stack horizontal>
<Stack className="checkboxRow" horizontalAlign="space-between">
{leftBooleanFeatures.map(f => (
<Checkbox
key={f.key}
label={f.label}
checked={f.reactState[0]}
onChange={f.onChange}
disabled={f.disabled && f.disabled()}
/>
))}
</Stack>
<Stack className="checkboxRow" horizontalAlign="space-between">
{rightBooleanFeatures.map(f => (
<Checkbox
key={f.key}
label={f.label}
checked={f.reactState[0]}
onChange={f.onChange}
disabled={f.disabled && f.disabled()}
/>
))}
</Stack>
</Stack>
<Stack horizontal tokens={stackTokens}>
<Stack horizontalAlign="space-between">
{leftStringFeatures.map(f => (
<TextField
key={f.key}
value={f.reactState[0]}
label={f.label}
onChange={f.onChange}
styles={textFieldStyles}
placeholder={f.placeholder}
disabled={f.disabled && f.disabled()}
/>
))}
</Stack>
<Stack horizontalAlign="space-between">
{rightStringFeatures.map(f => (
<TextField
key={f.key}
value={f.reactState[0]}
label={f.label}
onChange={f.onChange}
styles={textFieldStyles}
placeholder={f.placeholder}
disabled={f.disabled && f.disabled()}
/>
))}
</Stack>
</Stack>
</Stack>
</div>
);
};

View File

@@ -0,0 +1,18 @@
.featurePanelLauncherContainer {
.featurePanelLauncherModal {
overflow-y: hidden;
display: flex;
flex-direction: column;
}
.ms-Dialog-main {
overflow: hidden;
}
}
.activePatch {
position: absolute;
height: 20px;
width: 20px;
top: 20px;
left: -20px;
}

View File

@@ -0,0 +1,86 @@
import * as React from "react";
import { FeaturePanelComponent } from "./FeaturePanelComponent";
import { getTheme, mergeStyleSets, FontWeights, Modal, IconButton, IIconProps } from "office-ui-fabric-react";
import "./FeaturePanelLauncher.less";
// Modal wrapper
export const FeaturePanelLauncher: React.FunctionComponent = (): JSX.Element => {
const [isModalOpen, showModal] = React.useState<boolean>(false);
const onActivate = (event: React.MouseEvent<HTMLSpanElement>): void => {
if (!event.shiftKey || !event.ctrlKey) {
return;
}
event.stopPropagation();
showModal(true);
};
const theme = getTheme();
const contentStyles = mergeStyleSets({
container: {
display: "flex",
flexFlow: "column nowrap",
alignItems: "stretch"
},
header: [
// tslint:disable-next-line:deprecation
theme.fonts.xLargePlus,
{
flex: "1 1 auto",
borderTop: `4px solid ${theme.palette.themePrimary}`,
color: theme.palette.neutralPrimary,
display: "flex",
alignItems: "center",
fontWeight: FontWeights.semibold,
padding: "12px 12px 14px 24px"
}
],
body: {
flex: "4 4 auto",
overflowY: "hidden",
marginBottom: 40,
height: "100%",
display: "flex"
}
});
const iconButtonStyles = {
root: {
color: theme.palette.neutralPrimary,
marginLeft: "auto",
marginTop: "4px",
marginRight: "2px"
},
rootHovered: {
color: theme.palette.neutralDark
}
};
const cancelIcon: IIconProps = { iconName: "Cancel" };
const hideModal = (): void => showModal(false);
return (
<span className="activePatch" onDoubleClick={onActivate}>
<Modal
className="featurePanelLauncherContainer"
titleAriaId="Features"
isOpen={isModalOpen}
onDismiss={hideModal}
isBlocking={false}
scrollableContentClassName="featurePanelLauncherModal"
>
<div className={contentStyles.header}>
<span>Data Explorer Launcher</span>
<IconButton
styles={iconButtonStyles}
iconProps={cancelIcon}
ariaLabel="Close popup modal"
onClick={hideModal}
/>
</div>
<div className={contentStyles.body}>
<FeaturePanelComponent />
</div>
</Modal>
</span>
);
};

View File

@@ -0,0 +1,318 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Feature panel renders all flags 1`] = `
<div
className="featurePanelComponentContainer"
>
<div
className="urlContainer"
>
<a
href="https://localhost:1234/explorer.html?platform=Hosted"
rel="noopener"
target="_blank"
>
https://localhost:1234/explorer.html?platform=Hosted
</a>
</div>
<Stack
className="options"
tokens={
Object {
"childrenGap": 10,
}
}
>
<Stack
horizontal={true}
horizontalAlign="space-between"
tokens={
Object {
"childrenGap": 10,
}
}
>
<CustomizedDefaultButton
onClick={[Function]}
>
Notebooks on localhost
</CustomizedDefaultButton>
<CustomizedDefaultButton
onClick={[Function]}
>
Portal points to local DE
</CustomizedDefaultButton>
<CustomizedDefaultButton
onClick={[Function]}
>
Reset
</CustomizedDefaultButton>
</Stack>
<Stack
horizontal={true}
horizontalAlign="start"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StyledWithResponsiveMode
label="Base Url"
onChange={[Function]}
options={
Array [
Object {
"key": "https://localhost:1234/explorer.html",
"text": "localhost:1234",
},
Object {
"key": "https://cosmos.azure.com/explorer.html",
"text": "cosmos.azure.com",
},
Object {
"key": "https://portal.azure.com",
"text": "portal",
},
]
}
selectedKey="https://localhost:1234/explorer.html"
styles={
Object {
"dropdown": Object {
"width": 200,
},
}
}
/>
<StyledWithResponsiveMode
label="Platform"
onChange={[Function]}
options={
Array [
Object {
"key": "Hosted",
"text": "Hosted",
},
Object {
"key": "Portal",
"text": "Portal",
},
Object {
"key": "Emulator",
"text": "Emulator",
},
Object {
"key": "",
"text": "None",
},
]
}
selectedKey="Hosted"
styles={
Object {
"dropdown": Object {
"width": 200,
},
}
}
/>
</Stack>
<Stack
horizontal={true}
>
<Stack
className="checkboxRow"
horizontalAlign="space-between"
>
<StyledCheckboxBase
checked={false}
key="feature.enablechangefeedpolicy"
label="Enable change feed policy"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enablerupm"
label="Enable RUPM"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.dataexplorerexecutesproc"
label="Execute stored procedure"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.hosteddataexplorerenabled"
label="Hosted Data Explorer (deprecated?)"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enablettl"
label="Enable TTL"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enablegallery"
label="Enable Notebook Gallery"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enablegallerypublish"
label="Enable Notebook Gallery Publishing"
onChange={[Function]}
/>
</Stack>
<Stack
className="checkboxRow"
horizontalAlign="space-between"
>
<StyledCheckboxBase
checked={false}
key="feature.canexceedmaximumvalue"
label="Can exceed max value"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enablefixedcollectionwithsharedthroughput"
label="Enable fixed collection with shared throughput"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.ttl90days"
label="TTL 90 days"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enablenotebooks"
label="Enable notebooks"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
disabled={true}
key="feature.customportal"
label="Force Production portal (portal only)"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enablespark"
label="Enable Synapse"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enableautopilotv2"
label="Enable Auto-pilot V2"
onChange={[Function]}
/>
</Stack>
</Stack>
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 10,
}
}
>
<Stack
horizontalAlign="space-between"
>
<StyledTextFieldBase
key="feature.notebookserverurl"
label="Notebook server URL"
onChange={[Function]}
placeholder="https://notebookserver"
styles={
Object {
"fieldGroup": Object {
"width": 300,
},
}
}
/>
<StyledTextFieldBase
key="feature.notebookservertoken"
label="Notebook server token"
onChange={[Function]}
placeholder=""
styles={
Object {
"fieldGroup": Object {
"width": 300,
},
}
}
/>
<StyledTextFieldBase
key="feature.notebookbasepath"
label="Notebook base path"
onChange={[Function]}
placeholder=""
styles={
Object {
"fieldGroup": Object {
"width": 300,
},
}
}
/>
</Stack>
<Stack
horizontalAlign="space-between"
>
<StyledTextFieldBase
key="key"
label="Auth key"
onChange={[Function]}
placeholder=""
styles={
Object {
"fieldGroup": Object {
"width": 300,
},
}
}
/>
<StyledTextFieldBase
disabled={true}
key="dataExplorerSource"
label="Data Explorer Source (portal only)"
onChange={[Function]}
placeholder="https://localhost:1234/explorer.html"
styles={
Object {
"fieldGroup": Object {
"width": 300,
},
}
}
/>
<StyledTextFieldBase
key="feature.livyendpoint"
label="Livy endpoint"
onChange={[Function]}
placeholder=""
styles={
Object {
"fieldGroup": Object {
"width": 300,
},
}
}
/>
</Stack>
</Stack>
</Stack>
</div>
`;

View File

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

View File

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

View File

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

@@ -1,80 +0,0 @@
/**
* Message handler to communicate with NotebookApp iframe
*/
import Q from "q";
import * as _ from "underscore";
import { HashMap } from "../../../Common/HashMap";
import { CachedDataPromise } from "../../../Common/MessageHandler";
import * as Constants from "../../../Common/Constants";
import {
MessageTypes,
FromNotebookMessage,
FromNotebookResponseMessage,
FromDataExplorerMessage
} from "../../../Terminal/NotebookAppContracts";
export class NotebookAppMessageHandler {
private requestMap: HashMap<CachedDataPromise<any>>;
constructor(private targetIFrameWindow: Window) {
this.requestMap = new HashMap();
}
public handleCachedDataMessage(message: FromNotebookMessage): void {
const messageContent = message && (message.message as FromNotebookResponseMessage);
if (
message == null ||
messageContent == null ||
messageContent.id == null ||
!this.requestMap.has(messageContent.id)
) {
return;
}
const cachedDataPromise = this.requestMap.get(messageContent.id);
if (messageContent.error != null) {
cachedDataPromise.deferred.reject(messageContent.error);
} else {
cachedDataPromise.deferred.resolve(messageContent.data);
}
this.runGarbageCollector();
}
public sendCachedDataMessage<TResponseDataModel>(
messageType: MessageTypes,
params?: any
): Q.Promise<TResponseDataModel> {
let cachedDataPromise: CachedDataPromise<TResponseDataModel> = {
deferred: Q.defer<TResponseDataModel>(),
startTime: new Date(),
id: _.uniqueId()
};
this.requestMap.set(cachedDataPromise.id, cachedDataPromise);
this.sendMessage({ type: messageType, params: params, id: cachedDataPromise.id });
//TODO: Use telemetry to measure optimal time to resolve/reject promises
return cachedDataPromise.deferred.promise.timeout(
Constants.ClientDefaults.requestTimeoutMs,
"Timed out while waiting for response from portal"
);
}
public sendMessage(data: FromDataExplorerMessage): void {
if (!this.targetIFrameWindow) {
console.error("targetIFrame not defined. This is not expected");
return;
}
this.targetIFrameWindow.postMessage(data, window.location.href || window.document.referrer);
}
protected runGarbageCollector() {
this.requestMap.keys().forEach((key: string) => {
const promise: Q.Promise<any> = this.requestMap.get(key).deferred.promise;
if (promise.isFulfilled() || promise.isRejected()) {
this.requestMap.delete(key);
}
});
}
}

View File

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

View File

@@ -1,15 +1,32 @@
import React from "react";
import { shallow } from "enzyme"; import { shallow } from "enzyme";
import React from "react";
import { GalleryCardComponent, GalleryCardComponentProps } from "./GalleryCardComponent"; import { GalleryCardComponent, GalleryCardComponentProps } from "./GalleryCardComponent";
describe("GalleryCardComponent", () => { describe("GalleryCardComponent", () => {
it("renders", () => { it("renders", () => {
const props: GalleryCardComponentProps = { const props: GalleryCardComponentProps = {
name: "mycard", data: {
url: "url", id: "id",
notebookMetadata: undefined, name: "name",
// eslint-disable-next-line @typescript-eslint/no-empty-function description: "description",
onClick: () => {} 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} />); const wrapper = shallow(<GalleryCardComponent {...props} />);

View File

@@ -1,65 +1,199 @@
import * as React from "react"; import { Card, ICardTokens } from "@uifabric/react-cards";
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 { import {
siteTextStyles, FontWeights,
descriptionTextStyles, Icon,
helpfulTextStyles, IconButton,
subtleHelpfulTextStyles, Image,
subtleIconStyles ImageFit,
} from "./CardStyleConstants"; 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 { export interface GalleryCardComponentProps {
name: string; data: IGalleryItem;
url: string; isFavorite: boolean;
notebookMetadata: DataModels.NotebookMetadata; showDelete: boolean;
onClick: () => void; onClick: () => void;
onTagClick: (tag: string) => void;
onFavoriteClick: () => void;
onUnfavoriteClick: () => void;
onDownloadClick: () => void;
onDeleteClick: () => void;
} }
export class GalleryCardComponent extends React.Component<GalleryCardComponentProps> { export class GalleryCardComponent extends React.Component<GalleryCardComponentProps> {
private cardTokens: ICardTokens = { childrenMargin: 12 }; public static readonly CARD_HEIGHT = 384;
private attendantsCardSectionTokens: ICardSectionTokens = { childrenGap: 6 }; 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 { public render(): JSX.Element {
return this.props.notebookMetadata != null ? ( const options: Intl.DateTimeFormatOptions = {
<Card aria-label="Notebook Card" onClick={this.props.onClick} tokens={this.cardTokens}> 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> <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>
<Card.Item fill> <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.Item>
<Card.Section> <Card.Section>
<Text variant="small" styles={siteTextStyles}> <Text variant="small" nowrap>
{this.props.notebookMetadata.tags.join(", ")} {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>
<Text styles={descriptionTextStyles}>{this.props.name}</Text> <Text styles={{ root: { fontWeight: FontWeights.semibold } }} nowrap>
<Text variant="small" styles={helpfulTextStyles}> {FileSystemUtil.stripExtension(this.props.data.name, "ipynb")}
{this.props.notebookMetadata.description} </Text>
<Text variant="small" styles={{ root: { height: 36 } }}>
{this.props.data.description.substr(0, GalleryCardComponent.cardDescriptionMaxChars)}
</Text> </Text>
</Card.Section> </Card.Section>
<Card.Section horizontal tokens={this.attendantsCardSectionTokens}>
<Icon iconName="RedEye" styles={subtleIconStyles} /> <Card.Section horizontal styles={{ root: { alignItems: "flex-end" } }}>
<Text variant="small" styles={subtleHelpfulTextStyles}> <Text variant="tiny" styles={{ root: { color: "#ccc" } }}>
{this.props.notebookMetadata.views} <Icon iconName="RedEye" styles={{ root: { verticalAlign: "middle" } }} /> {this.props.data.views}
</Text> </Text>
<Icon iconName="Download" styles={subtleIconStyles} /> <Text variant="tiny" styles={{ root: { color: "#ccc" } }}>
<Text variant="small" styles={subtleHelpfulTextStyles}> <Icon iconName="Download" styles={{ root: { verticalAlign: "middle" } }} /> {this.props.data.downloads}
{this.props.notebookMetadata.downloads}
</Text> </Text>
<Icon iconName="Heart" styles={subtleIconStyles} /> <Text variant="tiny" styles={{ root: { color: "#ccc" } }}>
<Text variant="small" styles={subtleHelpfulTextStyles}> <Icon iconName="Heart" styles={{ root: { verticalAlign: "middle" } }} /> {this.props.data.favorites}
{this.props.notebookMetadata.likes}
</Text> </Text>
</Card.Section> </Card.Section>
</Card>
) : ( <Card.Item>
<Card aria-label="Notebook Card" onClick={this.props.onClick} tokens={this.cardTokens}> <Separator styles={{ root: { padding: 0, height: 1 } }} />
<Card.Section> </Card.Item>
<Text styles={descriptionTextStyles}>{this.props.name}</Text>
<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.Section>
</Card> </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`] = ` exports[`GalleryCardComponent renders 1`] = `
<Card <Card
aria-label="Notebook Card" aria-label="Notebook Card"
onClick={[Function]}
tokens={ tokens={
Object { 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> <CardSection>
<Text <Text
nowrap={true}
variant="small"
>
<span
key="tag"
>
<StyledLinkBase
onClick={[Function]}
>
tag
</StyledLinkBase>
</span>
</Text>
<Text
nowrap={true}
styles={ styles={
Object { Object {
"root": Object { "root": Object {
"color": "#333333",
"fontWeight": 600, "fontWeight": 600,
}, },
} }
} }
> >
mycard name
</Text> </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> </CardSection>
</Card> </Card>
`; `;

View File

@@ -1,74 +1,17 @@
import React from "react";
import { shallow } from "enzyme"; import { shallow } from "enzyme";
import { import React from "react";
GalleryViewerContainerComponent, import { GalleryViewerComponent, GalleryViewerComponentProps, GalleryTab, SortBy } from "./GalleryViewerComponent";
GalleryViewerContainerComponentProps,
FullWidthTabs,
FullWidthTabsProps,
GalleryCardsComponent,
GalleryCardsComponentProps,
GalleryViewerComponent,
GalleryViewerComponentProps
} from "./GalleryViewerComponent";
import * as DataModels from "../../../Contracts/DataModels";
describe("GalleryCardsComponent", () => { describe("GalleryViewerComponent", () => {
it("renders", () => {
// TODO Mock this
const props: GalleryCardsComponentProps = {
data: [],
userMetadata: undefined,
onNotebookMetadataChange: (officialSamplesIndex: number, notebookMetadata: DataModels.NotebookMetadata) =>
Promise.resolve(),
onClick: (
url: string,
notebookMetadata: DataModels.NotebookMetadata,
onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>,
isLikedNotebook: boolean
) => Promise.resolve()
};
const wrapper = shallow(<GalleryCardsComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
describe("FullWidthTabs", () => {
it("renders", () => {
const props: FullWidthTabsProps = {
officialSamplesContent: [],
likedNotebooksContent: [],
userMetadata: undefined,
onClick: (
url: string,
notebookMetadata: DataModels.NotebookMetadata,
onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>,
isLikedNotebook: boolean
) => 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", () => {
it("renders", () => { it("renders", () => {
const props: GalleryViewerComponentProps = { const props: GalleryViewerComponentProps = {
container: undefined, junoClient: undefined,
officialSamplesData: [], selectedTab: GalleryTab.OfficialSamples,
likedNotebookData: undefined sortBy: SortBy.MostViewed,
searchText: undefined,
onSelectedTabChange: undefined,
onSortByChange: undefined,
onSearchTextChange: undefined
}; };
const wrapper = shallow(<GalleryViewerComponent {...props} />); const wrapper = shallow(<GalleryViewerComponent {...props} />);

View File

@@ -1,356 +1,513 @@
/** import {
* Gallery Viewer 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 React from "react";
import * as DataModels from "../../../Contracts/DataModels"; import * as Logger from "../../../Common/Logger";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
import { GalleryCardComponent } from "./Cards/GalleryCardComponent"; import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient";
import { Stack, IStackTokens } from "office-ui-fabric-react"; import * as GalleryUtils from "../../../Utils/GalleryUtils";
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 { NotificationConsoleUtils } from "../../../Utils/NotificationConsoleUtils"; import { NotificationConsoleUtils } from "../../../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent"; 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 "./GalleryViewerComponent.less";
import { HttpStatusCodes } from "../../../Common/Constants";
export interface GalleryCardsComponentProps { export interface GalleryViewerComponentProps {
data: DataModels.GitHubInfoJunoResponse[]; container?: ViewModels.Explorer;
userMetadata: DataModels.UserMetadata; junoClient: JunoClient;
onNotebookMetadataChange: ( selectedTab: GalleryTab;
officialSamplesIndex: number, sortBy: SortBy;
notebookMetadata: DataModels.NotebookMetadata searchText: string;
) => Promise<void>; onSelectedTabChange: (newTab: GalleryTab) => void;
onClick: ( onSortByChange: (sortBy: SortBy) => void;
url: string, onSearchTextChange: (searchText: string) => void;
notebookMetadata: DataModels.NotebookMetadata,
onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>,
isLikedNotebook: boolean
) => Promise<void>;
} }
export class GalleryCardsComponent extends React.Component<GalleryCardsComponentProps> { export enum GalleryTab {
private sectionStackTokens: IStackTokens = { childrenGap: 30 }; 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 { public render(): JSX.Element {
return ( const tabs: GalleryTabInfo[] = [this.createTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks)];
<Stack horizontal wrap tokens={this.sectionStackTokens}>
{this.props.data.map((githubInfo: DataModels.GitHubInfoJunoResponse, index: any) => {
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) =>
this.props.onNotebookMetadataChange(officialSamplesIndex, notebookMetadata)
: undefined;
return ( if (this.props.container) {
name !== ".gitignore" && if (this.props.container.isGalleryPublishEnabled()) {
url && ( tabs.push(this.createTab(GalleryTab.PublicGallery, this.state.publicNotebooks));
<GalleryCardComponent }
key={url}
name={name} tabs.push(this.createTab(GalleryTab.Favorites, this.state.favoriteNotebooks));
url={url}
notebookMetadata={notebookMetadata} if (this.props.container.isGalleryPublishEnabled()) {
onClick={() => this.props.onClick(url, notebookMetadata, updateTabsStatePerNotebook, isLikedNotebook)} 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> </Stack>
); );
} }
}
export interface FullWidthTabsProps { private createCardsTabContent(data: IGalleryItem[]): JSX.Element {
officialSamplesContent: DataModels.GitHubInfoJunoResponse[]; return (
likedNotebooksContent: DataModels.GitHubInfoJunoResponse[]; <FocusZone>
userMetadata: DataModels.UserMetadata; <List
onClick: ( items={data}
url: string, getPageSpecification={this.getPageSpecification}
notebookMetadata: DataModels.NotebookMetadata, renderedWindowsAhead={3}
onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>, onRenderCell={this.onRenderCell}
isLikedNotebook: boolean />
) => Promise<void>; </FocusZone>
} );
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: () => (
<GalleryCardsComponent
data={this.state.officialSamplesContent}
onClick={this.props.onClick}
userMetadata={this.state.userMetadata}
onNotebookMetadataChange={this.updateTabsState}
/>
)
},
isVisible: () => true
},
{
title: "Liked Notebooks",
content: {
className: "",
render: () => (
<GalleryCardsComponent
data={this.state.likedNotebooksContent}
onClick={this.props.onClick}
userMetadata={this.state.userMetadata}
onNotebookMetadataChange={this.updateTabsState}
/>
)
},
isVisible: () => true
}
];
} }
public updateTabsState = async (officialSamplesIndex: number, notebookMetadata: DataModels.NotebookMetadata) => { private loadTabContent(tab: GalleryTab, searchText: string, sortBy: SortBy, offline: boolean): void {
let currentLikedNotebooksContent = [...this.state.likedNotebooksContent]; switch (tab) {
let currentUserMetadata = { ...this.state.userMetadata }; case GalleryTab.OfficialSamples:
let currentLikedNotebooks = [...currentUserMetadata.likedNotebooks]; this.loadSampleNotebooks(searchText, sortBy, offline);
break;
const currentOfficialSamplesContent = [...this.state.officialSamplesContent]; case GalleryTab.PublicGallery:
const currentOfficialSamplesObject = { ...currentOfficialSamplesContent[officialSamplesIndex] }; this.loadPublicNotebooks(searchText, sortBy, offline);
const metadata = { ...currentOfficialSamplesObject.metadata }; break;
const metadataLikesUpdates = metadata.likes - notebookMetadata.likes;
metadata.views = notebookMetadata.views; case GalleryTab.Favorites:
metadata.downloads = notebookMetadata.downloads; this.loadFavoriteNotebooks(searchText, sortBy, offline);
metadata.likes = notebookMetadata.likes; break;
currentOfficialSamplesObject.metadata = metadata;
// Notebook has been liked. Add To likedNotebooksContent, update isLikedNotebook flag case GalleryTab.Published:
if (metadataLikesUpdates < 0) { this.loadPublishedNotebooks(searchText, sortBy, offline);
currentOfficialSamplesObject.isLikedNotebook = true; break;
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
currentOfficialSamplesObject.isLikedNotebook = false; default:
const likedNotebookIndex = currentLikedNotebooks.findIndex((path: string) => { throw new Error(`Unknown tab ${tab}`);
return path === currentOfficialSamplesObject.path;
});
currentLikedNotebooksContent.splice(likedNotebookIndex, 1);
currentLikedNotebooks.splice(likedNotebookIndex, 1);
currentUserMetadata = { likedNotebooks: currentLikedNotebooks };
} }
}
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({ this.setState({
activeTabIndex: 0, sampleNotebooks: this.sampleNotebooks && [...this.sort(sortBy, this.search(searchText, this.sampleNotebooks))]
userMetadata: currentUserMetadata, });
likedNotebooksContent: currentLikedNotebooksContent, }
officialSamplesContent: currentOfficialSamplesContent
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( // Refresh favorite button state
async returnedNotebookMetadata => { if (this.state.selectedTab !== GalleryTab.Favorites) {
if (metadataLikesUpdates !== 0) { this.refreshSelectedTab();
JunoUtils.updateUserMetadata(this.authorizationToken, currentUserMetadata); }
// TODO: update state here? }
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 => { this.publishedNotebooks = response.data;
NotificationConsoleUtils.logConsoleMessage( } catch (error) {
ConsoleDataType.Error, const message = `Failed to load published notebooks: ${error}`;
`Error updating notebook metadata: ${JSON.stringify(error)}` Logger.logError(message, "GalleryViewerComponent/loadPublishedNotebooks");
); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
// TODO add telemetry
} }
}
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) => this.setState({ activeTabIndex }); private openNotebook = (data: IGalleryItem, isFavorite: boolean): void => {
if (this.props.container && this.props.junoClient) {
public render() { this.props.container.openGallery(this.props.junoClient.getNotebookContentUrl(data.id), data, isFavorite);
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() {
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
) => {
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");
} else { } 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 // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FullWidthTabs renders 1`] = ` exports[`GalleryViewerComponent 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`] = `
<div <div
className="galleryContainer" className="galleryContainer"
> >
<GalleryCardsComponent <StyledPivotBase
data={Array []} onLinkClick={[Function]}
onClick={[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> </div>
`; `;
exports[`GalleryCardsComponent renders 1`] = `
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 30,
}
}
wrap={true}
/>
`;
exports[`GalleryViewerContainerComponent renders 1`] = `<Fragment />`;

View File

@@ -1,17 +1,30 @@
import React from "react";
import { shallow } from "enzyme"; import { shallow } from "enzyme";
import { NotebookMetadataComponentProps, NotebookMetadataComponent } from "./NotebookMetadataComponent"; import React from "react";
import { NotebookMetadata } from "../../../Contracts/DataModels"; import { NotebookMetadataComponent, NotebookMetadataComponentProps } from "./NotebookMetadataComponent";
describe("NotebookMetadataComponent", () => { describe("NotebookMetadataComponent", () => {
it("renders un-liked notebook", () => { it("renders un-liked notebook", () => {
const props: NotebookMetadataComponentProps = { const props: NotebookMetadataComponentProps = {
notebookName: "My notebook", data: {
container: undefined, id: "id",
notebookMetadata: undefined, name: "name",
notebookContent: {}, description: "description",
onNotebookMetadataChange: (newNotebookMetadata: NotebookMetadata) => Promise.resolve(), author: "author",
isLikedNotebook: false 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} />); const wrapper = shallow(<NotebookMetadataComponent {...props} />);
@@ -20,12 +33,26 @@ describe("NotebookMetadataComponent", () => {
it("renders liked notebook", () => { it("renders liked notebook", () => {
const props: NotebookMetadataComponentProps = { const props: NotebookMetadataComponentProps = {
notebookName: "My notebook", data: {
container: undefined, id: "id",
notebookMetadata: undefined, name: "name",
notebookContent: {}, description: "description",
onNotebookMetadataChange: (newNotebookMetadata: NotebookMetadata) => Promise.resolve(), author: "author",
isLikedNotebook: true 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} />); const wrapper = shallow(<NotebookMetadataComponent {...props} />);

View File

@@ -1,189 +1,85 @@
/** /**
* Wrapper around Notebook metadata * 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 { import {
siteTextStyles, FontWeights,
subtleIconStyles, Icon,
iconStyles, IconButton,
iconButtonStyles, Link,
mainHelpfulTextStyles, Persona,
subtleHelpfulTextStyles, PersonaSize,
helpfulTextStyles PrimaryButton,
} from "../NotebookGallery/Cards/CardStyleConstants"; 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"; import "./NotebookViewerComponent.less";
initializeIcons();
export interface NotebookMetadataComponentProps { export interface NotebookMetadataComponentProps {
notebookName: string; data: IGalleryItem;
container: ViewModels.Explorer; isFavorite: boolean;
notebookMetadata: NotebookMetadata; downloadButtonText: string;
notebookContent: any; onTagClick: (tag: string) => void;
onNotebookMetadataChange: (newNotebookMetadata: NotebookMetadata) => Promise<void>; onFavoriteClick: () => void;
isLikedNotebook: boolean; onUnfavoriteClick: () => void;
onDownloadClick: () => void;
} }
interface NotebookMetadatComponentState { export class NotebookMetadataComponent extends React.Component<NotebookMetadataComponentProps> {
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);
});
};
public render(): JSX.Element { 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 ( return (
<div className="notebookViewerMetadataContainer"> <Stack tokens={{ childrenGap: 10 }}>
<h3 className="title">{this.props.notebookName}</h3> <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 && ( <Stack horizontal verticalAlign="center" tokens={{ childrenGap: 10 }}>
<div className="decoration"> <Persona text={this.props.data.author} size={PersonaSize.size32} />
{this.props.container ? ( <Text>{dateString}</Text>
<IconButton <Text>
iconProps={{ iconName: this.state.liked ? "HeartFill" : "Heart" }} <Icon iconName="RedEye" /> {this.props.data.views}
styles={iconButtonStyles} </Text>
onClick={this.onLike} <Text>
/> <Icon iconName="Download" />
) : ( {this.props.data.downloads}
<Icon iconName="Heart" styles={iconStyles} /> </Text>
)} </Stack>
<Text variant="large" styles={mainHelpfulTextStyles}>
{this.state.notebookMetadata.likes} likes
</Text>
</div>
)}
{this.props.container && ( <Text nowrap>
<button aria-label="downloadButton" className="downloadButton" onClick={this.onDownload}> {this.props.data.tags?.map((tag, index, array) => (
Download Notebook <span key={tag}>
</button> <Link onClick={(): void => this.props.onTagClick(tag)}>{tag}</Link>
)} {index === array.length - 1 ? <></> : ", "}
</span>
))}
</Text>
{this.props.notebookMetadata && ( <Text variant="large" styles={{ root: { fontWeight: FontWeights.semibold } }}>
<> Description
<div> </Text>
<Persona
className="persona" <Text>{this.props.data.description}</Text>
text={this.props.notebookMetadata.author} </Stack>
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>
); );
} }
} }

View File

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

View File

@@ -1,36 +1,44 @@
/** /**
* Wrapper around Notebook Viewer Read only content * 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 * as React from "react";
import { contents } from "rx-jupyter";
import * as Logger from "../../../Common/Logger";
import * as ViewModels from "../../../Contracts/ViewModels"; 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 { NotebookClientV2 } from "../../Notebook/NotebookClientV2";
import { NotebookComponentBootstrapper } from "../../Notebook/NotebookComponent/NotebookComponentBootstrapper"; import { NotebookComponentBootstrapper } from "../../Notebook/NotebookComponent/NotebookComponentBootstrapper";
import { createContentRef } from "@nteract/core";
import NotebookReadOnlyRenderer from "../../Notebook/NotebookRenderer/NotebookReadOnlyRenderer"; import NotebookReadOnlyRenderer from "../../Notebook/NotebookRenderer/NotebookReadOnlyRenderer";
import { contents } from "rx-jupyter"; import { DialogComponent, DialogProps } from "../DialogReactComponent/DialogComponent";
import { NotebookMetadata } from "../../../Contracts/DataModels";
import { NotebookMetadataComponent } from "./NotebookMetadataComponent"; import { NotebookMetadataComponent } from "./NotebookMetadataComponent";
import "./NotebookViewerComponent.less"; import "./NotebookViewerComponent.less";
export interface NotebookViewerComponentProps { export interface NotebookViewerComponentProps {
notebookName: string;
notebookUrl: string;
container?: ViewModels.Explorer; container?: ViewModels.Explorer;
notebookMetadata: NotebookMetadata; junoClient?: JunoClient;
onNotebookMetadataChange?: (newNotebookMetadata: NotebookMetadata) => Promise<void>; notebookUrl: string;
isLikedNotebook?: boolean; galleryItem?: IGalleryItem;
hideInputs?: boolean; isFavorite?: boolean;
backNavigationText: string;
onBackClick: () => void;
onTagClick: (tag: string) => void;
} }
interface NotebookViewerComponentState { interface NotebookViewerComponentState {
content: any; content: Notebook;
galleryItem?: IGalleryItem;
isFavorite?: boolean;
dialogProps: DialogProps;
} }
export class NotebookViewerComponent extends React.Component< export class NotebookViewerComponent extends React.Component<NotebookViewerComponentProps, NotebookViewerComponentState>
NotebookViewerComponentProps, implements GalleryUtils.DialogEnabledComponent {
NotebookViewerComponentState
> {
private clientManager: NotebookClientV2; private clientManager: NotebookClientV2;
private notebookComponentBootstrapper: NotebookComponentBootstrapper; private notebookComponentBootstrapper: NotebookComponentBootstrapper;
@@ -52,40 +60,118 @@ export class NotebookViewerComponent extends React.Component<
contentRef: createContentRef() 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> { setDialogProps = (dialogProps: DialogProps): void => {
const response: Response = await fetch(this.props.notebookUrl); this.setState({ dialogProps });
if (response.ok) { };
return await response.json();
} else { private async loadNotebookContent(): Promise<void> {
return undefined; 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 { public render(): JSX.Element {
return ( return (
<div className="notebookViewerContainer"> <div className="notebookViewerContainer">
<NotebookMetadataComponent {this.props.backNavigationText ? (
notebookMetadata={this.props.notebookMetadata} <Link onClick={this.props.onBackClick}>
notebookName={this.props.notebookName} <Icon iconName="Back" /> {this.props.backNavigationText}
container={this.props.container} </Link>
notebookContent={this.state.content} ) : (
onNotebookMetadataChange={this.props.onNotebookMetadataChange} <></>
isLikedNotebook={this.props.isLikedNotebook} )}
/>
{this.notebookComponentBootstrapper.renderComponent(NotebookReadOnlyRenderer, { {this.state.galleryItem ? (
hideInputs: this.props.hideInputs <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> </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 // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NotebookMetadataComponent renders liked notebook 1`] = ` exports[`NotebookMetadataComponent renders liked notebook 1`] = `
<div <Stack
className="notebookViewerMetadataContainer" tokens={
Object {
"childrenGap": 10,
}
}
> >
<h3 <Stack
className="title" horizontal={true}
tokens={
Object {
"childrenGap": 30,
}
}
verticalAlign="center"
> >
My notebook <Text
</h3> nowrap={true}
</div> 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`] = ` exports[`NotebookMetadataComponent renders un-liked notebook 1`] = `
<div <Stack
className="notebookViewerMetadataContainer" tokens={
Object {
"childrenGap": 10,
}
}
> >
<h3 <Stack
className="title" horizontal={true}
tokens={
Object {
"childrenGap": 30,
}
}
verticalAlign="center"
> >
My notebook <Text
</h3> nowrap={true}
</div> 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

@@ -1,6 +1,7 @@
@import "../../../../less/Common/Constants"; @import "../../../../less/Common/Constants";
.tabComponentContainer { .tabComponentContainer {
overflow: hidden;
height: 100%; height: 100%;
.flex-display(); .flex-display();
.flex-direction(); .flex-direction();

View File

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

View File

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

View File

@@ -21,7 +21,6 @@ import EnvironmentUtility from "../Common/EnvironmentUtility";
import GraphStylingPane from "./Panes/GraphStylingPane"; import GraphStylingPane from "./Panes/GraphStylingPane";
import hasher from "hasher"; import hasher from "hasher";
import NewVertexPane from "./Panes/NewVertexPane"; import NewVertexPane from "./Panes/NewVertexPane";
import NotebookTab from "./Tabs/NotebookTab";
import NotebookV2Tab from "./Tabs/NotebookV2Tab"; import NotebookV2Tab from "./Tabs/NotebookV2Tab";
import Q from "q"; import Q from "q";
import ResourceTokenCollection from "./Tree/ResourceTokenCollection"; import ResourceTokenCollection from "./Tree/ResourceTokenCollection";
@@ -50,19 +49,15 @@ import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane";
import { ExplorerMetrics } from "../Common/Constants"; import { ExplorerMetrics } from "../Common/Constants";
import { ExplorerSettings } from "../Shared/ExplorerSettings"; import { ExplorerSettings } from "../Shared/ExplorerSettings";
import { FileSystemUtil } from "./Notebook/FileSystemUtil"; import { FileSystemUtil } from "./Notebook/FileSystemUtil";
import { GitHubOAuthService } from "../GitHub/GitHubOAuthService";
import { GitHubReposPane } from "./Panes/GitHubReposPane";
import { handleOpenAction } from "./OpenActions"; import { handleOpenAction } from "./OpenActions";
import { IContentProvider } from "@nteract/core";
import { isInvalidParentFrameOrigin } from "../Utils/MessageValidation"; import { isInvalidParentFrameOrigin } from "../Utils/MessageValidation";
import { JunoClient } from "../Juno/JunoClient"; import { IGalleryItem } from "../Juno/JunoClient";
import { LibraryManagePane } from "./Panes/LibraryManagePane"; import { LibraryManagePane } from "./Panes/LibraryManagePane";
import { LoadQueryPane } from "./Panes/LoadQueryPane"; import { LoadQueryPane } from "./Panes/LoadQueryPane";
import { Logger } from "../Common/Logger"; import * as Logger from "../Common/Logger";
import { ManageSparkClusterPane } from "./Panes/ManageSparkClusterPane"; import { ManageSparkClusterPane } from "./Panes/ManageSparkClusterPane";
import { MessageHandler } from "../Common/MessageHandler"; import { MessageHandler } from "../Common/MessageHandler";
import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem"; import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem";
import { NotebookContentProvider } from "./Notebook/NotebookComponent/NotebookContentProvider";
import { NotebookUtil } from "./Notebook/NotebookUtil"; import { NotebookUtil } from "./Notebook/NotebookUtil";
import { NotebookWorkspaceManager } from "../NotebookWorkspaceManager/NotebookWorkspaceManager"; import { NotebookWorkspaceManager } from "../NotebookWorkspaceManager/NotebookWorkspaceManager";
import { NotificationConsoleComponentAdapter } from "./Menus/NotificationConsole/NotificationConsoleComponentAdapter"; import { NotificationConsoleComponentAdapter } from "./Menus/NotificationConsole/NotificationConsoleComponentAdapter";
@@ -86,6 +81,8 @@ import { StringInputPane } from "./Panes/StringInputPane";
import { TableColumnOptionsPane } from "./Panes/Tables/TableColumnOptionsPane"; import { TableColumnOptionsPane } from "./Panes/Tables/TableColumnOptionsPane";
import { UploadFilePane } from "./Panes/UploadFilePane"; import { UploadFilePane } from "./Panes/UploadFilePane";
import { UploadItemsPane } from "./Panes/UploadItemsPane"; import { UploadItemsPane } from "./Panes/UploadItemsPane";
import { UploadItemsPaneAdapter } from "./Panes/UploadItemsPaneAdapter";
import { ReactAdapter } from "../Bindings/ReactBindingHandler";
BindingHandlersRegisterer.registerBindingHandlers(); BindingHandlersRegisterer.registerBindingHandlers();
// Hold a reference to ComponentRegisterer to prevent transpiler to ignore import // Hold a reference to ComponentRegisterer to prevent transpiler to ignore import
@@ -154,7 +151,6 @@ export default class Explorer implements ViewModels.Explorer {
public selectedNode: ko.Observable<ViewModels.TreeNode>; public selectedNode: ko.Observable<ViewModels.TreeNode>;
public isRefreshingExplorer: ko.Observable<boolean>; public isRefreshingExplorer: ko.Observable<boolean>;
private resourceTree: ResourceTreeAdapter; private resourceTree: ResourceTreeAdapter;
private enableLegacyResourceTree: ko.Observable<boolean>;
// Resource Token // Resource Token
public resourceTokenDatabaseId: ko.Observable<string>; public resourceTokenDatabaseId: ko.Observable<string>;
@@ -188,6 +184,7 @@ export default class Explorer implements ViewModels.Explorer {
public executeSprocParamsPane: ViewModels.ExecuteSprocParamsPane; public executeSprocParamsPane: ViewModels.ExecuteSprocParamsPane;
public renewAdHocAccessPane: ViewModels.RenewAdHocAccessPane; public renewAdHocAccessPane: ViewModels.RenewAdHocAccessPane;
public uploadItemsPane: ViewModels.UploadItemsPane; public uploadItemsPane: ViewModels.UploadItemsPane;
public uploadItemsPaneAdapter: UploadItemsPaneAdapter;
public loadQueryPane: ViewModels.LoadQueryPane; public loadQueryPane: ViewModels.LoadQueryPane;
public saveQueryPane: ViewModels.ContextualPane; public saveQueryPane: ViewModels.ContextualPane;
public browseQueriesPane: ViewModels.BrowseQueriesPane; public browseQueriesPane: ViewModels.BrowseQueriesPane;
@@ -199,12 +196,15 @@ export default class Explorer implements ViewModels.Explorer {
public libraryManagePane: ViewModels.ContextualPane; public libraryManagePane: ViewModels.ContextualPane;
public clusterLibraryPane: ViewModels.ContextualPane; public clusterLibraryPane: ViewModels.ContextualPane;
public gitHubReposPane: ViewModels.ContextualPane; public gitHubReposPane: ViewModels.ContextualPane;
public publishNotebookPaneAdapter: ReactAdapter;
// features // features
public isGalleryEnabled: ko.Computed<boolean>; public isGalleryEnabled: ko.Computed<boolean>;
public isGalleryPublishEnabled: ko.Computed<boolean>;
public isGitHubPaneEnabled: ko.Observable<boolean>; public isGitHubPaneEnabled: ko.Observable<boolean>;
public isGraphsEnabled: ko.Computed<boolean>; public isPublishNotebookPaneEnabled: ko.Observable<boolean>;
public isHostedDataExplorerEnabled: ko.Computed<boolean>; public isHostedDataExplorerEnabled: ko.Computed<boolean>;
public isRightPanelV2Enabled: ko.Computed<boolean>;
public canExceedMaximumValue: ko.Computed<boolean>; public canExceedMaximumValue: ko.Computed<boolean>;
public hasAutoPilotV2FeatureFlag: ko.Computed<boolean>; public hasAutoPilotV2FeatureFlag: ko.Computed<boolean>;
@@ -223,11 +223,7 @@ export default class Explorer implements ViewModels.Explorer {
// Notebooks // Notebooks
public isNotebookEnabled: ko.Observable<boolean>; public isNotebookEnabled: ko.Observable<boolean>;
public isNotebooksEnabledForAccount: ko.Observable<boolean>; public isNotebooksEnabledForAccount: ko.Observable<boolean>;
private notebookClient: ViewModels.INotebookContainerClient;
private notebookContentClient: ViewModels.INotebookContentClient;
public notebookServerInfo: ko.Observable<DataModels.NotebookWorkspaceConnectionInfo>; public notebookServerInfo: ko.Observable<DataModels.NotebookWorkspaceConnectionInfo>;
public notebookContentProvider: IContentProvider;
public gitHubOAuthService: GitHubOAuthService;
public notebookWorkspaceManager: ViewModels.NotebookWorkspaceManager; public notebookWorkspaceManager: ViewModels.NotebookWorkspaceManager;
public sparkClusterManager: ViewModels.SparkClusterManager; public sparkClusterManager: ViewModels.SparkClusterManager;
public sparkClusterConnectionInfo: ko.Observable<DataModels.SparkClusterConnectionInfo>; public sparkClusterConnectionInfo: ko.Observable<DataModels.SparkClusterConnectionInfo>;
@@ -239,6 +235,7 @@ export default class Explorer implements ViewModels.Explorer {
public isSynapseLinkUpdating: ko.Observable<boolean>; public isSynapseLinkUpdating: ko.Observable<boolean>;
public isNotebookTabActive: ko.Computed<boolean>; public isNotebookTabActive: ko.Computed<boolean>;
public memoryUsageInfo: ko.Observable<DataModels.MemoryUsageInfo>; public memoryUsageInfo: ko.Observable<DataModels.MemoryUsageInfo>;
public notebookManager?: any; // This is dynamically loaded
private _panes: ViewModels.ContextualPane[] = []; private _panes: ViewModels.ContextualPane[] = [];
private _importExplorerConfigComplete: boolean = false; private _importExplorerConfigComplete: boolean = false;
@@ -303,7 +300,7 @@ export default class Explorer implements ViewModels.Explorer {
this.openedTabs && this.openedTabs &&
this.openedTabs().forEach(tab => { this.openedTabs().forEach(tab => {
if (tab.tabKind === ViewModels.CollectionTabKind.Notebook) { if (tab.tabKind === ViewModels.CollectionTabKind.Notebook) {
(tab as NotebookTab).reconfigureServiceEndpoints(); throw new Error("NotebookTab is deprecated. Use NotebookV2Tab");
} else if (tab.tabKind === ViewModels.CollectionTabKind.NotebookV2) { } else if (tab.tabKind === ViewModels.CollectionTabKind.NotebookV2) {
(tab as NotebookV2Tab).reconfigureServiceEndpoints(); (tab as NotebookV2Tab).reconfigureServiceEndpoints();
} }
@@ -381,7 +378,6 @@ export default class Explorer implements ViewModels.Explorer {
this.armEndpoint = ko.observable<string>(undefined); this.armEndpoint = ko.observable<string>(undefined);
this.queriesClient = new QueriesClient(this); this.queriesClient = new QueriesClient(this);
this.isTryCosmosDBSubscription = ko.observable<boolean>(false); this.isTryCosmosDBSubscription = ko.observable<boolean>(false);
this.enableLegacyResourceTree = ko.observable<boolean>(false);
this.resourceTokenDatabaseId = ko.observable<string>(); this.resourceTokenDatabaseId = ko.observable<string>();
this.resourceTokenCollectionId = ko.observable<string>(); this.resourceTokenCollectionId = ko.observable<string>();
@@ -410,10 +406,11 @@ export default class Explorer implements ViewModels.Explorer {
this.shouldShowDataAccessExpiryDialog = ko.observable<boolean>(false); this.shouldShowDataAccessExpiryDialog = ko.observable<boolean>(false);
this.shouldShowContextSwitchPrompt = ko.observable<boolean>(false); this.shouldShowContextSwitchPrompt = ko.observable<boolean>(false);
this.isGalleryEnabled = ko.computed<boolean>(() => this.isFeatureEnabled(Constants.Features.enableGallery)); 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.isGitHubPaneEnabled = ko.observable<boolean>(false);
this.isGraphsEnabled = ko.computed<boolean>(() => { this.isPublishNotebookPaneEnabled = ko.observable<boolean>(false);
return this.isFeatureEnabled(Constants.Features.graphs);
});
this.canExceedMaximumValue = ko.computed<boolean>(() => this.canExceedMaximumValue = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.canExceedMaximumValue) this.isFeatureEnabled(Constants.Features.canExceedMaximumValue)
@@ -551,6 +548,9 @@ export default class Explorer implements ViewModels.Explorer {
!this.isRunningOnNationalCloud() && !this.isRunningOnNationalCloud() &&
!this.isPreferredApiGraph() !this.isPreferredApiGraph()
); );
this.isRightPanelV2Enabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableRightPanelV2)
);
this.defaultExperience.subscribe((defaultExperience: string) => { this.defaultExperience.subscribe((defaultExperience: string) => {
if ( if (
defaultExperience && defaultExperience &&
@@ -707,6 +707,8 @@ export default class Explorer implements ViewModels.Explorer {
container: this container: this
}); });
this.uploadItemsPaneAdapter = new UploadItemsPaneAdapter(this);
this.loadQueryPane = new LoadQueryPane({ this.loadQueryPane = new LoadQueryPane({
documentClientUtility: this.documentClientUtility, documentClientUtility: this.documentClientUtility,
id: "loadquerypane", id: "loadquerypane",
@@ -936,127 +938,33 @@ export default class Explorer implements ViewModels.Explorer {
startKey startKey
); );
const junoClient = new JunoClient(this.databaseAccount);
this.isNotebookEnabled = ko.observable(false); this.isNotebookEnabled = ko.observable(false);
this.isNotebookEnabled.subscribe(async (isEnabled: boolean) => { this.isNotebookEnabled.subscribe(async () => {
this.refreshCommandBarButtons(); 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); this.gitHubReposPane = this.notebookManager.gitHubReposPane;
this.isGitHubPaneEnabled(true);
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");
} }
const promptForCommitMsg = (title: string, primaryButtonLabel: string) => { this.refreshCommandBarButtons();
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.refreshNotebookList(); this.refreshNotebookList();
}); });
this.isSparkEnabled = ko.observable(false); this.isSparkEnabled = ko.observable(false);
this.isSparkEnabled.subscribe((isEnabled: boolean) => this.refreshCommandBarButtons()); this.isSparkEnabled.subscribe((isEnabled: boolean) => this.refreshCommandBarButtons());
this.resourceTree = new ResourceTreeAdapter(this, junoClient); this.resourceTree = new ResourceTreeAdapter(this);
this.resourceTreeForResourceToken = new ResourceTreeAdapterForResourceToken(this); this.resourceTreeForResourceToken = new ResourceTreeAdapterForResourceToken(this);
this.notebookServerInfo = ko.observable<DataModels.NotebookWorkspaceConnectionInfo>({ this.notebookServerInfo = ko.observable<DataModels.NotebookWorkspaceConnectionInfo>({
notebookServerEndpoint: undefined, notebookServerEndpoint: undefined,
@@ -1100,8 +1008,6 @@ export default class Explorer implements ViewModels.Explorer {
this.sparkClusterConnectionInfo.valueHasMutated(); this.sparkClusterConnectionInfo.valueHasMutated();
} }
this.enableLegacyResourceTree(this.isFeatureEnabled(Constants.Features.enableLegacyResourceTree));
featureSubcription.dispose(); featureSubcription.dispose();
}); });
@@ -1888,7 +1794,7 @@ export default class Explorer implements ViewModels.Explorer {
} }
public resetNotebookWorkspace() { 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"; const error = "Attempt to reset notebook workspace, but notebook is not enabled";
Logger.logError(error, "Explorer/resetNotebookWorkspace"); Logger.logError(error, "Explorer/resetNotebookWorkspace");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
@@ -1995,7 +1901,7 @@ export default class Explorer implements ViewModels.Explorer {
this._closeModalDialog(); this._closeModalDialog();
const id = NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, "Resetting notebook workspace"); const id = NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, "Resetting notebook workspace");
try { try {
await this.notebookClient.resetWorkspace(); await this.notebookManager?.notebookClient.resetWorkspace();
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, "Successfully reset notebook workspace"); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, "Successfully reset notebook workspace");
TelemetryProcessor.traceSuccess(Action.ResetNotebookWorkspace); TelemetryProcessor.traceSuccess(Action.ResetNotebookWorkspace);
} catch (error) { } catch (error) {
@@ -2564,17 +2470,17 @@ export default class Explorer implements ViewModels.Explorer {
} }
private uploadFile(name: string, content: string, parent: NotebookContentItem): Promise<NotebookContentItem> { 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"; const error = "Attempt to upload notebook, but notebook is not enabled";
Logger.logError(error, "Explorer/uploadFile"); Logger.logError(error, "Explorer/uploadFile");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
throw new Error(error); throw new Error(error);
} }
const promise = this.notebookContentClient.uploadFileAsync(name, content, parent); const promise = this.notebookManager?.notebookContentClient.uploadFileAsync(name, content, parent);
promise promise
.then(() => this.resourceTree.triggerRender()) .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; return promise;
} }
@@ -2583,7 +2489,7 @@ export default class Explorer implements ViewModels.Explorer {
const item = NotebookUtil.createNotebookContentItem(name, path, "file"); const item = NotebookUtil.createNotebookContentItem(name, path, "file");
const parent = this.resourceTree.myNotebooksContentRoot; 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) { if (this._filePathToImportAndOpen === path) {
this._filePathToImportAndOpen = null; // we don't want to try opening this path again this._filePathToImportAndOpen = null; // we don't want to try opening this path again
} }
@@ -2602,15 +2508,10 @@ export default class Explorer implements ViewModels.Explorer {
return Promise.resolve(false); return Promise.resolve(false);
} }
public async importAndOpenFromGallery(path: string, newName: string, content: any): Promise<boolean> { public async importAndOpenFromGallery(name: string, content: string): Promise<boolean> {
const name = newName;
const parent = this.resourceTree.myNotebooksContentRoot; const parent = this.resourceTree.myNotebooksContentRoot;
if (parent && this.isNotebookEnabled() && this.notebookClient) { if (parent && parent.children && this.isNotebookEnabled() && this.notebookManager?.notebookClient) {
if (this._filePathToImportAndOpen === path) {
this._filePathToImportAndOpen = undefined; // we don't want to try opening this path again
}
const existingItem = _.find(parent.children, node => node.name === name); const existingItem = _.find(parent.children, node => node.name === name);
if (existingItem) { if (existingItem) {
this.showOkModalDialog("Download failed", "Notebook with the same name already exists."); this.showOkModalDialog("Download failed", "Notebook with the same name already exists.");
@@ -2621,10 +2522,17 @@ export default class Explorer implements ViewModels.Explorer {
return this.openNotebook(uploadedItem); return this.openNotebook(uploadedItem);
} }
this._filePathToImportAndOpen = path; // we'll try opening this path later on
return Promise.resolve(false); 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 { public showOkModalDialog(title: string, msg: string): void {
this._dialogProps({ this._dialogProps({
isModal: true, isModal: true,
@@ -2757,7 +2665,7 @@ export default class Explorer implements ViewModels.Explorer {
} }
public renameNotebook(notebookFile: NotebookContentItem): Q.Promise<NotebookContentItem> { 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"; const error = "Attempt to rename notebook, but notebook is not enabled";
Logger.logError(error, "Explorer/renameNotebook"); Logger.logError(error, "Explorer/renameNotebook");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
@@ -2768,7 +2676,7 @@ export default class Explorer implements ViewModels.Explorer {
const openedNotebookTabs = this.openedTabs().filter( const openedNotebookTabs = this.openedTabs().filter(
(tab: ViewModels.Tab) => (tab: ViewModels.Tab) =>
tab.tabKind === ViewModels.CollectionTabKind.NotebookV2 && tab.tabKind === ViewModels.CollectionTabKind.NotebookV2 &&
(tab as NotebookTab).notebookPath() === notebookFile.path (tab as NotebookV2Tab).notebookPath() === notebookFile.path
); );
if (openedNotebookTabs.length > 0) { if (openedNotebookTabs.length > 0) {
this.showOkModalDialog("Unable to rename file", "This file is being edited. Please close the tab and try again."); this.showOkModalDialog("Unable to rename file", "This file is being edited. Please close the tab and try again.");
@@ -2785,19 +2693,19 @@ export default class Explorer implements ViewModels.Explorer {
paneTitle: "Rename Notebook", paneTitle: "Rename Notebook",
submitButtonLabel: "Rename", submitButtonLabel: "Rename",
defaultInput: FileSystemUtil.stripExtension(notebookFile.name, "ipynb"), 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 => { .then(newNotebookFile => {
this.openedTabs() this.openedTabs()
.filter( .filter(
(tab: ViewModels.Tab) => (tab: ViewModels.Tab) =>
tab.tabKind === ViewModels.CollectionTabKind.NotebookV2 && tab.tabKind === ViewModels.CollectionTabKind.NotebookV2 &&
FileSystemUtil.isPathEqual((tab as NotebookTab).notebookPath(), originalPath) FileSystemUtil.isPathEqual((tab as NotebookV2Tab).notebookPath(), originalPath)
) )
.forEach(tab => { .forEach(tab => {
tab.tabTitle(newNotebookFile.name); tab.tabTitle(newNotebookFile.name);
tab.tabPath(newNotebookFile.path); tab.tabPath(newNotebookFile.path);
(tab as NotebookTab).notebookPath(newNotebookFile.path); (tab as NotebookV2Tab).notebookPath(newNotebookFile.path);
}); });
return newNotebookFile; return newNotebookFile;
@@ -2807,7 +2715,7 @@ export default class Explorer implements ViewModels.Explorer {
} }
public onCreateDirectory(parent: NotebookContentItem): Q.Promise<NotebookContentItem> { 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"; const error = "Attempt to create notebook directory, but notebook is not enabled";
Logger.logError(error, "Explorer/onCreateDirectory"); Logger.logError(error, "Explorer/onCreateDirectory");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
@@ -2822,32 +2730,32 @@ export default class Explorer implements ViewModels.Explorer {
paneTitle: "Create new directory", paneTitle: "Create new directory",
submitButtonLabel: "Create", submitButtonLabel: "Create",
defaultInput: "", defaultInput: "",
onSubmit: (input: string) => this.notebookContentClient.createDirectory(parent, input) onSubmit: (input: string) => this.notebookManager?.notebookContentClient.createDirectory(parent, input)
}); });
result.then(() => this.resourceTree.triggerRender()); result.then(() => this.resourceTree.triggerRender());
return result; return result;
} }
public readFile(notebookFile: NotebookContentItem): Promise<string> { 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"; const error = "Attempt to read file, but notebook is not enabled";
Logger.logError(error, "Explorer/downloadFile"); Logger.logError(error, "Explorer/downloadFile");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
throw new 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> { 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"; const error = "Attempt to download file, but notebook is not enabled";
Logger.logError(error, "Explorer/downloadFile"); Logger.logError(error, "Explorer/downloadFile");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
throw new Error(error); throw new Error(error);
} }
return this.notebookContentClient.readFileContent(notebookFile.path).then( return this.notebookManager?.notebookContentClient.readFileContent(notebookFile.path).then(
(content: string) => { (content: string) => {
const blob = new Blob([content], { type: "octet/stream" }); const blob = new Blob([content], { type: "octet/stream" });
if (navigator.msSaveBlob) { if (navigator.msSaveBlob) {
@@ -2867,7 +2775,7 @@ export default class Explorer implements ViewModels.Explorer {
downloadLink.remove(); downloadLink.remove();
} }
}, },
error => { (error: any) => {
NotificationConsoleUtils.logConsoleMessage( NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error, ConsoleDataType.Error,
`Could not download notebook ${JSON.stringify(error)}` `Could not download notebook ${JSON.stringify(error)}`
@@ -3014,7 +2922,7 @@ export default class Explorer implements ViewModels.Explorer {
} }
private refreshNotebookList = async (): Promise<void> => { private refreshNotebookList = async (): Promise<void> => {
if (!this.isNotebookEnabled() || !this.notebookContentClient) { if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) {
return; return;
} }
@@ -3025,7 +2933,7 @@ export default class Explorer implements ViewModels.Explorer {
}; };
public deleteNotebookFile(item: NotebookContentItem): Promise<void> { 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"; const error = "Attempt to delete notebook file, but notebook is not enabled";
Logger.logError(error, "Explorer/deleteNotebookFile"); Logger.logError(error, "Explorer/deleteNotebookFile");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
@@ -3035,7 +2943,7 @@ export default class Explorer implements ViewModels.Explorer {
// Don't delete if tab is open to avoid accidental deletion // Don't delete if tab is open to avoid accidental deletion
const openedNotebookTabs = this.openedTabs().filter( const openedNotebookTabs = this.openedTabs().filter(
(tab: ViewModels.Tab) => (tab: ViewModels.Tab) =>
tab.tabKind === ViewModels.CollectionTabKind.NotebookV2 && (tab as NotebookTab).notebookPath() === item.path tab.tabKind === ViewModels.CollectionTabKind.NotebookV2 && (tab as NotebookV2Tab).notebookPath() === item.path
); );
if (openedNotebookTabs.length > 0) { if (openedNotebookTabs.length > 0) {
this.showOkModalDialog("Unable to delete file", "This file is being edited. Please close the tab and try again."); this.showOkModalDialog("Unable to delete file", "This file is being edited. Please close the tab and try again.");
@@ -3056,11 +2964,11 @@ export default class Explorer implements ViewModels.Explorer {
return Promise.reject(); return Promise.reject();
} }
return this.notebookContentClient.deleteContentItem(item).then( return this.notebookManager?.notebookContentClient.deleteContentItem(item).then(
() => { () => {
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully deleted: ${item.path}`); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully deleted: ${item.path}`);
}, },
reason => { (reason: any) => {
NotificationConsoleUtils.logConsoleMessage( NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error, ConsoleDataType.Error,
`Failed to delete "${item.path}": ${JSON.stringify(reason)}` `Failed to delete "${item.path}": ${JSON.stringify(reason)}`
@@ -3073,7 +2981,7 @@ export default class Explorer implements ViewModels.Explorer {
* This creates a new notebook file, then opens the notebook * This creates a new notebook file, then opens the notebook
*/ */
public onNewNotebookClicked(parent?: NotebookContentItem): void { 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"; const error = "Attempt to create new notebook, but notebook is not enabled";
Logger.logError(error, "Explorer/onNewNotebookClicked"); Logger.logError(error, "Explorer/onNewNotebookClicked");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
@@ -3093,7 +3001,7 @@ export default class Explorer implements ViewModels.Explorer {
dataExplorerArea: Constants.Areas.Notebook dataExplorerArea: Constants.Areas.Notebook
}); });
this.notebookContentClient this.notebookManager?.notebookContentClient
.createNewNotebookFile(parent) .createNewNotebookFile(parent)
.then((newFile: NotebookContentItem) => { .then((newFile: NotebookContentItem) => {
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully created: ${newFile.name}`); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully created: ${newFile.name}`);
@@ -3109,7 +3017,7 @@ export default class Explorer implements ViewModels.Explorer {
return this.openNotebook(newFile); return this.openNotebook(newFile);
}) })
.then(() => this.resourceTree.triggerRender()) .then(() => this.resourceTree.triggerRender())
.catch(reason => { .catch((reason: any) => {
const error = `Failed to create a new notebook: ${reason}`; const error = `Failed to create a new notebook: ${reason}`;
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
TelemetryProcessor.traceFailure( TelemetryProcessor.traceFailure(
@@ -3159,14 +3067,14 @@ export default class Explorer implements ViewModels.Explorer {
} }
public refreshContentItem(item: NotebookContentItem): Promise<void> { 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"; const error = "Attempt to refresh notebook list, but notebook is not enabled";
Logger.logError(error, "Explorer/refreshContentItem"); Logger.logError(error, "Explorer/refreshContentItem");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error);
return Promise.reject(new Error(error)); return Promise.reject(new Error(error));
} }
return this.notebookContentClient.updateItemChildren(item); return this.notebookManager?.notebookContentClient.updateItemChildren(item);
} }
public getNotebookBasePath(): string { public getNotebookBasePath(): string {
@@ -3235,7 +3143,7 @@ export default class Explorer implements ViewModels.Explorer {
newTab.onTabClick(); newTab.onTabClick();
} }
public openGallery() { public async openGallery(notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean) {
let title: string; let title: string;
let hashLocation: string; let hashLocation: string;
@@ -3250,25 +3158,34 @@ export default class Explorer implements ViewModels.Explorer {
if (openedTabs[i].hashLocation() == hashLocation) { if (openedTabs[i].hashLocation() == hashLocation) {
openedTabs[i].onTabClick(); openedTabs[i].onTabClick();
openedTabs[i].onActivate(); openedTabs[i].onActivate();
(openedTabs[i] as any).updateGalleryParams(notebookUrl, galleryItem, isFavorite);
return; return;
} }
} }
if (!this.galleryTab) {
this.galleryTab = await import(/* webpackChunkName: "GalleryTab" */ "./Tabs/GalleryTab");
}
const newTab = new this.galleryTab.default({ const newTab = new this.galleryTab.default({
// GalleryTabOptions
account: CosmosClient.databaseAccount(), account: CosmosClient.databaseAccount(),
container: this,
junoClient: this.notebookManager?.junoClient,
notebookUrl,
galleryItem,
isFavorite,
// TabOptions
tabKind: ViewModels.CollectionTabKind.Gallery, tabKind: ViewModels.CollectionTabKind.Gallery,
node: null,
title: title, title: title,
tabPath: title, tabPath: title,
documentClientUtility: null, documentClientUtility: null,
collection: null,
selfLink: null, selfLink: null,
hashLocation: hashLocation,
isActive: ko.observable(false), isActive: ko.observable(false),
hashLocation: hashLocation,
onUpdateTabsButtons: this.onUpdateTabsButtons,
isTabsContentExpanded: ko.observable(true), isTabsContentExpanded: ko.observable(true),
onLoadStartKey: null, onLoadStartKey: null,
onUpdateTabsButtons: this.onUpdateTabsButtons,
container: this,
openedTabs: this.openedTabs() openedTabs: this.openedTabs()
}); });
@@ -3278,16 +3195,14 @@ export default class Explorer implements ViewModels.Explorer {
newTab.onTabClick(); newTab.onTabClick();
} }
public openNotebookViewer( public async openNotebookViewer(notebookUrl: string) {
notebookUrl: string, const title = path.basename(notebookUrl);
notebookMetadata: DataModels.NotebookMetadata,
onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise<void>,
isLikedNotebook: boolean
) {
const notebookName = path.basename(notebookUrl);
const title = notebookName;
const hashLocation = notebookUrl; const hashLocation = notebookUrl;
if (!this.notebookViewerTab) {
this.notebookViewerTab = await import(/* webpackChunkName: "NotebookViewerTab" */ "./Tabs/NotebookViewerTab");
}
const notebookViewerTabModule = this.notebookViewerTab; const notebookViewerTabModule = this.notebookViewerTab;
let isNotebookViewerOpen = (tab: ViewModels.Tab) => { let isNotebookViewerOpen = (tab: ViewModels.Tab) => {
@@ -3323,11 +3238,7 @@ export default class Explorer implements ViewModels.Explorer {
onUpdateTabsButtons: this.onUpdateTabsButtons, onUpdateTabsButtons: this.onUpdateTabsButtons,
container: this, container: this,
openedTabs: this.openedTabs(), openedTabs: this.openedTabs(),
notebookUrl: notebookUrl, notebookUrl
notebookName: notebookName,
notebookMetadata: notebookMetadata,
onNotebookMetadataChange: onNotebookMetadataChange,
isLikedNotebook: isLikedNotebook
}); });
this.openedTabs.push(newTab); this.openedTabs.push(newTab);

View File

@@ -11,7 +11,6 @@ import * as ViewModels from "../../../Contracts/ViewModels";
import * as DataModels from "../../../Contracts/DataModels"; import * as DataModels from "../../../Contracts/DataModels";
import * as StorageUtility from "../../../Shared/StorageUtility"; import * as StorageUtility from "../../../Shared/StorageUtility";
import GraphTab from "../../Tabs/GraphTab"; import GraphTab from "../../Tabs/GraphTab";
import DocumentClientUtilityBase from "../../../Common/DocumentClientUtilityBase";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
describe("Check whether query result is vertex array", () => { 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", () => { describe("Create proper pkid pair", () => {
it("should enclose string pk with quotes", () => { 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", () => { 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 => { 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 => { 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 => { const isVisible = (selector: string): boolean => {
@@ -293,7 +356,7 @@ describe("GraphExplorer", () => {
describe("Load Graph button", () => { describe("Load Graph button", () => {
beforeEach(async done => { beforeEach(async done => {
const backendResponses: BackendResponses = {}; const backendResponses: BackendResponses = {};
backendResponses["g.V()"] = backendResponses['g.V("1")'] = { backendResponses["g.V()"] = backendResponses["g.V('1')"] = {
response: [{ id: "1", type: "vertex" }], response: [{ id: "1", type: "vertex" }],
isLast: false isLast: false
}; };
@@ -341,7 +404,7 @@ describe("GraphExplorer", () => {
describe("Execute Gremlin Query button", () => { describe("Execute Gremlin Query button", () => {
beforeEach(done => { beforeEach(done => {
const backendResponses: BackendResponses = {}; const backendResponses: BackendResponses = {};
backendResponses["g.V()"] = backendResponses['g.V("2")'] = { backendResponses["g.V()"] = backendResponses["g.V('2')"] = {
response: [{ id: "2", type: "vertex" }], response: [{ id: "2", type: "vertex" }],
isLast: false isLast: false
}; };
@@ -411,7 +474,7 @@ describe("GraphExplorer", () => {
beforeEach(done => { beforeEach(done => {
const backendResponses: BackendResponses = {}; const backendResponses: BackendResponses = {};
// TODO Make this less dependent on spaces, order and quotes // 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: [ response: [
{ {
id: node1Id, id: node1Id,
@@ -667,7 +730,7 @@ describe("GraphExplorer", () => {
describe("when isGraphAutoVizDisabled setting is true (autoviz disabled)", () => { describe("when isGraphAutoVizDisabled setting is true (autoviz disabled)", () => {
beforeEach(done => { beforeEach(done => {
const backendResponses: BackendResponses = {}; const backendResponses: BackendResponses = {};
backendResponses["g.V()"] = backendResponses['g.V("3")'] = { backendResponses["g.V()"] = backendResponses["g.V('3')"] = {
response: [{ id: "3", type: "vertex" }], response: [{ id: "3", type: "vertex" }],
isLast: true isLast: true
}; };

View File

@@ -327,8 +327,8 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
* @param id * @param id
*/ */
public static generatePkIdPair(pk: PartitionKeyValueType, id: string) { public static generatePkIdPair(pk: PartitionKeyValueType, id: string) {
const pkStr = typeof pk === "string" ? `"${pk}"` : `${pk}`; const pkStr = typeof pk === "string" ? `'${pk}'` : `${pk}`;
return `[${pkStr}, "${GraphUtil.escapeDoubleQuotes(id)}"]`; return `[${pkStr}, '${GraphUtil.escapeSingleQuotes(id)}']`;
} }
public updateVertexProperties(editedProperties: EditedProperties): Q.Promise<GremlinClient.GremlinRequestResult> { 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; const pk = v.properties[this.props.collectionPartitionKeyProperty][0].value;
return GraphExplorer.generatePkIdPair(pk, v.id); return GraphExplorer.generatePkIdPair(pk, v.id);
} else { } 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 not partitioned, return 'id'.
* If collection is partitioned, return pk-id pair. * If collection is partitioned, return pk-id pair.
* public for testing purposes
* @param vertex * @param vertex
* @return id * @return id
*/ */
private getPkIdFromDocumentId(d: DataModels.DocumentId): string { public static getPkIdFromDocumentId(d: DataModels.DocumentId, collectionPartitionKeyProperty: string): string {
if (this.props.collectionPartitionKeyProperty && d.hasOwnProperty(this.props.collectionPartitionKeyProperty)) { let { id } = d;
const pk = (d as any)[this.props.collectionPartitionKeyProperty]; if (typeof id !== "string") {
return GraphExplorer.generatePkIdPair(pk, d.id); 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 { } 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 || []; const documents = results.documents || [];
return documents.map( return documents.map(
(item: DataModels.DocumentId) => { (item: DataModels.DocumentId) => {
return this.getPkIdFromDocumentId(item); return GraphExplorer.getPkIdFromDocumentId(item, this.props.collectionPartitionKeyProperty);
}, },
(reason: any) => { (reason: any) => {
// Failure // Failure

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import * as ViewModels from "../../../Contracts/ViewModels";
import { CommandBarComponentButtonFactory } from "./CommandBarComponentButtonFactory"; import { CommandBarComponentButtonFactory } from "./CommandBarComponentButtonFactory";
import { ExplorerStub } from "../../OpenActionsStubs"; import { ExplorerStub } from "../../OpenActionsStubs";
import { GitHubOAuthService } from "../../../GitHub/GitHubOAuthService"; import { GitHubOAuthService } from "../../../GitHub/GitHubOAuthService";
import NotebookManager from "../../Notebook/NotebookManager";
describe("CommandBarComponentButtonFactory tests", () => { describe("CommandBarComponentButtonFactory tests", () => {
let mockExplorer: ViewModels.Explorer; let mockExplorer: ViewModels.Explorer;
@@ -19,6 +20,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
mockExplorer.isPreferredApiCassandra = ko.computed<boolean>(() => false); mockExplorer.isPreferredApiCassandra = ko.computed<boolean>(() => false);
mockExplorer.isSparkEnabled = ko.observable(true); mockExplorer.isSparkEnabled = ko.observable(true);
mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false); mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false);
mockExplorer.isGalleryPublishEnabled = ko.computed<boolean>(() => false);
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true); mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true; mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
}); });
@@ -81,6 +83,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
mockExplorer.isPreferredApiCassandra = ko.computed<boolean>(() => false); mockExplorer.isPreferredApiCassandra = ko.computed<boolean>(() => false);
mockExplorer.isSparkEnabled = ko.observable(true); mockExplorer.isSparkEnabled = ko.observable(true);
mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false); mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false);
mockExplorer.isGalleryPublishEnabled = ko.computed<boolean>(() => false);
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true); mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true; mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
}); });
@@ -161,6 +164,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
mockExplorer.isPreferredApiMongoDB = ko.computed<boolean>(() => false); mockExplorer.isPreferredApiMongoDB = ko.computed<boolean>(() => false);
mockExplorer.isSparkEnabled = ko.observable(true); mockExplorer.isSparkEnabled = ko.observable(true);
mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false); mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => false);
mockExplorer.isGalleryPublishEnabled = ko.computed<boolean>(() => false);
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true); mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true; mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
}); });
@@ -247,7 +251,9 @@ describe("CommandBarComponentButtonFactory tests", () => {
mockExplorer.isNotebooksEnabledForAccount = ko.observable(false); mockExplorer.isNotebooksEnabledForAccount = ko.observable(false);
mockExplorer.isRunningOnNationalCloud = ko.observable(false); mockExplorer.isRunningOnNationalCloud = ko.observable(false);
mockExplorer.isGalleryEnabled = ko.computed<boolean>(() => 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(() => { beforeEach(() => {
@@ -268,7 +274,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
it("Notebooks is enabled and GitHubOAuthService is logged in - manage github settings button should be visible", () => { it("Notebooks is enabled and GitHubOAuthService is logged in - manage github settings button should be visible", () => {
mockExplorer.isNotebookEnabled = ko.observable(true); 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 buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer);
const manageGitHubSettingsBtn = buttons.find( 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 NewNotebookIcon from "../../../../images/notebook/Notebook-new.svg";
import ResetWorkspaceIcon from "../../../../images/notebook/Notebook-reset-workspace.svg"; import ResetWorkspaceIcon from "../../../../images/notebook/Notebook-reset-workspace.svg";
import LibraryManageIcon from "../../../../images/notebook/Spark-library-manage.svg"; import LibraryManageIcon from "../../../../images/notebook/Spark-library-manage.svg";
import GalleryIcon from "../../../../images/GalleryIcon.svg";
import GitHubIcon from "../../../../images/github.svg"; import GitHubIcon from "../../../../images/github.svg";
import SynapseIcon from "../../../../images/synapse-link.svg"; import SynapseIcon from "../../../../images/synapse-link.svg";
import { config, Platform } from "../../../Config"; import { config, Platform } from "../../../Config";
@@ -64,7 +63,7 @@ export class CommandBarComponentButtonFactory {
]; ];
buttons.push(newNotebookButton); buttons.push(newNotebookButton);
if (container.gitHubOAuthService) { if (container.notebookManager?.gitHubOAuthService) {
buttons.push(CommandBarComponentButtonFactory.createManageGitHubAccountButton(container)); buttons.push(CommandBarComponentButtonFactory.createManageGitHubAccountButton(container));
} }
} }
@@ -87,10 +86,6 @@ export class CommandBarComponentButtonFactory {
buttons.push(CommandBarComponentButtonFactory.createOpenTerminalButton(container)); buttons.push(CommandBarComponentButtonFactory.createOpenTerminalButton(container));
buttons.push(CommandBarComponentButtonFactory.createNotebookWorkspaceResetButton(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 // 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 { private static createOpenMongoTerminalButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
const label = "Open Mongo Shell"; const label = "Open Mongo Shell";
const tooltip = const tooltip =
@@ -654,7 +636,7 @@ export class CommandBarComponentButtonFactory {
} }
private static createManageGitHubAccountButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig { 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"; const label = connectedToGitHub ? "Manage GitHub settings" : "Connect to GitHub";
return { return {
iconSrc: GitHubIcon, iconSrc: GitHubIcon,

View File

@@ -36,11 +36,12 @@ export class CommandBarUtil {
const result: ICommandBarItemProps = { const result: ICommandBarItemProps = {
iconProps: { iconProps: {
iconType: IconType.image,
style: { 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, onClick: btn.onCommandClick,
key: `${btn.commandButtonLabel}${index}`, key: `${btn.commandButtonLabel}${index}`,

View File

@@ -1,43 +0,0 @@
import * as ko from "knockout";
import * as ViewModels from "../../Contracts/ViewModels";
import { CommandButtonOptions } from "./../Controls/CommandButton/CommandButton";
export default class ContextMenu implements ViewModels.ContextMenu {
public container: ViewModels.Explorer;
public visible: ko.Observable<boolean>;
public elementId: string;
public options: ko.ObservableArray<CommandButtonOptions>;
public tabIndex: ko.Observable<number>;
constructor(container: ViewModels.Explorer, rid: string) {
this.container = container;
this.visible = ko.observable<boolean>(false);
this.elementId = `contextMenu${rid}`;
this.options = ko.observableArray<CommandButtonOptions>([]);
this.tabIndex = ko.observable<number>(0);
}
public show(source: ViewModels.TreeNode, event: MouseEvent) {
if (source && source.contextMenu && source.contextMenu.visible && source.contextMenu.visible()) {
return;
}
this.container.selectedNode(source);
const elementId = source.contextMenu.elementId;
const htmlElement = document.getElementById(elementId);
htmlElement.style.left = `${event.clientX}px`;
htmlElement.style.top = `${event.clientY}px`;
!!source.contextMenu && source.contextMenu.visible(true);
source.contextMenu.tabIndex(0);
htmlElement.focus();
}
public hide(source: ViewModels.TreeNode, event: MouseEvent) {
if (!source || !source.contextMenu || !source.contextMenu.visible || !source.contextMenu.visible()) {
return;
}
source.contextMenu.tabIndex(-1);
source.contextMenu.visible(false);
}
}

View File

@@ -17,7 +17,7 @@ import {
} from "@nteract/core"; } from "@nteract/core";
import * as Immutable from "immutable"; import * as Immutable from "immutable";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { CellType, CellId } from "@nteract/commutable"; import { CellType, CellId, toJS } from "@nteract/commutable";
import { Store, AnyAction } from "redux"; import { Store, AnyAction } from "redux";
import "./NotebookComponent.less"; 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 { public setContent(name: string, content: any): void {
this.getStore().dispatch( this.getStore().dispatch(
actions.fetchContentFulfilled({ actions.fetchContentFulfilled({

View File

@@ -2,7 +2,7 @@ import { ServerConfig, IContentProvider, FileType, IContent, IGetParams } from "
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { AjaxResponse } from "rxjs/ajax"; import { AjaxResponse } from "rxjs/ajax";
import { GitHubContentProvider } from "../../../GitHub/GitHubContentProvider"; import { GitHubContentProvider } from "../../../GitHub/GitHubContentProvider";
import { GitHubUtils } from "../../../Utils/GitHubUtils"; import * as GitHubUtils from "../../../Utils/GitHubUtils";
export class NotebookContentProvider implements IContentProvider { export class NotebookContentProvider implements IContentProvider {
constructor(private gitHubContentProvider: GitHubContentProvider, private jupyterContentProvider: 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 * as Constants from "../../Common/Constants";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils"; import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
import { Logger } from "../../Common/Logger"; import * as Logger from "../../Common/Logger";
export class NotebookContainerClient implements ViewModels.INotebookContainerClient { export class NotebookContainerClient implements ViewModels.INotebookContainerClient {
private reconnectingNotificationId: string; 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 { NotebookUtil } from "./NotebookUtil";
import { GitHubUtils } from "../../Utils/GitHubUtils"; import * as GitHubUtils from "../../Utils/GitHubUtils";
const fileName = "file"; const fileName = "file";
const notebookName = "file.ipynb"; const notebookName = "file.ipynb";

View File

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

View File

@@ -6,8 +6,6 @@ import Q from "q";
import { ArcadiaWorkspaceItem } from "./Controls/Arcadia/ArcadiaMenuPicker"; import { ArcadiaWorkspaceItem } from "./Controls/Arcadia/ArcadiaMenuPicker";
import { CassandraTableKey, CassandraTableKeys, TableDataClient } from "../../src/Explorer/Tables/TableDataClient"; import { CassandraTableKey, CassandraTableKeys, TableDataClient } from "../../src/Explorer/Tables/TableDataClient";
import { ConsoleData } from "../../src/Explorer/Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleData } from "../../src/Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import { GitHubOAuthService } from "../GitHub/GitHubOAuthService";
import { IContentProvider } from "@nteract/core";
import { MostRecentActivity } from "./MostRecentActivity/MostRecentActivity"; import { MostRecentActivity } from "./MostRecentActivity/MostRecentActivity";
import { NotebookContentItem } from "./Notebook/NotebookContentItem"; import { NotebookContentItem } from "./Notebook/NotebookContentItem";
import { PlatformType } from "../../src/PlatformType"; import { PlatformType } from "../../src/PlatformType";
@@ -19,8 +17,11 @@ import { TableColumnOptionsPane } from "../../src/Explorer/Panes/Tables/TableCol
import { TextFieldProps } from "./Controls/DialogReactComponent/DialogComponent"; import { TextFieldProps } from "./Controls/DialogReactComponent/DialogComponent";
import { UploadDetails } from "../workers/upload/definitions"; import { UploadDetails } from "../workers/upload/definitions";
import { UploadFilePane } from "./Panes/UploadFilePane"; import { UploadFilePane } from "./Panes/UploadFilePane";
import { UploadItemsPaneAdapter } from "./Panes/UploadItemsPaneAdapter";
import { Versions } from "../../src/Contracts/ExplorerContracts"; import { Versions } from "../../src/Contracts/ExplorerContracts";
import { CollectionCreationDefaults } from "../Shared/Constants"; import { CollectionCreationDefaults } from "../Shared/Constants";
import { IGalleryItem } from "../Juno/JunoClient";
import { ReactAdapter } from "../Bindings/ReactBindingHandler";
export class ExplorerStub implements ViewModels.Explorer { export class ExplorerStub implements ViewModels.Explorer {
public flight: ko.Observable<string>; public flight: ko.Observable<string>;
@@ -86,6 +87,7 @@ export class ExplorerStub implements ViewModels.Explorer {
public settingsPane: ViewModels.SettingsPane; public settingsPane: ViewModels.SettingsPane;
public executeSprocParamsPane: ViewModels.ExecuteSprocParamsPane; public executeSprocParamsPane: ViewModels.ExecuteSprocParamsPane;
public uploadItemsPane: ViewModels.UploadItemsPane; public uploadItemsPane: ViewModels.UploadItemsPane;
public uploadItemsPaneAdapter: UploadItemsPaneAdapter;
public loadQueryPane: ViewModels.LoadQueryPane; public loadQueryPane: ViewModels.LoadQueryPane;
public saveQueryPane: ViewModels.ContextualPane; public saveQueryPane: ViewModels.ContextualPane;
public browseQueriesPane: ViewModels.BrowseQueriesPane; public browseQueriesPane: ViewModels.BrowseQueriesPane;
@@ -95,8 +97,10 @@ export class ExplorerStub implements ViewModels.Explorer {
public setupSparkClusterPane: ViewModels.ContextualPane; public setupSparkClusterPane: ViewModels.ContextualPane;
public manageSparkClusterPane: ViewModels.ContextualPane; public manageSparkClusterPane: ViewModels.ContextualPane;
public isGalleryEnabled: ko.Computed<boolean>; public isGalleryEnabled: ko.Computed<boolean>;
public isGalleryPublishEnabled: ko.Computed<boolean>;
public isGitHubPaneEnabled: ko.Observable<boolean>; public isGitHubPaneEnabled: ko.Observable<boolean>;
public isGraphsEnabled: ko.Computed<boolean>; public isPublishNotebookPaneEnabled: ko.Observable<boolean>;
public isRightPanelV2Enabled: ko.Computed<boolean>;
public canExceedMaximumValue: ko.Computed<boolean>; public canExceedMaximumValue: ko.Computed<boolean>;
public isHostedDataExplorerEnabled: ko.Computed<boolean>; public isHostedDataExplorerEnabled: ko.Computed<boolean>;
public parentFrameDataExplorerVersion: ko.Observable<string> = ko.observable<string>(Versions.DataExplorer); public parentFrameDataExplorerVersion: ko.Observable<string> = ko.observable<string>(Versions.DataExplorer);
@@ -109,19 +113,19 @@ export class ExplorerStub implements ViewModels.Explorer {
public arcadiaToken: ko.Observable<string>; public arcadiaToken: ko.Observable<string>;
public notebookWorkspaceManager: ViewModels.NotebookWorkspaceManager; public notebookWorkspaceManager: ViewModels.NotebookWorkspaceManager;
public sparkClusterManager: ViewModels.SparkClusterManager; public sparkClusterManager: ViewModels.SparkClusterManager;
public notebookContentProvider: IContentProvider;
public gitHubOAuthService: GitHubOAuthService;
public notebookServerInfo: ko.Observable<DataModels.NotebookWorkspaceConnectionInfo>; public notebookServerInfo: ko.Observable<DataModels.NotebookWorkspaceConnectionInfo>;
public sparkClusterConnectionInfo: ko.Observable<DataModels.SparkClusterConnectionInfo>; public sparkClusterConnectionInfo: ko.Observable<DataModels.SparkClusterConnectionInfo>;
public libraryManagePane: ViewModels.ContextualPane; public libraryManagePane: ViewModels.ContextualPane;
public clusterLibraryPane: ViewModels.ContextualPane; public clusterLibraryPane: ViewModels.ContextualPane;
public gitHubReposPane: ViewModels.ContextualPane; public gitHubReposPane: ViewModels.ContextualPane;
public publishNotebookPaneAdapter: ReactAdapter;
public arcadiaWorkspaces: ko.ObservableArray<ArcadiaWorkspaceItem>; public arcadiaWorkspaces: ko.ObservableArray<ArcadiaWorkspaceItem>;
public hasStorageAnalyticsAfecFeature: ko.Observable<boolean>; public hasStorageAnalyticsAfecFeature: ko.Observable<boolean>;
public isSynapseLinkUpdating: ko.Observable<boolean>; public isSynapseLinkUpdating: ko.Observable<boolean>;
public isNotebookTabActive: ko.Computed<boolean>; public isNotebookTabActive: ko.Computed<boolean>;
public memoryUsageInfo: ko.Observable<DataModels.MemoryUsageInfo>; 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 openNotebookViewer: (notebookUrl: string) => void;
public resourceTokenDatabaseId: ko.Observable<string>; public resourceTokenDatabaseId: ko.Observable<string>;
public resourceTokenCollectionId: ko.Observable<string>; public resourceTokenCollectionId: ko.Observable<string>;
@@ -329,7 +333,11 @@ export class ExplorerStub implements ViewModels.Explorer {
throw new Error("Not implemented"); 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"); throw new Error("Not implemented");
} }
@@ -447,7 +455,6 @@ export class DatabaseStub implements ViewModels.Database {
public collections: ko.ObservableArray<ViewModels.Collection>; public collections: ko.ObservableArray<ViewModels.Collection>;
public isDatabaseExpanded: ko.Observable<boolean>; public isDatabaseExpanded: ko.Observable<boolean>;
public isDatabaseShared: ko.Computed<boolean>; public isDatabaseShared: ko.Computed<boolean>;
public contextMenu: ViewModels.ContextMenu;
public selectedSubnodeKind: ko.Observable<ViewModels.CollectionTabKind>; public selectedSubnodeKind: ko.Observable<ViewModels.CollectionTabKind>;
public offer: ko.Observable<DataModels.Offer>; public offer: ko.Observable<DataModels.Offer>;
@@ -459,7 +466,6 @@ export class DatabaseStub implements ViewModels.Database {
this.id = options.id; this.id = options.id;
this.collections = options.collections; this.collections = options.collections;
this.isDatabaseExpanded = options.isDatabaseExpanded; this.isDatabaseExpanded = options.isDatabaseExpanded;
this.contextMenu = options.contextMenu;
this.offer = options.offer; this.offer = options.offer;
this.selectedSubnodeKind = options.selectedSubnodeKind; this.selectedSubnodeKind = options.selectedSubnodeKind;
} }
@@ -562,8 +568,6 @@ export class CollectionStub implements ViewModels.Collection {
public storedProceduresFocused: ko.Observable<boolean>; public storedProceduresFocused: ko.Observable<boolean>;
public userDefinedFunctionsFocused: ko.Observable<boolean>; public userDefinedFunctionsFocused: ko.Observable<boolean>;
public triggersFocused: ko.Observable<boolean>; public triggersFocused: ko.Observable<boolean>;
public contextMenu: ViewModels.ContextMenu;
public documentsContextMenu: ViewModels.ContextMenu;
public conflictResolutionPolicy: ko.Observable<DataModels.ConflictResolutionPolicy>; public conflictResolutionPolicy: ko.Observable<DataModels.ConflictResolutionPolicy>;
public changeFeedPolicy: ko.Observable<DataModels.ChangeFeedPolicy>; public changeFeedPolicy: ko.Observable<DataModels.ChangeFeedPolicy>;
public geospatialConfig: ko.Observable<DataModels.GeospatialConfig>; public geospatialConfig: ko.Observable<DataModels.GeospatialConfig>;
@@ -608,69 +612,8 @@ export class CollectionStub implements ViewModels.Collection {
this.storedProceduresFocused = options.storedProceduresFocused; this.storedProceduresFocused = options.storedProceduresFocused;
this.userDefinedFunctionsFocused = options.userDefinedFunctionsFocused; this.userDefinedFunctionsFocused = options.userDefinedFunctionsFocused;
this.triggersFocused = options.triggersFocused; this.triggersFocused = options.triggersFocused;
this.contextMenu = options.contextMenu;
this.documentsContextMenu = options.documentsContextMenu;
} }
public onKeyPress = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onKeyDown = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onMenuKeyDown = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onDocumentDBDocumentsKeyDown = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onDocumentDBDocumentsKeyPress = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onMongoDBDocumentsKeyDown = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onMongoDBDocumentsKeyPress = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onSettingsKeyDown = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onSettingsKeyPress = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onStoredProceduresKeyDown = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onStoredProceduresKeyPress = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onUserDefinedFunctionsKeyDown = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onUserDefinedFunctionsKeyPress = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onTriggersKeyDown = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public onTriggersKeyPress = (source: any, event: KeyboardEvent): boolean => {
throw new Error("Not implemented");
};
public expandCollapseCollection() { public expandCollapseCollection() {
throw new Error("Not implemented"); throw new Error("Not implemented");
} }

View File

@@ -5,8 +5,6 @@ import Explorer from "../Explorer";
import ko from "knockout"; import ko from "knockout";
import { AutopilotTier } from "../../Contracts/DataModels"; import { AutopilotTier } from "../../Contracts/DataModels";
jest.mock("../Tabs/NotebookTab");
describe("Add Collection Pane", () => { describe("Add Collection Pane", () => {
describe("isValid()", () => { describe("isValid()", () => {
let explorer: ViewModels.Explorer; let explorer: ViewModels.Explorer;

View File

@@ -2,8 +2,6 @@ import * as ViewModels from "../../Contracts/ViewModels";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import AddDatabasePane from "./AddDatabasePane"; import AddDatabasePane from "./AddDatabasePane";
jest.mock("../Tabs/NotebookTab");
describe("Add Database Pane", () => { describe("Add Database Pane", () => {
describe("getSharedThroughputDefault()", () => { describe("getSharedThroughputDefault()", () => {
let explorer: ViewModels.Explorer; let explorer: ViewModels.Explorer;

View File

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

View File

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

View File

@@ -0,0 +1,146 @@
import * as React from "react";
import * as ViewModels from "../../Contracts/ViewModels";
import { IconButton, PrimaryButton } from "office-ui-fabric-react/lib/Button";
import { KeyCodes } from "../../Common/Constants";
import { Subscription } from "knockout";
import ErrorRedIcon from "../../../images/error_red.svg";
import LoadingIndicatorIcon from "../../../images/LoadingIndicator_3Squares.gif";
export interface GenericRightPaneProps {
container: ViewModels.Explorer;
content: JSX.Element;
formError: string;
formErrorDetail: string;
id: string;
isExecuting: boolean;
onClose: () => void;
onSubmit: () => void;
submitButtonText: string;
title: string;
}
export interface GenericRightPaneState {
panelHeight: number;
}
export class GenericRightPaneComponent extends React.Component<GenericRightPaneProps, GenericRightPaneState> {
private notificationConsoleSubscription: Subscription;
constructor(props: GenericRightPaneProps) {
super(props);
this.state = {
panelHeight: this.getPanelHeight()
};
}
public componentDidMount(): void {
this.notificationConsoleSubscription = this.props.container.isNotificationConsoleExpanded.subscribe(() => {
this.setState({ panelHeight: this.getPanelHeight() });
});
this.props.container.isNotificationConsoleExpanded.extend({ rateLimit: 10 });
}
public componentWillUnmount(): void {
this.notificationConsoleSubscription && this.notificationConsoleSubscription.dispose();
}
public render(): JSX.Element {
return (
<div tabIndex={-1} onKeyDown={this.onKeyDown}>
<div className="contextual-pane-out" onClick={this.props.onClose}></div>
<div
className="contextual-pane"
id={this.props.id}
style={{ height: this.state.panelHeight }}
onKeyDown={this.onKeyDown}
>
<div className="panelContentWrapper">
{this.createPanelHeader()}
{this.createErrorSection()}
{this.props.content}
{this.createPanelFooter()}
</div>
{this.createLoadingScreen()}
</div>
</div>
);
}
private createPanelHeader = (): JSX.Element => {
return (
<div className="firstdivbg headerline">
<span id="databaseTitle">{this.props.title}</span>
<IconButton
ariaLabel="Close pane"
title="Close pane"
onClick={this.props.onClose}
tabIndex={0}
className="closePaneBtn"
iconProps={{ iconName: "Cancel" }}
/>
</div>
);
};
private createErrorSection = (): JSX.Element => {
return (
<div className="warningErrorContainer" aria-live="assertive" hidden={!this.props.formError}>
<div className="warningErrorContent">
<span>
<img className="paneErrorIcon" src={ErrorRedIcon} alt="Error" />
</span>
<span className="warningErrorDetailsLinkContainer">
<span className="formErrors" title={this.props.formError}>
{this.props.formError}
</span>
<a className="errorLink" role="link" hidden={!this.props.formErrorDetail} onClick={this.showErrorDetail}>
More details
</a>
</span>
</div>
</div>
);
};
private createPanelFooter = (): JSX.Element => {
return (
<div className="paneFooter">
<div className="leftpanel-okbut">
<PrimaryButton
ariaLabel="Submit"
title="Submit"
onClick={this.props.onSubmit}
tabIndex={0}
className="genericPaneSubmitBtn"
text={this.props.submitButtonText}
/>
</div>
</div>
);
};
private createLoadingScreen = (): JSX.Element => {
return (
<div className="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" hidden={!this.props.isExecuting}>
<img className="dataExplorerLoader" src={LoadingIndicatorIcon} />
</div>
);
};
private onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
if (event.keyCode === KeyCodes.Escape) {
this.props.onClose();
event.stopPropagation();
}
};
private showErrorDetail = (): void => {
this.props.container.expandConsole();
};
private getPanelHeight = (): number => {
const notificationConsoleElement: HTMLElement = document.getElementById("explorerNotificationConsole");
return window.innerHeight - $(notificationConsoleElement).height();
};
}

View File

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

View File

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

View File

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

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