Compare commits

...

26 Commits

Author SHA1 Message Date
Srinath Narayanan
c7192b99f2 resolved merge conflicts 2020-11-19 08:57:57 -08:00
Laurent Nguyen
5c93c11bd9 Bug fix: match monaco-editor version with @nteract/monaco-editor (#322)
Opening notebook (which contains code cell), then "Items"-> document editor is broken. Our JsonEditor component will hang at `monaco.editor.create()`.

Matching the `monaco-editor` version with `@nteract/monaco-editor` fixes it.

I looked into using one, but we cannot rely nteract, because it does not get loaded if notebook isn't enabled. Forcing nteract to use ours (if at all possible) isn't a good idea, since their code is tuned to their version.

For now, we'll have to keep the versions in sync.
2020-11-19 14:33:23 +00:00
Srinath Narayanan
05396203cc reverted package.json change 2020-11-18 14:35:37 -08:00
Srinath Narayanan
0c5f62afd6 removed deletion 2020-11-18 14:27:26 -08:00
Srinath Narayanan
2b38f8d3b1 fix format error 2020-11-18 14:04:12 -08:00
Srinath Narayanan
76e6573702 added delay 2020-11-18 14:01:32 -08:00
Srinath Narayanan
3d27eb6d68 removed logging 2020-11-18 13:48:08 -08:00
Srinath Narayanan
c4c538022d Merge branch 'master' into users/srnara/pupeteerForNotebooks 2020-11-18 13:37:52 -08:00
Srinath Narayanan
fe3e5f9383 increased delay for render 2020-11-18 13:29:21 -08:00
Srinath Narayanan
92fd5a73e6 added ci env variables 2020-11-18 12:56:27 -08:00
Srinath Narayanan
c0e10e1c1d format errors fixed 2020-11-18 12:31:21 -08:00
Srinath Narayanan
543c876430 added logging statements 2020-11-18 12:28:17 -08:00
Srinath Narayanan
85d2378d3a Removed SettingsV1 code paths (#325)
* removed settingsv1 code path in collection.ts

* removed Settingsv1 code

* Moved AAD error message up the chain
2020-11-18 12:11:25 -08:00
zfoster
d7a077c558 Updates mongo spec to remove waitFor on already awaited selector 2020-11-18 14:14:53 -05:00
Srinath Narayanan
194d78af19 fixed formatting error 2020-11-17 11:37:48 -08:00
Srinath Narayanan
2a89879a5f renamed param 2020-11-17 11:29:46 -08:00
Srinath Narayanan
9b6d261d2b Merge branch 'master' into users/srnara/pupeteerForNotebooks 2020-11-17 11:07:16 -08:00
Srinath Narayanan
ca29ccb703 renamed params 2020-11-17 11:05:17 -08:00
Srinath Narayanan
dc40b6618e Addressed PR comments 2020-11-17 03:50:12 -08:00
Srinath Narayanan
394e1398d1 added try catch block with screenshot for error 2020-11-13 14:30:30 -08:00
Srinath Narayanan
58b5caed7e Added Auth 2020-11-13 14:27:27 -08:00
Steve Faulkner
84b6075ee8 Use Puppeteer for Emulator Test (#321)
* Use Puppeteer for Emulator Test

* Fix yaml

* more fixes

* Cleanup

* README

Co-authored-by: Steve Faulkner <stfaul@microsoft.com>
2020-11-13 10:58:38 -06:00
Steve Faulkner
d880723be9 React Wrapper Take 2 (#310) 2020-11-13 02:10:59 +00:00
Garrett Ausfeldt
4ce9dcc024 Add analytical store schema POC (#164)
* add schema APIs to JunoClient

* start implementing buildSchemaNode

* finish getSchemaNodes

* finish implementing addSchema

* cleanup

* make schema optional

* handle undefined/null schema and fields. Also don't retry on gettting schema failures.

* fix request schema and get schema endpoints

* add feature flag

* try to get most recent schema when refreshed or initialized.

* add tests

* cleanup

* cleanup

* cleanup

* fix merge conflict typos

* fix lint errors

* fix tests and update snapshot

Co-authored-by: REDMOND\gaausfel <gaausfel@microsoft.com>
2020-11-12 13:33:37 -08:00
Steve Faulkner
addcfedd5e MinRU survey for SettingsV2 component (#320)
Adds survey link to remove the RU/GB minimum on an account
2020-11-12 19:35:39 +00:00
Srinath Narayanan
9f4fda13e7 initial commit for notbooks pupeteer tests 2020-11-11 04:53:04 -08:00
83 changed files with 4012 additions and 9518 deletions

View File

@@ -3,7 +3,11 @@ PORTAL_RUNNER_PASSWORD=
PORTAL_RUNNER_SUBSCRIPTION=
PORTAL_RUNNER_RESOURCE_GROUP=
PORTAL_RUNNER_DATABASE_ACCOUNT=
PORTAL_RUNNER_DATABASE_ACCOUNT_KEY=
PORTAL_RUNNER_CONNECTION_STRING=
NOTEBOOKS_TEST_RUNNER_TENANT_ID=
NOTEBOOKS_TEST_RUNNER_CLIENT_ID=
NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET=
CASSANDRA_CONNECTION_STRING=
MONGO_CONNECTION_STRING=
TABLES_CONNECTION_STRING=

View File

@@ -202,8 +202,6 @@ src/Explorer/Tabs/QueryTab.test.ts
src/Explorer/Tabs/QueryTab.ts
src/Explorer/Tabs/QueryTablesTab.ts
src/Explorer/Tabs/ScriptTabBase.ts
src/Explorer/Tabs/SettingsTab.test.ts
src/Explorer/Tabs/SettingsTab.ts
src/Explorer/Tabs/SparkMasterTab.ts
src/Explorer/Tabs/StoredProcedureTab.ts
src/Explorer/Tabs/TabComponents.ts
@@ -396,19 +394,5 @@ src/Explorer/Tree/ResourceTreeAdapterForResourceToken.tsx
src/GalleryViewer/Cards/GalleryCardComponent.tsx
src/GalleryViewer/GalleryViewer.tsx
src/GalleryViewer/GalleryViewerComponent.tsx
cypress/integration/dataexplorer/CASSANDRA/addCollection.spec.ts
cypress/integration/dataexplorer/GRAPH/addCollection.spec.ts
cypress/integration/dataexplorer/ci-tests/addCollectionPane.spec.ts
cypress/integration/dataexplorer/ci-tests/createDatabase.spec.ts
cypress/integration/dataexplorer/ci-tests/deleteCollection.spec.ts
cypress/integration/dataexplorer/ci-tests/deleteDatabase.spec.ts
cypress/integration/dataexplorer/MONGO/addCollection.spec.ts
cypress/integration/dataexplorer/MONGO/addCollectionAutopilot.spec.ts
cypress/integration/dataexplorer/MONGO/addCollectionExistingDatabase.spec.ts
cypress/integration/dataexplorer/MONGO/provisionDatabaseThroughput.spec.ts
cypress/integration/dataexplorer/SQL/addCollection.spec.ts
cypress/integration/dataexplorer/TABLE/addCollection.spec.ts
cypress/integration/notebook/newNotebook.spec.ts
cypress/integration/notebook/resourceTree.spec.ts
__mocks__/monaco-editor.ts
src/Explorer/Tree/ResourceTreeAdapterForResourceToken.test.tsx

View File

@@ -79,32 +79,31 @@ jobs:
name: dist
path: dist/
endtoendemulator:
name: "End To End Tests | Emulator | SQL"
name: "End To End Emulator Tests"
needs: [lint, format, compile, unittest]
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
- uses: southpolesteve/cosmos-emulator-github-action@v1
- 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
- uses: southpolesteve/cosmos-emulator-github-action@v1
- name: End to End Tests
run: |
npm ci
npm start &
npm ci --prefix ./cypress
npm run test:ci --prefix ./cypress -- --spec ./integration/dataexplorer/ci-tests/createDatabase.spec.ts
npm run wait-for-server
npx jest -c ./jest.config.e2e.js --detectOpenHandles sql
shell: bash
env:
EMULATOR_ENDPOINT: https://0.0.0.0:8081/
DATA_EXPLORER_ENDPOINT: "https://localhost:1234/explorer.html?platform=Emulator"
PLATFORM: "Emulator"
NODE_TLS_REJECT_UNAUTHORIZED: 0
CYPRESS_CACHE_FOLDER: ~/.cache/Cypress
- uses: actions/upload-artifact@v2
with:
name: screenshots
path: failed-*
accessibility:
name: "Accessibility | Hosted"
needs: [lint, format, compile, unittest]
@@ -123,13 +122,13 @@ jobs:
sudo sysctl -p
npm ci
npm start &
npx wait-on -i 5000 https-get://0.0.0.0:1234/
npx wait-on -i 5000 https-get://0.0.0.0:1234/
node utils/accesibilityCheck.js
shell: bash
env:
NODE_TLS_REJECT_UNAUTHORIZED: 0
endtoendpuppeteer:
name: "End to end puppeteer tests"
endtoendhosted:
name: "End to End Hosted Tests"
needs: [lint, format, compile, unittest]
runs-on: ubuntu-latest
steps:
@@ -138,7 +137,7 @@ jobs:
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: End to End Puppeteer Tests
- name: End to End Hosted Tests
run: |
npm ci
npm start &
@@ -147,6 +146,13 @@ jobs:
shell: bash
env:
NODE_TLS_REJECT_UNAUTHORIZED: 0
PORTAL_RUNNER_SUBSCRIPTION: ${{ secrets.PORTAL_RUNNER_SUBSCRIPTION }}
PORTAL_RUNNER_RESOURCE_GROUP: ${{ secrets.PORTAL_RUNNER_RESOURCE_GROUP }}
PORTAL_RUNNER_DATABASE_ACCOUNT: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT }}
PORTAL_RUNNER_DATABASE_ACCOUNT_KEY: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY }}
NOTEBOOKS_TEST_RUNNER_TENANT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_TENANT_ID }}
NOTEBOOKS_TEST_RUNNER_CLIENT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_ID }}
NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET }}
PORTAL_RUNNER_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_SQL }}
MONGO_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_MONGO }}
CASSANDRA_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_CASSANDRA }}
@@ -159,7 +165,7 @@ jobs:
nuget:
name: Publish Nuget
if: github.ref == 'refs/heads/master' || contains(github.ref, 'hotfix/') || contains(github.ref, 'release/')
needs: [lint, format, compile, build, unittest, endtoendemulator, endtoendpuppeteer]
needs: [lint, format, compile, build, unittest, endtoendemulator, endtoendhosted]
runs-on: ubuntu-latest
env:
NUGET_SOURCE: ${{ secrets.NUGET_SOURCE }}
@@ -183,7 +189,7 @@ jobs:
nugetmpac:
name: Publish Nuget MPAC
if: github.ref == 'refs/heads/master' || contains(github.ref, 'hotfix/') || contains(github.ref, 'release/')
needs: [lint, format, compile, build, unittest, endtoendemulator, endtoendpuppeteer]
needs: [lint, format, compile, build, unittest, endtoendemulator, endtoendhosted]
runs-on: ubuntu-latest
env:
NUGET_SOURCE: ${{ secrets.NUGET_SOURCE }}

3
.gitignore vendored
View File

@@ -9,9 +9,6 @@ pkg/DataExplorer/*
test/out/*
workers/**/*.js
*.trx
cypress/videos
cypress/screenshots
cypress/fixtures
notebookapp/*
Contracts/*
.DS_Store

View File

@@ -76,17 +76,7 @@ Unit tests are located adjacent to the code under test and run with [Jest](https
#### 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:
1. Ensure the emulator is running
2. Start cosmos explorer in emulator mode: `PLATFORM=Emulator npm run watch`
3. Move into `cypress/` folder: `cd cypress`
4. Install dependencies: `npm install`
5. Run cypress headless(`npm run test`) or in interactive mode(`npm run test:debug`)
#### End to End Production Tests
Jest and Puppeteer are used for end to end production runners and are contained in `test/`. To run these tests locally:
Jest and Puppeteer are used for end to end browser based tests and are contained in `test/`. To run these tests locally:
1. Copy .env.example to .env
2. Update the values in .env including your local data explorer endpoint (ask a teammate/codeowner for help with .env values)

4
cypress/.gitignore vendored
View File

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

View File

@@ -1,51 +0,0 @@
// 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

@@ -1,15 +0,0 @@
{
"integrationFolder": "./integration",
"pluginsFile": false,
"fixturesFolder": false,
"supportFile": "./support/index.js",
"defaultCommandTimeout": 90000,
"chromeWebSecurity": false,
"reporter": "mochawesome",
"reporterOptions": {
"reportDir": "cypress/report",
"json": true,
"overwrite": false,
"html": false
}
}

View File

@@ -1,66 +0,0 @@
// 1. Click on "New Container" on the command bar.
// 2. Pane with the title "Add Container" should appear on the right side of the screen
// 3. It includes an input box for the database Id.
// 4. It includes a checkbox called "Create now".
// 5. When the checkbox is marked, enter new database id.
// 3. Create a database WITH "Provision throughput" checked.
// 4. Enter minimum throughput value of 400.
// 5. Enter container id to the container id text box.
// 6. Enter partition key to the partition key text box.
// 7. Click "OK" to create a new container.
// 8. Verify the new container is created along with the database id and should appead in the Data Explorer list in the left side of the screen.
const connectionString = require("../../../utilities/connectionString");
let crypt = require("crypto");
context("Cassandra API Test - createDatabase", () => {
beforeEach(() => {
connectionString.loginUsingConnectionString(connectionString.constants.cassandra);
});
it("Create a new table in Cassandra API", () => {
const keyspaceId = `KeyspaceId${crypt.randomBytes(8).toString("hex")}`;
const tableId = `TableId112`;
cy.get("iframe").then($element => {
const $body = $element.contents().find("body");
cy.wrap($body)
.find('div[class="commandBarContainer"]')
.should("be.visible")
.find('button[data-test="New Table"]')
.should("be.visible")
.click();
cy.wrap($body)
.find('div[class="contextual-pane-in"]')
.should("be.visible")
.find('span[id="containerTitle"]');
cy.wrap($body)
.find('input[id="keyspace-id"]')
.should("be.visible")
.type(keyspaceId);
cy.wrap($body)
.find('input[class="textfontclr"]')
.type(tableId);
cy.wrap($body)
.find('input[data-test="databaseThroughputValue"]')
.should("have.value", "400");
cy.wrap($body)
.find('data-test="addCollection-createCollection"')
.click();
cy.wait(10000);
cy.wrap($body)
.find('div[data-test="resourceTreeId"]')
.should("exist")
.find('div[class="treeComponent dataResourceTree"]')
.should("contain", tableId);
});
});
});

View File

@@ -1,81 +0,0 @@
// 1. Click on "New Graph" on the command bar.
// 2. Pane with the title "Add Container" should appear on the right side of the screen
// 3. It includes an input box for the database Id.
// 4. It includes a checkbox called "Create now".
// 5. When the checkbox is marked, enter new database id.
// 3. Create a database WITH "Provision throughput" checked.
// 4. Enter minimum throughput value of 400.
// 5. Enter container id to the container id text box.
// 6. Enter partition key to the partition key text box.
// 7. Click "OK" to create a new container.
// 8. Verify the new container is created along with the database id and should appead in the Data Explorer list in the left side of the screen.
const connectionString = require("../../../utilities/connectionString");
let crypt = require("crypto");
context("Graph API Test", () => {
beforeEach(() => {
connectionString.loginUsingConnectionString(connectionString.constants.graph);
});
it("Create a new graph in Graph API", () => {
const dbId = `TestDatabase${crypt.randomBytes(8).toString("hex")}`;
const graphId = `TestGraph${crypt.randomBytes(8).toString("hex")}`;
const partitionKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`;
cy.get("iframe").then($element => {
const $body = $element.contents().find("body");
cy.wrap($body)
.find('div[class="commandBarContainer"]')
.should("be.visible")
.find('button[data-test="New Graph"]')
.should("be.visible")
.click();
cy.wrap($body)
.find('div[class="contextual-pane-in"]')
.should("be.visible")
.find('span[id="containerTitle"]');
cy.wrap($body)
.find('input[data-test="addCollection-createNewDatabase"]')
.check();
cy.wrap($body)
.find('input[data-test="addCollection-newDatabaseId"]')
.should("be.visible")
.type(dbId);
cy.wrap($body)
.find('input[data-test="addCollectionPane-databaseSharedThroughput"]')
.check();
cy.wrap($body)
.find('input[data-test="databaseThroughputValue"]')
.should("have.value", "400");
cy.wrap($body)
.find('input[data-test="addCollection-collectionId"]')
.type(graphId);
cy.wrap($body)
.find('input[data-test="addCollection-partitionKeyValue"]')
.type(partitionKey);
cy.wrap($body)
.find('input[data-test="addCollection-createCollection"]')
.click();
cy.wait(10000);
cy.wrap($body)
.find('div[data-test="resourceTreeId"]')
.should("exist")
.find('div[class="treeComponent dataResourceTree"]')
.should("contain", dbId)
.click()
.should("contain", graphId);
});
});
});

View File

@@ -1,80 +0,0 @@
// 1. Click on "New Container" on the command bar.
// 2. Pane with the title "Add Container" should appear on the right side of the screen
// 3. It includes an input box for the database Id.
// 4. It includes a checkbox called "Create now".
// 5. When the checkbox is marked, enter new database id.
// 3. Create a database WITH "Provision throughput" checked.
// 4. Enter minimum throughput value of 400.
// 5. Enter container id to the container id text box.
// 6. Enter partition key to the partition key text box.
// 7. Click "OK" to create a new container.
// // 8. Verify the new container is created along with the database id and should appead in the Data Explorer list in the left side of the screen.
const connectionString = require("../../../utilities/connectionString");
let crypt = require("crypto");
context("Mongo API Test - createDatabase", () => {
beforeEach(() => {
connectionString.loginUsingConnectionString();
});
it("Create a new collection in Mongo API", () => {
const dbId = `TestDatabase${crypt.randomBytes(8).toString("hex")}`;
const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`;
const sharedKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`;
cy.get("iframe").then($element => {
const $body = $element.contents().find("body");
cy.wrap($body)
.find('div[class="commandBarContainer"]')
.should("be.visible")
.find('button[data-test="New Collection"]')
.should("be.visible")
.click();
cy.wrap($body)
.find('div[class="contextual-pane-in"]')
.should("be.visible")
.find('span[id="containerTitle"]');
cy.wrap($body)
.find('input[data-test="addCollection-createNewDatabase"]')
.check();
cy.wrap($body)
.find('input[data-test="addCollection-newDatabaseId"]')
.type(dbId);
cy.wrap($body)
.find('input[data-test="addCollectionPane-databaseSharedThroughput"]')
.check();
cy.wrap($body)
.find('input[data-test="addCollection-collectionId"]')
.type(collectionId);
cy.wrap($body)
.find('input[data-test="databaseThroughputValue"]')
.should("have.value", "400");
cy.wrap($body)
.find('input[data-test="addCollection-partitionKeyValue"]')
.type(sharedKey);
cy.wrap($body)
.find("#submitBtnAddCollection")
.click();
cy.wait(10000);
cy.wrap($body)
.find('div[data-test="resourceTreeId"]')
.should("exist")
.find('div[class="treeComponent dataResourceTree"]')
.should("contain", dbId)
.click()
.should("contain", collectionId);
});
});
});

View File

@@ -1,96 +0,0 @@
// 1. Click on "New Container" on the command bar.
// 2. Pane with the title "Add Container" should appear on the right side of the screen
// 3. It includes an input box for the database Id.
// 4. It includes a checkbox called "Create now".
// 5. When the checkbox is marked, enter new database id.
// 3. Create a database WITH "Provision throughput" checked.
// 4. Enter minimum throughput value of 400.
// 5. Enter container id to the container id text box.
// 6. Enter partition key to the partition key text box.
// 7. Click "OK" to create a new container.
// 8. Verify the new container is created along with the database id and should appead in the Data Explorer list in the left side of the screen.
const connectionString = require("../../../utilities/connectionString");
let crypt = require("crypto");
context("Mongo API Test", () => {
beforeEach(() => {
connectionString.loginUsingConnectionString();
});
it.skip("Create a new collection in Mongo API - Autopilot", () => {
const dbId = `TestDatabase${crypt.randomBytes(8).toString("hex")}`;
const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`;
const sharedKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`;
cy.get("iframe").then($element => {
const $body = $element.contents().find("body");
cy.wrap($body)
.find('div[class="commandBarContainer"]')
.should("be.visible")
.find('button[data-test="New Collection"]')
.should("be.visible")
.click();
cy.wrap($body)
.find('div[class="contextual-pane-in"]')
.should("be.visible")
.find('span[id="containerTitle"]');
cy.wrap($body)
.find('input[data-test="addCollection-createNewDatabase"]')
.check();
cy.wrap($body)
.find('input[data-test="addCollection-newDatabaseId"]')
.type(dbId);
cy.wrap($body)
.find('input[data-test="addCollectionPane-databaseSharedThroughput"]')
.check();
cy.wrap($body)
.find('div[class="throughputModeContainer"]')
.should("be.visible")
.and(input => {
expect(input.get(0).textContent, "first item").contains("Autopilot (preview)");
expect(input.get(1).textContent, "second item").contains("Manual");
});
cy.wrap($body)
.find('input[id="newContainer-databaseThroughput-autoPilotRadio"]')
.check();
cy.wrap($body)
.find('select[name="autoPilotTiers"]')
// .eq(1).should('contain', '4,000 RU/s');
// // .select('4,000 RU/s').should('have.value', '1');
.find('option[value="2"]')
.then($element => $element.get(1).setAttribute("selected", "selected"));
cy.wrap($body)
.find('input[data-test="addCollection-collectionId"]')
.type(collectionId);
cy.wrap($body)
.find('input[data-test="addCollection-partitionKeyValue"]')
.type(sharedKey);
cy.wrap($body)
.find('input[data-test="addCollection-createCollection"]')
.click();
cy.wait(10000);
cy.wrap($body)
.find('div[data-test="resourceTreeId"]')
.should("exist")
.find('div[class="treeComponent dataResourceTree"]')
.should("contain", dbId)
.click()
.should("contain", collectionId);
});
});
});

View File

@@ -1,67 +0,0 @@
const connectionString = require("../../../utilities/connectionString");
let crypt = require("crypto");
context("Mongo API Test", () => {
beforeEach(() => {
connectionString.loginUsingConnectionString();
});
it.skip("Create a new collection in existing database in Mongo API", () => {
const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`;
const sharedKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`;
cy.get("iframe").then($element => {
const $body = $element.contents().find("body");
cy.wrap($body)
.find('span[class="nodeLabel"]')
.should("be.visible")
.then($span => {
const dbId1 = $span.text();
cy.log("DBBB", dbId1);
cy.wrap($body)
.find('div[class="commandBarContainer"]')
.should("be.visible")
.find('button[data-test="New Collection"]')
.should("be.visible")
.click();
cy.wrap($body)
.find('div[class="contextual-pane-in"]')
.should("be.visible")
.find('span[id="containerTitle"]');
cy.wrap($body)
.find('input[data-test="addCollection-existingDatabase"]')
.check();
cy.wrap($body)
.find('input[data-test="addCollection-existingDatabase"]')
.type(dbId1);
cy.wrap($body)
.find('input[data-test="addCollection-collectionId"]')
.type(collectionId);
cy.wrap($body)
.find('input[data-test="addCollection-partitionKeyValue"]')
.type(sharedKey);
cy.wrap($body)
.find('input[data-test="addCollection-createCollection"]')
.click();
cy.wait(10000);
cy.wrap($body)
.find('div[data-test="resourceTreeId"]')
.should("exist")
.find('div[class="treeComponent dataResourceTree"]')
.click()
.should("contain", collectionId);
});
});
});
});

View File

@@ -1,203 +0,0 @@
const connectionString = require("../../../utilities/connectionString");
let crypt = require("crypto");
context.skip("Mongo API Test", () => {
beforeEach(() => {
connectionString.loginUsingConnectionString();
});
it("Create a new collection in Mongo API - Provision database throughput", () => {
const dbId = `TestDatabase${crypt.randomBytes(8).toString("hex")}`;
const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`;
const sharedKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`;
cy.get("iframe").then($element => {
const $body = $element.contents().find("body");
cy.wrap($body)
.find('div[class="commandBarContainer"]')
.should("be.visible")
.find('button[data-test="New Collection"]')
.should("be.visible")
.click();
cy.wrap($body)
.find('div[class="contextual-pane-in"]')
.should("be.visible")
.find('span[id="containerTitle"]');
cy.wrap($body)
.find(".createNewDatabaseOrUseExisting")
.should("have.length", 2)
.and(input => {
expect(input.get(0).textContent, "first item").contains("Create new");
expect(input.get(1).textContent, "second item").contains("Use existing");
});
cy.wrap($body)
.find('input[data-test="addCollection-createNewDatabase"]')
.check();
cy.wrap($body)
.find('input[data-test="addCollectionPane-databaseSharedThroughput"]')
.check();
cy.wrap($body)
.find('input[data-test="addCollection-newDatabaseId"]')
.type(dbId);
cy.wrap($body)
.find('input[data-test="addCollectionPane-databaseSharedThroughput"]')
.check();
cy.wrap($body)
.find('input[data-test="databaseThroughputValue"]')
.should("have.value", "400");
cy.wrap($body)
.find('input[data-test="addCollection-collectionId"]')
.type(collectionId);
cy.wrap($body)
.find('input[data-test="addCollection-partitionKeyValue"]')
.type(sharedKey);
cy.wrap($body)
.find('input[data-test="addCollection-createCollection"]')
.click();
cy.wait(10000);
cy.wrap($body)
.find('div[data-test="resourceTreeId"]')
.should("exist")
.find('div[class="treeComponent dataResourceTree"]')
.should("contain", dbId)
.click()
.should("contain", collectionId);
});
});
it("Create a new collection - without provision database throughput", () => {
const dbId = `TestDatabase${crypt.randomBytes(8).toString("hex")}`;
const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`;
const collectionIdTitle = `Add Collection`;
const sharedKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`;
cy.get("iframe").then($element => {
const $body = $element.contents().find("body");
cy.wrap($body)
.find('div[class="commandBarContainer"]')
.should("be.visible")
.find('button[data-test="New Collection"]')
.should("be.visible")
.click();
cy.wrap($body)
.find('div[class="contextual-pane-in"]')
.should("be.visible")
.find('span[id="containerTitle"]');
cy.wrap($body)
.find('input[data-test="addCollection-createNewDatabase"]')
.check();
cy.wrap($body)
.find('input[data-test="addCollection-newDatabaseId"]')
.type(dbId);
cy.wrap($body)
.find('input[data-test="addCollectionPane-databaseSharedThroughput"]')
.uncheck();
cy.wrap($body)
.find('input[data-test="addCollection-collectionId"]')
.type(collectionId);
cy.wrap($body)
.find('input[id="tab2"]')
.check({ force: true });
cy.wrap($body)
.find('input[data-test="addCollection-partitionKeyValue"]')
.type(sharedKey);
cy.wrap($body)
.find('input[data-test="databaseThroughputValue"]')
.should("have.value", "400");
cy.wrap($body)
.find('input[data-test="addCollection-createCollection"]')
.click();
cy.wait(10000);
cy.wrap($body)
.find('div[data-test="resourceTreeId"]')
.should("exist")
.find('div[class="treeComponent dataResourceTree"]')
.should("contain", dbId)
.click()
.should("contain", collectionId);
});
});
it("Create a new collection - without provision database throughput Fixed Storage Capacity", () => {
const dbId = `TestDatabase${crypt.randomBytes(8).toString("hex")}`;
const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`;
const sharedKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`;
cy.get("iframe").then($element => {
const $body = $element.contents().find("body");
cy.wrap($body)
.find('div[class="commandBarContainer"]')
.should("be.visible")
.find('button[data-test="New Collection"]')
.should("be.visible")
.click();
cy.wrap($body)
.find('div[class="contextual-pane-in"]')
.should("be.visible")
.find('span[id="containerTitle"]');
cy.wrap($body)
.find('input[data-test="addCollection-createNewDatabase"]')
.check();
cy.wrap($body)
.find('input[data-test="addCollection-newDatabaseId"]')
.type(dbId);
cy.wrap($body)
.find('input[data-test="addCollectionPane-databaseSharedThroughput"]')
.uncheck();
cy.wrap($body)
.find('input[data-test="addCollection-collectionId"]')
.type(collectionId);
cy.wrap($body)
.find('input[id="tab1"]')
.check({ force: true });
cy.wrap($body)
.find('input[data-test="databaseThroughputValue"]')
.should("have.value", "400");
cy.wrap($body)
.find('input[data-test="addCollection-createCollection"]')
.click();
cy.wait(10000);
cy.wrap($body)
.find('div[data-test="resourceTreeId"]')
.should("exist")
.find('div[class="treeComponent dataResourceTree"]')
.should("contain", dbId)
.click()
.should("contain", collectionId);
});
});
});

View File

@@ -1,79 +0,0 @@
// 1. Click on "New Container" on the command bar.
// 2. Pane with the title "Add Container" should appear on the right side of the screen
// 3. It includes an input box for the database Id.
// 4. It includes a checkbox called "Create now".
// 5. When the checkbox is marked, enter new database id.
// 3. Create a database WITH "Provision throughput" checked.
// 4. Enter minimum throughput value of 400.
// 5. Enter container id to the container id text box.
// 6. Enter partition key to the partition key text box.
// 7. Click "OK" to create a new container.
// 8. Verify the new container is created along with the database id and should appead in the Data Explorer list in the left side of the screen.
const connectionString = require("../../../utilities/connectionString");
let crypt = require("crypto");
context("SQL API Test", () => {
beforeEach(() => {
connectionString.loginUsingConnectionString();
});
it("Create a new container in SQL API", () => {
const dbId = `TestDatabase${crypt.randomBytes(8).toString("hex")}`;
const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`;
const sharedKey = `SharedKey${crypt.randomBytes(8).toString("hex")}`;
connectionString.loginUsingConnectionString();
cy.get("iframe").then($element => {
const $body = $element.contents().find("body");
cy.wrap($body)
.find('div[class="commandBarContainer"]')
.should("be.visible")
.find('button[data-test="New Container"]')
.should("be.visible")
.click();
cy.wrap($body)
.find('div[class="contextual-pane-in"]')
.should("be.visible")
.find('span[id="containerTitle"]');
cy.wrap($body)
.find('input[data-test="addCollection-createNewDatabase"]')
.check();
cy.wrap($body)
.find('input[data-test="addCollectionPane-databaseSharedThroughput"]')
.check();
cy.wrap($body)
.find('input[data-test="addCollection-newDatabaseId"]')
.type(dbId);
cy.wrap($body)
.find('input[data-test="addCollection-collectionId"]')
.type(collectionId);
cy.wrap($body)
.find('input[data-test="databaseThroughputValue"]')
.should("have.value", "400");
cy.wrap($body)
.find('input[data-test="addCollection-partitionKeyValue"]')
.type(sharedKey);
cy.wrap($body)
.find("#submitBtnAddCollection")
.click();
cy.wait(10000);
cy.wrap($body)
.find('div[data-test="resourceTreeId"]')
.should("exist")
.find('div[class="treeComponent dataResourceTree"]')
.should("contain", dbId);
});
});
});

View File

@@ -1,60 +0,0 @@
// 1. Click on "New Container" on the command bar.
// 2. Pane with the title "Add Container" should appear on the right side of the screen
// 3. It includes an input box for the database Id.
// 4. It includes a checkbox called "Create now".
// 5. When the checkbox is marked, enter new database id.
// 3. Create a database WITH "Provision throughput" checked.
// 4. Enter minimum throughput value of 400.
// 5. Enter container id to the container id text box.
// 6. Enter partition key to the partition key text box.
// 7. Click "OK" to create a new container.
// 8. Verify the new container is created along with the database id and should appead in the Data Explorer list in the left side of the screen.
const connectionString = require("../../../utilities/connectionString");
let crypt = require("crypto");
context("Table API Test", () => {
beforeEach(() => {
connectionString.loginUsingConnectionString(connectionString.constants.table);
});
it("Create a new table in Table API", () => {
const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`;
cy.get("iframe").then($element => {
const $body = $element.contents().find("body");
cy.wrap($body)
.find('div[class="commandBarContainer"]')
.should("be.visible")
.find('button[data-test="New Table"]')
.should("be.visible")
.click();
cy.wrap($body)
.find('div[class="contextual-pane-in"]')
.should("be.visible")
.find('span[id="containerTitle"]');
cy.wrap($body)
.find('input[data-test="addCollection-collectionId"]')
.type(collectionId);
cy.wrap($body)
.find('input[data-test="databaseThroughputValue"]')
.should("have.value", "400");
cy.wrap($body)
.find('input[data-test="addCollection-createCollection"]')
.click();
cy.wait(10000);
cy.wrap($body)
.find('div[data-test="resourceTreeId"]')
.should("exist")
.find('div[class="treeComponent dataResourceTree"]')
.should("contain", collectionId);
});
});
});

View File

@@ -1,55 +0,0 @@
// 1. Click on "New Container" on the command bar.
// 2. Pane with the title "Add Container" should appear on the right side of the screen
// 3. It includes an input box for the database Id.
// 4. It includes a checkbox called "Create now".
// 5. When the checkbox is marked, enter new database id.
// 3. Create a database WITH "Provision throughput" checked.
// 4. Enter minimum throughput value of 400.
// 5. Enter container id to the container id text box.
// 6. Enter partition key to the partition key text box.
// 7. Click "OK" to create a new container.
// 8. Verify the new container is created along with the database id and should appead in the Data Explorer list in the left side of the screen.
let crypt = require("crypto");
context("Emulator - createDatabase", () => {
beforeEach(() => {
cy.visit("http://localhost:1234/explorer.html");
});
const dbId = `TestDatabase${crypt.randomBytes(8).toString("hex")}`;
const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`;
const collectionIdTitle = `Add Collection`;
const partitionKey = `PartitionKey${crypt.randomBytes(8).toString("hex")}`;
it("Create a new collection", () => {
cy.contains("New Container").click();
// cy.contains(collectionIdTitle);
cy.get(".createNewDatabaseOrUseExisting")
.should("have.length", 2)
.and(input => {
expect(input.get(0).textContent, "first item").contains("Create new");
expect(input.get(1).textContent, "second item").contains("Use existing");
});
cy.get('input[data-test="addCollection-createNewDatabase"]').check();
cy.get('input[data-test="addCollection-newDatabaseId"]').type(dbId);
cy.get('input[data-test="addCollection-collectionId"]').type(collectionId);
cy.get('input[data-test="databaseThroughputValue"]').should("have.value", "400");
cy.get('input[data-test="addCollection-partitionKeyValue"]').type(partitionKey);
cy.get('input[data-test="addCollection-createCollection"]').click();
cy.get('div[data-test="resourceTreeId"]').should("exist");
cy.get('div[data-test="resourceTree-collectionsTree"]').should("contain", dbId);
cy.get('div[data-test="databaseList"]').should("contain", collectionId);
});
});

View File

@@ -1,65 +0,0 @@
// 1. Click on "New Database" on the command bar
// 2. a Pane with the title "Add Database" should appear on the right side of the screen
// i. It includes an input box for the database Id.
// ii. It includes a checkbox called "Provision throughput".
// iii. Whe the checkbox is marked, a new input with a throughput control let's you customize RU at the database level
// 3. Create a database WITHOUT "Provision throughput" checked.
// 4. It should appear in the Data Explorer list.
// 5. Repeat steps 1-3 but create a database WITH "Provision throughput" with the default RU value.
// 6. It should appear in the Data Explorer list.
// 7. If expanded, it should have the list item called "Scale", that once clicked, it should show the "Scale" tab.
// 8. Inside that tab, a throughput control will let you change the RU value within the permited range.
// 9. If you change the value, it should enable the "Save" button.
// 10. Click "Save" and verify that the process completes without error.
// 11. Close the tab and reopen it and verify that the input contains the last saved value.%
const crypto = require("crypto");
const client = require("../../../utilities/cosmosClient");
const randomString = crypto.randomBytes(2).toString("hex");
const databaseId = `TestDB-${randomString}`;
const collectionId = `TestColl-${randomString}`;
context("Emulator - Create database -> container -> item", () => {
beforeEach(async () => {
const { resources } = await client.databases.readAll().fetchAll();
for (const database of resources) {
await client.database(database.id).delete();
}
});
it("creates a new database", () => {
cy.visit("https://0.0.0.0:1234/explorer.html?platform=Emulator");
cy.contains("New Container").click();
cy.get("[data-test=addCollection-newDatabaseId]").click();
cy.get("[data-test=addCollection-newDatabaseId]").type(databaseId);
cy.get("[data-test=addCollection-collectionId]").click();
cy.get("[data-test=addCollection-collectionId]").type(collectionId);
cy.get("[data-test=addCollection-partitionKeyValue]").click();
cy.get("[data-test=addCollection-partitionKeyValue]").type("/pk");
cy.get('input[name="createCollection"]').click();
cy.get(".dataResourceTree").should("contain", databaseId);
cy.get(".dataResourceTree")
.contains(databaseId)
.click();
cy.get(".dataResourceTree").should("contain", collectionId);
cy.get(".dataResourceTree")
.contains(collectionId)
.click();
cy.get(".dataResourceTree")
.contains("Items")
.click();
cy.get(".dataResourceTree")
.contains("Items")
.click();
cy.wait(1000); // React rendering inside KO causes some weird async rendering that makes this test flaky without waiting
cy.get(".commandBarContainer")
.contains("New Item")
.click();
cy.wait(1000); // React rendering inside KO causes some weird async rendering that makes this test flaky without waiting
cy.get(".commandBarContainer")
.contains("Save")
.click();
cy.wait(1000); // React rendering inside KO causes some weird async rendering that makes this test flaky without waiting
cy.get(".documentsGridHeaderContainer").should("contain", "replace_with_new_document_id");
});
});

View File

@@ -1,46 +0,0 @@
// 1. Click last database in the resource tree
// 2. Click the last collection within the database
// 3. Select the context menu within the collection
// 4. Select "Delete Container" option in the dropdown
// 5. On Selection, Delete Container pane opens on the right side
// 6. Enter the same collection id that is to be deleted and click ok
// 7. Now, the resource tree refreshes, the deleted collection should not appear under the database
let crypt = require("crypto");
context("Emulator - deleteCollection", () => {
beforeEach(() => {
cy.visit("http://localhost:1234/explorer.html");
});
it("Delete a collection", () => {
cy.get(".databaseId")
.last()
.click();
cy.get(".collectionList")
.last()
.then($id => {
const collectionId = $id.text();
cy.get('span[data-test="collectionEllipsisMenu"]').should("exist");
cy.get('span[data-test="collectionEllipsisMenu"]')
.invoke("show")
.last()
.click();
cy.get('div[data-test="collectionContextMenu"]')
.contains("Delete Container")
.click({ force: true });
cy.get('input[data-test="confirmCollectionId"]').type(collectionId.trim());
cy.get('input[data-test="deleteCollection"]').click();
cy.get('div[data-test="databaseList"]').should("not.contain", collectionId);
cy.get('div[data-test="databaseMenu"]').should("not.contain", collectionId);
});
});
});

View File

@@ -1,83 +0,0 @@
// 1. Click last database in the resource tree
// 2. Select the context menu within the database
// 4. Select "Delete Database" option in the dropdown
// 5. On Selection, Delete Database pane opens on the right side
// 6. Enter the same database id that is to be deleted and click ok
// 7. Now, the resource tree refreshes, the deleted database should not appear in the resource tree
let crypt = require("crypto");
context("Emulator - deleteDatabase", () => {
beforeEach(() => {
const dbId = `TestDatabase${crypt.randomBytes(8).toString("hex")}`;
const collectionId = `TestCollection${crypt.randomBytes(8).toString("hex")}`;
let db_rid = "";
const date = new Date().toUTCString();
let authToken = "";
cy.visit("http://localhost:1234/explorer.html");
// Creating auth token for collection creation
cy.request({
method: "GET",
url: "https://localhost:8081/_explorer/authorization/post/dbs/",
headers: {
"x-ms-date": date,
authorization: "-"
}
})
.then(response => {
authToken = response.body.Token; // Getting auth token for collection creation
return new Cypress.Promise((resolve, reject) => {
return resolve();
});
})
.then(() => {
cy.request({
method: "POST",
url: "https://localhost:8081/dbs",
headers: {
"x-ms-date": date,
authorization: authToken,
"x-ms-version": "2018-12-31"
},
body: {
id: dbId
}
}).then(response => {
cy.log("Response", response);
db_rid = response.body._rid;
return new Cypress.Promise((resolve, reject) => {
cy.log("Rid", db_rid);
return resolve();
});
});
});
});
it("Delete a database", () => {
cy.get('span[data-test="refreshTree"]').click();
cy.get(".databaseId")
.last()
.then($id => {
const dbId = $id.text();
cy.get('span[data-test="databaseEllipsisMenu"]').should("exist");
cy.get('span[data-test="databaseEllipsisMenu"]')
.invoke("show")
.last()
.click();
cy.get('div[data-test="databaseContextMenu"]')
.contains("Delete Database")
.click({ force: true });
cy.get('input[data-test="confirmDatabaseId"]').type(dbId.trim());
cy.get('input[data-test="deleteDatabase"]').click();
cy.get('div[data-test="databaseList"]').should("not.contain", dbId);
});
});
});

View File

@@ -1,35 +0,0 @@
# Notebook end-to-end tests
This describes how to run the tests locally
## Stand up a local notebook container instance:
Instructions on how to build and run the container [here](https://microsoft.sharepoint.com/teams/DocDB/_layouts/OneNote.aspx?id=%2Fteams%2FDocDB%2FSiteAssets%2FDocDB%20Team%20Notebook&wd=target%28Tools%20_%20SDK%2FPortal%2FDevelopment.one%7CF800BE8E-1E31-48FE-90D7-EF698EF88112%2FHow%20to%20build%20notebook%20service%7C4BAA153B-422C-41E2-B997-F3FCE02CD743%2F%29)
## Run a local data explorer
Instructions are in [`DataExplorer/README.md`](https://msdata.visualstudio.com/CosmosDB/_git/cosmosdb-dataexplorer?path=%2FProduct%2FPortal%2FDataExplorer%2FREADME.md&_a=preview).
Make sure you can run Data Explorer locally from the web browser.
## Run cypress tests
1. Edit the URL for your DataExplorer in the `.spec.ts` file
2. Run the test:
```bash
cd DataExplorer/cypress
npm i
npm t -- --spec 'integration/notebook/newNotebook.spec.ts'
```
To run in Debug mode:
```
npm run test:debug
```
This opens Cypress UI
## Troubleshooting
* The tests are recorded in the `videos` folder.
* Cypress does not support hover: workarounds [here](https://docs.cypress.io/api/commands/hover.html#Workarounds).
## References
* [Cypress API](https://docs.cypress.io/api/api/table-of-contents.html)
* [Cypress cookbook](https://docs.cypress.io/faq/questions/using-cypress-faq.html#How-do-I-get-an-element%E2%80%99s-text-contents)
* [Cypress best practices](https://docs.cypress.io/guides/references/best-practices.html#Selecting-Elements)

View File

@@ -1,93 +0,0 @@
// THIS ADDS A NEW NOTEBOOK TO YOUR NOTEBOOKS
context("New Notebook smoke test", () => {
const timeout = 15000; // in ms
const explorerUrl =
"https://localhost:1234/explorer.html?feature.notebookserverurl=https%3A%2F%2Flocalhost%3A10001%2F12345%2Fnotebook&feature.notebookServerToken=token&feature.enablenotebooks=true";
/**
* Wait for UI to be ready
*/
const waitForReady = () => {
cy.get(".splashScreenContainer", { timeout }).should("be.visible");
};
beforeEach(() => {
cy.visit(explorerUrl);
waitForReady();
});
it("Create a new notebook and run some code", () => {
// Create new notebook
cy.contains("New Notebook").click();
// Check tab name
cy.get("li.tabList .tabNavText").should($span => {
const text = $span.text();
expect(text).to.match(/^Untitled.*\.ipynb$/);
});
// Wait for python3 | idle status
cy.get('[data-test="notebookStatusBar"] [data-test="kernelStatus"]', { timeout }).should($p => {
const text = $p.text();
expect(text).to.match(/^python3.*idle$/);
});
// Click on a cell
cy.get(".cell-container")
.as("cellContainer")
.click();
// Type in some code
cy.get("@cellContainer").type("2+4");
// Execute
cy.get('[data-test="Run"]')
.first()
.click();
// Verify results
cy.get("@cellContainer").within(() => {
cy.get("pre code span").should("contain", "6");
});
// Restart kernel
cy.get('[data-test="Run"] button')
.eq(-1)
.click();
cy.get("li")
.contains("Restart Kernel")
.click();
// Wait for python3 | restarting status
cy.get('[data-test="notebookStatusBar"] [data-test="kernelStatus"]', { timeout }).should($p => {
const text = $p.text();
expect(text).to.match(/^python3.*restarting$/);
});
// Wait for python3 | idle status
cy.get('[data-test="notebookStatusBar"] [data-test="kernelStatus"]', { timeout }).should($p => {
const text = $p.text();
expect(text).to.match(/^python3.*idle$/);
});
// Click on a cell
cy.get(".cell-container")
.as("cellContainer")
.find(".input")
.as("codeInput")
.click();
// Type in some code
cy.get("@codeInput").type("{backspace}{backspace}{backspace}4+5");
// Execute
cy.get('[data-test="Run"]')
.first()
.click();
// Verify results
cy.get("@cellContainer").within(() => {
cy.get("pre code span").should("contain", "9");
});
});
});

View File

@@ -1,172 +0,0 @@
context("Resource tree notebook file manipulation", () => {
const timeout = 15000; // in ms
const explorerUrl =
"https://localhost:1234/explorer.html?feature.notebookserverurl=https%3A%2F%2Flocalhost%3A10001%2F12345%2Fnotebook&feature.notebookServerToken=token&feature.enablenotebooks=true";
/**
* Wait for UI to be ready
*/
const waitForReady = () => {
cy.get(".splashScreenContainer", { timeout }).should("be.visible");
};
const clickContextMenuAndSelectOption = (nodeLabel, option) => {
cy.get(`.treeNodeHeader[data-test="${nodeLabel}"]`)
.find("button.treeMenuEllipsis")
.click();
cy.get('[data-test="treeComponentMenuItemContainer"]')
.contains(option)
.click();
};
const createFolder = folder => {
clickContextMenuAndSelectOption("My Notebooks/", "New Directory");
cy.get("#stringInputPane").within(() => {
cy.get('input[name="collectionIdConfirmation"]').type(folder);
cy.get("form").submit();
});
};
const deleteItem = nodeName => {
clickContextMenuAndSelectOption(`${nodeName}`, "Delete");
cy.get(".ms-Dialog-main")
.contains("Delete")
.click();
};
beforeEach(() => {
cy.visit(explorerUrl);
waitForReady();
});
it("Create and remove a directory", () => {
const folder = "e2etest_folder1";
createFolder(folder);
cy.get(`.treeNodeHeader[data-test="${folder}/"]`).should("exist");
deleteItem(`${folder}/`);
cy.get(`.treeNodeHeader[data-test="${folder}/"]`).should("not.exist");
});
it("Create and rename a directory", () => {
const folder = "e2etest_folder2";
const renamedFolder = "e2etest_folder2_renamed";
createFolder(folder);
// Rename
clickContextMenuAndSelectOption(`${folder}/`, "Rename");
cy.get("#stringInputPane").within(() => {
cy.get('input[name="collectionIdConfirmation"]')
.clear()
.type(renamedFolder);
cy.get("form").submit();
});
cy.get(`.treeNodeHeader[data-test="${renamedFolder}/"]`).should("exist");
cy.get(`.treeNodeHeader[data-test="${folder}/"]`).should("not.exist");
deleteItem(`${renamedFolder}/`);
cy.get(`.treeNodeHeader[data-test="${renamedFolder}/"]`).should("not.exist");
});
it("Create a notebook inside a directory", () => {
const folder = "e2etest_folder3";
const newNotebookName = "Untitled.ipynb";
createFolder(folder);
clickContextMenuAndSelectOption(`${folder}/`, "New Notebook");
// Verify tab is open
cy.get(".tabList")
.contains(newNotebookName)
.should("exist");
// Close tab
cy.get(`.tabList[title="notebooks/${folder}/${newNotebookName}"]`)
.find(".cancelButton")
.click();
// When running from command line, closing the tab is too fast
cy.get("body").then($body => {
if ($body.find(".ms-Dialog-main").length) {
// For some reason, this does not work
// cy.get(".ms-Dialog-main").contains("Close").click();
cy.get(".ms-Dialog-main .ms-Button--primary").click();
}
});
// Expand folder node
cy.get(`.treeNodeHeader[data-test="${folder}/"]`).click();
cy.get(`.nodeChildren[data-test="${folder}/"] .treeNodeHeader[data-test="${newNotebookName}"]`).should("exist");
// Delete notebook
cy.get(`.nodeChildren[data-test="${folder}/"] .treeNodeHeader[data-test="${newNotebookName}"]`)
.find("button.treeMenuEllipsis")
.click();
cy.get('[data-test="treeComponentMenuItemContainer"]')
.contains("Delete")
.click();
// Confirm
cy.get(".ms-Dialog-main")
.contains("Delete")
.click();
cy.get(`.nodeChildren[data-test="${folder}/"] .treeNodeHeader[data-test="${newNotebookName}"]`).should("not.exist");
deleteItem(`${folder}/`);
});
it("Create and rename a notebook inside a directory", () => {
const folder = "e2etest_folder4";
const newNotebookName = "Untitled.ipynb";
const renamedNotebookName = "mynotebook.ipynb";
createFolder(folder);
clickContextMenuAndSelectOption(`${folder}/`, "New Notebook");
// Close tab
cy.get(`.tabList[title="notebooks/${folder}/${newNotebookName}"]`)
.find(".cancelButton")
.click();
cy.get("body").then($body => {
if ($body.find(".ms-Dialog-main").length) {
// For some reason, this does not work
// cy.get(".ms-Dialog-main").contains("Close").click();
cy.get(".ms-Dialog-main .ms-Button--primary").click();
}
});
// Expand folder node
cy.get(`.treeNodeHeader[data-test="${folder}/"]`).click();
cy.get(`.nodeChildren[data-test="${folder}/"] .treeNodeHeader[data-test="${newNotebookName}"]`).should("exist");
// Rename notebook
cy.get(`.nodeChildren[data-test="${folder}/"] .treeNodeHeader[data-test="${newNotebookName}"]`)
.find("button.treeMenuEllipsis")
.click();
cy.get('[data-test="treeComponentMenuItemContainer"]')
.contains("Rename")
.click();
cy.get("#stringInputPane").within(() => {
cy.get('input[name="collectionIdConfirmation"]')
.clear()
.type(renamedNotebookName);
cy.get("form").submit();
});
cy.get(`.nodeChildren[data-test="${folder}/"] .treeNodeHeader[data-test="${newNotebookName}"]`).should("not.exist");
cy.get(`.nodeChildren[data-test="${folder}/"] .treeNodeHeader[data-test="${renamedNotebookName}"]`).should("exist");
// Delete notebook
cy.get(`.nodeChildren[data-test="${folder}/"] .treeNodeHeader[data-test="${renamedNotebookName}"]`)
.find("button.treeMenuEllipsis")
.click();
cy.get('[data-test="treeComponentMenuItemContainer"]')
.contains("Delete")
.click();
// Confirm
cy.get(".ms-Dialog-main")
.contains("Delete")
.click();
// Give it time to settle
cy.wait(1000);
deleteItem(`${folder}/`);
});
});

3066
cypress/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
{
"name": "cosmos-explorer-cypress",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"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 --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 edge --headless",
"test:debug": "cypress open"
},
"devDependencies": {
"cypress": "^4.8.0",
"mocha": "^7.0.1",
"mochawesome": "^4.1.0",
"mochawesome-merge": "^4.0.1",
"mochawesome-report-generator": "^4.1.0",
"typescript": "3.4.3",
"wait-on": "^4.0.2"
},
"dependencies": {
"@microsoft/applicationinsights-web": "^2.5.2"
}
}

View File

@@ -1,23 +0,0 @@
let appInsightsLib = require("@microsoft/applicationinsights-web");
const appInsights = new appInsightsLib.ApplicationInsights({
config: {
instrumentationKey: "fe61c39f-7d32-4488-a191-b13621965315"
/* ...Other Configuration Options... */
}
});
appInsights.loadAppInsights();
Cypress.on("fail", (error, runnable) => {
// App Insights will record the fail tests for Create Collection
let message = JSON.stringify(runnable.title);
appInsights.trackTrace({
message: `${message}`,
properties: {
passed: false,
error: error
}
});
throw error; // throw error to have test still fail
});

View File

@@ -1,11 +0,0 @@
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"module": "commonjs",
"target": "es5",
"lib": ["es5", "dom", "es6"],
"types": ["cypress", "node"]
},
"include": ["**/*.ts", "**/*.spec.ts"]
}

View File

@@ -1,41 +0,0 @@
module.exports = {
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");
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("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);
});
}
};

View File

@@ -1,6 +0,0 @@
const { CosmosClient } = require("@azure/cosmos");
module.exports = new CosmosClient({
endpoint: "https://0.0.0.0:8081",
key: "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
});

3650
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,10 @@
"description": "Cosmos Explorer",
"main": "index.js",
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "3.9.0",
"@azure/cosmos-language-service": "0.0.4",
"@azure/identity": "1.1.0",
"@azure/cosmos-language-service": "0.0.5",
"@jupyterlab/services": "6.0.0-rc.2",
"@jupyterlab/terminal": "3.0.0-rc.2",
"@microsoft/applicationinsights-web": "2.5.9",
@@ -66,7 +68,7 @@
"jquery-ui-dist": "1.12.1",
"knockout": "3.5.1",
"mkdirp": "1.0.4",
"monaco-editor": "0.15.6",
"monaco-editor": "0.18.1",
"object.entries": "1.1.0",
"office-ui-fabric-react": "7.134.1",
"p-retry": "4.2.0",
@@ -115,7 +117,7 @@
"@types/prop-types": "15.5.8",
"@types/puppeteer": "3.0.1",
"@types/q": "1.5.1",
"@types/react": "16.9.49",
"@types/react": "16.9.56",
"@types/react-dom": "16.0.7",
"@types/react-notification-system": "0.2.39",
"@types/react-redux": "7.1.7",
@@ -194,8 +196,8 @@
"compile": "tsc",
"compile:contracts": "tsc -p ./tsconfig.contracts.json",
"compile:strict": "tsc -p ./tsconfig.strict.json",
"format": "prettier --write \"{src,cypress,test}/**/*.{ts,tsx,html}\" \"*.{js,html}\"",
"format:check": "prettier --check \"{src,cypress,test}/**/*.{ts,tsx,html}\" \"*.{js,html}\"",
"format": "prettier --write \"{src,test}/**/*.{ts,tsx,html}\" \"*.{js,html}\"",
"format:check": "prettier --check \"{src,test}/**/*.{ts,tsx,html}\" \"*.{js,html}\"",
"lint": "tslint --project tsconfig.json && eslint \"**/*.{ts,tsx}\"",
"build:contracts": "npm run compile:contracts",
"strictEligibleFiles": "node ./strict-migration-tools/index.js",

View File

@@ -125,7 +125,9 @@ export class Features {
public static readonly enableFixedCollectionWithSharedThroughput = "enablefixedcollectionwithsharedthroughput";
public static readonly ttl90Days = "ttl90days";
public static readonly enableRightPanelV2 = "enablerightpanelv2";
public static readonly enableSchema = "enableschema";
public static readonly enableSDKoperations = "enablesdkoperations";
public static readonly showMinRUSurvey = "showminrusurvey";
}
// flight names returned from the portal are always lowercase

View File

@@ -1,7 +1,7 @@
import { ARMError } from "../Utils/arm/request";
import { HttpStatusCodes } from "./Constants";
import { MessageTypes } from "../Contracts/ExplorerContracts";
import { SubscriptionType } from "../Contracts/ViewModels";
import { SubscriptionType } from "../Contracts/SubscriptionType";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import { logError } from "./Logger";
import { sendMessage } from "./MessageHandler";

View File

@@ -16,7 +16,7 @@ const notificationsPath = () => {
};
export const fetchPortalNotifications = async (): Promise<DataModels.Notification[]> => {
if (configContext.platform === Platform.Emulator) {
if (configContext.platform === Platform.Emulator || configContext.platform === Platform.Hosted) {
return [];
}

View File

@@ -88,6 +88,38 @@ export interface Resource {
id: string;
}
export interface IType {
name: string;
code: number;
}
export interface IDataField {
dataType: IType;
hasNulls: boolean;
isArray: boolean;
schemaType: IType;
name: string;
path: string;
maxRepetitionLevel: number;
maxDefinitionLevel: number;
}
export interface ISchema {
id: string;
accountName: string;
resource: string;
fields: IDataField[];
}
export interface ISchemaRequest {
id: string;
subscriptionId: string;
resourceGroup: string;
accountName: string;
resource: string;
status: string;
}
export interface Collection extends Resource {
defaultTtl?: number;
indexingPolicy?: IndexingPolicy;
@@ -98,6 +130,8 @@ export interface Collection extends Resource {
changeFeedPolicy?: ChangeFeedPolicy;
analyticalStorageTtl?: number;
geospatialConfig?: GeospatialConfig;
schema?: ISchema;
requestSchema?: () => void;
}
export interface Database extends Resource {

View File

@@ -32,7 +32,8 @@ export enum MessageTypes {
GetArcadiaToken,
CreateWorkspace,
CreateSparkPool,
RefreshDatabaseAccount
RefreshDatabaseAccount,
InitTestExplorer
}
export { Versions, ActionContracts, Diagnostics };

View File

@@ -0,0 +1,7 @@
export enum SubscriptionType {
Benefits,
EA,
Free,
Internal,
PAYG
}

View File

@@ -17,6 +17,7 @@ import Trigger from "../Explorer/Tree/Trigger";
import UserDefinedFunction from "../Explorer/Tree/UserDefinedFunction";
import { UploadDetails } from "../workers/upload/definitions";
import * as DataModels from "./DataModels";
import { SubscriptionType } from "./SubscriptionType";
export interface TokenProvider {
getAuthHeader(): Promise<Headers>;
@@ -115,6 +116,8 @@ export interface CollectionBase extends TreeNode {
export interface Collection extends CollectionBase {
defaultTtl: ko.Observable<number>;
analyticalStorageTtl: ko.Observable<number>;
schema?: DataModels.ISchema;
requestSchema?: () => void;
indexingPolicy: ko.Observable<DataModels.IndexingPolicy>;
uniqueKeyPolicy: DataModels.UniqueKeyPolicy;
quotaInfo: ko.Observable<DataModels.CollectionQuotaInfo>;
@@ -358,6 +361,7 @@ export enum CollectionTabKind {
SparkMasterTab = 16,
Gallery = 17,
NotebookViewer = 18,
Schema = 19,
SettingsV2 = 19
}
@@ -412,14 +416,6 @@ export interface ThroughputDefaults {
shared: number;
}
export enum SubscriptionType {
Benefits,
EA,
Free,
Internal,
PAYG
}
export class MonacoEditorSettings {
public readonly language: string;
public readonly readOnly: boolean;

View File

@@ -44,10 +44,6 @@ describe("Component Registerer", () => {
expect(ko.components.isRegistered("user-defined-function-tab")).toBe(true);
});
it("should register settings-tab component", () => {
expect(ko.components.isRegistered("settings-tab")).toBe(true);
});
it("should register settings-tab-v2 component", () => {
expect(ko.components.isRegistered("settings-tab-v2")).toBe(true);
});

View File

@@ -31,7 +31,6 @@ ko.components.register("mongo-documents-tab", new TabComponents.MongoDocumentsTa
ko.components.register("stored-procedure-tab", new TabComponents.StoredProcedureTab());
ko.components.register("trigger-tab", new TabComponents.TriggerTab());
ko.components.register("user-defined-function-tab", new TabComponents.UserDefinedFunctionTab());
ko.components.register("settings-tab", new TabComponents.SettingsTab());
ko.components.register("settings-tab-v2", new TabComponents.SettingsTabV2());
ko.components.register("query-tab", new TabComponents.QueryTab());
ko.components.register("tables-query-tab", new TabComponents.QueryTablesTab());

View File

@@ -16,7 +16,7 @@ import { CommandButtonComponentProps } from "../../Controls/CommandButton/Comman
import { userContext } from "../../../UserContext";
import { updateOfferThroughputBeyondLimit } from "../../../Common/dataAccess/updateOfferThroughputBeyondLimit";
import SettingsTab from "../../Tabs/SettingsTabV2";
import { throughputUnit } from "./SettingsRenderUtils";
import { mongoIndexingPolicyAADError, throughputUnit } from "./SettingsRenderUtils";
import { ScaleComponent, ScaleComponentProps } from "./SettingsSubComponents/ScaleComponent";
import {
MongoIndexingPolicyComponent,
@@ -49,6 +49,7 @@ import { MongoDBCollectionResource, MongoIndex } from "../../../Utils/arm/genera
import { readMongoDBCollectionThroughRP } from "../../../Common/dataAccess/readMongoDBCollection";
import { getIndexTransformationProgress } from "../../../Common/dataAccess/getIndexTransformationProgress";
import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils";
import { isEmpty } from "underscore";
interface SettingsV2TabInfo {
tab: SettingsV2TabTypes;
@@ -227,7 +228,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
public loadMongoIndexes = async (): Promise<void> => {
if (
this.container.isMongoIndexEditorEnabled() &&
this.container.isPreferredApiMongoDB() &&
this.container.isEnableMongoCapabilityPresent() &&
this.container.databaseAccount()
@@ -1000,15 +1000,18 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
tab: SettingsV2TabTypes.IndexingPolicyTab,
content: <IndexingPolicyComponent {...indexingPolicyComponentProps} />
});
} else if (
this.container.isMongoIndexEditorEnabled() &&
this.container.isPreferredApiMongoDB() &&
this.container.isEnableMongoCapabilityPresent()
) {
tabs.push({
tab: SettingsV2TabTypes.IndexingPolicyTab,
content: <MongoIndexingPolicyComponent {...mongoIndexingPolicyComponentProps} />
});
} else if (this.container.isPreferredApiMongoDB()) {
if (isEmpty(this.container.features())) {
tabs.push({
tab: SettingsV2TabTypes.IndexingPolicyTab,
content: mongoIndexingPolicyAADError
});
} else if (this.container.isEnableMongoCapabilityPresent()) {
tabs.push({
tab: SettingsV2TabTypes.IndexingPolicyTab,
content: <MongoIndexingPolicyComponent {...mongoIndexingPolicyComponentProps} />
});
}
}
if (this.hasConflictResolution()) {

View File

@@ -24,7 +24,6 @@ import {
transparentDetailsRowStyles,
createAndAddMongoIndexStackProps,
separatorStyles,
mongoIndexingPolicyAADError,
indexingPolicynUnsavedWarningMessage,
infoAndToolTipTextStyle
} from "../../SettingsRenderUtils";
@@ -40,7 +39,6 @@ import {
} from "../../SettingsUtils";
import { AddMongoIndexComponent } from "./AddMongoIndexComponent";
import { CollapsibleSectionComponent } from "../../../CollapsiblePanel/CollapsibleSectionComponent";
import { AuthType } from "../../../../../AuthType";
import { IndexingPolicyRefreshComponent } from "../IndexingPolicyRefresh/IndexingPolicyRefreshComponent";
export interface MongoIndexingPolicyComponentProps {
@@ -321,7 +319,7 @@ export class MongoIndexingPolicyComponent extends React.Component<MongoIndexingP
</Stack>
);
} else {
return window.authType !== AuthType.AAD ? mongoIndexingPolicyAADError : <Spinner size={SpinnerSize.large} />;
return <Spinner size={SpinnerSize.large} />;
}
}
}

View File

@@ -200,6 +200,7 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
onScaleSaveableChange={this.props.onScaleSaveableChange}
onScaleDiscardableChange={this.props.onScaleDiscardableChange}
getThroughputWarningMessage={this.getThroughputWarningMessage}
usageSizeInKB={this.props.collection.quotaInfo().usageSizeInKB}
/>
);

View File

@@ -17,6 +17,7 @@ describe("ThroughputInputAutoPilotV3Component", () => {
minimum: 10000,
maximum: 400,
step: 100,
usageSizeInKB: 10000,
isEnabled: true,
isEmulator: false,
spendAckChecked: false,

View File

@@ -30,6 +30,10 @@ import { getSanitizedInputValue, IsComponentDirtyResult, isDirty } from "../../S
import * as SharedConstants from "../../../../../Shared/Constants";
import * as DataModels from "../../../../../Contracts/DataModels";
import { Int32 } from "../../../../Panes/Tables/Validators/EntityPropertyValidationCommon";
import { userContext } from "../../../../../UserContext";
import { SubscriptionType } from "../../../../../Contracts/SubscriptionType";
import { usageInGB } from "../../../../../Utils/PricingUtils";
import { Features } from "../../../../../Common/Constants";
export interface ThroughputInputAutoPilotV3Props {
databaseAccount: DataModels.DatabaseAccount;
@@ -60,6 +64,7 @@ export interface ThroughputInputAutoPilotV3Props {
onScaleSaveableChange: (isScaleSaveable: boolean) => void;
onScaleDiscardableChange: (isScaleDiscardable: boolean) => void;
getThroughputWarningMessage: () => JSX.Element;
usageSizeInKB: number;
}
interface ThroughputInputAutoPilotV3State {
@@ -224,6 +229,29 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
option?: IChoiceGroupOption
): void => this.props.onAutoPilotSelected(option.key === "true");
private minRUperGBSurvey = (): JSX.Element => {
const href = `https://ncv.microsoft.com/vRBTO37jmO?ctx={"AzureSubscriptionId":"${userContext.subscriptionId}","CosmosDBAccountName":"${userContext.databaseAccount?.name}"}`;
const oneTBinKB = 1000000000;
const minRUperGB = 10;
const featureFlagEnabled = window.dataExplorer?.isFeatureEnabled(Features.showMinRUSurvey);
const collectionIsEligible =
userContext.subscriptionType !== SubscriptionType.Internal &&
this.props.usageSizeInKB > oneTBinKB &&
this.props.minimum >= usageInGB(this.props.usageSizeInKB) * minRUperGB;
if (featureFlagEnabled || collectionIsEligible) {
return (
<Text>
Need to scale below {this.props.minimum} RU/s? Reach out by filling{" "}
<a target="_blank" rel="noreferrer" href={href}>
this questionnaire
</a>
.
</Text>
);
}
return undefined;
};
private renderThroughputModeChoices = (): JSX.Element => {
const labelId = "settingsV2RadioButtonLabelId";
return (
@@ -275,6 +303,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
onChange={this.onAutoPilotThroughputChange}
/>
{!this.overrideWithProvisionedThroughputSettings() && this.getAutoPilotUsageCost()}
{this.minRUperGBSurvey()}
{this.props.spendAckVisible && (
<Checkbox
id="spendAckCheckBox"
@@ -305,15 +334,13 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
}
onChange={this.onThroughputChange}
/>
{this.props.getThroughputWarningMessage() && (
<MessageBar messageBarType={MessageBarType.warning} styles={messageBarStyles}>
{this.props.getThroughputWarningMessage()}
</MessageBar>
)}
{!this.props.isEmulator && this.getRequestUnitsUsageCost()}
{this.minRUperGBSurvey()}
{this.props.spendAckVisible && (
<Checkbox
id="spendAckCheckBox"
@@ -323,7 +350,6 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
onChange={this.onSpendAckChecked}
/>
)}
{this.props.isFixed && <p>When using a collection with fixed storage capacity, you can set up to 10,000 RU/s.</p>}
</Stack>
);

View File

@@ -956,7 +956,6 @@ exports[`SettingsComponent renders 1`] = `
"isHostedDataExplorerEnabled": [Function],
"isLeftPaneExpanded": [Function],
"isLinkInjectionEnabled": [Function],
"isMongoIndexEditorEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
@@ -971,8 +970,8 @@ exports[`SettingsComponent renders 1`] = `
"isRefreshingExplorer": [Function],
"isResourceTokenCollectionNodeSelected": [Function],
"isRightPanelV2Enabled": [Function],
"isSchemaEnabled": [Function],
"isServerlessEnabled": [Function],
"isSettingsV2Enabled": [Function],
"isSparkEnabled": [Function],
"isSparkEnabledForAccount": [Function],
"isSynapseLinkUpdating": [Function],
@@ -2236,7 +2235,6 @@ exports[`SettingsComponent renders 1`] = `
"isHostedDataExplorerEnabled": [Function],
"isLeftPaneExpanded": [Function],
"isLinkInjectionEnabled": [Function],
"isMongoIndexEditorEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
@@ -2251,8 +2249,8 @@ exports[`SettingsComponent renders 1`] = `
"isRefreshingExplorer": [Function],
"isResourceTokenCollectionNodeSelected": [Function],
"isRightPanelV2Enabled": [Function],
"isSchemaEnabled": [Function],
"isServerlessEnabled": [Function],
"isSettingsV2Enabled": [Function],
"isSparkEnabled": [Function],
"isSparkEnabledForAccount": [Function],
"isSynapseLinkUpdating": [Function],
@@ -3529,7 +3527,6 @@ exports[`SettingsComponent renders 1`] = `
"isHostedDataExplorerEnabled": [Function],
"isLeftPaneExpanded": [Function],
"isLinkInjectionEnabled": [Function],
"isMongoIndexEditorEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
@@ -3544,8 +3541,8 @@ exports[`SettingsComponent renders 1`] = `
"isRefreshingExplorer": [Function],
"isResourceTokenCollectionNodeSelected": [Function],
"isRightPanelV2Enabled": [Function],
"isSchemaEnabled": [Function],
"isServerlessEnabled": [Function],
"isSettingsV2Enabled": [Function],
"isSparkEnabled": [Function],
"isSparkEnabledForAccount": [Function],
"isSynapseLinkUpdating": [Function],
@@ -4809,7 +4806,6 @@ exports[`SettingsComponent renders 1`] = `
"isHostedDataExplorerEnabled": [Function],
"isLeftPaneExpanded": [Function],
"isLinkInjectionEnabled": [Function],
"isMongoIndexEditorEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
@@ -4824,8 +4820,8 @@ exports[`SettingsComponent renders 1`] = `
"isRefreshingExplorer": [Function],
"isResourceTokenCollectionNodeSelected": [Function],
"isRightPanelV2Enabled": [Function],
"isSchemaEnabled": [Function],
"isServerlessEnabled": [Function],
"isSettingsV2Enabled": [Function],
"isSparkEnabled": [Function],
"isSparkEnabledForAccount": [Function],
"isSynapseLinkUpdating": [Function],

View File

@@ -87,6 +87,7 @@ import { updateUserContext, userContext } from "../UserContext";
import { stringToBlob } from "../Utils/BlobUtils";
import { IChoiceGroupProps } from "office-ui-fabric-react";
import { getErrorMessage, handleError, getErrorStack } from "../Common/ErrorHandlingUtils";
import { SubscriptionType } from "../Contracts/SubscriptionType";
BindingHandlersRegisterer.registerBindingHandlers();
// Hold a reference to ComponentRegisterer to prevent transpiler to ignore import
@@ -119,7 +120,7 @@ export default class Explorer {
public databaseAccount: ko.Observable<DataModels.DatabaseAccount>;
public collectionCreationDefaults: ViewModels.CollectionCreationDefaults = SharedConstants.CollectionCreationDefaults;
public subscriptionType: ko.Observable<ViewModels.SubscriptionType>;
public subscriptionType: ko.Observable<SubscriptionType>;
public quotaId: ko.Observable<string>;
public defaultExperience: ko.Observable<string>;
public isPreferredApiDocumentDB: ko.Computed<boolean>;
@@ -205,8 +206,6 @@ export default class Explorer {
public isGalleryPublishEnabled: ko.Computed<boolean>;
public isCodeOfConductEnabled: ko.Computed<boolean>;
public isLinkInjectionEnabled: ko.Computed<boolean>;
public isSettingsV2Enabled: ko.Observable<boolean>;
public isMongoIndexEditorEnabled: ko.Observable<boolean>;
public isGitHubPaneEnabled: ko.Observable<boolean>;
public isPublishNotebookPaneEnabled: ko.Observable<boolean>;
public isCopyNotebookPaneEnabled: ko.Observable<boolean>;
@@ -225,6 +224,7 @@ export default class Explorer {
public shareTokenCopyHelperText: ko.Observable<string>;
public shouldShowDataAccessExpiryDialog: ko.Observable<boolean>;
public shouldShowContextSwitchPrompt: ko.Observable<boolean>;
public isSchemaEnabled: ko.Computed<boolean>;
// Notebooks
public isNotebookEnabled: ko.Observable<boolean>;
@@ -278,9 +278,7 @@ export default class Explorer {
this.refreshTreeTitle = ko.observable<string>("Refresh collections");
this.databaseAccount = ko.observable<DataModels.DatabaseAccount>();
this.subscriptionType = ko.observable<ViewModels.SubscriptionType>(
SharedConstants.CollectionCreation.DefaultSubscriptionType
);
this.subscriptionType = ko.observable<SubscriptionType>(SharedConstants.CollectionCreation.DefaultSubscriptionType);
this.quotaId = ko.observable<string>("");
let firstInitialization = true;
this.isRefreshingExplorer = ko.observable<boolean>(true);
@@ -412,8 +410,6 @@ export default class Explorer {
this.isLinkInjectionEnabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableLinkInjection)
);
this.isSettingsV2Enabled = ko.observable(false);
this.isMongoIndexEditorEnabled = ko.observable(false);
this.isGitHubPaneEnabled = ko.observable<boolean>(false);
this.isPublishNotebookPaneEnabled = ko.observable<boolean>(false);
this.isCopyNotebookPaneEnabled = ko.observable<boolean>(false);
@@ -422,6 +418,7 @@ export default class Explorer {
this.isFeatureEnabled(Constants.Features.canExceedMaximumValue)
);
this.isSchemaEnabled = ko.computed<boolean>(() => this.isFeatureEnabled(Constants.Features.enableSchema));
this.isNotificationConsoleExpanded = ko.observable<boolean>(false);
this.databases = ko.observableArray<ViewModels.Database>();
@@ -1733,6 +1730,7 @@ export default class Explorer {
case MessageTypes.SendNotification:
case MessageTypes.ClearNotification:
case MessageTypes.LoadingStatus:
case MessageTypes.InitTestExplorer:
return true;
}
}
@@ -1890,7 +1888,8 @@ export default class Explorer {
masterKey,
databaseAccount,
resourceGroup: inputs.resourceGroup,
subscriptionId: inputs.subscriptionId
subscriptionId: inputs.subscriptionId,
subscriptionType: inputs.subscriptionType
});
TelemetryProcessor.traceSuccess(
Action.LoadDatabaseAccount,
@@ -1911,14 +1910,6 @@ export default class Explorer {
if (!flights) {
return;
}
if (flights.indexOf(Constants.Flights.SettingsV2) !== -1) {
this.isSettingsV2Enabled(true);
}
if (flights.indexOf(Constants.Flights.MongoIndexEditor) !== -1) {
this.isMongoIndexEditorEnabled(true);
}
}
public findSelectedCollection(): ViewModels.Collection {

View File

@@ -7,6 +7,7 @@ import * as ko from "knockout";
import * as PricingUtils from "../../Utils/PricingUtils";
import * as SharedConstants from "../../Shared/Constants";
import * as ViewModels from "../../Contracts/ViewModels";
import { SubscriptionType } from "../../Contracts/SubscriptionType";
import editable from "../../Common/EditableUtility";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
@@ -648,10 +649,8 @@ export default class AddCollectionPane extends ContextualPaneBase {
}
public getSharedThroughputDefault(): boolean {
const subscriptionType: ViewModels.SubscriptionType =
this.container.subscriptionType && this.container.subscriptionType();
if (subscriptionType === ViewModels.SubscriptionType.EA || this.container.isServerlessEnabled()) {
const subscriptionType = this.container.subscriptionType && this.container.subscriptionType();
if (subscriptionType === SubscriptionType.EA || this.container.isServerlessEnabled()) {
return false;
}
@@ -690,7 +689,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
databaseId: this.databaseId(),
rupm: this.rupm()
}),
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
subscriptionType: SubscriptionType[this.container.subscriptionType()],
subscriptionQuotaId: this.container.quotaId(),
defaultsCheck: {
storage: this.storage() === Constants.BackendDefaults.singlePartitionStorageInGb ? "f" : "u",
@@ -793,7 +792,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
uniqueKeyPolicy,
collectionWithThroughputInShared: this.collectionWithThroughputInShared()
}),
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
subscriptionType: SubscriptionType[this.container.subscriptionType()],
subscriptionQuotaId: this.container.quotaId(),
defaultsCheck: {
storage: this.storage() === Constants.BackendDefaults.singlePartitionStorageInGb ? "f" : "u",
@@ -868,7 +867,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
uniqueKeyPolicy,
collectionWithThroughputInShared: this.collectionWithThroughputInShared()
}),
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
subscriptionType: SubscriptionType[this.container.subscriptionType()],
subscriptionQuotaId: this.container.quotaId(),
defaultsCheck: {
storage: this.storage() === Constants.BackendDefaults.singlePartitionStorageInGb ? "f" : "u",
@@ -903,7 +902,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
uniqueKeyPolicy,
collectionWithThroughputInShared: this.collectionWithThroughputInShared()
},
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
subscriptionType: SubscriptionType[this.container.subscriptionType()],
subscriptionQuotaId: this.container.quotaId(),
defaultsCheck: {
storage: this.storage() === Constants.BackendDefaults.singlePartitionStorageInGb ? "f" : "u",

View File

@@ -1,5 +1,5 @@
import * as Constants from "../../Common/Constants";
import * as ViewModels from "../../Contracts/ViewModels";
import { SubscriptionType } from "../../Contracts/SubscriptionType";
import Explorer from "../Explorer";
import AddDatabasePane from "./AddDatabasePane";
import { DatabaseAccount } from "../../Contracts/DataModels";
@@ -44,31 +44,31 @@ describe("Add Database Pane", () => {
});
it("should be true if subscription type is Benefits", () => {
explorer.subscriptionType(ViewModels.SubscriptionType.Benefits);
explorer.subscriptionType(SubscriptionType.Benefits);
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
expect(addDatabasePane.getSharedThroughputDefault()).toBe(true);
});
it("should be false if subscription type is EA", () => {
explorer.subscriptionType(ViewModels.SubscriptionType.EA);
explorer.subscriptionType(SubscriptionType.EA);
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
expect(addDatabasePane.getSharedThroughputDefault()).toBe(false);
});
it("should be true if subscription type is Free", () => {
explorer.subscriptionType(ViewModels.SubscriptionType.Free);
explorer.subscriptionType(SubscriptionType.Free);
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
expect(addDatabasePane.getSharedThroughputDefault()).toBe(true);
});
it("should be true if subscription type is Internal", () => {
explorer.subscriptionType(ViewModels.SubscriptionType.Internal);
explorer.subscriptionType(SubscriptionType.Internal);
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
expect(addDatabasePane.getSharedThroughputDefault()).toBe(true);
});
it("should be true if subscription type is PAYG", () => {
explorer.subscriptionType(ViewModels.SubscriptionType.PAYG);
explorer.subscriptionType(SubscriptionType.PAYG);
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
expect(addDatabasePane.getSharedThroughputDefault()).toBe(true);
});

View File

@@ -12,6 +12,7 @@ import { ContextualPaneBase } from "./ContextualPaneBase";
import { createDatabase } from "../../Common/dataAccess/createDatabase";
import { configContext, Platform } from "../../ConfigContext";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
import { SubscriptionType } from "../../Contracts/SubscriptionType";
export default class AddDatabasePane extends ContextualPaneBase {
public defaultExperience: ko.Computed<string>;
@@ -256,7 +257,7 @@ export default class AddDatabasePane extends ContextualPaneBase {
const addDatabasePaneOpenMessage = {
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
subscriptionType: SubscriptionType[this.container.subscriptionType()],
subscriptionQuotaId: this.container.quotaId(),
defaultsCheck: {
throughput: this.throughput(),
@@ -284,7 +285,7 @@ export default class AddDatabasePane extends ContextualPaneBase {
shared: this.databaseCreateNewShared()
}),
offerThroughput,
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
subscriptionType: SubscriptionType[this.container.subscriptionType()],
subscriptionQuotaId: this.container.quotaId(),
defaultsCheck: {
flight: this.container.flight()
@@ -327,10 +328,9 @@ export default class AddDatabasePane extends ContextualPaneBase {
}
public getSharedThroughputDefault(): boolean {
const subscriptionType: ViewModels.SubscriptionType =
this.container.subscriptionType && this.container.subscriptionType();
const subscriptionType = this.container.subscriptionType && this.container.subscriptionType();
if (subscriptionType === ViewModels.SubscriptionType.EA || this.container.isServerlessEnabled()) {
if (subscriptionType === SubscriptionType.EA || this.container.isServerlessEnabled()) {
return false;
}
@@ -349,7 +349,7 @@ export default class AddDatabasePane extends ContextualPaneBase {
shared: this.databaseCreateNewShared()
}),
offerThroughput: offerThroughput,
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
subscriptionType: SubscriptionType[this.container.subscriptionType()],
subscriptionQuotaId: this.container.quotaId(),
defaultsCheck: {
flight: this.container.flight()
@@ -373,7 +373,7 @@ export default class AddDatabasePane extends ContextualPaneBase {
shared: this.databaseCreateNewShared()
}),
offerThroughput: offerThroughput,
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
subscriptionType: SubscriptionType[this.container.subscriptionType()],
subscriptionQuotaId: this.container.quotaId(),
defaultsCheck: {
flight: this.container.flight()

View File

@@ -14,6 +14,7 @@ import { ContextualPaneBase } from "./ContextualPaneBase";
import { HashMap } from "../../Common/HashMap";
import { configContext, Platform } from "../../ConfigContext";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
import { SubscriptionType } from "../../Contracts/SubscriptionType";
export default class CassandraAddCollectionPane extends ContextualPaneBase {
public createTableQuery: ko.Observable<string>;
@@ -314,7 +315,7 @@ export default class CassandraAddCollectionPane extends ContextualPaneBase {
databaseId: this.keyspaceId(),
rupm: false
}),
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
subscriptionType: SubscriptionType[this.container.subscriptionType()],
subscriptionQuotaId: this.container.quotaId(),
defaultsCheck: {
storage: "u",
@@ -369,7 +370,7 @@ export default class CassandraAddCollectionPane extends ContextualPaneBase {
hasDedicatedThroughput: this.dedicateTableThroughput()
}),
keyspaceHasSharedOffer: this.keyspaceHasSharedOffer(),
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
subscriptionType: SubscriptionType[this.container.subscriptionType()],
subscriptionQuotaId: this.container.quotaId(),
defaultsCheck: {
storage: "u",
@@ -416,7 +417,7 @@ export default class CassandraAddCollectionPane extends ContextualPaneBase {
hasDedicatedThroughput: this.dedicateTableThroughput()
}),
keyspaceHasSharedOffer: this.keyspaceHasSharedOffer(),
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
subscriptionType: SubscriptionType[this.container.subscriptionType()],
subscriptionQuotaId: this.container.quotaId(),
defaultsCheck: {
storage: "u",
@@ -447,7 +448,7 @@ export default class CassandraAddCollectionPane extends ContextualPaneBase {
hasDedicatedThroughput: this.dedicateTableThroughput()
},
keyspaceHasSharedOffer: this.keyspaceHasSharedOffer(),
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
subscriptionType: SubscriptionType[this.container.subscriptionType()],
subscriptionQuotaId: this.container.quotaId(),
defaultsCheck: {
storage: "u",

View File

@@ -50,13 +50,24 @@
id="fileImportLinkNotebook"
data-bind="event: { click: onImportLinkClick, keypress: onImportLinkKeyPress }"
>
<img class="fileImportImg" src="/folder_16x16.svg" alt="upload files" title="Upload files" />
<img
id="importFileButton"
class="fileImportImg"
src="/folder_16x16.svg"
alt="upload files"
title="Upload files"
/>
</a>
</div>
</div>
<div class="paneFooter">
<div class="leftpanel-okbut">
<input type="submit" data-bind="attr: { value: submitButtonLabel }" class="btncreatecoll1" />
<input
id="uploadFileButton"
type="submit"
data-bind="attr: { value: submitButtonLabel }"
class="btncreatecoll1"
/>
</div>
</div>
<!-- Upload File inputs - End -->

View File

@@ -1,723 +0,0 @@
<div
class="tab-pane flexContainer"
data-bind="
attr:{
id: tabId
},
visible: isActive"
role="tabpanel"
>
<div class="warningErrorContainer scaleWarningContainer" data-bind="visible: shouldShowStatusBar">
<div>
<div class="warningErrorContent" data-bind="visible: shouldShowNotificationStatusPrompt">
<span><img src="/info_color.svg" alt="Info"/></span>
<span class="warningErrorDetailsLinkContainer" data-bind="html: notificationStatusInfo"></span>
</div>
<div class="warningErrorContent" data-bind="visible: !shouldShowNotificationStatusPrompt()">
<span><img src="/warning.svg" alt="Warning"/></span>
<span class="warningErrorDetailsLinkContainer" data-bind="html: warningMessage"></span>
</div>
</div>
</div>
<div class="tabForm scaleSettingScrollable">
<!-- ko if: shouldShowKeyspaceSharedThroughputMessage -->
<div>This table shared throughput is configured at the keyspace</div>
<!-- /ko -->
<!-- ko ifnot: hasDatabaseSharedThroughput -->
<div>
<div
class="scaleDivison"
data-bind="click:toggleScale, event: { keypress: onScaleKeyPress }, attr:{ 'aria-expanded': scaleExpanded() ? 'true' : 'false' }"
role="button"
tabindex="0"
aria-label="Scale"
aria-controls="scaleRegion"
>
<span class="themed-images" type="text/html" id="ExpandChevronRightScale" data-bind="visible: !scaleExpanded()">
<img
class="imgiconwidth ssExpandCollapseIcon ssCollapseIcon "
src="/Triangle-right.svg"
alt="Show scale properties"
/>
</span>
<span class="themed-images" type="text/html" id="ExpandChevronDownScale" data-bind="visible: scaleExpanded">
<img class="imgiconwidth ssExpandCollapseIcon " src="/Triangle-down.svg" alt="Hide scale properties" />
</span>
<span class="scaleSettingTitle">Scale</span>
</div>
<div class="ssTextAllignment" data-bind="visible: scaleExpanded" id="scaleRegion">
<!-- ko ifnot: isAutoScaleEnabled -->
<throughput-input-autopilot-v3
params="{
testId: testId,
class: 'scaleForm dirty',
value: throughput,
minimum: minRUs,
maximum: maxRUThroughputInputLimit,
isEnabled: !hasDatabaseSharedThroughput(),
canExceedMaximumValue: canThroughputExceedMaximumValue,
label: throughputTitle,
ariaLabel: throughputAriaLabel,
costsVisible: costsVisible,
requestUnitsUsageCost: requestUnitsUsageCost,
throughputAutoPilotRadioId: throughputAutoPilotRadioId,
throughputProvisionedRadioId: throughputProvisionedRadioId,
throughputModeRadioName: throughputModeRadioName,
showAutoPilot: userCanChangeProvisioningTypes,
isAutoPilotSelected: isAutoPilotSelected,
maxAutoPilotThroughputSet: autoPilotThroughput,
autoPilotUsageCost: autoPilotUsageCost,
overrideWithAutoPilotSettings: overrideWithAutoPilotSettings,
overrideWithProvisionedThroughputSettings: overrideWithProvisionedThroughputSettings
}"
>
</throughput-input-autopilot-v3>
<div class="storageCapacityTitle throughputStorageValue" data-bind="html: storageCapacityTitle"></div>
<!-- /ko -->
<div data-bind="visible: rupmVisible">
<div class="formTitle">RU/m</div>
<div class="tabs" aria-label="RU/m">
<div class="tab">
<label
data-bind="
attr:{
for: rupmOnId
},
css: {
dirty: rupm.editableIsDirty,
selectedRadio: rupm() === 'on',
unselectedRadio: rupm() !== 'on'
}"
>On</label
>
<input
type="radio"
name="rupm"
value="on"
class="radio"
data-bind="
attr:{
id: rupmOnId
},
checked: rupm"
/>
</div>
<div class="tab">
<label
data-bind="
attr:{
for: rupmOffId
},
css: {
dirty: rupm.editableIsDirty,
selectedRadio: rupm() === 'off',
unselectedRadio: rupm() !== 'off'
}"
>Off</label
>
<input
type="radio"
name="rupm"
value="off"
class="radio"
data-bind="
attr:{
id: rupmOffId
},
checked: rupm"
/>
</div>
</div>
</div>
<!-- TODO: Replace link with call to the Azure Support blade -->
<div data-bind="visible: isAutoScaleEnabled">
<div class="autoScaleThroughputTitle">Throughput (RU/s)</div>
<input
class="formReadOnly collid-white"
readonly
aria-label="Throughput input"
data-bind="textInput: throughput"
/>
<div class="autoScaleDescription">
Your account has custom settings that prevents setting throughput at the container level. Please work with
your Cosmos DB engineering team point of contact to make changes.
</div>
</div>
</div>
</div>
<!-- /ko -->
<div data-bind="visible: hasConflictResolution">
<div
class="formTitle"
data-bind="click:toggleConflictResolution, event: { keypress: onConflictResolutionKeyPress }, attr:{ 'aria-expanded': conflictResolutionExpanded() ? 'true' : 'false' }"
role="button"
tabindex="0"
aria-label="Conflict Resolution"
aria-controls="conflictResolutionRegion"
>
<span
class="themed-images"
type="text/html"
id="ExpandChevronRightConflictResolution"
data-bind="visible: !conflictResolutionExpanded()"
>
<img
class="imgiconwidth ssExpandCollapseIcon ssCollapseIcon"
src="/Triangle-right.svg"
alt="Show conflict resolution"
/>
</span>
<span
class="themed-images"
type="text/html"
id="ExpandChevronDownConflictResolution"
data-bind="visible: conflictResolutionExpanded"
>
<img class="imgiconwidth ssExpandCollapseIcon" src="/Triangle-down.svg" alt="Show conflict resolution" />
</span>
<span class="scaleSettingTitle">Conflict resolution</span>
</div>
<div id="conflictResolutionRegion" class="ssTextAllignment" data-bind="visible: conflictResolutionExpanded">
<div class="formTitle">Mode</div>
<div class="tabs" aria-label="Mode" role="radiogroup">
<div class="tab">
<label
tabindex="0"
role="radio"
data-bind="
attr:{
for: conflictResolutionPolicyModeLWW,
'aria-checked': conflictResolutionPolicyMode() !== 'Custom' ? 'true' : 'false'
},
css: {
dirty: conflictResolutionPolicyMode.editableIsDirty,
selectedRadio: conflictResolutionPolicyMode() === 'LastWriterWins',
unselectedRadio: conflictResolutionPolicyMode() !== 'LastWriterWins'
},
event: {
keypress: onConflictResolutionLWWKeyPress
}"
>Last Write Wins (default)</label
>
<input
type="radio"
name="conflictresolution"
value="LastWriterWins"
class="radio"
data-bind="
attr:{
id: conflictResolutionPolicyModeLWW
},
checked: conflictResolutionPolicyMode"
/>
</div>
<div class="tab">
<label
tabindex="0"
role="radio"
data-bind="
attr:{
for: conflictResolutionPolicyModeCustom,
'aria-checked': conflictResolutionPolicyMode() === 'Custom' ? 'true' : 'false'
},
css: {
dirty: conflictResolutionPolicyMode.editableIsDirty,
selectedRadio: conflictResolutionPolicyMode() === 'Custom',
unselectedRadio: conflictResolutionPolicyMode() !== 'Custom'
},
event: {
keypress: onConflictResolutionCustomKeyPress
}"
>Merge Procedure (custom)</label
>
<input
type="radio"
name="conflictresolution"
value="Custom"
class="radio"
data-bind="
attr:{
id: conflictResolutionPolicyModeCustom
},
checked: conflictResolutionPolicyMode"
/>
</div>
</div>
<div data-bind="visible: conflictResolutionPolicyMode() === 'LastWriterWins'">
<p class="formTitle">
Conflict Resolver Property
<span class="infoTooltip" role="tooltip" tabindex="0">
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span class="tooltiptext infoTooltipWidth"
>Gets or sets the name of a integer property in your documents which is used for the Last Write Wins
(LWW) based conflict resolution scheme. By default, the system uses the system defined timestamp
property, _ts to decide the winner for the conflicting versions of the document. Specify your own
integer property if you want to override the default timestamp based conflict resolution.</span
>
</span>
</p>
<p>
<input
type="text"
aria-label="Document path for conflict resolution"
data-bind="
css: {
dirty: conflictResolutionPolicyPath.editableIsDirty
},
textInput: conflictResolutionPolicyPath,
enable: conflictResolutionPolicyMode() === 'LastWriterWins'"
/>
</p>
</div>
<div data-bind="visible: conflictResolutionPolicyMode() === 'Custom'">
<p class="formTitle">
Stored procedure
<span class="infoTooltip" role="tooltip" tabindex="0">
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span class="tooltiptext infoTooltipWidth"
>Gets or sets the name of a stored procedure (aka merge procedure) for resolving the conflicts. You can
write application defined logic to determine the winner of the conflicting versions of a document. The
stored procedure will get executed transactionally, exactly once, on the server side. If you do not
provide a stored procedure, the conflicts will be populated in the
<a class="linkDarkBackground" href="https://aka.ms/dataexplorerconflics" target="_blank"
>conflicts feed</a
>. You can update/re-register the stored procedure at any time.</span
>
</span>
</p>
<p>
<input
type="text"
aria-label="Stored procedure name for conflict resolution"
data-bind="
css: {
dirty: conflictResolutionPolicyProcedure.editableIsDirty
},
textInput: conflictResolutionPolicyProcedure,
enable: conflictResolutionPolicyMode() === 'Custom'"
/>
</p>
</div>
</div>
</div>
<div
class="formTitle"
data-bind="click:toggleSettings, event: { keypress: onSettingsKeyPress }, attr:{ 'aria-expanded': settingsExpanded() ? 'true' : 'false' }, visible: shouldShowIndexingPolicyEditor() || ttlVisible()"
role="button"
tabindex="0"
aria-label="Settings"
aria-controls="settingsRegion"
>
<span
class="themed-images"
type="text/html"
id="ExpandChevronRightSettings"
data-bind="visible: !settingsExpanded() && !hasDatabaseSharedThroughput()"
>
<img class="imgiconwidth ssExpandCollapseIcon ssCollapseIcon" src="/Triangle-right.svg" alt="Show settings" />
</span>
<span
class="themed-images"
type="text/html"
id="ExpandChevronDownSettings"
data-bind="visible: settingsExpanded() && !hasDatabaseSharedThroughput()"
>
<img class="imgiconwidth ssExpandCollapseIcon" src="/Triangle-down.svg" alt="Show settings" />
</span>
<span class="scaleSettingTitle">Settings</span>
</div>
<div class="ssTextAllignment" data-bind="visible: settingsExpanded" id="settingsRegion">
<div data-bind="visible: ttlVisible">
<div class="formTitle">Time to Live</div>
<div class="tabs disableFocusDefaults" aria-label="Time to Live" role="radiogroup">
<div class="tab">
<label
class="ttlIndexingPolicyFocusElement"
tabindex="0"
role="radio"
data-bind="
attr:{
for: ttlOffId,
'aria-checked': timeToLive() === 'off' ? 'true' : 'false'
},
css: {
dirty: timeToLive.editableIsDirty,
selectedRadio: timeToLive() === 'off',
unselectedRadio: timeToLive() !== 'off'
},
event: {
keypress: onTtlOffKeyPress
},
hasFocus: ttlOffFocused"
>Off</label
>
<input
type="radio"
name="ttl"
value="off"
class="radio"
data-bind="
attr:{
id: ttlOffId
},
checked: timeToLive"
/>
</div>
<div class="tab">
<label
class="ttlIndexingPolicyFocusElement"
tabindex="0"
role="radio"
data-bind="
attr:{
for: ttlOnNoDefaultId,
'aria-checked': timeToLive() === 'on-nodefault' ? 'true' : 'false'
},
css: {
dirty: timeToLive.editableIsDirty,
selectedRadio: timeToLive() === 'on-nodefault',
unselectedRadio: timeToLive() !== 'on-nodefault'
},
event: {
keypress: onTtlOnNoDefaultKeyPress
},
hasFocus: ttlOnDefaultFocused"
>On (no default)</label
>
<input
type="radio"
name="ttl"
value="on-nodefault"
class="radio"
data-bind="
attr:{
id: ttlOnNoDefaultId
},
checked: timeToLive"
/>
</div>
<div class="tab">
<label
class="ttlIndexingPolicyFocusElement"
tabindex="0"
role="radio"
for="ttl3"
data-bind="
attr:{
for: ttlOnId,
'aria-checked': timeToLive() === 'on' ? 'true' : 'false'
},
css: {
dirty: timeToLive.editableIsDirty,
selectedRadio: timeToLive() === 'on',
unselectedRadio: timeToLive() !== 'on'
},
event: {
keypress: onTtlOnKeyPress
},
hasFocus: ttlOnFocused"
>On</label
>
<input
type="radio"
name="ttl"
value="on"
class="radio"
data-bind="
attr:{
id: ttlOnId
},
checked: timeToLive"
/>
</div>
</div>
<div data-bind="visible: timeToLive() === 'on'">
<input
class="dirtyTextbox"
type="number"
required
min="1"
max="2147483647"
aria-label="Time to live in seconds"
data-bind="
css: {
dirty: timeToLive.editableIsDirty
},
textInput: timeToLiveSeconds,
enable: timeToLive() === 'on'"
/>
second(s)
</div>
</div>
<!-- Geospatial - start -->
<div data-bind="visible: geospatialVisible">
<div class="formTitle">Geospatial Configuration</div>
<div class="tabs disableFocusDefaults" aria-label="Geospatial Configuration" role="radiogroup">
<div class="tab">
<label
for="geography"
tabindex="0"
role="radio"
data-bind="
attr:{
'aria-checked': geospatialConfigType().toLowerCase() !== GEOMETRY.toLowerCase() ? 'true' : 'false'
},
css: {
dirty: geospatialConfigType.editableIsDirty,
selectedRadio: geospatialConfigType().toLowerCase() !== GEOMETRY.toLowerCase(),
unselectedRadio: geospatialConfigType().toLowerCase() === GEOMETRY.toLowerCase()
},
event: {
keypress: onGeographyKeyPress
}"
>Geography</label
>
<input
type="radio"
name="geospatial"
id="geography"
class="radio"
data-bind="
attr: {
value: GEOGRAPHY
},
checked: geospatialConfigType"
/>
</div>
<div class="tab">
<label
for="geometry"
tabindex="0"
role="radio"
data-bind="
attr:{
'aria-checked': geospatialConfigType().toLowerCase() === GEOMETRY.toLowerCase() ? 'true' : 'false'
},
css: {
dirty: geospatialConfigType.editableIsDirty,
selectedRadio: geospatialConfigType().toLowerCase() === GEOMETRY.toLowerCase(),
unselectedRadio: geospatialConfigType().toLowerCase() !== GEOMETRY.toLowerCase()
},
event: {
keypress: onGeometryKeyPress
}"
>Geometry</label
>
<input
type="radio"
name="geospatial"
id="geometry"
class="radio"
data-bind="
attr: {
value: GEOMETRY
},
checked: geospatialConfigType"
/>
</div>
</div>
</div>
<!-- Geospatial - end -->
<div data-bind="visible: isAnalyticalStorageEnabled">
<div class="formTitle">Analytical Storage Time to Live</div>
<div class="tabs disableFocusDefaults" aria-label="Analytical Storage Time to Live" role="radiogroup">
<div class="tab">
<label tabindex="0" role="radio" class="disabledRadio">Off </label>
</div>
<div class="tab">
<label
tabindex="0"
role="radio"
data-bind="
attr:{
for: 'analyticalStorageTtlOnNoDefaultId',
'aria-checked': analyticalStorageTtlSelection() === 'on-nodefault' ? 'true' : 'false'
},
css: {
dirty: analyticalStorageTtlSelection.editableIsDirty,
selectedRadio: analyticalStorageTtlSelection() === 'on-nodefault',
unselectedRadio: analyticalStorageTtlSelection() !== 'on-nodefault'
},
event: {
keypress: onAnalyticalStorageTtlOnNoDefaultKeyPress
}"
>On (no default)
</label>
<input
type="radio"
name="analyticalStorageTtl"
value="on-nodefault"
class="radio"
data-bind="
attr:{
id: 'analyticalStorageTtlOnNoDefaultId'
},
checked: analyticalStorageTtlSelection"
/>
</div>
<div class="tab">
<label
tabindex="0"
role="radio"
for="ttl3"
data-bind="
attr:{
for: 'analyticalStorageTtlOnId',
'aria-checked': analyticalStorageTtlSelection() === 'on' ? 'true' : 'false'
},
css: {
dirty: analyticalStorageTtlSelection.editableIsDirty,
selectedRadio: analyticalStorageTtlSelection() === 'on',
unselectedRadio: analyticalStorageTtlSelection() !== 'on'
},
event: {
keypress: onAnalyticalStorageTtlOnKeyPress
}"
>On</label
>
<input
type="radio"
name="analyticalStorageTtl"
value="on"
class="radio"
data-bind="
attr:{
id: 'analyticalStorageTtlOnId'
},
checked: analyticalStorageTtlSelection"
/>
</div>
</div>
<div data-bind="visible: analyticalStorageTtlSelection() === 'on'">
<input
class="dirtyTextbox"
type="number"
required
min="1"
max="2147483647"
aria-label="Time to live in seconds"
data-bind="
css: {
dirty: analyticalStorageTtlSelection.editableIsDirty
},
textInput: analyticalStorageTtlSeconds,
enable: analyticalStorageTtlSelection() === 'on'"
/>
second(s)
</div>
</div>
<div data-bind="visible: changeFeedPolicyVisible">
<div class="formTitle">
<span>Change feed log retention policy</span>
<span class="infoTooltip" role="tooltip" tabindex="0">
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span class="tooltiptext infoTooltipWidth"
>Enable change feed log retention policy to retain last 10 minutes of history for items in the container
by default. To support this, the request unit (RU) charge for this container will be multiplied by a
factor of two for writes. Reads are unaffected.</span
>
</span>
</div>
<div class="tabs disableFocusDefaults" aria-label="Change feed selection tabs">
<div class="tab">
<label
tabindex="0"
data-bind="
attr:{
for: changeFeedPolicyOffId
},
css: {
dirty: changeFeedPolicyToggled.editableIsDirty,
selectedRadio: changeFeedPolicyToggled() === 'Off',
unselectedRadio: changeFeedPolicyToggled() === 'On'
},
event: {
keypress: onChangeFeedPolicyOffKeyPress
}"
>Off</label
>
<input
type="radio"
name="changeFeedPolicy"
value="Off"
class="radio"
data-bind="
attr:{
id: changeFeedPolicyOffId
},
checked: changeFeedPolicyToggled"
/>
</div>
<div class="tab">
<label
tabindex="0"
data-bind="
attr:{
for: changeFeedPolicyOnId
},
css: {
dirty: changeFeedPolicyToggled.editableIsDirty,
selectedRadio: changeFeedPolicyToggled() === 'On',
unselectedRadio: changeFeedPolicyToggled() === 'Off'
},
event: {
keypress: onChangeFeedPolicyOnKeyPress
}"
>On</label
>
<input
type="radio"
name="changeFeedPolicy"
value="On"
class="radio"
data-bind="
attr:{
id: changeFeedPolicyOnId
},
checked: changeFeedPolicyToggled"
/>
</div>
</div>
</div>
<div data-bind="visible: partitionKeyVisible">
<div class="formTitle" data-bind="text: partitionKeyName">Partition Key</div>
<input
class="formReadOnly collid-white"
data-bind="textInput: partitionKeyValue, attr: { 'aria-label':partitionKeyName }"
readonly
/>
</div>
<div class="largePartitionKeyEnabled" data-bind="visible: isLargePartitionKeyEnabled">
<p data-bind="visible: isLargePartitionKeyEnabled">
Large <span data-bind="text:lowerCasePartitionKeyName"></span> has been enabled
</p>
</div>
<div data-bind="visible: shouldShowIndexingPolicyEditor">
<div class="formTitle">Indexing Policy</div>
<div
class="indexingPolicyEditor ttlIndexingPolicyFocusElement"
tabindex="0"
data-bind="setTemplateReady: true, attr:{ id: indexingPolicyEditorId }"
></div>
</div>
</div>
</div>
</div>

View File

@@ -1,449 +0,0 @@
import * as Constants from "../../Common/Constants";
import * as DataModels from "../../Contracts/DataModels";
import * as ko from "knockout";
import * as ViewModels from "../../Contracts/ViewModels";
import Collection from "../Tree/Collection";
import Database from "../Tree/Database";
import Explorer from "../Explorer";
import SettingsTab from "./SettingsTab";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { IndexingPolicies } from "../../Shared/Constants";
describe("Settings tab", () => {
const baseCollection: DataModels.Collection = {
defaultTtl: 200,
partitionKey: null,
conflictResolutionPolicy: {
mode: DataModels.ConflictResolutionMode.LastWriterWins,
conflictResolutionPath: "/_ts"
},
indexingPolicy: IndexingPolicies.SharedDatabaseDefault,
_rid: "",
_self: "",
_etag: "",
_ts: 0,
id: "mycoll"
};
const baseDatabase: DataModels.Database = {
_rid: "",
_self: "",
_etag: "",
_ts: 0,
id: "mydb",
collections: [baseCollection]
};
const quotaInfo: DataModels.CollectionQuotaInfo = {
storedProcedures: 0,
triggers: 0,
functions: 0,
documentsSize: 0,
documentsCount: 0,
collectionSize: 0,
usageSizeInKB: 0,
numPartitions: 0
};
describe("Conflict Resolution", () => {
describe("should show conflict resolution", () => {
let explorer: Explorer;
const baseCollectionWithoutConflict: DataModels.Collection = {
defaultTtl: 200,
partitionKey: null,
conflictResolutionPolicy: null,
indexingPolicy: IndexingPolicies.SharedDatabaseDefault,
_rid: "",
_self: "",
_etag: "",
_ts: 0,
id: "mycoll"
};
const getSettingsTab = (conflictResolution: boolean = true) => {
return new SettingsTab({
tabKind: ViewModels.CollectionTabKind.Settings,
title: "Scale & Settings",
tabPath: "",
hashLocation: "",
isActive: ko.observable(false),
collection: new Collection(
explorer,
"mydb",
conflictResolution ? baseCollection : baseCollectionWithoutConflict,
quotaInfo,
null
),
onUpdateTabsButtons: undefined
});
};
beforeEach(() => {
explorer = new Explorer();
});
it("single master, should not show conflict resolution", () => {
const settingsTab = getSettingsTab();
expect(settingsTab.hasConflictResolution()).toBe(false);
});
it("multi master with resolution conflict, show conflict resolution", () => {
explorer.databaseAccount({
id: "test",
kind: "",
location: "",
name: "",
tags: "",
type: "",
properties: {
enableMultipleWriteLocations: true,
documentEndpoint: "",
cassandraEndpoint: "",
gremlinEndpoint: "",
tableEndpoint: ""
}
});
const settingsTab = getSettingsTab();
expect(settingsTab.hasConflictResolution()).toBe(true);
});
it("multi master without resolution conflict, show conflict resolution", () => {
explorer.databaseAccount({
id: "test",
kind: "",
location: "",
name: "",
tags: "",
type: "",
properties: {
enableMultipleWriteLocations: true,
documentEndpoint: "",
cassandraEndpoint: "",
gremlinEndpoint: "",
tableEndpoint: ""
}
});
const settingsTab = getSettingsTab(false /* no resolution conflict*/);
expect(settingsTab.hasConflictResolution()).toBe(false);
});
});
describe("Parse Conflict Resolution Mode from backend", () => {
it("should parse any casing", () => {
expect(SettingsTab.parseConflictResolutionMode("custom")).toBe(DataModels.ConflictResolutionMode.Custom);
expect(SettingsTab.parseConflictResolutionMode("Custom")).toBe(DataModels.ConflictResolutionMode.Custom);
expect(SettingsTab.parseConflictResolutionMode("lastWriterWins")).toBe(
DataModels.ConflictResolutionMode.LastWriterWins
);
expect(SettingsTab.parseConflictResolutionMode("LastWriterWins")).toBe(
DataModels.ConflictResolutionMode.LastWriterWins
);
});
it("should parse empty as null", () => {
expect(SettingsTab.parseConflictResolutionMode("")).toBe(null);
});
it("should parse null as null", () => {
expect(SettingsTab.parseConflictResolutionMode(null)).toBe(null);
});
});
describe("Parse Conflict Resolution procedure from backend", () => {
it("should parse path as name", () => {
expect(SettingsTab.parseConflictResolutionProcedure("/dbs/xxxx/colls/xxxx/sprocs/validsproc")).toBe(
"validsproc"
);
});
it("should parse name as name", () => {
expect(SettingsTab.parseConflictResolutionProcedure("validsproc")).toBe("validsproc");
});
it("should parse invalid path as null", () => {
expect(SettingsTab.parseConflictResolutionProcedure("/not/a/valid/path")).toBe(null);
});
it("should parse empty or null as null", () => {
expect(SettingsTab.parseConflictResolutionProcedure("")).toBe(null);
expect(SettingsTab.parseConflictResolutionProcedure(null)).toBe(null);
});
});
});
describe("Should update collection", () => {
let explorer: Explorer;
beforeEach(() => {
explorer = new Explorer();
});
it("On TTL changed", () => {
const settingsTab = new SettingsTab({
tabKind: ViewModels.CollectionTabKind.Settings,
title: "Scale & Settings",
tabPath: "",
hashLocation: "",
isActive: ko.observable(false),
collection: new Collection(explorer, "mydb", baseCollection, quotaInfo, null),
onUpdateTabsButtons: (buttons: CommandButtonComponentProps[]): void => {}
});
expect(settingsTab.shouldUpdateCollection()).toBe(false);
settingsTab.timeToLive("off");
expect(settingsTab.shouldUpdateCollection()).toBe(true);
settingsTab.onRevertClick();
expect(settingsTab.shouldUpdateCollection()).toBe(false);
settingsTab.timeToLiveSeconds(100);
expect(settingsTab.shouldUpdateCollection()).toBe(true);
});
it("On Index Policy changed", () => {
const settingsTab = new SettingsTab({
tabKind: ViewModels.CollectionTabKind.Settings,
title: "Scale & Settings",
tabPath: "",
hashLocation: "",
isActive: ko.observable(false),
collection: new Collection(explorer, "mydb", baseCollection, quotaInfo, null),
onUpdateTabsButtons: (buttons: CommandButtonComponentProps[]): void => {}
});
expect(settingsTab.shouldUpdateCollection()).toBe(false);
settingsTab.indexingPolicyContent({ somethingDifferent: "" });
expect(settingsTab.shouldUpdateCollection()).toBe(true);
});
it("On Conflict Resolution Mode changed", () => {
const settingsTab = new SettingsTab({
tabKind: ViewModels.CollectionTabKind.Settings,
title: "Scale & Settings",
tabPath: "",
hashLocation: "",
isActive: ko.observable(false),
collection: new Collection(explorer, "mydb", baseCollection, quotaInfo, null),
onUpdateTabsButtons: (buttons: CommandButtonComponentProps[]): void => {}
});
expect(settingsTab.shouldUpdateCollection()).toBe(false);
settingsTab.conflictResolutionPolicyMode(DataModels.ConflictResolutionMode.Custom);
expect(settingsTab.shouldUpdateCollection()).toBe(true);
settingsTab.onRevertClick();
expect(settingsTab.shouldUpdateCollection()).toBe(false);
settingsTab.conflictResolutionPolicyPath("/somethingElse");
expect(settingsTab.shouldUpdateCollection()).toBe(true);
settingsTab.onRevertClick();
expect(settingsTab.shouldUpdateCollection()).toBe(false);
settingsTab.conflictResolutionPolicyMode(DataModels.ConflictResolutionMode.Custom);
settingsTab.conflictResolutionPolicyProcedure("resolver");
expect(settingsTab.shouldUpdateCollection()).toBe(true);
});
});
describe("Get Conflict Resolution configuration from user", () => {
let explorer: Explorer;
beforeEach(() => {
explorer = new Explorer();
});
it("null if it didnt change", () => {
const settingsTab = new SettingsTab({
tabKind: ViewModels.CollectionTabKind.Settings,
title: "Scale & Settings",
tabPath: "",
hashLocation: "",
isActive: ko.observable(false),
collection: new Collection(explorer, "mydb", baseCollection, quotaInfo, null),
onUpdateTabsButtons: (buttons: CommandButtonComponentProps[]): void => {}
});
expect(settingsTab.getUpdatedConflictResolutionPolicy()).toBe(null);
});
it("Custom contains valid backend path", () => {
const settingsTab = new SettingsTab({
tabKind: ViewModels.CollectionTabKind.Settings,
title: "Scale & Settings",
tabPath: "",
hashLocation: "",
isActive: ko.observable(false),
collection: new Collection(explorer, "mydb", baseCollection, quotaInfo, null),
onUpdateTabsButtons: (buttons: CommandButtonComponentProps[]): void => {}
});
expect(settingsTab.getUpdatedConflictResolutionPolicy()).toBe(null);
settingsTab.conflictResolutionPolicyMode(DataModels.ConflictResolutionMode.Custom);
settingsTab.conflictResolutionPolicyProcedure("resolver");
let updatedPolicy = settingsTab.getUpdatedConflictResolutionPolicy();
expect(updatedPolicy.mode).toBe(DataModels.ConflictResolutionMode.Custom);
expect(updatedPolicy.conflictResolutionProcedure).toBe("/dbs/mydb/colls/mycoll/sprocs/resolver");
settingsTab.conflictResolutionPolicyProcedure("");
updatedPolicy = settingsTab.getUpdatedConflictResolutionPolicy();
expect(updatedPolicy.conflictResolutionProcedure).toBe(undefined);
});
it("LWW contains valid property path", () => {
const settingsTab = new SettingsTab({
tabKind: ViewModels.CollectionTabKind.Settings,
title: "Scale & Settings",
tabPath: "",
hashLocation: "",
isActive: ko.observable(false),
collection: new Collection(explorer, "mydb", baseCollection, quotaInfo, null),
onUpdateTabsButtons: (buttons: CommandButtonComponentProps[]): void => {}
});
expect(settingsTab.getUpdatedConflictResolutionPolicy()).toBe(null);
settingsTab.conflictResolutionPolicyPath("someAttr");
let updatedPolicy = settingsTab.getUpdatedConflictResolutionPolicy();
expect(updatedPolicy.conflictResolutionPath).toBe("/someAttr");
settingsTab.conflictResolutionPolicyPath("/someAttr");
updatedPolicy = settingsTab.getUpdatedConflictResolutionPolicy();
expect(updatedPolicy.conflictResolutionPath).toBe("/someAttr");
settingsTab.conflictResolutionPolicyPath("");
updatedPolicy = settingsTab.getUpdatedConflictResolutionPolicy();
expect(updatedPolicy.conflictResolutionPath).toBe("");
});
});
describe("partitionKeyVisible", () => {
enum PartitionKeyOption {
None,
System,
NonSystem
}
function getCollection(defaultApi: string, partitionKeyOption: PartitionKeyOption) {
const explorer = new Explorer();
explorer.defaultExperience(defaultApi);
const offer: DataModels.Offer = null;
const defaultTtl = 200;
const conflictResolutionPolicy = {
mode: DataModels.ConflictResolutionMode.LastWriterWins,
conflictResolutionPath: "/_ts"
};
return new Collection(
explorer,
"mydb",
{
defaultTtl: defaultTtl,
partitionKey:
partitionKeyOption != PartitionKeyOption.None
? {
paths: ["/foo"],
kind: "Hash",
version: 2,
systemKey: partitionKeyOption === PartitionKeyOption.System
}
: null,
conflictResolutionPolicy: conflictResolutionPolicy,
indexingPolicy: IndexingPolicies.SharedDatabaseDefault,
_rid: "",
_self: "",
_etag: "",
_ts: 0,
id: "mycoll"
},
quotaInfo,
offer
);
}
function getSettingsTab(defaultApi: string, partitionKeyOption: PartitionKeyOption): SettingsTab {
return new SettingsTab({
tabKind: ViewModels.CollectionTabKind.Settings,
title: "Scale & Settings",
tabPath: "",
hashLocation: "",
isActive: ko.observable(false),
collection: getCollection(defaultApi, partitionKeyOption),
onUpdateTabsButtons: (buttons: CommandButtonComponentProps[]): void => {}
});
}
it("on SQL container with no partition key should be false", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.DocumentDB, PartitionKeyOption.None);
expect(settingsTab.partitionKeyVisible()).toBe(false);
});
it("on Mongo container with no partition key should be false", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.MongoDB, PartitionKeyOption.None);
expect(settingsTab.partitionKeyVisible()).toBe(false);
});
it("on Gremlin container with no partition key should be false", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Graph, PartitionKeyOption.None);
expect(settingsTab.partitionKeyVisible()).toBe(false);
});
it("on Cassandra container with no partition key should be false", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Cassandra, PartitionKeyOption.None);
expect(settingsTab.partitionKeyVisible()).toBe(false);
});
it("on Table container with no partition key should be false", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Table, PartitionKeyOption.None);
expect(settingsTab.partitionKeyVisible()).toBe(false);
});
it("on SQL container with system partition key should be true", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.DocumentDB, PartitionKeyOption.System);
expect(settingsTab.partitionKeyVisible()).toBe(true);
});
it("on Mongo container with system partition key should be false", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.MongoDB, PartitionKeyOption.System);
expect(settingsTab.partitionKeyVisible()).toBe(false);
});
it("on Gremlin container with system partition key should be true", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Graph, PartitionKeyOption.System);
expect(settingsTab.partitionKeyVisible()).toBe(true);
});
it("on Cassandra container with system partition key should be false", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Cassandra, PartitionKeyOption.System);
expect(settingsTab.partitionKeyVisible()).toBe(false);
});
it("on Table container with system partition key should be false", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Table, PartitionKeyOption.System);
expect(settingsTab.partitionKeyVisible()).toBe(false);
});
it("on SQL container with non-system partition key should be true", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.DocumentDB, PartitionKeyOption.NonSystem);
expect(settingsTab.partitionKeyVisible()).toBe(true);
});
it("on Mongo container with non-system partition key should be true", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.MongoDB, PartitionKeyOption.NonSystem);
expect(settingsTab.partitionKeyVisible()).toBe(true);
});
it("on Gremlin container with non-system partition key should be true", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Graph, PartitionKeyOption.NonSystem);
expect(settingsTab.partitionKeyVisible()).toBe(true);
});
it("on Cassandra container with non-system partition key should be false", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Cassandra, PartitionKeyOption.NonSystem);
expect(settingsTab.partitionKeyVisible()).toBe(false);
});
it("on Table container with non-system partition key should be false", () => {
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Table, PartitionKeyOption.NonSystem);
expect(settingsTab.partitionKeyVisible()).toBe(false);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,7 @@ export default class SettingsTabV2 extends TabsBase {
this.currentCollection.loadOffer().then(
() => {
// passed in options and set by parent as "Settings" by default
this.tabTitle("Scale & Settings");
this.tabTitle(this.currentCollection.offer() ? "Settings" : "Scale & Settings");
this.offerRead(true);
this.options.getPendingNotification.then(
(data: DataModels.Notification) => {

View File

@@ -9,7 +9,6 @@ import MongoQueryTabTemplate from "./MongoQueryTab.html";
import MongoShellTabTemplate from "./MongoShellTab.html";
import QueryTabTemplate from "./QueryTab.html";
import QueryTablesTabTemplate from "./QueryTablesTab.html";
import SettingsTabTemplate from "./SettingsTab.html";
import SettingsTabV2Template from "./SettingsTabV2.html";
import DatabaseSettingsTabTemplate from "./DatabaseSettingsTab.html";
import StoredProcedureTabTemplate from "./StoredProcedureTab.html";
@@ -133,15 +132,6 @@ export class QueryTablesTab {
}
}
export class SettingsTab {
constructor() {
return {
viewModel: TabComponent,
template: SettingsTabTemplate
};
}
}
export class SettingsTabV2 {
constructor() {
return {

View File

@@ -29,7 +29,6 @@ import MongoShellTab from "../Tabs/MongoShellTab";
import QueryTab from "../Tabs/QueryTab";
import QueryTablesTab from "../Tabs/QueryTablesTab";
import SettingsTabV2 from "../Tabs/SettingsTabV2";
import SettingsTab from "../Tabs/SettingsTab";
import ConflictId from "./ConflictId";
import DocumentId from "./DocumentId";
import StoredProcedure from "./StoredProcedure";
@@ -63,6 +62,8 @@ export default class Collection implements ViewModels.Collection {
public throughput: ko.Computed<number>;
public rawDataModel: DataModels.Collection;
public analyticalStorageTtl: ko.Observable<number>;
public schema: DataModels.ISchema;
public requestSchema: () => void;
public geospatialConfig: ko.Observable<DataModels.GeospatialConfig>;
// TODO move this to API customization class
@@ -117,6 +118,8 @@ export default class Collection implements ViewModels.Collection {
this.conflictResolutionPolicy = ko.observable(data.conflictResolutionPolicy);
this.changeFeedPolicy = ko.observable<DataModels.ChangeFeedPolicy>(data.changeFeedPolicy);
this.analyticalStorageTtl = ko.observable(data.analyticalStorageTtl);
this.schema = data.schema;
this.requestSchema = data.requestSchema;
this.geospatialConfig = ko.observable(data.geospatialConfig);
// TODO fix this to only replace non-excaped single quotes
@@ -552,11 +555,6 @@ export default class Collection implements ViewModels.Collection {
dataExplorerArea: Constants.Areas.ResourceTree
});
const isSettingsV2Enabled = this.container.isSettingsV2Enabled();
if (!isSettingsV2Enabled) {
await this.loadOffer();
}
const tabTitle = !this.offer() ? "Settings" : "Scale & Settings";
const pendingNotificationsPromise: Q.Promise<DataModels.Notification> = this._getPendingThroughputSplitNotification();
const matchingTabs = this.container.tabsManager.getTabs(ViewModels.CollectionTabKind.Settings, tab => {
@@ -583,68 +581,8 @@ export default class Collection implements ViewModels.Collection {
onUpdateTabsButtons: this.container.onUpdateTabsButtons
};
if (isSettingsV2Enabled) {
let settingsTabV2 = matchingTabs && (matchingTabs[0] as SettingsTabV2);
this.launchSettingsTabV2(settingsTabV2, traceStartData, settingsTabOptions, pendingNotificationsPromise);
} else {
let settingsTab = matchingTabs && (matchingTabs[0] as SettingsTab);
this.launchSettingsTabV1(settingsTab, traceStartData, settingsTabOptions, pendingNotificationsPromise);
}
};
private launchSettingsTabV1 = (
settingsTab: SettingsTab,
traceStartData: any,
settingsTabOptions: ViewModels.TabOptions,
getPendingNotification: Q.Promise<DataModels.Notification>
): void => {
if (!settingsTab) {
const startKey: number = TelemetryProcessor.traceStart(Action.Tab, traceStartData);
settingsTabOptions.onLoadStartKey = startKey;
getPendingNotification.then(
(data: any) => {
const pendingNotification: DataModels.Notification = data && data[0];
settingsTabOptions.tabKind = ViewModels.CollectionTabKind.Settings;
settingsTab = new SettingsTab(settingsTabOptions);
this.container.tabsManager.activateNewTab(settingsTab);
settingsTab.pendingNotification(pendingNotification);
},
(error: any) => {
const errorMessage = getErrorMessage(error);
TelemetryProcessor.traceFailure(
Action.Tab,
{
databaseAccountName: this.container.databaseAccount().name,
databaseName: this.databaseId,
collectionName: this.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: settingsTabOptions.title,
error: errorMessage,
errorStack: getErrorStack(error)
},
startKey
);
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error while fetching container settings for container ${this.id()}: ${errorMessage}`
);
throw error;
}
);
} else {
getPendingNotification.then(
(pendingNotification: DataModels.Notification) => {
settingsTab.pendingNotification(pendingNotification);
this.container.tabsManager.activateTab(settingsTab);
},
(error: any) => {
settingsTab.pendingNotification(undefined);
this.container.tabsManager.activateTab(settingsTab);
}
);
}
let settingsTabV2 = matchingTabs && (matchingTabs[0] as SettingsTabV2);
this.launchSettingsTabV2(settingsTabV2, traceStartData, settingsTabOptions, pendingNotificationsPromise);
};
private launchSettingsTabV2 = (

View File

@@ -0,0 +1,82 @@
import * as DataModels from "../../Contracts/DataModels";
import * as ko from "knockout";
import Database from "./Database";
import Explorer from "../Explorer";
import { HttpStatusCodes } from "../../Common/Constants";
import { JunoClient } from "../../Juno/JunoClient";
import { userContext, updateUserContext } from "../../UserContext";
const createMockContainer = (): Explorer => {
const mockContainer = new Explorer();
return mockContainer;
};
updateUserContext({
subscriptionId: "fakeSubscriptionId",
resourceGroup: "fakeResourceGroup",
databaseAccount: {
id: "id",
name: "fakeName",
location: "fakeLocation",
type: "fakeType",
tags: undefined,
kind: "fakeKind",
properties: {
documentEndpoint: "fakeEndpoint",
tableEndpoint: "fakeEndpoint",
gremlinEndpoint: "fakeEndpoint",
cassandraEndpoint: "fakeEndpoint"
}
}
});
describe("Add Schema", () => {
it("should not call requestSchema or getSchema if analyticalStorageTtl is undefined", () => {
const collection: DataModels.Collection = {} as DataModels.Collection;
collection.analyticalStorageTtl = undefined;
const database = new Database(createMockContainer(), { id: "fakeId" });
database.container = createMockContainer();
database.container.isSchemaEnabled = ko.computed<boolean>(() => false);
database.junoClient = new JunoClient();
database.junoClient.requestSchema = jest.fn();
database.junoClient.getSchema = jest.fn();
database.addSchema(collection);
expect(database.junoClient.requestSchema).toBeCalledTimes(0);
});
it("should call requestSchema or getSchema if analyticalStorageTtl is not undefined", () => {
const collection: DataModels.Collection = { id: "fakeId" } as DataModels.Collection;
collection.analyticalStorageTtl = 0;
const database = new Database(createMockContainer(), {});
database.container = createMockContainer();
database.container.isSchemaEnabled = ko.computed<boolean>(() => true);
database.junoClient = new JunoClient();
database.junoClient.requestSchema = jest.fn();
database.junoClient.getSchema = jest.fn().mockResolvedValue({ status: HttpStatusCodes.OK, data: {} });
jest.useFakeTimers();
const interval = 5000;
const checkForSchema: NodeJS.Timeout = database.addSchema(collection, interval);
jest.advanceTimersByTime(interval + 1000);
expect(database.junoClient.requestSchema).toBeCalledWith({
id: undefined,
subscriptionId: userContext.subscriptionId,
resourceGroup: userContext.resourceGroup,
accountName: userContext.databaseAccount.name,
resource: `dbs/${database.id}/colls/${collection.id}`,
status: "new"
});
expect(checkForSchema).not.toBeNull();
expect(database.junoClient.getSchema).toBeCalledWith(
userContext.databaseAccount.name,
database.id(),
collection.id
);
});
});

View File

@@ -13,6 +13,8 @@ import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsol
import * as Logger from "../../Common/Logger";
import Explorer from "../Explorer";
import { readCollections } from "../../Common/dataAccess/readCollections";
import { JunoClient, IJunoResponse } from "../../Juno/JunoClient";
import { userContext } from "../../UserContext";
import { readDatabaseOffer } from "../../Common/dataAccess/readDatabaseOffer";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { fetchPortalNotifications } from "../../Common/PortalNotifications";
@@ -29,6 +31,7 @@ export default class Database implements ViewModels.Database {
public isDatabaseExpanded: ko.Observable<boolean>;
public isDatabaseShared: ko.Computed<boolean>;
public selectedSubnodeKind: ko.Observable<ViewModels.CollectionTabKind>;
public junoClient: JunoClient;
constructor(container: Explorer, data: any) {
this.nodeKind = "Database";
@@ -43,6 +46,7 @@ export default class Database implements ViewModels.Database {
this.isDatabaseShared = ko.pureComputed(() => {
return this.offer && !!this.offer();
});
this.junoClient = new JunoClient();
}
public onSettingsClick = () => {
@@ -184,6 +188,10 @@ export default class Database implements ViewModels.Database {
const collections: DataModels.Collection[] = await readCollections(this.id());
const deltaCollections = this.getDeltaCollections(collections);
collections.forEach((collection: DataModels.Collection) => {
this.addSchema(collection);
});
deltaCollections.toAdd.forEach((collection: DataModels.Collection) => {
const collectionVM: Collection = new Collection(this.container, this.id(), collection, null, null);
collectionVMs.push(collectionVM);
@@ -308,4 +316,42 @@ export default class Database implements ViewModels.Database {
this.collections(collectionsToKeep);
}
public addSchema(collection: DataModels.Collection, interval?: number): NodeJS.Timeout {
let checkForSchema: NodeJS.Timeout = null;
interval = interval || 5000;
if (collection.analyticalStorageTtl !== undefined && this.container.isSchemaEnabled()) {
collection.requestSchema = () => {
this.junoClient.requestSchema({
id: undefined,
subscriptionId: userContext.subscriptionId,
resourceGroup: userContext.resourceGroup,
accountName: userContext.databaseAccount.name,
resource: `dbs/${this.id}/colls/${collection.id}`,
status: "new"
});
checkForSchema = setInterval(async () => {
const response: IJunoResponse<DataModels.ISchema> = await this.junoClient.getSchema(
userContext.databaseAccount.name,
this.id(),
collection.id
);
if (response.status >= 404) {
clearInterval(checkForSchema);
}
if (response.data !== null) {
clearInterval(checkForSchema);
collection.schema = response.data;
}
}, interval);
};
collection.requestSchema();
}
return checkForSchema;
}
}

View File

@@ -0,0 +1,253 @@
import * as ko from "knockout";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import React from "react";
import { ResourceTreeAdapter } from "./ResourceTreeAdapter";
import { shallow } from "enzyme";
import { TreeComponent, TreeNode, TreeComponentProps } from "../Controls/TreeComponent/TreeComponent";
import Explorer from "../Explorer";
import Collection from "./Collection";
const schema: DataModels.ISchema = {
id: "fakeSchemaId",
accountName: "fakeAccountName",
resource: "dbs/FakeDbName/colls/FakeCollectionName",
fields: [
{
dataType: {
code: 15,
name: "String"
},
hasNulls: true,
isArray: false,
schemaType: {
code: 0,
name: "Data"
},
name: "_rid",
path: "_rid",
maxRepetitionLevel: 0,
maxDefinitionLevel: 1
},
{
dataType: {
code: 11,
name: "Int64"
},
hasNulls: true,
isArray: false,
schemaType: {
code: 0,
name: "Data"
},
name: "_ts",
path: "_ts",
maxRepetitionLevel: 0,
maxDefinitionLevel: 1
},
{
dataType: {
code: 15,
name: "String"
},
hasNulls: true,
isArray: false,
schemaType: {
code: 0,
name: "Data"
},
name: "id",
path: "id",
maxRepetitionLevel: 0,
maxDefinitionLevel: 1
},
{
dataType: {
code: 15,
name: "String"
},
hasNulls: true,
isArray: false,
schemaType: {
code: 0,
name: "Data"
},
name: "pk",
path: "pk",
maxRepetitionLevel: 0,
maxDefinitionLevel: 1
},
{
dataType: {
code: 15,
name: "String"
},
hasNulls: true,
isArray: false,
schemaType: {
code: 0,
name: "Data"
},
name: "other",
path: "other",
maxRepetitionLevel: 0,
maxDefinitionLevel: 1
},
{
dataType: {
code: 15,
name: "String"
},
hasNulls: true,
isArray: false,
schemaType: {
code: 0,
name: "Data"
},
name: "name",
path: "nested.name",
maxRepetitionLevel: 0,
maxDefinitionLevel: 1
},
{
dataType: {
code: 11,
name: "Int64"
},
hasNulls: true,
isArray: false,
schemaType: {
code: 0,
name: "Data"
},
name: "someNumber",
path: "nested.someNumber",
maxRepetitionLevel: 0,
maxDefinitionLevel: 1
},
{
dataType: {
code: 17,
name: "Double"
},
hasNulls: true,
isArray: false,
schemaType: {
code: 0,
name: "Data"
},
name: "anotherNumber",
path: "nested.anotherNumber",
maxRepetitionLevel: 0,
maxDefinitionLevel: 1
},
{
dataType: {
code: 15,
name: "String"
},
hasNulls: true,
isArray: false,
schemaType: {
code: 0,
name: "Data"
},
name: "name",
path: "items.list.items.name",
maxRepetitionLevel: 1,
maxDefinitionLevel: 3
},
{
dataType: {
code: 11,
name: "Int64"
},
hasNulls: true,
isArray: false,
schemaType: {
code: 0,
name: "Data"
},
name: "someNumber",
path: "items.list.items.someNumber",
maxRepetitionLevel: 1,
maxDefinitionLevel: 3
},
{
dataType: {
code: 17,
name: "Double"
},
hasNulls: true,
isArray: false,
schemaType: {
code: 0,
name: "Data"
},
name: "anotherNumber",
path: "items.list.items.anotherNumber",
maxRepetitionLevel: 1,
maxDefinitionLevel: 3
},
{
dataType: {
code: 15,
name: "String"
},
hasNulls: true,
isArray: false,
schemaType: {
code: 0,
name: "Data"
},
name: "_etag",
path: "_etag",
maxRepetitionLevel: 0,
maxDefinitionLevel: 1
}
]
};
const createMockContainer = (): Explorer => {
const mockContainer = new Explorer();
mockContainer.selectedNode = ko.observable<ViewModels.TreeNode>();
mockContainer.onUpdateTabsButtons = () => {
return;
};
return mockContainer;
};
const createMockCollection = (): ViewModels.Collection => {
const mockCollection = {} as DataModels.Collection;
mockCollection._rid = "fakeRid";
mockCollection._self = "fakeSelf";
mockCollection.id = "fakeId";
mockCollection.analyticalStorageTtl = 0;
mockCollection.schema = schema;
const mockCollectionVM: ViewModels.Collection = new Collection(
createMockContainer(),
"fakeDatabaseId",
mockCollection,
undefined,
undefined
);
return mockCollectionVM;
};
describe("Resource tree for schema", () => {
const mockContainer: Explorer = createMockContainer();
const resourceTree = new ResourceTreeAdapter(mockContainer);
it("should render", () => {
const rootNode: TreeNode = resourceTree.buildSchemaNode(createMockCollection());
const props: TreeComponentProps = {
rootNode,
className: "dataResourceTree"
};
const wrapper = shallow(<TreeComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -2,7 +2,7 @@ import * as ko from "knockout";
import * as React from "react";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import { AccordionComponent, AccordionItemComponent } from "../Controls/Accordion/AccordionComponent";
import { TreeComponent, TreeNode, TreeNodeMenuItem } from "../Controls/TreeComponent/TreeComponent";
import { TreeComponent, TreeNode, TreeNodeMenuItem, TreeNodeComponent } from "../Controls/TreeComponent/TreeComponent";
import * as ViewModels from "../../Contracts/ViewModels";
import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem";
import { ResourceTreeContextMenuButtonFactory } from "../ContextMenuButtonFactory";
@@ -32,6 +32,7 @@ import StoredProcedure from "./StoredProcedure";
import Trigger from "./Trigger";
import TabsBase from "../Tabs/TabsBase";
import { userContext } from "../../UserContext";
import * as DataModels from "../../Contracts/DataModels";
export class ResourceTreeAdapter implements ReactAdapter {
public static readonly MyNotebooksTitle = "My Notebooks";
@@ -289,6 +290,11 @@ export class ResourceTreeAdapter implements ReactAdapter {
});
}
const schemaNode: TreeNode = this.buildSchemaNode(collection);
if (schemaNode) {
children.push(schemaNode);
}
if (ResourceTreeAdapter.showScriptNodes(this.container)) {
children.push(this.buildStoredProcedureNode(collection));
children.push(this.buildUserDefinedFunctionsNode(collection));
@@ -405,6 +411,75 @@ export class ResourceTreeAdapter implements ReactAdapter {
};
}
public buildSchemaNode(collection: ViewModels.Collection): TreeNode {
if (collection.analyticalStorageTtl() == undefined) {
return undefined;
}
if (!collection.schema || !collection.schema.fields) {
return undefined;
}
return {
label: "Schema",
children: this.getSchemaNodes(collection.schema.fields),
onClick: () => {
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Schema);
this.container.tabsManager.refreshActiveTab(
(tab: TabsBase) => tab.collection && tab.collection.rid === collection.rid
);
}
};
}
private getSchemaNodes(fields: DataModels.IDataField[]): TreeNode[] {
const schema: any = {};
//unflatten
fields.forEach((field: DataModels.IDataField, fieldIndex: number) => {
const path: string[] = field.path.split(".");
const fieldProperties = [field.dataType.name, `HasNulls: ${field.hasNulls}`];
let current: any = {};
path.forEach((name: string, pathIndex: number) => {
if (pathIndex === 0) {
if (schema[name] === undefined) {
if (pathIndex === path.length - 1) {
schema[name] = fieldProperties;
} else {
schema[name] = {};
}
}
current = schema[name];
} else {
if (current[name] === undefined) {
if (pathIndex === path.length - 1) {
current[name] = fieldProperties;
} else {
current[name] = {};
}
}
current = current[name];
}
});
});
const traverse = (obj: any): TreeNode[] => {
const children: TreeNode[] = [];
if (obj !== null && !Array.isArray(obj) && typeof obj === "object") {
Object.entries(obj).forEach(([key, value]) => {
children.push({ label: key, children: traverse(value) });
});
} else if (Array.isArray(obj)) {
return [{ label: obj[0] }, { label: obj[1] }];
}
return children;
};
return traverse(schema);
}
private buildNotebooksTrees(): TreeNode {
let notebooksTree: TreeNode = {
label: undefined,

View File

@@ -0,0 +1,172 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Resource tree for schema should render 1`] = `
<div
className="treeComponent dataResourceTree"
>
<TreeNodeComponent
generation={0}
node={
Object {
"children": Array [
Object {
"children": Array [
Object {
"label": "String",
},
Object {
"label": "HasNulls: true",
},
],
"label": "_rid",
},
Object {
"children": Array [
Object {
"label": "Int64",
},
Object {
"label": "HasNulls: true",
},
],
"label": "_ts",
},
Object {
"children": Array [
Object {
"label": "String",
},
Object {
"label": "HasNulls: true",
},
],
"label": "id",
},
Object {
"children": Array [
Object {
"label": "String",
},
Object {
"label": "HasNulls: true",
},
],
"label": "pk",
},
Object {
"children": Array [
Object {
"label": "String",
},
Object {
"label": "HasNulls: true",
},
],
"label": "other",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"label": "String",
},
Object {
"label": "HasNulls: true",
},
],
"label": "name",
},
Object {
"children": Array [
Object {
"label": "Int64",
},
Object {
"label": "HasNulls: true",
},
],
"label": "someNumber",
},
Object {
"children": Array [
Object {
"label": "Double",
},
Object {
"label": "HasNulls: true",
},
],
"label": "anotherNumber",
},
],
"label": "nested",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [
Object {
"label": "String",
},
Object {
"label": "HasNulls: true",
},
],
"label": "name",
},
Object {
"children": Array [
Object {
"label": "Int64",
},
Object {
"label": "HasNulls: true",
},
],
"label": "someNumber",
},
Object {
"children": Array [
Object {
"label": "Double",
},
Object {
"label": "HasNulls: true",
},
],
"label": "anotherNumber",
},
],
"label": "items",
},
],
"label": "list",
},
],
"label": "items",
},
Object {
"children": Array [
Object {
"label": "String",
},
Object {
"label": "HasNulls: true",
},
],
"label": "_etag",
},
],
"label": "Schema",
"onClick": [Function],
}
}
paddingLeft={0}
/>
</div>
`;

View File

@@ -7,6 +7,7 @@ import { IGitHubResponse } from "../GitHub/GitHubClient";
import { IGitHubOAuthToken } from "../GitHub/GitHubOAuthService";
import { userContext } from "../UserContext";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
import { number } from "prop-types";
export interface IJunoResponse<T> {
status: number;
@@ -427,6 +428,51 @@ export class JunoClient {
};
}
public async requestSchema(
schemaRequest: DataModels.ISchemaRequest
): Promise<IJunoResponse<DataModels.ISchemaRequest>> {
const response = await window.fetch(`${this.getAnalyticsUrl()}/${schemaRequest.accountName}/schema/request`, {
method: "POST",
body: JSON.stringify(schemaRequest),
headers: JunoClient.getHeaders()
});
let data: DataModels.ISchemaRequest;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async getSchema(
accountName: string,
databaseName: string,
containerName: string
): Promise<IJunoResponse<DataModels.ISchema>> {
const response = await window.fetch(
`${this.getAnalyticsUrl()}/${accountName}/schema/${databaseName}/${containerName}`,
{
method: "GET",
headers: JunoClient.getHeaders()
}
);
let data: DataModels.ISchema;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
private async getNotebooks(input: RequestInfo, init?: RequestInit): Promise<IJunoResponse<IGalleryItem[]>> {
const response = await window.fetch(input, init);
@@ -457,6 +503,10 @@ export class JunoClient {
return `${this.getNotebooksUrl()}/${this.getAccount()}`;
}
private getAnalyticsUrl(): string {
return `${configContext.JUNO_ENDPOINT}/api/analytics`;
}
private static getHeaders(): HeadersInit {
const authorizationHeader = getAuthorizationHeader();
return {

View File

@@ -1,117 +0,0 @@
// CSS Dependencies
import "bootstrap/dist/css/bootstrap.css";
import "../less/documentDB.less";
import "../less/tree.less";
import "../less/forms.less";
import "../less/menus.less";
import "../less/infobox.less";
import "../less/messagebox.less";
import "./Explorer/Controls/ErrorDisplayComponent/ErrorDisplayComponent.less";
import "./Explorer/Menus/NotificationConsole/NotificationConsole.less";
import "./Explorer/Menus/CommandBar/CommandBarComponent.less";
import "./Explorer/Menus/CommandBar/MemoryTrackerComponent.less";
import "./Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.less";
import "./Explorer/Controls/DynamicList/DynamicListComponent.less";
import "./Explorer/Controls/JsonEditor/JsonEditorComponent.less";
import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less";
import "../less/TableStyles/queryBuilder.less";
import "../externals/jquery.dataTables.min.css";
import "../less/TableStyles/fulldatatables.less";
import "../less/TableStyles/EntityEditor.less";
import "../less/TableStyles/CustomizeColumns.less";
import "../less/resourceTree.less";
import "../externals/jquery.typeahead.min.css";
import "../externals/jquery-ui.min.css";
import "../externals/jquery-ui.structure.min.css";
import "../externals/jquery-ui.theme.min.css";
import "./Explorer/Graph/NewVertexComponent/newVertexComponent.less";
import "./Explorer/Panes/GraphNewVertexPane.less";
import "./Explorer/Tabs/QueryTab.less";
import "./Explorer/Controls/TreeComponent/treeComponent.less";
import "./Explorer/Controls/Accordion/AccordionComponent.less";
import "./Explorer/SplashScreen/SplashScreenComponent.less";
import "./Explorer/Controls/Notebook/NotebookTerminalComponent.less";
// Image Dependencies
import "../images/CosmosDB_rgb_ui_lighttheme.ico";
import "../images/favicon.ico";
import "./Shared/appInsights";
import "babel-polyfill";
import "es6-symbol/implement";
import "webcrypto-liner/build/webcrypto-liner.shim.min";
import "./Libs/jquery";
import "bootstrap/dist/js/npm";
import "../externals/jquery.typeahead.min.js";
import "../externals/jquery-ui.min.js";
import "../externals/adal.js";
import "promise-polyfill/src/polyfill";
import "abort-controller/polyfill";
import "whatwg-fetch";
import "es6-object-assign/auto";
import "promise.prototype.finally/auto";
import "object.entries/auto";
import "./Libs/is-integer-polyfill";
import "url-polyfill/url-polyfill.min";
// TODO: Enable ReactDevTools after fixing the portal CORS issue
// import "./ReactDevTools"
import * as ko from "knockout";
import * as TelemetryProcessor from "./Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "./Shared/Telemetry/TelemetryConstants";
import { BindingHandlersRegisterer } from "./Bindings/BindingHandlersRegisterer";
import * as Emulator from "./Platform/Emulator/Main";
import Hosted from "./Platform/Hosted/Main";
import * as Portal from "./Platform/Portal/Main";
import { AuthType } from "./AuthType";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import { applyExplorerBindings } from "./applyExplorerBindings";
import { initializeConfiguration, Platform } from "./ConfigContext";
import Explorer from "./Explorer/Explorer";
initializeIcons(/* optional base url */);
// TODO: Encapsulate and reuse all global variables as environment variables
window.authType = AuthType.AAD;
initializeConfiguration().then(config => {
if (config.platform === Platform.Hosted) {
try {
Hosted.initializeExplorer().then(
(explorer: Explorer) => {
applyExplorerBindings(explorer);
Hosted.configureTokenValidationDisplayPrompt(explorer);
},
(error: any) => {
try {
const uninitializedExplorer: Explorer = Hosted.getUninitializedExplorerForGuestAccess();
window.dataExplorer = uninitializedExplorer;
ko.applyBindings(uninitializedExplorer);
BindingHandlersRegisterer.registerBindingHandlers();
if (window.authType !== AuthType.AAD) {
uninitializedExplorer.isRefreshingExplorer(false);
uninitializedExplorer.displayConnectExplorerForm();
}
} catch (e) {
console.log(e);
}
console.error(error);
}
);
} catch (e) {
console.log(e);
}
} else if (config.platform === Platform.Emulator) {
window.authType = AuthType.MasterKey;
const explorer = Emulator.initializeExplorer();
applyExplorerBindings(explorer);
} else if (config.platform === Platform.Portal) {
TelemetryProcessor.trace(Action.InitializeDataExplorer, ActionModifiers.Open, {});
const explorer = Portal.initializeExplorer();
TelemetryProcessor.trace(Action.InitializeDataExplorer, ActionModifiers.IFrameReady, {});
applyExplorerBindings(explorer);
}
});

452
src/Main.tsx Normal file
View File

@@ -0,0 +1,452 @@
// CSS Dependencies
import "bootstrap/dist/css/bootstrap.css";
import "../less/documentDB.less";
import "../less/tree.less";
import "../less/forms.less";
import "../less/menus.less";
import "../less/infobox.less";
import "../less/messagebox.less";
import "./Explorer/Controls/ErrorDisplayComponent/ErrorDisplayComponent.less";
import "./Explorer/Menus/NotificationConsole/NotificationConsole.less";
import "./Explorer/Menus/CommandBar/CommandBarComponent.less";
import "./Explorer/Menus/CommandBar/MemoryTrackerComponent.less";
import "./Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.less";
import "./Explorer/Controls/DynamicList/DynamicListComponent.less";
import "./Explorer/Controls/JsonEditor/JsonEditorComponent.less";
import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less";
import "../less/TableStyles/queryBuilder.less";
import "../externals/jquery.dataTables.min.css";
import "../less/TableStyles/fulldatatables.less";
import "../less/TableStyles/EntityEditor.less";
import "../less/TableStyles/CustomizeColumns.less";
import "../less/resourceTree.less";
import "../externals/jquery.typeahead.min.css";
import "../externals/jquery-ui.min.css";
import "../externals/jquery-ui.structure.min.css";
import "../externals/jquery-ui.theme.min.css";
import "./Explorer/Graph/NewVertexComponent/newVertexComponent.less";
import "./Explorer/Panes/GraphNewVertexPane.less";
import "./Explorer/Tabs/QueryTab.less";
import "./Explorer/Controls/TreeComponent/treeComponent.less";
import "./Explorer/Controls/Accordion/AccordionComponent.less";
import "./Explorer/SplashScreen/SplashScreenComponent.less";
import "./Explorer/Controls/Notebook/NotebookTerminalComponent.less";
// Image Dependencies
import "../images/CosmosDB_rgb_ui_lighttheme.ico";
import "../images/favicon.ico";
import "./Shared/appInsights";
import "babel-polyfill";
import "es6-symbol/implement";
import "webcrypto-liner/build/webcrypto-liner.shim.min";
import "./Libs/jquery";
import "bootstrap/dist/js/npm";
import "../externals/jquery.typeahead.min.js";
import "../externals/jquery-ui.min.js";
import "../externals/adal.js";
import "promise-polyfill/src/polyfill";
import "abort-controller/polyfill";
import "whatwg-fetch";
import "es6-object-assign/auto";
import "promise.prototype.finally/auto";
import "object.entries/auto";
import "./Libs/is-integer-polyfill";
import "url-polyfill/url-polyfill.min";
initializeIcons();
import * as ko from "knockout";
import * as TelemetryProcessor from "./Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "./Shared/Telemetry/TelemetryConstants";
import { BindingHandlersRegisterer } from "./Bindings/BindingHandlersRegisterer";
import * as Emulator from "./Platform/Emulator/Main";
import Hosted from "./Platform/Hosted/Main";
import * as Portal from "./Platform/Portal/Main";
import { AuthType } from "./AuthType";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import { applyExplorerBindings } from "./applyExplorerBindings";
import { initializeConfiguration, Platform } from "./ConfigContext";
import Explorer from "./Explorer/Explorer";
import React, { useEffect } from "react";
import ReactDOM from "react-dom";
import errorImage from "../images/error.svg";
import copyImage from "../images/Copy.svg";
import hdeConnectImage from "../images/HdeConnectCosmosDB.svg";
import refreshImg from "../images/refresh-cosmos.svg";
import arrowLeftImg from "../images/imgarrowlefticon.svg";
import { KOCommentEnd, KOCommentIfStart } from "./koComment";
// TODO: Encapsulate and reuse all global variables as environment variables
window.authType = AuthType.AAD;
const App: React.FunctionComponent = () => {
useEffect(() => {
initializeConfiguration().then(config => {
if (config.platform === Platform.Hosted) {
try {
Hosted.initializeExplorer().then(
(explorer: Explorer) => {
applyExplorerBindings(explorer);
Hosted.configureTokenValidationDisplayPrompt(explorer);
},
(error: unknown) => {
try {
const uninitializedExplorer: Explorer = Hosted.getUninitializedExplorerForGuestAccess();
window.dataExplorer = uninitializedExplorer;
ko.applyBindings(uninitializedExplorer);
BindingHandlersRegisterer.registerBindingHandlers();
if (window.authType !== AuthType.AAD) {
uninitializedExplorer.isRefreshingExplorer(false);
uninitializedExplorer.displayConnectExplorerForm();
}
} catch (e) {
console.log(e);
}
console.error(error);
}
);
} catch (e) {
console.log(e);
}
} else if (config.platform === Platform.Emulator) {
window.authType = AuthType.MasterKey;
const explorer = Emulator.initializeExplorer();
applyExplorerBindings(explorer);
} else if (config.platform === Platform.Portal) {
TelemetryProcessor.trace(Action.InitializeDataExplorer, ActionModifiers.Open, {});
const explorer = Portal.initializeExplorer();
TelemetryProcessor.trace(Action.InitializeDataExplorer, ActionModifiers.IFrameReady, {});
applyExplorerBindings(explorer);
}
});
}, []);
return (
<div className="flexContainer">
<div id="divExplorer" className="flexContainer hideOverflows" style={{ display: "none" }}>
{/* Main Command Bar - Start */}
<div data-bind="react: commandBarComponentAdapter" />
{/* Main Command Bar - End */}
{/* Share url flyout - Start */}
<div
id="shareDataAccessFlyout"
className="shareDataAccessFlyout"
data-bind="visible: shouldShowShareDialogContents"
>
<div className="shareDataAccessFlyoutContent">
<div className="urlContainer">
<span className="urlContentText">
Open this database account in a new browser tab with Cosmos DB Explorer. Or copy the read-write or read
only access urls below to share with others. For security purposes, the URLs grant time-bound access to
the account. When access expires, you can reconnect, using a valid connection string for the account.
</span>
<br />
<div
className="toggles"
data-bind="event: { keydown: onToggleKeyDown }, visible: shareAccessData().readWriteUrl != null"
tabIndex={0}
aria-label="Read-Write and Read toggle"
>
<div className="tab">
<input type="radio" className="radio" defaultValue="readwrite" />
<span
className="toggleSwitch"
role="presentation"
data-bind="click: toggleReadWrite, css:{ selectedToggle: isReadWriteToggled(), unselectedToggle: !isReadWriteToggled() }"
>
Read-Write
</span>
</div>
<div className="tab">
<input type="radio" className="radio" defaultValue="read" />
<span
className="toggleSwitch"
role="presentation"
data-bind="click: toggleRead, css:{ selectedToggle: isReadToggled(), unselectedToggle: !isReadToggled() }"
>
Read
</span>
</div>
</div>
<div className="urlSpace">
<input
id="shareUrlLink"
aria-label="Share url link"
className="shareLink"
type="text"
read-only
data-bind="value: shareAccessUrl"
/>
<span
className="urlTokenCopyInfoTooltip"
data-bind="click: copyUrlLink, event: { keypress: onCopyUrlLinkKeyPress }"
aria-label="Copy url link"
role="button"
tabIndex={0}
>
<img src={copyImage} alt="Copy link" />
<span className="urlTokenCopyTooltiptext" data-bind="text: shareUrlCopyHelperText" />
</span>
</div>
</div>
</div>
</div>
{/* Share url flyout - End */}
{/* Collections Tree and Tabs - Begin */}
<div className="resourceTreeAndTabs">
{/* Collections Tree - Start */}
<div id="resourcetree" data-test="resourceTreeId" className="resourceTree">
<div className="collectionsTreeWithSplitter">
{/* Collections Tree Expanded - Start */}
<div
id="main"
className="main"
data-bind="
visible: isLeftPaneExpanded()"
>
{/* Collections Window - - Start */}
<div id="mainslide" className="flexContainer">
{/* Collections Window Title/Command Bar - Start */}
<div className="collectiontitle">
<div className="coltitle">
<span className="titlepadcol" data-bind="text: collectionTitle" />
<div className="float-right">
<span
className="padimgcolrefresh"
data-test="refreshTree"
role="button"
data-bind="
click: onRefreshResourcesClick, clickBubble: false, event: { keypress: onRefreshDatabasesKeyPress }"
tabIndex={0}
aria-label="Refresh tree"
title="Refresh tree"
>
<img className="refreshcol" src={refreshImg} data-bind="attr: { alt: refreshTreeTitle }" />
</span>
<span
className="padimgcolrefresh1"
id="expandToggleLeftPaneButton"
role="button"
data-bind="
click: toggleLeftPaneExpanded, event: { keypress: toggleLeftPaneExpandedKeyPress }"
tabIndex={0}
aria-label="Collapse Tree"
title="Collapse Tree"
>
<img className="refreshcol1" src={arrowLeftImg} alt="Hide" />
</span>
</div>
</div>
</div>
{/* Collections Window Title/Command Bar - End */}
{!window.dataExplorer?.isAuthWithResourceToken() && (
<div style={{ overflowY: "auto" }} data-bind="react:resourceTree" />
)}
{window.dataExplorer?.isAuthWithResourceToken() && (
<div style={{ overflowY: "auto" }} data-bind="react:resourceTreeForResourceToken" />
)}
</div>
{/* Collections Window - End */}
</div>
{/* Collections Tree Expanded - End */}
{/* Collections Tree Collapsed - Start */}
<div
id="mini"
className="mini toggle-mini"
data-bind="visible: !isLeftPaneExpanded()
attr: { style: { width: collapsedResourceTreeWidth }}"
>
<div className="main-nav nav">
<ul className="nav">
<li
className="resourceTreeCollapse"
id="collapseToggleLeftPaneButton"
role="button"
data-bind="event: { keypress: toggleLeftPaneExpandedKeyPress }"
tabIndex={0}
aria-label="Expand Tree"
>
<span
className="leftarrowCollapsed"
data-bind="
click: toggleLeftPaneExpanded"
>
<img className="arrowCollapsed" src={arrowLeftImg} alt="Expand" />
</span>
<span
className="collectionCollapsed"
data-bind="
click: toggleLeftPaneExpanded"
>
<span
data-bind="
text: collectionTitle"
/>
</span>
</li>
</ul>
</div>
</div>
{/* Collections Tree Collapsed - End */}
</div>
{/* Splitter - Start */}
<div className="splitter ui-resizable-handle ui-resizable-e" id="h_splitter1" />
{/* Splitter - End */}
</div>
{/* Collections Tree - End */}
<div
className="connectExplorerContainer"
data-bind="visible: !isRefreshingExplorer() && tabsManager.openedTabs().length === 0"
>
<form className="connectExplorerFormContainer">
<div className="connectExplorer" data-bind="react: splashScreenAdapter" />
</form>
</div>
<div
className="tabsManagerContainer"
data-bind='component: { name: "tabs-manager", params: {data: tabsManager} }'
/>
</div>
{/* Collections Tree and Tabs - End */}
<div
className="dataExplorerErrorConsoleContainer"
role="contentinfo"
aria-label="Notification console"
id="explorerNotificationConsole"
data-bind="react: notificationConsoleComponentAdapter"
/>
</div>
{/* Explorer Connection - Encryption Token / AAD - Start */}
<div id="connectExplorer" className="connectExplorerContainer" style={{ display: "none" }}>
<div className="connectExplorerFormContainer">
<div className="connectExplorer">
<p className="connectExplorerContent">
<img src={hdeConnectImage} alt="Azure Cosmos DB" />
</p>
<p className="welcomeText">Welcome to Azure Cosmos DB</p>
<div id="connectWithAad">
<input
className="filterbtnstyle"
data-test="cosmosdb-signinBtn"
type="button"
defaultValue="Sign In"
data-bind="click: $data.signInAad"
/>
<p
className="switchConnectTypeText"
data-test="cosmosdb-connectionString"
data-bind="click: $data.onSwitchToConnectionString"
>
Connect to your account with connection string
</p>
</div>
<form id="connectWithConnectionString" data-bind="submit: renewToken" style={{ display: "none" }}>
<p className="connectExplorerContent connectStringText">Connect to your account with connection string</p>
<p className="connectExplorerContent">
<input
className="inputToken"
type="text"
required
placeholder="Please enter a connection string"
data-bind="value: tokenForRenewal"
/>
<span className="errorDetailsInfoTooltip" data-bind="visible: renewTokenError().length > 0">
<img className="errorImg" src={errorImage} alt="Error notification" />
<span className="errorDetails" data-bind="text: renewTokenError" />
</span>
</p>
<p className="connectExplorerContent">
<input className="filterbtnstyle" type="submit" value="Connect" />
</p>
<p className="switchConnectTypeText" data-bind="click: $data.signInAad">
Sign In with Azure Account
</p>
</form>
</div>
</div>
</div>
{/* Explorer Connection - Encryption Token / AAD - End */}
{/* Global loader - Start */}
<div className="splashLoaderContainer" data-bind="visible: isRefreshingExplorer">
<div className="splashLoaderContentContainer">
<p className="connectExplorerContent">
<img src={hdeConnectImage} alt="Azure Cosmos DB" />
</p>
<p className="splashLoaderTitle" id="explorerLoadingStatusTitle">
Welcome to Azure Cosmos DB
</p>
<p className="splashLoaderText" id="explorerLoadingStatusText" role="alert">
Connecting...
</p>
</div>
</div>
{/* Global loader - End */}
<div data-bind="react:uploadItemsPaneAdapter" />
<div data-bind='component: { name: "add-database-pane", params: {data: addDatabasePane} }' />
<div data-bind='component: { name: "add-collection-pane", params: { data: addCollectionPane} }' />
<div data-bind='component: { name: "delete-collection-confirmation-pane", params: { data: deleteCollectionConfirmationPane} }' />
<div data-bind='component: { name: "delete-database-confirmation-pane", params: { data: deleteDatabaseConfirmationPane} }' />
<div data-bind='component: { name: "graph-new-vertex-pane", params: { data: newVertexPane} }' />
<div data-bind='component: { name: "graph-styling-pane", params: { data: graphStylingPane} }' />
<div data-bind='component: { name: "table-add-entity-pane", params: { data: addTableEntityPane} }' />
<div data-bind='component: { name: "table-edit-entity-pane", params: { data: editTableEntityPane} }' />
<div data-bind='component: { name: "table-column-options-pane", params: { data: tableColumnOptionsPane} }' />
<div data-bind='component: { name: "table-query-select-pane", params: { data: querySelectPane} }' />
<div data-bind='component: { name: "cassandra-add-collection-pane", params: { data: cassandraAddCollectionPane} }' />
<div data-bind='component: { name: "settings-pane", params: { data: settingsPane} }' />
<div data-bind='component: { name: "upload-items-pane", params: { data: uploadItemsPane} }' />
<div data-bind='component: { name: "load-query-pane", params: { data: loadQueryPane} }' />
<div data-bind='component: { name: "execute-sproc-params-pane", params: { data: executeSprocParamsPane} }' />
<div data-bind='component: { name: "renew-adhoc-access-pane", params: { data: renewAdHocAccessPane} }' />
<div data-bind='component: { name: "save-query-pane", params: { data: saveQueryPane} }' />
<div data-bind='component: { name: "browse-queries-pane", params: { data: browseQueriesPane} }' />
<div data-bind='component: { name: "upload-file-pane", params: { data: uploadFilePane} }' />
<div data-bind='component: { name: "string-input-pane", params: { data: stringInputPane} }' />
<div data-bind='component: { name: "setup-notebooks-pane", params: { data: setupNotebooksPane} }' />
<KOCommentIfStart if="isGitHubPaneEnabled" />
<div data-bind='component: { name: "github-repos-pane", params: { data: gitHubReposPane } }' />
<KOCommentEnd />
<KOCommentIfStart if="isPublishNotebookPaneEnabled" />
<div data-bind="react: publishNotebookPaneAdapter" />
<KOCommentEnd />
<KOCommentIfStart if="isCopyNotebookPaneEnabled" />
<div data-bind="react: copyNotebookPaneAdapter" />
<KOCommentEnd />
{/* Global access token expiration dialog - Start */}
<div
id="dataAccessTokenModal"
className="dataAccessTokenModal"
style={{ display: "none" }}
data-bind="visible: shouldShowDataAccessExpiryDialog"
>
<div className="dataAccessTokenModalContent">
<p className="dataAccessTokenExpireText">Please reconnect to the account using the connection string.</p>
</div>
</div>
{/* Global access token expiration dialog - End */}
{/* Context switch prompt - Start */}
<div
id="contextSwitchPrompt"
className="dataAccessTokenModal"
style={{ display: "none" }}
data-bind="visible: shouldShowContextSwitchPrompt"
>
<div className="dataAccessTokenModalContent">
<p className="dataAccessTokenExpireText">
Please save your work before you switch! When you switch to a different Azure Cosmos DB account, current
Data Explorer tabs will be closed.
</p>
<p className="dataAccessTokenExpireText">Proceed anyway?</p>
</div>
</div>
<div data-bind="react: dialogComponentAdapter" />
<div data-bind="react: addSynapseLinkDialog" />
</div>
);
};
ReactDOM.render(<App />, document.body);

View File

@@ -1,4 +1,4 @@
import { SubscriptionType } from "../Contracts/ViewModels";
import { SubscriptionType } from "../Contracts/SubscriptionType";
export const hoursInAMonth = 730;
export class AutoscalePricing {

View File

@@ -1,4 +1,5 @@
import { DatabaseAccount } from "./Contracts/DataModels";
import { SubscriptionType } from "./Contracts/SubscriptionType";
import { DefaultAccountExperienceType } from "./DefaultAccountExperienceType";
interface UserContext {
@@ -12,6 +13,7 @@ interface UserContext {
resourceToken?: string;
defaultExperience?: DefaultAccountExperienceType;
useSDKOperations?: boolean;
subscriptionType?: SubscriptionType;
}
const userContext: Readonly<UserContext> = {} as const;

View File

@@ -5,12 +5,12 @@ import Explorer from "./Explorer/Explorer";
export const applyExplorerBindings = (explorer: Explorer) => {
if (!!explorer) {
ko.applyBindings(explorer);
// This message should ideally be sent immediately after explorer has been initialized for optimal data explorer load times.
// TODO: Send another message to describe that the bindings have been applied, and handle message transfers accordingly in the portal
sendMessage("ready");
window.dataExplorer = explorer;
BindingHandlersRegisterer.registerBindingHandlers();
ko.applyBindings(explorer);
$("#divExplorer").show();
}
};

View File

@@ -8,329 +8,5 @@
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
</head>
<body>
<div class="flexContainer">
<div id="divExplorer" class="flexContainer hideOverflows" style="display: none">
<!-- Main Command Bar - Start -->
<div data-bind="react: commandBarComponentAdapter"></div>
<!-- Main Command Bar - End -->
<!-- Share url flyout - Start -->
<div
id="shareDataAccessFlyout"
class="shareDataAccessFlyout"
data-bind="visible: shouldShowShareDialogContents"
>
<div class="shareDataAccessFlyoutContent">
<div class="urlContainer">
<span class="urlContentText"
>Open this database account in a new browser tab with Cosmos DB Explorer. Or copy the read-write or read
only access urls below to share with others. For security purposes, the URLs grant time-bound access to
the account. When access expires, you can reconnect, using a valid connection string for the
account.</span
>
<br />
<div
class="toggles"
data-bind="event: { keydown: onToggleKeyDown }, visible: shareAccessData().readWriteUrl != null"
tabindex="0"
aria-label="Read-Write and Read toggle"
>
<div class="tab">
<input type="radio" class="radio" value="readwrite" />
<span
class="toggleSwitch"
role="presentation"
data-bind="click: toggleReadWrite, css:{ selectedToggle: isReadWriteToggled(), unselectedToggle: !isReadWriteToggled() }"
>Read-Write</span
>
</div>
<div class="tab">
<input type="radio" class="radio" value="read" />
<span
class="toggleSwitch"
role="presentation"
data-bind="click: toggleRead, css:{ selectedToggle: isReadToggled(), unselectedToggle: !isReadToggled() }"
>Read</span
>
</div>
</div>
<div class="urlSpace">
<input
id="shareUrlLink"
aria-label="Share url link"
class="shareLink"
type="text"
read-only
data-bind="value: shareAccessUrl"
/>
<span
class="urlTokenCopyInfoTooltip"
data-bind="click: copyUrlLink, event: { keypress: onCopyUrlLinkKeyPress }"
aria-label="Copy url link"
role="button"
tabindex="0"
>
<img src="/Copy.svg" alt="Copy link" />
<span class="urlTokenCopyTooltiptext" data-bind="text: shareUrlCopyHelperText"></span>
</span>
</div>
</div>
</div>
</div>
<!-- Share url flyout - End -->
<!-- Collections Tree and Tabs - Begin -->
<div class="resourceTreeAndTabs">
<!-- Collections Tree - Start -->
<div id="resourcetree" data-test="resourceTreeId" class="resourceTree">
<div class="collectionsTreeWithSplitter">
<!-- Collections Tree Expanded - Start -->
<div
id="main"
class="main"
data-bind="
visible: isLeftPaneExpanded()"
>
<!-- Collections Window - - Start -->
<div id="mainslide" class="flexContainer">
<!-- Collections Window Title/Command Bar - Start -->
<div class="collectiontitle">
<div class="coltitle">
<span class="titlepadcol" data-bind="text: collectionTitle"></span>
<div class="float-right">
<span
class="padimgcolrefresh"
data-test="refreshTree"
role="button"
data-bind="
click: onRefreshResourcesClick, clickBubble: false, event: { keypress: onRefreshDatabasesKeyPress }"
tabindex="0"
aria-label="Refresh tree"
title="Refresh tree"
>
<img
class="refreshcol"
src="/refresh-cosmos.svg"
data-bind="attr: { alt: refreshTreeTitle }"
/>
</span>
<span
class="padimgcolrefresh1"
id="expandToggleLeftPaneButton"
role="button"
data-bind="
click: toggleLeftPaneExpanded, event: { keypress: toggleLeftPaneExpandedKeyPress }"
tabindex="0"
aria-label="Collapse Tree"
title="Collapse Tree"
>
<img class="refreshcol1" src="/imgarrowlefticon.svg" alt="Hide" />
</span>
</div>
</div>
</div>
<!-- Collections Window Title/Command Bar - End -->
<!-- ko if: !isAuthWithResourceToken() -->
<div style="overflow-y: auto" data-bind="react:resourceTree"></div>
<!-- /ko -->
<!-- ko if: isAuthWithResourceToken() -->
<div style="overflow-y: auto" data-bind="react:resourceTreeForResourceToken"></div>
<!-- /ko -->
</div>
<!-- Collections Window - End -->
</div>
<!-- Collections Tree Expanded - End -->
<!-- Collections Tree Collapsed - Start -->
<div
id="mini"
class="mini toggle-mini"
data-bind="visible: !isLeftPaneExpanded()
attr: { style: { width: collapsedResourceTreeWidth }}"
>
<div class="main-nav nav">
<ul class="nav">
<li
class="resourceTreeCollapse"
id="collapseToggleLeftPaneButton"
role="button"
data-bind="event: { keypress: toggleLeftPaneExpandedKeyPress }"
tabindex="0"
aria-label="Expand Tree"
>
<span
class="leftarrowCollapsed"
data-bind="
click: toggleLeftPaneExpanded"
>
<img class="arrowCollapsed" src="/imgarrowlefticon.svg" alt="Expand" />
</span>
<span
class="collectionCollapsed"
data-bind="
click: toggleLeftPaneExpanded"
>
<span
data-bind="
text: collectionTitle"
></span>
</span>
</li>
</ul>
</div>
</div>
<!-- Collections Tree Collapsed - End -->
</div>
<!-- Splitter - Start -->
<div class="splitter ui-resizable-handle ui-resizable-e" id="h_splitter1"></div>
<!-- Splitter - End -->
</div>
<!-- Collections Tree - End -->
<div
class="connectExplorerContainer"
data-bind="visible: !isRefreshingExplorer() && tabsManager.openedTabs().length === 0"
>
<form class="connectExplorerFormContainer">
<div class="connectExplorer" data-bind="react: splashScreenAdapter"></div>
</form>
</div>
<tabs-manager
class="tabsManagerContainer"
params="{data: tabsManager}"
data-bind="visible: tabsManager.openedTabs().length > 0"
></tabs-manager>
</div>
<!-- Collections Tree and Tabs - End -->
<div
class="dataExplorerErrorConsoleContainer"
role="contentinfo"
aria-label="Notification console"
id="explorerNotificationConsole"
data-bind="react: notificationConsoleComponentAdapter"
></div>
</div>
<!-- Explorer Connection - Encryption Token / AAD - Start -->
<div id="connectExplorer" class="connectExplorerContainer" style="display: none;">
<div class="connectExplorerFormContainer">
<div class="connectExplorer">
<p class="connectExplorerContent"><img src="/HdeConnectCosmosDB.svg" alt="Azure Cosmos DB" /></p>
<p class="welcomeText">Welcome to Azure Cosmos DB</p>
<div id="connectWithAad">
<input
class="filterbtnstyle"
data-test="cosmosdb-signinBtn"
type="button"
value="Sign In"
data-bind="click: $data.signInAad"
/>
<p
class="switchConnectTypeText"
data-test="cosmosdb-connectionString"
data-bind="click: $data.onSwitchToConnectionString"
>
Connect to your account with connection string
</p>
</div>
<form id="connectWithConnectionString" data-bind="submit: renewToken" style="display: none;">
<p class="connectExplorerContent connectStringText">Connect to your account with connection string</p>
<p class="connectExplorerContent">
<input
class="inputToken"
type="text"
required
placeholder="Please enter a connection string"
data-bind="value: tokenForRenewal"
/>
<span class="errorDetailsInfoTooltip" data-bind="visible: renewTokenError().length > 0">
<img class="errorImg" src="/error.svg" alt="Error notification" />
<span class="errorDetails" data-bind="text: renewTokenError"></span>
</span>
</p>
<p class="connectExplorerContent"><input class="filterbtnstyle" type="submit" value="Connect" /></p>
<p class="switchConnectTypeText" data-bind="click: $data.signInAad">Sign In with Azure Account</p>
</form>
</div>
</div>
</div>
<!-- Explorer Connection - Encryption Token / AAD - End -->
<!-- Global loader - Start -->
<div class="splashLoaderContainer" data-bind="visible: isRefreshingExplorer">
<div class="splashLoaderContentContainer">
<p class="connectExplorerContent"><img src="/HdeConnectCosmosDB.svg" alt="Azure Cosmos DB" /></p>
<p class="splashLoaderTitle" id="explorerLoadingStatusTitle">Welcome to Azure Cosmos DB</p>
<p class="splashLoaderText" id="explorerLoadingStatusText" role="alert">Connecting...</p>
</div>
</div>
<!-- Global loader - End -->
<div data-bind="react:uploadItemsPaneAdapter"></div>
<add-database-pane params="{data: addDatabasePane}"></add-database-pane>
<add-collection-pane params="{data: addCollectionPane}"></add-collection-pane>
<delete-collection-confirmation-pane params="{data: deleteCollectionConfirmationPane}">
</delete-collection-confirmation-pane>
<delete-database-confirmation-pane params="{data: deleteDatabaseConfirmationPane}">
</delete-database-confirmation-pane>
<graph-new-vertex-pane params="{data: newVertexPane}"></graph-new-vertex-pane>
<graph-styling-pane params="{data: graphStylingPane}"></graph-styling-pane>
<table-add-entity-pane params="{data: addTableEntityPane}"></table-add-entity-pane>
<table-edit-entity-pane params="{data: editTableEntityPane}"></table-edit-entity-pane>
<table-column-options-pane params="{data: tableColumnOptionsPane}"></table-column-options-pane>
<table-query-select-pane params="{data: querySelectPane}"></table-query-select-pane>
<cassandra-add-collection-pane params="{data: cassandraAddCollectionPane}"></cassandra-add-collection-pane>
<settings-pane params="{data: settingsPane}"></settings-pane>
<upload-items-pane params="{data: uploadItemsPane}"></upload-items-pane>
<load-query-pane params="{data: loadQueryPane}"></load-query-pane>
<execute-sproc-params-pane params="{data: executeSprocParamsPane}"></execute-sproc-params-pane>
<renew-adhoc-access-pane params="{data: renewAdHocAccessPane}"></renew-adhoc-access-pane>
<save-query-pane params="{data: saveQueryPane}"></save-query-pane>
<browse-queries-pane params="{data: browseQueriesPane}"></browse-queries-pane>
<upload-file-pane params="{data: uploadFilePane}"></upload-file-pane>
<string-input-pane params="{data: stringInputPane}"></string-input-pane>
<setup-notebooks-pane params="{data: setupNotebooksPane}"></setup-notebooks-pane>
<!-- ko if: isGitHubPaneEnabled -->
<github-repos-pane params="{data: gitHubReposPane}"></github-repos-pane>
<!-- /ko -->
<!-- ko if: isPublishNotebookPaneEnabled -->
<div data-bind="react: publishNotebookPaneAdapter"></div>
<!-- /ko -->
<!-- ko if: isCopyNotebookPaneEnabled -->
<div data-bind="react: copyNotebookPaneAdapter"></div>
<!-- /ko -->
<!-- Global access token expiration dialog - Start -->
<div
id="dataAccessTokenModal"
class="dataAccessTokenModal"
style="display: none"
data-bind="visible: shouldShowDataAccessExpiryDialog"
>
<div class="dataAccessTokenModalContent">
<p class="dataAccessTokenExpireText">Please reconnect to the account using the connection string.</p>
</div>
</div>
<!-- Global access token expiration dialog - End -->
<!-- Context switch prompt - Start -->
<div
id="contextSwitchPrompt"
class="dataAccessTokenModal"
style="display: none"
data-bind="visible: shouldShowContextSwitchPrompt"
>
<div class="dataAccessTokenModalContent">
<p class="dataAccessTokenExpireText">
Please save your work before you switch! When you switch to a different Azure Cosmos DB account, current
Data Explorer tabs will be closed.
</p>
<p class="dataAccessTokenExpireText">Proceed anyway?</p>
</div>
</div>
<div data-bind="react: dialogComponentAdapter"></div>
<div data-bind="react: addSynapseLinkDialog"></div>
</div>
</body>
<body></body>
</html>

20
src/koComment.tsx Normal file
View File

@@ -0,0 +1,20 @@
/* eslint-disable react/prop-types */
import React, { useEffect, useRef } from "react";
export const KOCommentIfStart: React.FunctionComponent<{ if: string }> = props => {
const el = useRef();
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(el.current as any).outerHTML = `<!-- ko if: ${props.if} -->`;
}, []);
return <div ref={el} />;
};
export const KOCommentEnd: React.FunctionComponent = () => {
const el = useRef();
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(el.current as any).outerHTML = `<!-- /ko -->`;
}, []);
return <div ref={el} />;
};

View File

@@ -1,58 +0,0 @@
import "expect-puppeteer";
import { trackEvent, trackException } from "./utils";
jest.setTimeout(300000);
describe.skip("Collection CRUD", () => {
it("should complete collection crud", async () => {
try {
// Login to Azure Portal
await page.goto("https://portal.azure.com");
await page.waitFor("input[name=loginfmt]");
await page.type("input[name=loginfmt]", process.env.PORTAL_RUNNER_USERNAME);
await page.click("input[type=submit]");
await page.waitFor(3000);
await page.waitFor("input[name=loginfmt]");
await page.type("input[name=passwd]", process.env.PORTAL_RUNNER_PASSWORD);
await page.click("input[type=submit]");
await page.waitFor(3000);
await page.waitForNavigation();
await page.goto(
`https://ms.portal.azure.com/#@microsoft.onmicrosoft.com/resource/subscriptions/${process.env.PORTAL_RUNNER_SUBSCRIPTION}/resourceGroups/${process.env.PORTAL_RUNNER_RESOURCE_GROUP}/providers/Microsoft.DocumentDb/databaseAccounts/${process.env.PORTAL_RUNNER_DATABASE_ACCOUNT}/dataExplorer`
);
// Wait for page to settle
await page.waitFor(10000);
// Find Data Explorer iFrame
const frames = page.frames();
const dataExplorer = frames.find(frame => frame.url().includes("cosmos.azure.com"));
// Click "New Container"
const newContainerButton = await dataExplorer.$('button[data-test="New Container"]');
await newContainerButton.click();
// Wait for side pane to appear
await dataExplorer.waitFor(".contextual-pane-in");
// Fill out New Container form
const databaseIdInput = await dataExplorer.$("#databaseId");
await databaseIdInput.type("foo");
const collectionIdInput = await dataExplorer.$("#containerId");
await collectionIdInput.type("foo");
const partitionKeyInput = await dataExplorer.$('input[data-test="addCollection-partitionKeyValue"]');
await partitionKeyInput.type("/partitionKey");
trackEvent({ name: "ProductionRunnerSuccess" });
// TODO: Submit form and assert results
// cy.wrap($body)
// .find("#submitBtnAddCollection")
// .click();
// cy.wait(10000);
// cy.wrap($body)
// .find('div[data-test="resourceTreeId"]')
// .should("exist")
// .find('div[class="treeComponent dataResourceTree"]')
// .should("contain", dbId);
} catch (error) {
await page.screenshot({ path: "failure.png" });
trackException(error);
throw error;
}
});
});

View File

@@ -82,10 +82,6 @@ describe("Collection Add and Delete Mongo spec", () => {
);
if (collections.length) {
await frame.waitFor(`div[class="collectionHeader main2 nodeItem "] > div[class="treeNodeHeader "]`, {
visible: true
});
const textId = await frame.evaluate(element => {
return element.attributes["data-test"].textContent;
}, collections[0]);

View File

@@ -0,0 +1,101 @@
import { ElementHandle, Frame } from "puppeteer";
import { TestExplorerParams } from "./testExplorer/TestExplorerParams";
import * as path from "path";
export const NOTEBOOK_OPERATION_DELAY = 5000;
export const RENDER_DELAY = 2500;
let testExplorerFrame: Frame;
export const getTestExplorerFrame = async (): Promise<Frame> => {
if (testExplorerFrame) {
return testExplorerFrame;
}
const notebooksTestRunnerTenantId = process.env.NOTEBOOKS_TEST_RUNNER_TENANT_ID;
const notebooksTestRunnerClientId = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_ID;
const notebooksTestRunnerClientSecret = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET;
const portalRunnerDatabaseAccount = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT;
const portalRunnerDatabaseAccountKey = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY;
const portalRunnerSubscripton = process.env.PORTAL_RUNNER_SUBSCRIPTION;
const portalRunnerResourceGroup = process.env.PORTAL_RUNNER_RESOURCE_GROUP;
const testExplorerUrl = new URL("testExplorer.html", "https://localhost:1234");
testExplorerUrl.searchParams.append(
TestExplorerParams.notebooksTestRunnerTenantId,
encodeURI(notebooksTestRunnerTenantId)
);
testExplorerUrl.searchParams.append(
TestExplorerParams.notebooksTestRunnerClientId,
encodeURI(notebooksTestRunnerClientId)
);
testExplorerUrl.searchParams.append(
TestExplorerParams.notebooksTestRunnerClientSecret,
encodeURI(notebooksTestRunnerClientSecret)
);
testExplorerUrl.searchParams.append(
TestExplorerParams.portalRunnerDatabaseAccount,
encodeURI(portalRunnerDatabaseAccount)
);
testExplorerUrl.searchParams.append(
TestExplorerParams.portalRunnerDatabaseAccountKey,
encodeURI(portalRunnerDatabaseAccountKey)
);
testExplorerUrl.searchParams.append(TestExplorerParams.portalRunnerSubscripton, encodeURI(portalRunnerSubscripton));
testExplorerUrl.searchParams.append(
TestExplorerParams.portalRunnerResourceGroup,
encodeURI(portalRunnerResourceGroup)
);
await page.goto(testExplorerUrl.toString());
const handle = await page.waitForSelector("iframe");
testExplorerFrame = await handle.contentFrame();
await testExplorerFrame.waitForSelector(".galleryHeader");
return testExplorerFrame;
};
export const uploadNotebookIfNotExist = async (frame: Frame, notebookName: string): Promise<ElementHandle<Element>> => {
const notebookNode = await getNotebookNode(frame, notebookName);
if (notebookNode) {
return notebookNode;
}
const uploadNotebookPath = path.join(__dirname, "testNotebooks", notebookName);
const notebookResourceTree = await frame.waitForSelector(".notebookResourceTree");
const treeNodeHeadersBeforeUpload = await notebookResourceTree.$$(".treeNodeHeader");
const ellipses = await treeNodeHeadersBeforeUpload[2].$("button");
await ellipses.click();
await frame.waitFor(RENDER_DELAY);
const menuItems = await frame.$$(".ms-ContextualMenu-item");
await menuItems[4].click();
const uploadFileButton = await frame.waitForSelector("#importFileButton");
uploadFileButton.click();
const fileChooser = await page.waitForFileChooser();
fileChooser.accept([uploadNotebookPath]);
const submitButton = await frame.waitForSelector("#uploadFileButton");
await submitButton.click();
await frame.waitFor(NOTEBOOK_OPERATION_DELAY);
return await getNotebookNode(frame, notebookName);
};
export const getNotebookNode = async (frame: Frame, uploadNotebookName: string): Promise<ElementHandle<Element>> => {
const notebookResourceTree = await frame.waitForSelector(".notebookResourceTree");
let currentNotebookNode: ElementHandle<Element>;
const treeNodeHeaders = await notebookResourceTree.$$(".treeNodeHeader");
for (let i = 1; i < treeNodeHeaders.length; i++) {
currentNotebookNode = treeNodeHeaders[i];
const nodeLabel = await currentNotebookNode.$eval(".nodeLabel", element => element.textContent);
if (nodeLabel === uploadNotebookName) {
return currentNotebookNode;
}
}
return undefined;
};

View File

@@ -0,0 +1,138 @@
import { MessageTypes } from "../../../src/Contracts/ExplorerContracts";
import "../../../less/hostedexplorer.less";
import { TestExplorerParams } from "./TestExplorerParams";
import { ClientSecretCredential } from "@azure/identity";
import { DatabaseAccountsGetResponse } from "@azure/arm-cosmosdb/esm/models";
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
import * as msRest from "@azure/ms-rest-js";
import * as ViewModels from "../../../src/Contracts/ViewModels";
class CustomSigner implements msRest.ServiceClientCredentials {
private token: string;
constructor(token: string) {
this.token = token;
}
async signRequest(webResource: msRest.WebResourceLike): Promise<msRest.WebResourceLike> {
webResource.headers.set("authorization", `bearer ${this.token}`);
return webResource;
}
}
const handleMessage = (event: MessageEvent): void => {
if (event.data.type === MessageTypes.InitTestExplorer) {
sendMessageToExplorerFrame(event.data);
}
};
const AADLogin = async (
notebooksTestRunnerApplicationId: string,
notebooksTestRunnerClientId: string,
notebooksTestRunnerClientSecret: string
): Promise<string> => {
const credentials = new ClientSecretCredential(
notebooksTestRunnerApplicationId,
notebooksTestRunnerClientId,
notebooksTestRunnerClientSecret
);
const token = await credentials.getToken("https://management.core.windows.net/.default");
return token.token;
};
const getDatabaseAccount = async (
token: string,
notebooksAccountSubscriptonId: string,
notebooksAccountResourceGroup: string,
notebooksAccountName: string
): Promise<DatabaseAccountsGetResponse> => {
const client = new CosmosDBManagementClient(new CustomSigner(token), notebooksAccountSubscriptonId);
return client.databaseAccounts.get(notebooksAccountResourceGroup, notebooksAccountName);
};
const sendMessageToExplorerFrame = (data: unknown): void => {
const explorerFrame = document.getElementById("explorerMenu") as HTMLIFrameElement;
explorerFrame &&
explorerFrame.contentDocument &&
explorerFrame.contentDocument.referrer &&
explorerFrame.contentWindow.postMessage(
{
signature: "pcIframe",
data: data
},
explorerFrame.contentDocument.referrer || window.location.href
);
};
const initTestExplorer = async (): Promise<void> => {
window.addEventListener("message", handleMessage, false);
const urlSearchParams = new URLSearchParams(window.location.search);
const notebooksTestRunnerTenantId = decodeURIComponent(
urlSearchParams.get(TestExplorerParams.notebooksTestRunnerTenantId)
);
const notebooksTestRunnerClientId = decodeURIComponent(
urlSearchParams.get(TestExplorerParams.notebooksTestRunnerClientId)
);
const notebooksTestRunnerClientSecret = decodeURIComponent(
urlSearchParams.get(TestExplorerParams.notebooksTestRunnerClientSecret)
);
const portalRunnerDatabaseAccount = decodeURIComponent(
urlSearchParams.get(TestExplorerParams.portalRunnerDatabaseAccount)
);
const portalRunnerDatabaseAccountKey = decodeURIComponent(
urlSearchParams.get(TestExplorerParams.portalRunnerDatabaseAccountKey)
);
const portalRunnerSubscripton = decodeURIComponent(urlSearchParams.get(TestExplorerParams.portalRunnerSubscripton));
const portalRunnerResourceGroup = decodeURIComponent(
urlSearchParams.get(TestExplorerParams.portalRunnerResourceGroup)
);
const token = await AADLogin(
notebooksTestRunnerTenantId,
notebooksTestRunnerClientId,
notebooksTestRunnerClientSecret
);
const databaseAccount = await getDatabaseAccount(
token,
portalRunnerSubscripton,
portalRunnerResourceGroup,
portalRunnerDatabaseAccount
);
const initTestExplorerContent = {
type: MessageTypes.InitTestExplorer,
inputs: {
databaseAccount: databaseAccount,
subscriptionId: portalRunnerSubscripton,
resourceGroup: portalRunnerResourceGroup,
authorizationToken: `Bearer ${token}`,
features: {},
hasWriteAccess: true,
csmEndpoint: "https://management.azure.com",
dnsSuffix: "documents.azure.com",
serverId: "prod1",
extensionEndpoint: "/proxy",
subscriptionType: 3,
quotaId: "Internal_2014-09-01",
addCollectionDefaultFlight: "2",
isTryCosmosDBSubscription: false,
masterKey: portalRunnerDatabaseAccountKey,
loadDatabaseAccountTimestamp: 1604663109836,
dataExplorerVersion: "1.0.1",
sharedThroughputMinimum: 400,
sharedThroughputMaximum: 1000000,
sharedThroughputDefault: 400,
defaultCollectionThroughput: {
storage: "100",
throughput: { fixed: 400, unlimited: 400, unlimitedmax: 100000, unlimitedmin: 400, shared: 400 }
},
// add UI test only when feature is not dependent on flights anymore
flights: []
} as ViewModels.DataExplorerInputsFrame
};
window.postMessage(initTestExplorerContent, window.location.href);
};
window.addEventListener("load", initTestExplorer);

View File

@@ -0,0 +1,9 @@
export enum TestExplorerParams {
notebooksTestRunnerTenantId = "notebooksTestRunnerTenantId",
notebooksTestRunnerClientId = "notebooksTestRunnerClientId",
notebooksTestRunnerClientSecret = "notebooksTestRunnerClientSecret",
portalRunnerDatabaseAccount = "portalRunnerDatabaseAccount",
portalRunnerDatabaseAccountKey = "portalRunnerDatabaseAccountKey",
portalRunnerSubscripton = "portalRunnerSubscripton",
portalRunnerResourceGroup = "portalRunnerResourceGroup"
}

View File

@@ -0,0 +1,18 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="height=device-height, width=device-width, initial-scale=1.0" />
<title>Azure Cosmos DB</title>
<link rel="shortcut icon" href="images/CosmosDB_rgb_ui_lighttheme.ico" type="image/x-icon" />
</head>
<body>
<iframe
id="explorerMenu"
name="explorer"
class="iframe"
title="explorer"
src="explorer.html?v=1.0.1&platform=Portal"
></iframe>
</body>
</html>

View File

@@ -0,0 +1,110 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": "# Getting started with Cosmos notebooks\nIn this notebook, we'll learn how to use Cosmos notebook features. We'll create a database and container, import some sample data in a container in Azure Cosmos DB and run some queries over it."
},
{
"cell_type": "markdown",
"metadata": {},
"source": "### Create new database and container\n\nTo connect to the service, you can use our built-in instance of ```cosmos_client```. This is a ready to use instance of [CosmosClient](https://docs.microsoft.com/python/api/azure-cosmos/azure.cosmos.cosmos_client.cosmosclient?view=azure-python) from our Python SDK. It already has the context of this account baked in. We'll use ```cosmos_client``` to create a new database called **RetailDemo** and container called **WebsiteData**.\n\nOur dataset will contain events that occurred on the website - e.g. a user viewing an item, adding it to their cart, or purchasing it. We will partition by CartId, which represents the individual cart of each user. This will give us an even distribution of throughput and storage in our container. Learn more about how to [choose a good partition key.](https://docs.microsoft.com/azure/cosmos-db/partition-data)"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"trusted": false
},
"outputs": [],
"source": "import azure.cosmos\nfrom azure.cosmos.partition_key import PartitionKey\n\ndatabase = cosmos_client.create_database_if_not_exists('RetailDemo')\nprint('Database RetailDemo created')\n\ncontainer = database.create_container_if_not_exists(id='WebsiteData', partition_key=PartitionKey(path='/CartID'))\nprint('Container WebsiteData created')\n"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "#### Set the default database and container context to the new resources\n\nWe can use the ```%database {database_id}``` and ```%container {container_id}``` syntax."
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"trusted": false
},
"outputs": [],
"source": "%database RetailDemo"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"trusted": false
},
"outputs": [],
"source": "%container WebsiteData"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "### Load in sample JSON data and insert into the container. \nWe'll use the **%%upload** magic function to insert items into the container"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"collapsed": false,
"inputHidden": false,
"outputHidden": false,
"trusted": false
},
"outputs": [],
"source": "%%upload --databaseName RetailDemo --containerName WebsiteData --url https://cosmosnotebooksdata.blob.core.windows.net/notebookdata/websiteData-small.json"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "The new database and container should show up under the **Data** section. Use the refresh icon after completing the previous cell. \n\n<img src=\"https://cosmosnotebooksdata.blob.core.windows.net/notebookdata/refreshData.png\" alt=\"Refresh Data resource tree to see newly created resources\" width=\"40%\"/>"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "### Run a query using the built-in Azure Cosmos notebook magic\n"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"trusted": false
},
"outputs": [],
"source": "%%sql\nSELECT c.Action, c.Price as ItemRevenue, c.Country, c.Item FROM c"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "We can get more information about the %%sql command using ```%%sql?```"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "### Next steps\n\nNow that you've learned how to use basic notebook functionality, follow the **Visualization.ipynb** notebook to further analyze and visualize our data. You can find it under the **Sample Notebooks** section."
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"version": "3.6.8"
},
"nteract": {
"version": "dataExplorer 1.0"
}
},
"nbformat": 4,
"nbformat_minor": 2
}

View File

@@ -0,0 +1,29 @@
import "expect-puppeteer";
import { getTestExplorerFrame, uploadNotebookIfNotExist } from "./notebookTestUtils";
import { ElementHandle, Frame } from "puppeteer";
jest.setTimeout(300000);
const notebookName = "GettingStarted.ipynb";
let frame: Frame;
let uploadedNotebookNode: ElementHandle<Element>;
describe("Notebook UI tests", () => {
it("Upload, Open and Delete Notebook", async () => {
try {
frame = await getTestExplorerFrame();
uploadedNotebookNode = await uploadNotebookIfNotExist(frame, notebookName);
await uploadedNotebookNode.click();
await frame.waitForSelector(".tabNavText");
const tabTitle = await frame.$eval(".tabNavText", element => element.textContent);
expect(tabTitle).toEqual(notebookName);
const closeIcon = await frame.waitForSelector(".close-Icon");
await closeIcon.click();
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const testName = (expect as any).getState().currentTestName;
await page.screenshot({ path: `Test Failed ${testName}.jpg` });
throw error;
}
});
});

View File

@@ -3,8 +3,11 @@ import { Frame } from "puppeteer";
export async function login(connectionString: string): Promise<Frame> {
const prodUrl = process.env.DATA_EXPLORER_ENDPOINT;
page.goto(prodUrl, { waitUntil: "networkidle2" });
await page.goto(prodUrl);
if (process.env.PLATFORM === "Emulator") {
return page.mainFrame();
}
// log in with connection string
const handle = await page.waitForSelector("iframe");
const frame = await handle.contentFrame();

View File

@@ -19,6 +19,6 @@
"noEmit": true,
"types": ["jest"]
},
"include": ["./src/**/*"],
"include": ["./src/**/*", "./test/notebooks/testExplorer/**/*"],
"exclude": ["./src/**/__mocks__/**/*"]
}

View File

@@ -13,8 +13,10 @@
"./src/Common/ArrayHashMap.ts",
"./src/Common/Constants.ts",
"./src/Common/DeleteFeedback.ts",
"./src/Common/EnvironmentUtility.ts",
"./src/Common/HashMap.ts",
"./src/Common/HeadersUtility.ts",
"./src/Common/Logger.ts",
"./src/Common/MessageHandler.ts",
"./src/Common/MongoUtility.ts",
"./src/Common/ObjectCache.ts",
@@ -25,9 +27,11 @@
"./src/Contracts/DataModels.ts",
"./src/Contracts/Diagnostics.ts",
"./src/Contracts/ExplorerContracts.ts",
"./src/Contracts/SubscriptionType.ts",
"./src/Contracts/Versions.ts",
"./src/Controls/Heatmap/Heatmap.ts",
"./src/Controls/Heatmap/HeatmapDatatypes.ts",
"./src/DefaultAccountExperienceType.ts",
"./src/Definitions/globals.d.ts",
"./src/Definitions/html.d.ts",
"./src/Definitions/jquery-ui.d.ts",
@@ -37,6 +41,7 @@
"./src/Explorer/Controls/ErrorDisplayComponent/ErrorDisplayComponent.ts",
"./src/Explorer/Controls/GitHub/GitHubStyleConstants.ts",
"./src/Explorer/Controls/SmartUi/InputUtils.ts",
"./src/Explorer/Graph/GraphExplorerComponent/__mocks__/GremlinClient.ts",
"./src/Explorer/Notebook/FileSystemUtil.ts",
"./src/Explorer/Notebook/NTeractUtil.ts",
"./src/Explorer/Notebook/NotebookComponent/actions.ts",
@@ -49,6 +54,7 @@
"./src/Explorer/Panes/Tables/Validators/EntityPropertyNameValidator.ts",
"./src/Explorer/Panes/Tables/Validators/EntityPropertyValidationCommon.ts",
"./src/Explorer/Tables/Constants.ts",
"./src/Explorer/Tables/CqlUtilities.ts",
"./src/Explorer/Tables/QueryBuilder/DateTimeUtilities.ts",
"./src/Explorer/Tabs/TabComponents.ts",
"./src/GitHub/GitHubConnector.ts",
@@ -56,15 +62,23 @@
"./src/NotebookWorkspaceManager/NotebookWorkspaceResourceProviderMockClients.ts",
"./src/ReactDevTools.ts",
"./src/ResourceProvider/IResourceProviderClient.ts",
"./src/Shared/Constants.ts",
"./src/Shared/ExplorerSettings.ts",
"./src/Shared/PriceEstimateCalculator.ts",
"./src/Shared/StorageUtility.ts",
"./src/Shared/StringUtility.ts",
"./src/Shared/Telemetry/TelemetryConstants.ts",
"./src/Shared/Telemetry/TelemetryProcessor.ts",
"./src/Shared/appInsights.ts",
"./src/Terminal/JupyterLabAppFactory.ts",
"./src/UserContext.ts",
"./src/Utils/Base64Utils.ts",
"./src/Utils/BlobUtils.ts",
"./src/Utils/GitHubUtils.ts",
"./src/Utils/MessageValidation.ts",
"./src/Utils/OfferUtils.ts",
"./src/Utils/StringUtils.ts",
"./src/Utils/WindowUtils.ts",
"./src/Utils/arm/generatedClients/2020-04-01/types.ts",
"./src/quickstart.ts",
"./src/setupTests.ts",

View File

@@ -140,6 +140,11 @@ module.exports = function(env = {}, argv = {}) {
template: "src/hostedExplorer.html",
chunks: ["hostedExplorer"]
}),
new HtmlWebpackPlugin({
filename: "testExplorer.html",
template: "test/notebooks/testExplorer/testExplorer.html",
chunks: ["testExplorer"]
}),
new HtmlWebpackPlugin({
filename: "Heatmap.html",
template: "src/Controls/Heatmap/Heatmap.html",
@@ -174,10 +179,11 @@ module.exports = function(env = {}, argv = {}) {
return {
mode: mode,
entry: {
main: "./src/Main.ts",
main: "./src/Main.tsx",
index: "./src/Index.ts",
quickstart: "./src/quickstart.ts",
hostedExplorer: "./src/HostedExplorer.ts",
testExplorer: "./test/notebooks/testExplorer/TestExplorer.ts",
heatmap: "./src/Controls/Heatmap/Heatmap.ts",
terminal: "./src/Terminal/index.ts",
notebookViewer: "./src/NotebookViewer/NotebookViewer.tsx",