Compare commits

..

32 Commits

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

* Fix bugs

* Add tests to increase coverage

* Move favorites functionality behind feature.enableGalleryPublish flag

* Show code cells in NotebookViewer

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

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

* Add validation. Rename to widgetRenderer

* Remove test code from splash screen

* Clean up infobox

* Fix styling/layout

* Move test code into unit test

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

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

* Use MessageBar for error message

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

* Remove eslint-disable and use non-null assertion
2020-07-06 17:16:24 +02:00
Steve Faulkner
c51a55013c Upload screenshot for runner failures (#72) 2020-07-02 09:58:36 -05:00
Steve Faulkner
9a95c7d069 Update @azure/cosmos SDK to 3.7.4 (#67) 2020-06-30 18:32:19 -05:00
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
188 changed files with 14567 additions and 6533 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,15 +170,17 @@ 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
- uses: actions/upload-artifact@v2 - uses: actions/upload-artifact@v2
name: packages
with: with:
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,10 +193,12 @@ 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}"
- run: nuget push -Source "$NUGET_SOURCE" -ApiKey Az *.nupkg - run: nuget push -Source "$NUGET_SOURCE" -ApiKey Az *.nupkg
- uses: actions/upload-artifact@v2 - uses: actions/upload-artifact@v2
name: packages
with: with:
path: "*.nupkg" path: "*.nupkg"

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

@@ -0,0 +1,25 @@
name: Runners
on:
schedule:
- cron: "*/5 * * * *"
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
- uses: actions/upload-artifact@v2
if: failure()
with:
name: screenshots
path: failure.png

4
.gitignore vendored
View File

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

52
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,52 @@
# 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.
* Prefer using [Fluent UI controls](https://developer.microsoft.com/en-us/fluentui#/controls/web) over creating your own, in order to maintain consistency and support a11y.
### React
* Prefer using React class components over function components and hooks unless you have a simple component and require no nested functions:
* 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);
});
}
};

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

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

After

Width:  |  Height:  |  Size: 2.6 KiB

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 {

9834
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.7.0", "@azure/cosmos": "3.7.4",
"@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

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

View File

@@ -104,29 +104,24 @@ 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 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 {
@@ -448,6 +443,17 @@ export class KeyCodes {
public static Tab: number = 9; public static Tab: number = 9;
} }
// Normalized per: https://www.w3.org/TR/uievents-key/#named-key-attribute-values
export class NormalizedEventKey {
public static readonly Space = " ";
public static readonly Enter = "Enter";
public static readonly Escape = "Escape";
public static readonly UpArrow = "ArrowUp";
public static readonly DownArrow = "ArrowDown";
public static readonly LeftArrow = "ArrowLeft";
public static readonly RightArrow = "ArrowRight";
}
export class TryCosmosExperience { export class TryCosmosExperience {
public static extendUrl: string = "https://trycosmosdb.azure.com/api/resource/extendportal?userId={0}"; public static extendUrl: string = "https://trycosmosdb.azure.com/api/resource/extendportal?userId={0}";
public static deleteUrl: string = "https://trycosmosdb.azure.com/api/resource/deleteportal?userId={0}"; public static deleteUrl: string = "https://trycosmosdb.azure.com/api/resource/deleteportal?userId={0}";

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,
message,
area,
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

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

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

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

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;
@@ -84,9 +83,10 @@ export interface Explorer {
extensionEndpoint: ko.Observable<string>; extensionEndpoint: ko.Observable<string>;
armEndpoint: ko.Observable<string>; armEndpoint: ko.Observable<string>;
isFeatureEnabled: (feature: string) => boolean; isFeatureEnabled: (feature: string) => 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 +141,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 +153,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 +225,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>; importAndOpenContent: (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 +334,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 +357,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 +521,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 +535,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 +550,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 +590,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 +879,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 +1169,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 +1212,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 +1226,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

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

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,252 @@
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.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,312 @@
// 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.enablegallerypublish"
label="Enable Notebook Gallery Publishing"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.canexceedmaximumvalue"
label="Can exceed max value"
onChange={[Function]}
/>
</Stack>
<Stack
className="checkboxRow"
horizontalAlign="space-between"
>
<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,207 @@
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";
import CosmosDBLogo from "../../../../../images/CosmosDB-logo.svg";
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
imageUrl={this.props.data.isSample && CosmosDBLogo}
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}> {this.generateIconText("RedEye", this.props.data.views.toString())}
{this.props.notebookMetadata.views} {this.generateIconText("Download", this.props.data.downloads.toString())}
</Text> {this.props.isFavorite !== undefined && this.generateIconText("Heart", this.props.data.favorites.toString())}
<Icon iconName="Download" styles={subtleIconStyles} />
<Text variant="small" styles={subtleHelpfulTextStyles}>
{this.props.notebookMetadata.downloads}
</Text>
<Icon iconName="Heart" styles={subtleIconStyles} />
<Text variant="small" styles={subtleHelpfulTextStyles}>
{this.props.notebookMetadata.likes}
</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.props.isFavorite !== undefined &&
this.generateIconButtonWithTooltip(
this.props.isFavorite ? "HeartFill" : "Heart",
this.props.isFavorite ? "Unlike" : "Like",
this.props.isFavorite ? this.onUnfavoriteClick : this.onFavoriteClick
)}
{this.generateIconButtonWithTooltip("Download", "Download", this.onDownloadClick)}
{this.props.showDelete && (
<div style={{ width: "100%", textAlign: "right" }}>
{this.generateIconButtonWithTooltip("Delete", "Remove", this.onDeleteClick)}
</div>
)}
</Card.Section> </Card.Section>
</Card> </Card>
); );
} }
private generateIconText = (iconName: string, text: string): JSX.Element => {
return (
<Text variant="tiny" styles={{ root: { color: "#ccc" } }}>
<Icon iconName={iconName} styles={{ root: { verticalAlign: "middle" } }} /> {text}
</Text>
);
};
/*
* Fluent UI doesn't support tooltips on IconButtons out of the box. In the meantime the recommendation is
* to do the following (from https://developer.microsoft.com/en-us/fluentui#/controls/web/button)
*/
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,265 @@
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
imageUrl={false}
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",
}
}
onClick={[Function]}
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,511 @@
/** 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 liked";
private static readonly mostRecentText = "Most recent";
private readonly sortingOptions: IDropdownOption[];
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.sortingOptions = [
{
key: SortBy.MostViewed,
text: GalleryViewerComponent.mostViewedText
},
{
key: SortBy.MostDownloaded,
text: GalleryViewerComponent.mostDownloadedText
},
{
key: SortBy.MostRecent,
text: GalleryViewerComponent.mostRecentText
}
];
if (this.props.container?.isGalleryPublishEnabled()) {
this.sortingOptions.push({
key: SortBy.MostFavorited,
text: GalleryViewerComponent.mostFavoritedText
});
}
this.loadTabContent(this.state.selectedTab, this.state.searchText, this.state.sortBy, false);
if (this.props.container?.isGalleryPublishEnabled()) {
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?.isGalleryPublishEnabled()) {
name !== ".gitignore" && tabs.push(this.createTab(GalleryTab.PublicGallery, this.state.publicNotebooks));
url && ( tabs.push(this.createTab(GalleryTab.Favorites, this.state.favoriteNotebooks));
<GalleryCardComponent tabs.push(this.createTab(GalleryTab.Published, this.state.publishedNotebooks));
key={url} }
name={name}
url={url} const pivotProps: IPivotProps = {
notebookMetadata={notebookMetadata} onLinkClick: this.onPivotChange,
onClick={() => this.props.onClick(url, notebookMetadata, updateTabsStatePerNotebook, isLikedNotebook)} 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={this.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 => {
let isFavorite: boolean;
if (this.props.container?.isGalleryPublishEnabled()) {
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
});
const location = new URL("./notebookViewer.html", window.location.href).href;
window.open(`${location}?${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,84 @@
// 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": 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,89 @@
/** /**
* 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>
{this.props.notebookMetadata && ( {FileSystemUtil.stripExtension(this.props.data.name, "ipynb")}
<div className="decoration"> </Text>
{this.props.container ? ( <Text>
<IconButton {this.props.isFavorite !== undefined && (
iconProps={{ iconName: this.state.liked ? "HeartFill" : "Heart" }} <>
styles={iconButtonStyles} <IconButton
onClick={this.onLike} iconProps={{ iconName: this.props.isFavorite ? "HeartFill" : "Heart" }}
/> onClick={this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick}
) : ( />
<Icon iconName="Heart" styles={iconStyles} /> {this.props.data.favorites} likes
</>
)} )}
<Text variant="large" styles={mainHelpfulTextStyles}> </Text>
{this.state.notebookMetadata.likes} likes <PrimaryButton text={this.props.downloadButtonText} onClick={this.props.onDownloadClick} />
</Text> </Stack>
</div>
)}
{this.props.container && ( <Stack horizontal verticalAlign="center" tokens={{ childrenGap: 10 }}>
<button aria-label="downloadButton" className="downloadButton" onClick={this.onDownload}> <Persona text={this.props.data.author} size={PersonaSize.size32} />
Download Notebook <Text>{dateString}</Text>
</button> <Text>
)} <Icon iconName="RedEye" /> {this.props.data.views}
</Text>
<Text>
<Icon iconName="Download" />
{this.props.data.downloads}
</Text>
</Stack>
{this.props.notebookMetadata && ( <Text nowrap>
<> {this.props.data.tags?.map((tag, index, array) => (
<div> <span key={tag}>
<Persona <Link onClick={(): void => this.props.onTagClick(tag)}>{tag}</Link>
className="persona" {index === array.length - 1 ? <></> : ", "}
text={this.props.notebookMetadata.author} </span>
secondaryText={this.props.notebookMetadata.date} ))}
/> </Text>
</div>
<div> <Text variant="large" styles={{ root: { fontWeight: FontWeights.semibold } }}>
<div className="extras"> Description
<Icon iconName="RedEye" styles={subtleIconStyles} /> </Text>
<Text variant="small" styles={subtleHelpfulTextStyles}>
{this.state.notebookMetadata.views} <Text>{this.props.data.description}</Text>
</Text> </Stack>
<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: false })}
{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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,9 @@ 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";
import { toRawContentUri, fromContentUri } from "../Utils/GitHubUtils";
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 +152,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 +185,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 +197,14 @@ 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 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;
@@ -247,7 +244,10 @@ export default class Explorer implements ViewModels.Explorer {
private _isInitializingSparkConnectionInfo: boolean; private _isInitializingSparkConnectionInfo: boolean;
private notebookBasePath: ko.Observable<string>; private notebookBasePath: ko.Observable<string>;
private _arcadiaManager: ViewModels.ArcadiaResourceManager; private _arcadiaManager: ViewModels.ArcadiaResourceManager;
private _filePathToImportAndOpen: string; private notebookToImport: {
name: string;
content: string;
};
// React adapters // React adapters
private commandBarComponentAdapter: CommandBarComponentAdapter; private commandBarComponentAdapter: CommandBarComponentAdapter;
@@ -303,7 +303,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();
} }
@@ -347,7 +347,7 @@ export default class Explorer implements ViewModels.Explorer {
await this.initNotebooks(this.databaseAccount()); await this.initNotebooks(this.databaseAccount());
const workspaces = await this._getArcadiaWorkspaces(); const workspaces = await this._getArcadiaWorkspaces();
this.arcadiaWorkspaces(workspaces); this.arcadiaWorkspaces(workspaces);
} else if (this._filePathToImportAndOpen) { } else if (this.notebookToImport) {
// if notebooks is not enabled but the user is trying to do a quickstart setup with notebooks, open the SetupNotebooksPane // if notebooks is not enabled but the user is trying to do a quickstart setup with notebooks, open the SetupNotebooksPane
this._openSetupNotebooksPaneForQuickstart(); this._openSetupNotebooksPaneForQuickstart();
} }
@@ -381,7 +381,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>();
@@ -409,11 +408,11 @@ export default class Explorer implements ViewModels.Explorer {
this.shouldShowShareDialogContents = ko.observable<boolean>(false); this.shouldShowShareDialogContents = ko.observable<boolean>(false);
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.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 +550,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 +709,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 +940,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 +1010,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 +1796,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 +1903,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 +2472,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,11 +2491,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) {
this._filePathToImportAndOpen = null; // 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) {
return this.openNotebook(existingItem); return this.openNotebook(existingItem);
@@ -2598,33 +2502,38 @@ 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 async importAndOpenFromGallery(path: string, newName: string, content: any): Promise<boolean> { public async importAndOpenContent(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) { if (this.notebookToImport && this.notebookToImport.name === name && this.notebookToImport.content === content) {
this._filePathToImportAndOpen = undefined; // we don't want to try opening this path again this.notebookToImport = undefined; // we don't want to try opening this notebook 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."); return this.openNotebook(existingItem);
return Promise.reject(false);
} }
const uploadedItem = await this.uploadFile(name, content, parent); const uploadedItem = await this.uploadFile(name, content, parent);
return this.openNotebook(uploadedItem); return this.openNotebook(uploadedItem);
} }
this._filePathToImportAndOpen = path; // we'll try opening this path later on this.notebookToImport = { name, content }; // we'll try opening this notebook 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 +2666,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 +2677,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 +2694,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 +2716,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 +2731,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 +2776,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,18 +2923,18 @@ 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;
} }
await this.resourceTree.initialize(); await this.resourceTree.initialize();
if (this._filePathToImportAndOpen) { if (this.notebookToImport) {
this.importAndOpen(this._filePathToImportAndOpen); this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content);
} }
}; };
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 +2944,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 +2965,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 +2982,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 +3002,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 +3018,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 +3068,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 +3144,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 +3159,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 +3196,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 +3239,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);
@@ -3440,6 +3352,21 @@ export default class Explorer implements ViewModels.Explorer {
this._openSetupNotebooksPaneForQuickstart(); this._openSetupNotebooksPaneForQuickstart();
} }
this.importAndOpen(path); // We still use github urls like https://github.com/Azure-Samples/cosmos-notebooks/blob/master/CSharp_quickstarts/GettingStarted_CSharp.ipynb
// when launching a notebook quickstart from Portal. In future we should just use gallery id and use Juno to fetch instead of directly
// calling GitHub. For now convert this url to a raw url and download content.
const gitHubInfo = fromContentUri(path);
if (gitHubInfo) {
const rawUrl = toRawContentUri(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.branch, gitHubInfo.path);
const response = await fetch(rawUrl);
if (response.status === Constants.HttpStatusCodes.OK) {
this.notebookToImport = {
name: NotebookUtil.getName(path),
content: await response.text()
};
this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content);
}
}
} }
} }

View File

@@ -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;
@@ -18,7 +19,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
mockExplorer.isPreferredApiMongoDB = ko.computed<boolean>(() => false); mockExplorer.isPreferredApiMongoDB = ko.computed<boolean>(() => false);
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.isGalleryPublishEnabled = ko.computed<boolean>(() => false);
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true); mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true; mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
}); });
@@ -80,7 +81,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
mockExplorer.isPreferredApiTable = ko.computed(() => true); mockExplorer.isPreferredApiTable = ko.computed(() => true);
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.isGalleryPublishEnabled = ko.computed<boolean>(() => false);
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true); mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true; mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
}); });
@@ -160,7 +161,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
mockExplorer.isPreferredApiTable = ko.computed(() => true); mockExplorer.isPreferredApiTable = ko.computed(() => true);
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.isGalleryPublishEnabled = ko.computed<boolean>(() => false);
mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true); mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
mockExplorer.isDatabaseNodeOrNoneSelected = () => true; mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
}); });
@@ -246,8 +247,9 @@ describe("CommandBarComponentButtonFactory tests", () => {
mockExplorer.isDatabaseNodeOrNoneSelected = () => true; mockExplorer.isDatabaseNodeOrNoneSelected = () => true;
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.isGalleryPublishEnabled = ko.computed<boolean>(() => false);
mockExplorer.gitHubOAuthService = new GitHubOAuthService(undefined); mockExplorer.notebookManager = new NotebookManager();
mockExplorer.notebookManager.gitHubOAuthService = new GitHubOAuthService(undefined);
}); });
beforeEach(() => { beforeEach(() => {
@@ -268,7 +270,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

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

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

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

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;

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