Compare commits

..

40 Commits

Author SHA1 Message Date
Laurent Nguyen
77f895d343 Merge branch 'master' into replace-codemirror-with-monaco 2020-08-28 11:19:33 +02:00
Laurent Nguyen
92073a5646 Start kernel when opening notebook tab (#168)
* Add Auto-start kernel epic

* Replace deprecated rxjs empty() with EMPTY constant

* Fix format
2020-08-28 09:51:44 +02:00
Laurent Nguyen
3bc2701356 Format and fix type issues 2020-08-27 16:43:39 +02:00
Laurent Nguyen
35dbaeea96 Add code completion implementation 2020-08-27 15:52:38 +02:00
Laurent Nguyen
18745a9ae6 Merge branch 'master' into replace-codemirror-with-monaco 2020-08-27 09:13:52 +02:00
Jordi Bunster
5c84b3a7d4 Allow 'platform' only to be overriden (#167)
ConfigContext defines all kinds of URLs and what not, I'm not
sure about the security implications of allowing all this stuff to
be modifiable by just anyone.
2020-08-25 22:48:58 -07:00
victor-meng
3223ff7685 Move createDatabase to RP (#166) 2020-08-25 15:45:37 -07:00
Steve Faulkner
38732af907 Triple equal lint rule (#162) 2020-08-21 19:51:36 -05:00
Steve Faulkner
e837f574a8 Fix Telemetry for String Case (#163) 2020-08-21 14:38:30 -05:00
victor-meng
47a5c315b5 Move updateCollection to ARM (#152) 2020-08-21 11:24:01 -07:00
victor-meng
1c80ced259 Fix settings tab issue for cassandra and add feature flag (#159)
* Fix settings tab issue for cassandra and add feature flag

* Adding the rest of the changes....

Co-authored-by: Steve Faulkner <southpolesteve@gmail.com>
2020-08-19 18:11:43 -05:00
victor-meng
5e6ac78b7d Tables - ReadCollection: Switch back to using SDK (#157) 2020-08-19 17:54:23 -05:00
Steve Faulkner
999196193f Update Config context ARM endpoint (#158) 2020-08-19 13:20:10 -05:00
victor-meng
951289e190 Switch back to SDK for deleteDatabase and use RP for readCollections for table API (#155) 2020-08-18 11:04:37 -07:00
Tanuj Mittal
3279460cfd Update @azure/cosmos to 3.9.0 (#154) 2020-08-17 14:26:19 -07:00
victor-meng
07b9c1d1b7 Switch back to using SDK to read databases and collections for Mongo API (#153) 2020-08-17 11:42:02 -07:00
Tanuj Mittal
dde2ca75c4 Show submit button by default in GenericRightPaneComponent & other fixes (#144)
* Bug fixes

* Fix build
2020-08-17 11:39:11 -07:00
Laurent Nguyen
f44a3da568 Allow boolean partition key in graph visualization (#150)
* Allow boolean pk values to be displayed

* Add unit tests
2020-08-17 09:50:57 +02:00
Tanuj Mittal
22b2e1df48 Support empty offers for serverless accounts (#132)
* Add support for no offers when serverless accounts

* Add workaround for ignoring failed /offers when using connection string

* Revert @azure/cosmos upgrade
2020-08-14 17:45:13 -07:00
Steve Faulkner
2752d6af00 Create Cassandra Keyspace via ARM (#142) 2020-08-13 13:26:52 -05:00
Laurent Nguyen
5be6f982f9 Resolve merge conflict 2020-08-13 16:35:26 +02:00
Laurent Nguyen
4fc9393b76 Merge branch 'master' into replace-codemirror-with-monaco 2020-08-13 16:30:46 +02:00
Laurent Nguyen
cb5fe5316e Fix handling numeric partition keys (#113)
* Fix pk extraction from documentId in g.V() case

* Add unit test, cleanup pk related unit tests
2020-08-13 12:00:11 +02:00
Steve Faulkner
c0ce637eec Turn off HMR (#147) 2020-08-12 20:12:31 -05:00
victor-meng
b61a235bf6 Move readCollection, readCollections, and readDatabases to ARM (#134) 2020-08-12 17:13:43 -07:00
Steve Faulkner
0fa97c2ce9 Increase Cypress Timeout and Disable LiveReload in test (#145)
Co-authored-by: Tanuj Mittal <tamitta@microsoft.com>
2020-08-12 15:25:53 -05:00
victor-meng
fb71fb4e82 Refactor GenericRightPaneComponent to accept a ReactComponent as its content (#146) 2020-08-12 11:41:19 -07:00
victor-meng
455722c316 Fix deleteDatabase and deleteCollection with ARM (#143) 2020-08-11 18:36:42 -07:00
Tanuj Mittal
5886db81e9 Copy To functionality for notebooks (#141)
* Add Copy To functionality for notebooks

* Fix formatting

* Fix linting errors

* Fixes

* Fix build failure

* Rebase and address feedback

* Increase test coverage
2020-08-11 09:27:57 -07:00
Srinath Narayanan
7a3e54d43e Added support for acknowledging code of conduct for using public Notebook Gallery (#117)
* minro code edits

* Added support for acknowledging code of conduct

- Added CodeOfConduct component that shows links and a checkbox that must be acknwledged before seeing the public galley
- Added verbose message for notebook publish error (when another notebook with the same name exists in the gallery)
- Added a feature flag for enabling code of conduct acknowledgement

* Added Info Component

* minor edit

* fixed failign tests

* publish tab displayed only when code of conduct accepted

* added code of conduct fetch during publish

* fixed bug

* added test and addressed PR comments

* changed line endings

* added comment

* addressed PR comments
2020-08-11 00:37:05 -07:00
Steve Faulkner
3051961093 Add subscriptionId and authType to telemetry (#140) 2020-08-10 18:43:45 -05:00
Vignesh Rangaishenvi
abce15a6b2 Add init message when warming up notebook workspace (#139)
* Add init message

* Address comments
2020-08-10 15:02:24 -07:00
Steve Faulkner
a5b824ebb5 Fix master compile error 2020-08-10 12:02:18 -05:00
victor-meng
e28765d740 Add null check when reading offerAutopilotSettings (#138) 2020-08-10 11:55:43 -05:00
Srinath Narayanan
95f1efc03f Added support for notebook viewer link injection (#124)
* Added support for notebook viewer link injection

* updated tests
2020-08-10 01:53:51 -07:00
Steve Faulkner
455a6ac81b Fix update offer beyond throughput limit error (#135) 2020-08-06 18:15:40 -05:00
Vignesh Rangaishenvi
08ee86ecf1 Fix connection string renew token pane (#136)
* Fix IcM issue + conn string parsing

* format code

* Undo fix for IcM issue
2020-08-06 16:15:31 -07:00
Steve Faulkner
0011007d5f Refactor Global state into Context Files (#128) 2020-08-06 14:03:46 -05:00
Laurent Nguyen
ee51e873b8 Fix build issues 2020-07-13 13:52:23 +02:00
Laurent Nguyen
206a8ef93b New Monaco Editor 2020-07-13 13:52:05 +02:00
143 changed files with 5090 additions and 2280 deletions

View File

@@ -41,6 +41,7 @@ module.exports = {
"@typescript-eslint/no-extraneous-class": "error",
"no-null/no-null": "error",
"@typescript-eslint/no-explicit-any": "error",
"prefer-arrow/prefer-arrow-functions": ["error", { allowStandaloneDeclarations: true }]
"prefer-arrow/prefer-arrow-functions": ["error", { allowStandaloneDeclarations: true }],
eqeqeq: "error"
}
};

View File

@@ -134,6 +134,11 @@ jobs:
NODE_TLS_REJECT_UNAUTHORIZED: 0
CYPRESS_CACHE_FOLDER: ~/.cache/Cypress
CYPRESS_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_SQL }}
- uses: actions/upload-artifact@v2
name: videos
if: ${{ failure() }}
with:
path: "**/*.mp4"
endtoendmongo:
name: "End To End Tests | Mongo"
needs: [lint, format, compile, unittest]
@@ -163,6 +168,11 @@ jobs:
NODE_TLS_REJECT_UNAUTHORIZED: 0
CYPRESS_CACHE_FOLDER: ~/.cache/Cypress
CYPRESS_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_MONGO }}
- uses: actions/upload-artifact@v2
if: ${{ failure() }}
name: videos
with:
path: "**/*.mp4"
accessibility:
name: "Accessibility | Hosted"
needs: [lint, format, compile, unittest]

View File

@@ -3,7 +3,7 @@
"pluginsFile": false,
"fixturesFolder": false,
"supportFile": "./support/index.js",
"defaultCommandTimeout": 60000,
"defaultCommandTimeout": 90000,
"chromeWebSecurity": false,
"reporter": "mochawesome",
"reporterOptions": {

View File

@@ -6,7 +6,7 @@
"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 --headless --spec \"./integration/dataexplorer/SQL/*\"",
"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 chrome --headless",
"test:debug": "cypress open"
},

42
package-lock.json generated
View File

@@ -5,13 +5,14 @@
"requires": true,
"dependencies": {
"@azure/cosmos": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-3.7.4.tgz",
"integrity": "sha512-IbSEadapQDajSCXj7gUc8OklkOd/oAY4w7XBLHouWc4iKQTtntb2DmGjhrbh2W5Ku+pmBSr1GTApCjQ55iIjlQ==",
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-3.9.0.tgz",
"integrity": "sha512-SA+QB54I8Dvg/ZolHpsEDLK/sbSB9sFmSU1ElnMTFw88TVik+LYHq4o/srU2TY6Gr1BketjPmgLVEqrmnRvjkw==",
"requires": {
"@types/debug": "^4.1.4",
"debug": "^4.1.1",
"fast-json-stable-stringify": "^2.0.0",
"jsbi": "^3.1.3",
"node-abort-controller": "^1.0.4",
"node-fetch": "^2.6.0",
"os-name": "^3.1.0",
@@ -22,14 +23,14 @@
},
"dependencies": {
"tslib": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz",
"integrity": "sha512-lTqkx847PI7xEDYJntxZH89L2/aXInsyF2luSafe/+0fHOMjlBNXdH6th7f70qxLDhul7KZK0zC8V5ZIyHl0/g=="
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz",
"integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ=="
},
"uuid": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.2.0.tgz",
"integrity": "sha512-CYpGiFTUrmI6OBMkAdjSDM0k5h8SkkiTP4WAjQgDgNB1S3Ou9VBEvr6q0Kv2H1mMk7IWfxYGpMH5sd5AvcIV2Q=="
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz",
"integrity": "sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ=="
}
}
},
@@ -10132,9 +10133,9 @@
"dev": true
},
"canvas": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/canvas/-/canvas-2.6.0.tgz",
"integrity": "sha512-bEO9f1ThmbknLPxCa8Es7obPlN9W3stB1bo7njlhOFKIdUTldeTqXCh9YclCPAi2pSQs84XA0jq/QEZXSzgyMw==",
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/canvas/-/canvas-2.6.1.tgz",
"integrity": "sha512-S98rKsPcuhfTcYbtF53UIJhcbgIAK533d1kJKMwsMwAIFgfd58MOyxRud3kktlzWiEkFliaJtvyZCBtud/XVEA==",
"requires": {
"nan": "^2.14.0",
"node-pre-gyp": "^0.11.0",
@@ -20204,6 +20205,11 @@
"esprima": "^4.0.0"
}
},
"jsbi": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-3.1.3.tgz",
"integrity": "sha512-nBJqA0C6Qns+ZxurbEoIR56wyjiUszpNy70FHvxO5ervMoCbZVE3z3kxr5nKGhlxr/9MhKTSUBs7cAwwuf3g9w=="
},
"jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
@@ -21534,9 +21540,9 @@
}
},
"needle": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/needle/-/needle-2.4.1.tgz",
"integrity": "sha512-x/gi6ijr4B7fwl6WYL9FwlCvRQKGlUNvnceho8wxkwXqN8jvVmmmATTmZPRRG7b/yC1eode26C2HO9jl78Du9g==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/needle/-/needle-2.5.0.tgz",
"integrity": "sha512-o/qITSDR0JCyCKEQ1/1bnUXMmznxabbwi/Y4WwJElf+evwJNFNwIDMCCt5IigFVxgeGBJESLohGtIS9gEzo1fA==",
"requires": {
"debug": "^3.2.6",
"iconv-lite": "^0.4.4",
@@ -24630,9 +24636,9 @@
"integrity": "sha1-ZfDBWZNSs1Ny7KrlolDmEHN27Wk="
},
"simple-concat": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.0.tgz",
"integrity": "sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY="
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="
},
"simple-get": {
"version": "3.1.0",

View File

@@ -4,7 +4,7 @@
"description": "Cosmos Explorer",
"main": "index.js",
"dependencies": {
"@azure/cosmos": "3.7.4",
"@azure/cosmos": "3.9.0",
"@azure/cosmos-language-service": "0.0.4",
"@jupyterlab/services": "4.2.0",
"@jupyterlab/terminal": "1.2.1",
@@ -42,7 +42,7 @@
"applicationinsights": "1.8.0",
"babel-polyfill": "6.26.0",
"bootstrap": "3.4.1",
"canvas": "2.6.0",
"canvas": "2.6.1",
"clean-webpack-plugin": "0.1.19",
"copy-webpack-plugin": "6.0.2",
"crossroads": "0.12.2",

View File

@@ -1,5 +1,5 @@
import { AutopilotTier } from "../Contracts/DataModels";
import { config } from "../Config";
import { configContext } from "../ConfigContext";
import { HashMap } from "./HashMap";
export class AuthorizationEndpoints {
@@ -7,14 +7,23 @@ export class AuthorizationEndpoints {
public static common: string = "https://login.windows.net/";
}
export class CodeOfConductEndpoints {
public static privacyStatement: string = "https://aka.ms/ms-privacy-policy";
public static codeOfConduct: string = "https://aka.ms/cosmos-code-of-conduct";
public static termsOfUse: string = "https://aka.ms/ms-terms-of-use";
}
export class BackendEndpoints {
public static localhost: string = "https://localhost:12900";
public static dev: string = "https://ext.documents-dev.windows-int.net";
public static productionPortal: string = config.BACKEND_ENDPOINT || "https://main.documentdb.ext.azure.com";
public static productionPortal: string = configContext.BACKEND_ENDPOINT || "https://main.documentdb.ext.azure.com";
}
export class EndpointsRegex {
public static readonly cassandra = "AccountEndpoint=(.*).cassandra.cosmosdb.azure.com";
public static readonly cassandra = [
"AccountEndpoint=(.*).cassandra.cosmosdb.azure.com",
"HostName=(.*).cassandra.cosmos.azure.com"
];
public static readonly mongo = "mongodb://.*:(.*)@(.*).documents.azure.com";
public static readonly mongoCompute = "mongodb://.*:(.*)@(.*).mongo.cosmos.azure.com";
public static readonly sql = "AccountEndpoint=https://(.*).documents.azure.com";
@@ -113,6 +122,8 @@ export class Features {
public static readonly enableTtl = "enablettl";
public static readonly enableNotebooks = "enablenotebooks";
public static readonly enableGalleryPublish = "enablegallerypublish";
public static readonly enableCodeOfConduct = "enablecodeofconduct";
public static readonly enableLinkInjection = "enablelinkinjection";
public static readonly enableSpark = "enablespark";
public static readonly livyEndpoint = "livyendpoint";
public static readonly notebookServerUrl = "notebookserverurl";
@@ -123,6 +134,7 @@ export class Features {
public static readonly enableAutoPilotV2 = "enableautopilotv2";
public static readonly ttl90Days = "ttl90days";
public static readonly enableRightPanelV2 = "enablerightpanelv2";
public static readonly enableSDKoperations = "enablesdkoperations";
}
export class AfecFeatures {

View File

@@ -1,6 +1,7 @@
import { CosmosClient, tokenProvider, endpoint, requestPlugin, getTokenFromAuthService } from "./CosmosClient";
import { ResourceType } from "@azure/cosmos/dist-esm/common/constants";
import { config, Platform } from "../Config";
import { configContext, Platform, updateConfigContext, resetConfigContext } from "../ConfigContext";
import { updateUserContext } from "../UserContext";
import { endpoint, getTokenFromAuthService, requestPlugin, tokenProvider } from "./CosmosClient";
describe("tokenProvider", () => {
const options = {
@@ -32,7 +33,9 @@ describe("tokenProvider", () => {
});
it("does not call the auth service if a master key is set", async () => {
CosmosClient.masterKey("foo");
updateUserContext({
masterKey: "foo"
});
await tokenProvider(options);
expect((window.fetch as any).mock.calls.length).toBe(0);
});
@@ -41,7 +44,7 @@ describe("tokenProvider", () => {
describe("getTokenFromAuthService", () => {
beforeEach(() => {
delete window.dataExplorer;
delete config.BACKEND_ENDPOINT;
resetConfigContext();
window.fetch = jest.fn().mockImplementation(() => {
return {
json: () => "{}",
@@ -64,7 +67,9 @@ describe("getTokenFromAuthService", () => {
});
it("builds the correct URL in dev", () => {
config.BACKEND_ENDPOINT = "https://localhost:1234";
updateConfigContext({
BACKEND_ENDPOINT: "https://localhost:1234"
});
getTokenFromAuthService("GET", "dbs", "foo");
expect(window.fetch).toHaveBeenCalledWith(
"https://localhost:1234/api/guest/runtimeproxy/authorizationTokens",
@@ -75,24 +80,28 @@ describe("getTokenFromAuthService", () => {
describe("endpoint", () => {
it("falls back to _databaseAccount", () => {
CosmosClient.databaseAccount({
id: "foo",
name: "foo",
location: "foo",
type: "foo",
kind: "foo",
tags: [],
properties: {
documentEndpoint: "bar",
gremlinEndpoint: "foo",
tableEndpoint: "foo",
cassandraEndpoint: "foo"
updateUserContext({
databaseAccount: {
id: "foo",
name: "foo",
location: "foo",
type: "foo",
kind: "foo",
tags: [],
properties: {
documentEndpoint: "bar",
gremlinEndpoint: "foo",
tableEndpoint: "foo",
cassandraEndpoint: "foo"
}
}
});
expect(endpoint()).toEqual("bar");
});
it("uses _endpoint if set", () => {
CosmosClient.endpoint("baz");
updateUserContext({
endpoint: "baz"
});
expect(endpoint()).toEqual("baz");
});
});
@@ -100,17 +109,17 @@ describe("endpoint", () => {
describe("requestPlugin", () => {
beforeEach(() => {
delete window.dataExplorerPlatform;
delete config.PROXY_PATH;
delete config.BACKEND_ENDPOINT;
delete config.PROXY_PATH;
resetConfigContext();
});
describe("Hosted", () => {
it("builds a proxy URL in development", () => {
const next = jest.fn();
config.platform = Platform.Hosted;
config.BACKEND_ENDPOINT = "https://localhost:1234";
config.PROXY_PATH = "/proxy";
updateConfigContext({
platform: Platform.Hosted,
BACKEND_ENDPOINT: "https://localhost:1234",
PROXY_PATH: "/proxy"
});
const headers = {};
const endpoint = "https://docs.azure.com";
const path = "/dbs/foo";
@@ -122,8 +131,7 @@ describe("requestPlugin", () => {
describe("Emulator", () => {
it("builds a url for emulator proxy via webpack", () => {
const next = jest.fn();
config.platform = Platform.Emulator;
config.PROXY_PATH = "/proxy";
updateConfigContext({ platform: Platform.Emulator, PROXY_PATH: "/proxy" });
const headers = {};
const endpoint = "";
const path = "/dbs/foo";

View File

@@ -1,39 +1,28 @@
import * as Cosmos from "@azure/cosmos";
import { RequestInfo, setAuthorizationTokenHeaderUsingMasterKey } from "@azure/cosmos";
import { DatabaseAccount } from "../Contracts/DataModels";
import { HttpHeaders, EmulatorMasterKey } from "./Constants";
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import { config, Platform } from "../Config";
let _client: Cosmos.CosmosClient;
let _masterKey: string;
let _endpoint: string;
let _authorizationToken: string;
let _accessToken: string;
let _databaseAccount: DatabaseAccount;
let _subscriptionId: string;
let _resourceGroup: string;
let _resourceToken: string;
import { configContext, Platform } from "../ConfigContext";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
import { userContext } from "../UserContext";
const _global = typeof self === "undefined" ? window : self;
export const tokenProvider = async (requestInfo: RequestInfo) => {
const { verb, resourceId, resourceType, headers } = requestInfo;
if (config.platform === Platform.Emulator) {
if (configContext.platform === Platform.Emulator) {
// TODO This SDK method mutates the headers object. Find a better one or fix the SDK.
await setAuthorizationTokenHeaderUsingMasterKey(verb, resourceId, resourceType, headers, EmulatorMasterKey);
return decodeURIComponent(headers.authorization);
}
if (_masterKey) {
if (userContext.masterKey) {
// TODO This SDK method mutates the headers object. Find a better one or fix the SDK.
await setAuthorizationTokenHeaderUsingMasterKey(verb, resourceId, resourceType, headers, EmulatorMasterKey);
return decodeURIComponent(headers.authorization);
}
if (_resourceToken) {
return _resourceToken;
if (userContext.resourceToken) {
return userContext.resourceToken;
}
const result = await getTokenFromAuthService(verb, resourceType, resourceId);
@@ -42,28 +31,33 @@ export const tokenProvider = async (requestInfo: RequestInfo) => {
};
export const requestPlugin: Cosmos.Plugin<any> = async (requestContext, next) => {
requestContext.endpoint = config.PROXY_PATH;
requestContext.endpoint = configContext.PROXY_PATH;
requestContext.headers["x-ms-proxy-target"] = endpoint();
return next(requestContext);
};
export const endpoint = () => {
if (config.platform === Platform.Emulator) {
if (configContext.platform === Platform.Emulator) {
// In worker scope, _global(self).parent does not exist
const location = _global.parent ? _global.parent.location : _global.location;
return config.EMULATOR_ENDPOINT || location.origin;
return configContext.EMULATOR_ENDPOINT || location.origin;
}
return _endpoint || (_databaseAccount && _databaseAccount.properties && _databaseAccount.properties.documentEndpoint);
return (
userContext.endpoint ||
(userContext.databaseAccount &&
userContext.databaseAccount.properties &&
userContext.databaseAccount.properties.documentEndpoint)
);
};
export async function getTokenFromAuthService(verb: string, resourceType: string, resourceId?: string): Promise<any> {
try {
const host = config.BACKEND_ENDPOINT || _global.dataExplorer.extensionEndpoint();
const host = configContext.BACKEND_ENDPOINT || _global.dataExplorer.extensionEndpoint();
const response = await _global.fetch(host + "/api/guest/runtimeproxy/authorizationTokens", {
method: "POST",
headers: {
"content-type": "application/json",
"x-ms-encrypted-auth-token": _accessToken
"x-ms-encrypted-auth-token": userContext.accessToken
},
body: JSON.stringify({
verb,
@@ -75,106 +69,25 @@ export async function getTokenFromAuthService(verb: string, resourceType: string
const result = JSON.parse(await response.json());
return result;
} catch (error) {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Failed to get authorization headers for ${resourceType}: ${JSON.stringify(error)}`
);
logConsoleError(`Failed to get authorization headers for ${resourceType}: ${JSON.stringify(error)}`);
return Promise.reject(error);
}
}
export const CosmosClient = {
client(): Cosmos.CosmosClient {
if (_client) {
return _client;
}
const options: Cosmos.CosmosClientOptions = {
endpoint: endpoint() || " ", // CosmosClient gets upset if we pass a falsy value here
key: _masterKey,
tokenProvider,
connectionPolicy: {
enableEndpointDiscovery: false
},
userAgentSuffix: "Azure Portal"
};
export function client(): Cosmos.CosmosClient {
const options: Cosmos.CosmosClientOptions = {
endpoint: endpoint() || " ", // CosmosClient gets upset if we pass a falsy value here
key: userContext.masterKey,
tokenProvider,
connectionPolicy: {
enableEndpointDiscovery: false
},
userAgentSuffix: "Azure Portal"
};
// In development we proxy requests to the backend via webpack. This is removed in production bundles.
if (process.env.NODE_ENV === "development") {
(options as any).plugins = [{ on: "request", plugin: requestPlugin }];
}
_client = new Cosmos.CosmosClient(options);
return _client;
},
authorizationToken(value?: string): string {
if (typeof value === "undefined") {
return _authorizationToken;
}
_authorizationToken = value;
_client = null;
return value;
},
accessToken(value?: string): string {
if (typeof value === "undefined") {
return _accessToken;
}
_accessToken = value;
_client = null;
return value;
},
masterKey(value?: string): string {
if (typeof value === "undefined") {
return _masterKey;
}
_client = null;
_masterKey = value;
return value;
},
endpoint(value?: string): string {
if (typeof value === "undefined") {
return _endpoint;
}
_client = null;
_endpoint = value;
return value;
},
databaseAccount(value?: DatabaseAccount): DatabaseAccount {
if (typeof value === "undefined") {
return _databaseAccount || ({} as any);
}
_client = null;
_databaseAccount = value;
return value;
},
subscriptionId(value?: string): string {
if (typeof value === "undefined") {
return _subscriptionId;
}
_client = null;
_subscriptionId = value;
return value;
},
resourceGroup(value?: string): string {
if (typeof value === "undefined") {
return _resourceGroup;
}
_client = null;
_resourceGroup = value;
return value;
},
resourceToken(value?: string): string {
if (typeof value === "undefined") {
return _resourceToken;
}
_client = null;
_resourceToken = value;
return value;
// In development we proxy requests to the backend via webpack. This is removed in production bundles.
if (process.env.NODE_ENV === "development") {
(options as any).plugins = [{ on: "request", plugin: requestPlugin }];
}
};
return new Cosmos.CosmosClient(options);
}

View File

@@ -6,18 +6,16 @@ import * as ViewModels from "../Contracts/ViewModels";
import Q from "q";
import {
ConflictDefinition,
ContainerDefinition,
ContainerResponse,
DatabaseResponse,
FeedOptions,
ItemDefinition,
PartitionKeyDefinition,
QueryIterator,
Resource,
TriggerDefinition
TriggerDefinition,
OfferDefinition
} from "@azure/cosmos";
import { ContainerRequest } from "@azure/cosmos/dist-esm/client/Container/ContainerRequest";
import { CosmosClient } from "./CosmosClient";
import { client } from "./CosmosClient";
import { DatabaseRequest } from "@azure/cosmos/dist-esm/client/Database/DatabaseRequest";
import { LocalStorageUtility, StorageKey } from "../Shared/StorageUtility";
import { sendCachedDataMessage } from "./MessageHandler";
@@ -25,8 +23,7 @@ import { MessageTypes } from "../Contracts/ExplorerContracts";
import { OfferUtils } from "../Utils/OfferUtils";
import { RequestOptions } from "@azure/cosmos/dist-esm";
import StoredProcedure from "../Explorer/Tree/StoredProcedure";
import { Platform, config } from "../Config";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
import { Platform, configContext } from "../ConfigContext";
import DocumentId from "../Explorer/Tree/DocumentId";
import ConflictId from "../Explorer/Tree/ConflictId";
@@ -54,7 +51,7 @@ export function queryDocuments(
options: any
): Q.Promise<QueryIterator<ItemDefinition & Resource>> {
options = getCommonQueryOptions(options);
const documentsIterator = CosmosClient.client()
const documentsIterator = client()
.database(databaseId)
.container(containerId)
.items.query(query, options);
@@ -66,7 +63,7 @@ export function readStoredProcedures(
options?: any
): Q.Promise<DataModels.StoredProcedure[]> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.storedProcedures.readAll(options)
@@ -81,7 +78,7 @@ export function readStoredProcedure(
options?: any
): Q.Promise<DataModels.StoredProcedure> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.storedProcedure(requestedResource.id)
@@ -94,7 +91,7 @@ export function readUserDefinedFunctions(
options: any
): Q.Promise<DataModels.UserDefinedFunction[]> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.userDefinedFunctions.readAll(options)
@@ -108,7 +105,7 @@ export function readUserDefinedFunction(
options?: any
): Q.Promise<DataModels.UserDefinedFunction> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.userDefinedFunction(requestedResource.id)
@@ -119,7 +116,7 @@ export function readUserDefinedFunction(
export function readTriggers(collection: ViewModels.Collection, options: any): Q.Promise<DataModels.Trigger[]> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.triggers.readAll(options)
@@ -134,7 +131,7 @@ export function readTrigger(
options?: any
): Q.Promise<DataModels.Trigger> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.trigger(requestedResource.id)
@@ -152,7 +149,7 @@ export function executeStoredProcedure(
// TODO remove this deferred. Kept it because of timeout code at bottom of function
const deferred = Q.defer<any>();
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.storedProcedure(storedProcedure.id())
@@ -175,7 +172,7 @@ export function readDocument(collection: ViewModels.CollectionBase, documentId:
const partitionKey = documentId.partitionKeyValue;
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.item(documentId.id(), partitionKey)
@@ -203,23 +200,6 @@ export function getPartitionKeyHeader(partitionKeyDefinition: DataModels.Partiti
return [partitionKeyValue];
}
export function updateCollection(
databaseId: string,
collectionId: string,
newCollection: DataModels.Collection,
options: any = {}
): Q.Promise<DataModels.Collection> {
return Q(
CosmosClient.client()
.database(databaseId)
.container(collectionId)
.replace(newCollection as ContainerDefinition, options)
.then(async (response: ContainerResponse) => {
return refreshCachedResources().then(() => response.resource as DataModels.Collection);
})
);
}
export function updateDocument(
collection: ViewModels.CollectionBase,
documentId: DocumentId,
@@ -228,7 +208,7 @@ export function updateDocument(
const partitionKey = documentId.partitionKeyValue;
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.item(documentId.id(), partitionKey)
@@ -243,9 +223,10 @@ export function updateOffer(
options?: RequestOptions
): Q.Promise<DataModels.Offer> {
return Q(
CosmosClient.client()
client()
.offer(offer.id)
.replace(newOffer, options)
// TODO Remove casting when SDK types are fixed (https://github.com/Azure/azure-sdk-for-js/issues/10660)
.replace((newOffer as unknown) as OfferDefinition, options)
.then(response => {
return Promise.all([refreshCachedOffers(), refreshCachedResources()]).then(() => response.resource);
})
@@ -258,7 +239,7 @@ export function updateStoredProcedure(
options: any
): Q.Promise<DataModels.StoredProcedure> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.storedProcedure(storedProcedure.id)
@@ -273,7 +254,7 @@ export function updateUserDefinedFunction(
options?: any
): Q.Promise<DataModels.UserDefinedFunction> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.userDefinedFunction(userDefinedFunction.id)
@@ -288,7 +269,7 @@ export function updateTrigger(
options?: any
): Q.Promise<DataModels.Trigger> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.trigger(trigger.id)
@@ -299,7 +280,7 @@ export function updateTrigger(
export function createDocument(collection: ViewModels.CollectionBase, newDocument: any): Q.Promise<any> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.items.create(newDocument)
@@ -313,7 +294,7 @@ export function createStoredProcedure(
options?: any
): Q.Promise<DataModels.StoredProcedure> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.storedProcedures.create(newStoredProcedure, options)
@@ -327,7 +308,7 @@ export function createUserDefinedFunction(
options: any
): Q.Promise<DataModels.UserDefinedFunction> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.userDefinedFunctions.create(newUserDefinedFunction, options)
@@ -341,7 +322,7 @@ export function createTrigger(
options?: any
): Q.Promise<DataModels.Trigger> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.triggers.create(newTrigger as TriggerDefinition, options)
@@ -353,7 +334,7 @@ export function deleteDocument(collection: ViewModels.CollectionBase, documentId
const partitionKey = documentId.partitionKeyValue;
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.item(documentId.id(), partitionKey)
@@ -369,7 +350,7 @@ export function deleteConflict(
options.partitionKey = options.partitionKey || getPartitionKeyHeaderForConflict(conflictId);
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.conflict(conflictId.id())
@@ -383,7 +364,7 @@ export function deleteStoredProcedure(
options: any
): Q.Promise<any> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.storedProcedure(storedProcedure.id)
@@ -397,7 +378,7 @@ export function deleteUserDefinedFunction(
options: any
): Q.Promise<any> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.userDefinedFunction(userDefinedFunction.id)
@@ -411,7 +392,7 @@ export function deleteTrigger(
options: any
): Q.Promise<any> {
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.scripts.trigger(trigger.id)
@@ -419,26 +400,6 @@ export function deleteTrigger(
);
}
export function readCollections(database: ViewModels.Database, options: any): Q.Promise<DataModels.Collection[]> {
return Q(
CosmosClient.client()
.database(database.id())
.containers.readAll()
.fetchAll()
.then(response => response.resources as DataModels.Collection[])
);
}
export function readCollection(databaseId: string, collectionId: string): Q.Promise<DataModels.Collection> {
return Q(
CosmosClient.client()
.database(databaseId)
.container(collectionId)
.read()
.then(response => response.resource as DataModels.Collection)
);
}
export function readCollectionQuotaInfo(
collection: ViewModels.Collection,
options: any
@@ -449,7 +410,7 @@ export function readCollectionQuotaInfo(
options.initialHeaders[Constants.HttpHeaders.populatePartitionStatistics] = true;
return Q(
CosmosClient.client()
client()
.database(collection.databaseId)
.container(collection.id())
.read(options)
@@ -475,8 +436,12 @@ export function readCollectionQuotaInfo(
}
export function readOffers(options: any): Q.Promise<DataModels.Offer[]> {
if (options.isServerless) {
return Q([]); // Reading offers is not supported for serverless accounts
}
try {
if (config.platform === Platform.Portal) {
if (configContext.platform === Platform.Portal) {
return sendCachedDataMessage<DataModels.Offer[]>(MessageTypes.AllOffers, [
(<any>window).dataExplorer.databaseAccount().id,
Constants.ClientDefaults.portalCacheTimeoutMs
@@ -486,10 +451,17 @@ export function readOffers(options: any): Q.Promise<DataModels.Offer[]> {
// If error getting cached Offers, continue on and read via SDK
}
return Q(
CosmosClient.client()
client()
.offers.readAll()
.fetchAll()
.then(response => response.resources)
.catch(error => {
// This should be removed when we can correctly identify if an account is serverless when connected using connection string too.
if (error.message.includes("Reading or replacing offers is not supported for serverless accounts")) {
return [];
}
throw error;
})
);
}
@@ -501,33 +473,13 @@ export function readOffer(requestedResource: DataModels.Offer, options: any): Q.
}
return Q(
CosmosClient.client()
client()
.offer(requestedResource.id)
.read(options)
.then(response => ({ ...response.resource, headers: response.headers }))
);
}
export function readDatabases(options: any): Q.Promise<DataModels.Database[]> {
try {
if (config.platform === Platform.Portal) {
return sendCachedDataMessage<DataModels.Database[]>(MessageTypes.AllDatabases, [
(<any>window).dataExplorer.databaseAccount().id,
Constants.ClientDefaults.portalCacheTimeoutMs
]);
}
} catch (error) {
// If error getting cached DBs, continue on and read via SDK
}
return Q(
CosmosClient.client()
.databases.readAll()
.fetchAll()
.then(response => response.resources as DataModels.Database[])
);
}
export function getOrCreateDatabaseAndCollection(
request: DataModels.CreateDatabaseAndCollectionRequest,
options: any
@@ -569,7 +521,7 @@ export function getOrCreateDatabaseAndCollection(
}
return Q(
CosmosClient.client()
client()
.databases.createIfNotExists(createBody, databaseOptions)
.then(response => {
return response.database.containers.create(
@@ -591,28 +543,8 @@ export function getOrCreateDatabaseAndCollection(
);
}
export function createDatabase(
request: DataModels.CreateDatabaseRequest,
options: any
): Q.Promise<DataModels.Database> {
var deferred = Q.defer<DataModels.Database>();
_createDatabase(request, options).then(
(createdDatabase: DataModels.Database) => {
refreshCachedOffers().then(() => {
deferred.resolve(createdDatabase);
});
},
_createDatabaseError => {
deferred.reject(_createDatabaseError);
}
);
return deferred.promise;
}
export function refreshCachedOffers(): Q.Promise<void> {
if (config.platform === Platform.Portal) {
if (configContext.platform === Platform.Portal) {
return sendCachedDataMessage(MessageTypes.RefreshOffers, []);
} else {
return Q();
@@ -620,7 +552,7 @@ export function refreshCachedOffers(): Q.Promise<void> {
}
export function refreshCachedResources(options?: any): Q.Promise<void> {
if (config.platform === Platform.Portal) {
if (configContext.platform === Platform.Portal) {
return sendCachedDataMessage(MessageTypes.RefreshResources, []);
} else {
return Q();
@@ -633,62 +565,9 @@ export function queryConflicts(
query: string,
options: any
): Q.Promise<QueryIterator<ConflictDefinition & Resource>> {
const documentsIterator = CosmosClient.client()
const documentsIterator = client()
.database(databaseId)
.container(containerId)
.conflicts.query(query, options);
return Q(documentsIterator);
}
export async function updateOfferThroughputBeyondLimit(
request: DataModels.UpdateOfferThroughputRequest
): Promise<void> {
if (config.platform !== Platform.Portal) {
throw new Error("Updating throughput beyond specified limit is not supported on this platform");
}
const explorer = window.dataExplorer;
const url = `${explorer.extensionEndpoint()}/api/offerthroughputrequest/updatebeyondspecifiedlimit`;
const authorizationHeader = getAuthorizationHeader();
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(request),
headers: { [authorizationHeader.header]: authorizationHeader.token }
});
if (response.ok) {
return undefined;
}
throw new Error(await response.text());
}
function _createDatabase(request: DataModels.CreateDatabaseRequest, options: any = {}): Q.Promise<DataModels.Database> {
const { databaseId, databaseLevelThroughput, offerThroughput, autoPilot, hasAutoPilotV2FeatureFlag } = request;
const createBody: DatabaseRequest = { id: databaseId };
const databaseOptions: any = options && _.omit(options, "sharedOfferThroughput");
// TODO: replace when SDK support autopilot
const initialHeaders = autoPilot
? !hasAutoPilotV2FeatureFlag
? {
[Constants.HttpHeaders.autoPilotThroughputSDK]: JSON.stringify({ maxThroughput: autoPilot.maxThroughput })
}
: {
[Constants.HttpHeaders.autoPilotTier]: autoPilot.autopilotTier
}
: undefined;
if (!!databaseLevelThroughput) {
if (autoPilot) {
databaseOptions.initialHeaders = initialHeaders;
}
createBody.throughput = offerThroughput;
}
return Q(
CosmosClient.client()
.databases.create(createBody, databaseOptions)
.then((response: DatabaseResponse) => {
return refreshCachedResources(databaseOptions).then(() => response.resource);
})
);
}

View File

@@ -266,42 +266,6 @@ export function readDocument(collection: ViewModels.CollectionBase, documentId:
return deferred.promise;
}
export function updateCollection(
databaseId: string,
collection: ViewModels.Collection,
newCollection: DataModels.Collection
): Q.Promise<DataModels.Collection> {
var deferred = Q.defer<any>();
const id = NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.InProgress,
`Updating container ${collection.id()}`
);
DataAccessUtilityBase.updateCollection(databaseId, collection.id(), newCollection)
.then(
(replacedCollection: DataModels.Collection) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Info,
`Successfully updated container ${collection.id()}`
);
deferred.resolve(replacedCollection);
},
(error: any) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Failed to update container ${collection.id()}: ${JSON.stringify(error)}`
);
Logger.logError(JSON.stringify(error), "UpdateCollection", error.code);
sendNotificationForError(error);
deferred.reject(error);
}
)
.finally(() => {
NotificationConsoleUtils.clearInProgressMessageWithId(id);
});
return deferred.promise;
}
export function updateDocument(
collection: ViewModels.CollectionBase,
documentId: DocumentId,
@@ -383,40 +347,6 @@ export function updateOffer(
return deferred.promise;
}
export function updateOfferThroughputBeyondLimit(
requestPayload: DataModels.UpdateOfferThroughputRequest
): Q.Promise<void> {
const deferred: Q.Deferred<void> = Q.defer<void>();
const resourceDescriptionInfo: string = requestPayload.collectionName
? `database ${requestPayload.databaseName} and container ${requestPayload.collectionName}`
: `database ${requestPayload.databaseName}`;
const id = NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.InProgress,
`Requesting increase in throughput to ${requestPayload.throughput} for ${resourceDescriptionInfo}`
);
DataAccessUtilityBase.updateOfferThroughputBeyondLimit(requestPayload)
.then(
() => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Info,
`Successfully requested an increase in throughput to ${requestPayload.throughput} for ${resourceDescriptionInfo}`
);
deferred.resolve();
},
(error: any) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Failed to request an increase in throughput for ${requestPayload.throughput}: ${JSON.stringify(error)}`
);
sendNotificationForError(error);
deferred.reject(error);
}
)
.finally(() => NotificationConsoleUtils.clearInProgressMessageWithId(id));
return deferred.promise.timeout(Constants.ClientDefaults.requestTimeoutMs);
}
export function updateStoredProcedure(
collection: ViewModels.Collection,
storedProcedure: DataModels.StoredProcedure,
@@ -840,63 +770,6 @@ export function refreshCachedOffers(): Q.Promise<void> {
return DataAccessUtilityBase.refreshCachedOffers();
}
export function readCollections(database: ViewModels.Database, options: any = {}): Q.Promise<DataModels.Collection[]> {
var deferred = Q.defer<DataModels.Collection[]>();
const id = NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.InProgress,
`Querying containers for database ${database.id()}`
);
DataAccessUtilityBase.readCollections(database, options)
.then(
(collections: DataModels.Collection[]) => {
deferred.resolve(collections);
},
(error: any) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error while querying containers for database ${database.id()}:\n ${JSON.stringify(error)}`
);
Logger.logError(JSON.stringify(error), "ReadCollections", error.code);
sendNotificationForError(error);
deferred.reject(error);
}
)
.finally(() => {
NotificationConsoleUtils.clearInProgressMessageWithId(id);
});
return deferred.promise;
}
export function readCollection(databaseId: string, collectionId: string): Q.Promise<DataModels.Collection> {
const deferred = Q.defer<DataModels.Collection>();
const id = NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.InProgress,
`Querying container ${collectionId}`
);
DataAccessUtilityBase.readCollection(databaseId, collectionId)
.then(
(collection: DataModels.Collection) => {
deferred.resolve(collection);
},
(error: any) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error while querying containers for database ${databaseId}:\n ${JSON.stringify(error)}`
);
Logger.logError(JSON.stringify(error), "ReadCollections", error.code);
sendNotificationForError(error);
deferred.reject(error);
}
)
.finally(() => {
NotificationConsoleUtils.clearInProgressMessageWithId(id);
});
return deferred.promise;
}
export function readCollectionQuotaInfo(
collection: ViewModels.Collection,
options?: any
@@ -984,31 +857,6 @@ export function readOffer(
return deferred.promise;
}
export function readDatabases(options: any): Q.Promise<DataModels.Database[]> {
var deferred = Q.defer<any>();
const id = NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, "Querying databases");
DataAccessUtilityBase.readDatabases(options)
.then(
(databases: DataModels.Database[]) => {
deferred.resolve(databases);
},
(error: any) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error while querying databases:\n ${JSON.stringify(error)}`
);
Logger.logError(JSON.stringify(error), "ReadDatabases", error.code);
sendNotificationForError(error);
deferred.reject(error);
}
)
.finally(() => {
NotificationConsoleUtils.clearInProgressMessageWithId(id);
});
return deferred.promise;
}
export function getOrCreateDatabaseAndCollection(
request: DataModels.CreateDatabaseAndCollectionRequest,
options: any = {}
@@ -1042,36 +890,3 @@ export function getOrCreateDatabaseAndCollection(
return deferred.promise;
}
export function createDatabase(
request: DataModels.CreateDatabaseRequest,
options: any = {}
): Q.Promise<DataModels.Database> {
const deferred: Q.Deferred<DataModels.Database> = Q.defer<DataModels.Database>();
const id = NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.InProgress,
`Creating a new database ${request.databaseId}`
);
DataAccessUtilityBase.createDatabase(request, options)
.then(
(database: DataModels.Database) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Info,
`Successfully created database ${request.databaseId}`
);
deferred.resolve(database);
},
(error: any) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error while creating database ${request.databaseId}:\n ${JSON.stringify(error)}`
);
sendNotificationForError(error);
deferred.reject(error);
}
)
.finally(() => NotificationConsoleUtils.clearInProgressMessageWithId(id));
return deferred.promise;
}

View File

@@ -1,69 +1,69 @@
import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
export function replaceKnownError(err: string): string {
if (
window.dataExplorer.subscriptionType() === ViewModels.SubscriptionType.Internal &&
err.indexOf("SharedOffer is Disabled for your account") >= 0
) {
return "Database throughput is not supported for internal subscriptions.";
} else if (err.indexOf("Partition key paths must contain only valid") >= 0) {
return "Partition key paths must contain only valid characters and not contain a trailing slash or wildcard character.";
}
return err;
}
export function parse(err: any): DataModels.ErrorDataModel[] {
try {
return _parse(err);
} catch (e) {
return [<DataModels.ErrorDataModel>{ message: JSON.stringify(err) }];
}
}
function _parse(err: any): DataModels.ErrorDataModel[] {
var normalizedErrors: DataModels.ErrorDataModel[] = [];
if (err.message && !err.code) {
normalizedErrors.push(err);
} else {
const innerErrors: any[] = _getInnerErrors(err.message);
normalizedErrors = innerErrors.map(innerError =>
typeof innerError === "string" ? { message: innerError } : innerError
);
}
return normalizedErrors;
}
function _getInnerErrors(message: string): any[] {
/*
The backend error message has an inner-message which is a stringified object.
For SQL errors, the "errors" property is an array of SqlErrorDataModel.
Example:
"Message: {"Errors":["Resource with specified id or name already exists"]}\r\nActivityId: 80005000008d40b6a, Request URI: /apps/19000c000c0a0005/services/mctestdocdbprod-MasterService-0-00066ab9937/partitions/900005f9000e676fb8/replicas/13000000000955p"
For non-SQL errors the "Errors" propery is an array of string.
Example:
"Message: {"errors":[{"severity":"Error","location":{"start":7,"end":8},"code":"SC1001","message":"Syntax error, incorrect syntax near '.'."}]}\r\nActivityId: d3300016d4084e310a, Request URI: /apps/12401f9e1df77/services/dc100232b1f44545/partitions/f86f3bc0001a2f78/replicas/13085003638s"
*/
let innerMessage: any = null;
const singleLineMessage = message.replace(/[\r\n]|\r|\n/g, "");
try {
// Multi-Partition error flavor
const regExp = /^(.*)ActivityId: (.*)/g;
const regString = regExp.exec(singleLineMessage);
const innerMessageString = regString[1];
innerMessage = JSON.parse(innerMessageString);
} catch (e) {
// Single-partition error flavor
const regExp = /^Message: (.*)ActivityId: (.*), Request URI: (.*)/g;
const regString = regExp.exec(singleLineMessage);
const innerMessageString = regString[1];
innerMessage = JSON.parse(innerMessageString);
}
return innerMessage.errors ? innerMessage.errors : innerMessage.Errors;
}
import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
export function replaceKnownError(err: string): string {
if (
window.dataExplorer.subscriptionType() === ViewModels.SubscriptionType.Internal &&
err.indexOf("SharedOffer is Disabled for your account") >= 0
) {
return "Database throughput is not supported for internal subscriptions.";
} else if (err.indexOf("Partition key paths must contain only valid") >= 0) {
return "Partition key paths must contain only valid characters and not contain a trailing slash or wildcard character.";
}
return err;
}
export function parse(err: any): DataModels.ErrorDataModel[] {
try {
return _parse(err);
} catch (e) {
return [<DataModels.ErrorDataModel>{ message: JSON.stringify(err) }];
}
}
function _parse(err: any): DataModels.ErrorDataModel[] {
var normalizedErrors: DataModels.ErrorDataModel[] = [];
if (err.message && !err.code) {
normalizedErrors.push(err);
} else {
const innerErrors: any[] = _getInnerErrors(err.message);
normalizedErrors = innerErrors.map(innerError =>
typeof innerError === "string" ? { message: innerError } : innerError
);
}
return normalizedErrors;
}
function _getInnerErrors(message: string): any[] {
/*
The backend error message has an inner-message which is a stringified object.
For SQL errors, the "errors" property is an array of SqlErrorDataModel.
Example:
"Message: {"Errors":["Resource with specified id or name already exists"]}\r\nActivityId: 80005000008d40b6a, Request URI: /apps/19000c000c0a0005/services/mctestdocdbprod-MasterService-0-00066ab9937/partitions/900005f9000e676fb8/replicas/13000000000955p"
For non-SQL errors the "Errors" propery is an array of string.
Example:
"Message: {"errors":[{"severity":"Error","location":{"start":7,"end":8},"code":"SC1001","message":"Syntax error, incorrect syntax near '.'."}]}\r\nActivityId: d3300016d4084e310a, Request URI: /apps/12401f9e1df77/services/dc100232b1f44545/partitions/f86f3bc0001a2f78/replicas/13085003638s"
*/
let innerMessage: any = null;
const singleLineMessage = message.replace(/[\r\n]|\r|\n/g, "");
try {
// Multi-Partition error flavor
const regExp = /^(.*)ActivityId: (.*)/g;
const regString = regExp.exec(singleLineMessage);
const innerMessageString = regString[1];
innerMessage = JSON.parse(innerMessageString);
} catch (e) {
// Single-partition error flavor
const regExp = /^Message: (.*)ActivityId: (.*), Request URI: (.*)/g;
const regString = regExp.exec(singleLineMessage);
const innerMessageString = regString[1];
innerMessage = JSON.parse(innerMessageString);
}
return innerMessage.errors ? innerMessage.errors : innerMessage.Errors;
}

View File

@@ -1,73 +1,73 @@
import { MessageTypes } from "../Contracts/ExplorerContracts";
import Q from "q";
import * as _ from "underscore";
import * as Constants from "./Constants";
export interface CachedDataPromise<T> {
deferred: Q.Deferred<T>;
startTime: Date;
id: string;
}
export const RequestMap: Record<string, CachedDataPromise<any>> = {};
export function handleCachedDataMessage(message: any): void {
const messageContent = message && message.message;
if (message == null || messageContent == null || messageContent.id == null || !RequestMap[messageContent.id]) {
return;
}
const cachedDataPromise = RequestMap[messageContent.id];
if (messageContent.error != null) {
cachedDataPromise.deferred.reject(messageContent.error);
} else {
cachedDataPromise.deferred.resolve(JSON.parse(messageContent.data));
}
runGarbageCollector();
}
export function sendCachedDataMessage<TResponseDataModel>(
messageType: MessageTypes,
params: Object[],
timeoutInMs?: number
): Q.Promise<TResponseDataModel> {
let cachedDataPromise: CachedDataPromise<TResponseDataModel> = {
deferred: Q.defer<TResponseDataModel>(),
startTime: new Date(),
id: _.uniqueId()
};
RequestMap[cachedDataPromise.id] = cachedDataPromise;
sendMessage({ type: messageType, params: params, id: cachedDataPromise.id });
//TODO: Use telemetry to measure optimal time to resolve/reject promises
return cachedDataPromise.deferred.promise.timeout(
timeoutInMs || Constants.ClientDefaults.requestTimeoutMs,
"Timed out while waiting for response from portal"
);
}
export function sendMessage(data: any): void {
if (canSendMessage()) {
window.parent.postMessage(
{
signature: "pcIframe",
data: data
},
window.document.referrer
);
}
}
export function canSendMessage(): boolean {
return window.parent !== window;
}
// TODO: This is exported just for testing. It should not be.
export function runGarbageCollector() {
Object.keys(RequestMap).forEach((key: string) => {
const promise: Q.Promise<any> = RequestMap[key].deferred.promise;
if (promise.isFulfilled() || promise.isRejected()) {
delete RequestMap[key];
}
});
}
import { MessageTypes } from "../Contracts/ExplorerContracts";
import Q from "q";
import * as _ from "underscore";
import * as Constants from "./Constants";
export interface CachedDataPromise<T> {
deferred: Q.Deferred<T>;
startTime: Date;
id: string;
}
export const RequestMap: Record<string, CachedDataPromise<any>> = {};
export function handleCachedDataMessage(message: any): void {
const messageContent = message && message.message;
if (message == null || messageContent == null || messageContent.id == null || !RequestMap[messageContent.id]) {
return;
}
const cachedDataPromise = RequestMap[messageContent.id];
if (messageContent.error != null) {
cachedDataPromise.deferred.reject(messageContent.error);
} else {
cachedDataPromise.deferred.resolve(JSON.parse(messageContent.data));
}
runGarbageCollector();
}
export function sendCachedDataMessage<TResponseDataModel>(
messageType: MessageTypes,
params: Object[],
timeoutInMs?: number
): Q.Promise<TResponseDataModel> {
let cachedDataPromise: CachedDataPromise<TResponseDataModel> = {
deferred: Q.defer<TResponseDataModel>(),
startTime: new Date(),
id: _.uniqueId()
};
RequestMap[cachedDataPromise.id] = cachedDataPromise;
sendMessage({ type: messageType, params: params, id: cachedDataPromise.id });
//TODO: Use telemetry to measure optimal time to resolve/reject promises
return cachedDataPromise.deferred.promise.timeout(
timeoutInMs || Constants.ClientDefaults.requestTimeoutMs,
"Timed out while waiting for response from portal"
);
}
export function sendMessage(data: any): void {
if (canSendMessage()) {
window.parent.postMessage(
{
signature: "pcIframe",
data: data
},
window.document.referrer
);
}
}
export function canSendMessage(): boolean {
return window.parent !== window;
}
// TODO: This is exported just for testing. It should not be.
export function runGarbageCollector() {
Object.keys(RequestMap).forEach((key: string) => {
const promise: Q.Promise<any> = RequestMap[key].deferred.promise;
if (promise.isFulfilled() || promise.isRejected()) {
delete RequestMap[key];
}
});
}

View File

@@ -1,18 +1,18 @@
import { AuthType } from "../AuthType";
import { configContext, resetConfigContext, updateConfigContext } from "../ConfigContext";
import { DatabaseAccount } from "../Contracts/DataModels";
import { Collection } from "../Contracts/ViewModels";
import DocumentId from "../Explorer/Tree/DocumentId";
import { ResourceProviderClient } from "../ResourceProvider/ResourceProviderClient";
import { updateUserContext } from "../UserContext";
import {
_createMongoCollectionWithARM,
deleteDocument,
getEndpoint,
queryDocuments,
readDocument,
updateDocument
updateDocument,
_createMongoCollectionWithARM
} from "./MongoProxyClient";
import { AuthType } from "../AuthType";
import { Collection } from "../Contracts/ViewModels";
import { config } from "../Config";
import { CosmosClient } from "./CosmosClient";
import { ResourceProviderClient } from "../ResourceProvider/ResourceProviderClient";
import DocumentId from "../Explorer/Tree/DocumentId";
import { DatabaseAccount } from "../Contracts/DataModels";
jest.mock("../ResourceProvider/ResourceProviderClient.ts");
const databaseId = "testDB";
@@ -62,13 +62,15 @@ const databaseAccount = {
tableEndpoint: "foo",
cassandraEndpoint: "foo"
}
};
} as DatabaseAccount;
describe("MongoProxyClient", () => {
describe("queryDocuments", () => {
beforeEach(() => {
delete config.BACKEND_ENDPOINT;
CosmosClient.databaseAccount(databaseAccount as any);
resetConfigContext();
updateUserContext({
databaseAccount
});
window.dataExplorer = {
extensionEndpoint: () => "https://main.documentdb.ext.azure.com",
serverId: () => ""
@@ -88,7 +90,7 @@ describe("MongoProxyClient", () => {
});
it("builds the correct proxy URL in development", () => {
config.MONGO_BACKEND_ENDPOINT = "https://localhost:1234";
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
queryDocuments(databaseId, collection, true, "{}");
expect(window.fetch).toHaveBeenCalledWith(
"https://localhost:1234/api/mongo/explorer/resourcelist?db=testDB&coll=testCollection&resourceUrl=bardbs%2FtestDB%2Fcolls%2FtestCollection%2Fdocs%2F&rid=testCollectionrid&rtype=docs&sid=&rg=&dba=foo&pk=pk",
@@ -98,8 +100,10 @@ describe("MongoProxyClient", () => {
});
describe("readDocument", () => {
beforeEach(() => {
delete config.MONGO_BACKEND_ENDPOINT;
CosmosClient.databaseAccount(databaseAccount as any);
resetConfigContext();
updateUserContext({
databaseAccount
});
window.dataExplorer = {
extensionEndpoint: () => "https://main.documentdb.ext.azure.com",
serverId: () => ""
@@ -119,7 +123,7 @@ describe("MongoProxyClient", () => {
});
it("builds the correct proxy URL in development", () => {
config.MONGO_BACKEND_ENDPOINT = "https://localhost:1234";
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
"https://localhost:1234/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
@@ -129,8 +133,10 @@ describe("MongoProxyClient", () => {
});
describe("createDocument", () => {
beforeEach(() => {
delete config.MONGO_BACKEND_ENDPOINT;
CosmosClient.databaseAccount(databaseAccount as any);
resetConfigContext();
updateUserContext({
databaseAccount
});
window.dataExplorer = {
extensionEndpoint: () => "https://main.documentdb.ext.azure.com",
serverId: () => ""
@@ -150,7 +156,7 @@ describe("MongoProxyClient", () => {
});
it("builds the correct proxy URL in development", () => {
config.MONGO_BACKEND_ENDPOINT = "https://localhost:1234";
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
"https://localhost:1234/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
@@ -160,8 +166,10 @@ describe("MongoProxyClient", () => {
});
describe("updateDocument", () => {
beforeEach(() => {
delete config.MONGO_BACKEND_ENDPOINT;
CosmosClient.databaseAccount(databaseAccount as any);
resetConfigContext();
updateUserContext({
databaseAccount
});
window.dataExplorer = {
extensionEndpoint: () => "https://main.documentdb.ext.azure.com",
serverId: () => ""
@@ -181,7 +189,7 @@ describe("MongoProxyClient", () => {
});
it("builds the correct proxy URL in development", () => {
config.MONGO_BACKEND_ENDPOINT = "https://localhost:1234";
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
updateDocument(databaseId, collection, documentId, "{}");
expect(window.fetch).toHaveBeenCalledWith(
"https://localhost:1234/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2Fdocs%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
@@ -191,8 +199,10 @@ describe("MongoProxyClient", () => {
});
describe("deleteDocument", () => {
beforeEach(() => {
delete config.MONGO_BACKEND_ENDPOINT;
CosmosClient.databaseAccount(databaseAccount as any);
resetConfigContext();
updateUserContext({
databaseAccount
});
window.dataExplorer = {
extensionEndpoint: () => "https://main.documentdb.ext.azure.com",
serverId: () => ""
@@ -212,7 +222,7 @@ describe("MongoProxyClient", () => {
});
it("builds the correct proxy URL in development", () => {
config.MONGO_BACKEND_ENDPOINT = "https://localhost:1234";
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
deleteDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
"https://localhost:1234/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2Fdocs%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
@@ -222,9 +232,11 @@ describe("MongoProxyClient", () => {
});
describe("getEndpoint", () => {
beforeEach(() => {
delete config.MONGO_BACKEND_ENDPOINT;
resetConfigContext();
delete window.authType;
CosmosClient.databaseAccount(databaseAccount as any);
updateUserContext({
databaseAccount
});
window.dataExplorer = {
extensionEndpoint: () => "https://main.documentdb.ext.azure.com",
serverId: () => ""
@@ -237,7 +249,7 @@ describe("MongoProxyClient", () => {
});
it("returns a development endpoint", () => {
config.MONGO_BACKEND_ENDPOINT = "https://localhost:1234";
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
const endpoint = getEndpoint(databaseAccount as DatabaseAccount);
expect(endpoint).toEqual("https://localhost:1234/api/mongo/explorer");
});

View File

@@ -1,22 +1,22 @@
import { Constants as CosmosSDKConstants } from "@azure/cosmos";
import queryString from "querystring";
import { AuthType } from "../AuthType";
import * as Constants from "../Common/Constants";
import * as DataExplorerConstants from "../Common/Constants";
import { configContext } from "../ConfigContext";
import * as DataModels from "../Contracts/DataModels";
import EnvironmentUtility from "./EnvironmentUtility";
import queryString from "querystring";
import { AddDbUtilities } from "../Shared/AddDatabaseUtility";
import { ApiType, HttpHeaders, HttpStatusCodes } from "./Constants";
import { AuthType } from "../AuthType";
import { Collection } from "../Contracts/ViewModels";
import { config } from "../Config";
import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import { Constants as CosmosSDKConstants } from "@azure/cosmos";
import { CosmosClient } from "./CosmosClient";
import { sendMessage } from "./MessageHandler";
import { MessageTypes } from "../Contracts/ExplorerContracts";
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
import { ResourceProviderClient } from "../ResourceProvider/ResourceProviderClient";
import { MinimalQueryIterator } from "./IteratorUtilities";
import { Collection } from "../Contracts/ViewModels";
import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import DocumentId from "../Explorer/Tree/DocumentId";
import { ResourceProviderClient } from "../ResourceProvider/ResourceProviderClient";
import { AddDbUtilities } from "../Shared/AddDatabaseUtility";
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
import { ApiType, HttpHeaders, HttpStatusCodes } from "./Constants";
import { userContext } from "../UserContext";
import EnvironmentUtility from "./EnvironmentUtility";
import { MinimalQueryIterator } from "./IteratorUtilities";
import { sendMessage } from "./MessageHandler";
const defaultHeaders = {
[HttpHeaders.apiType]: ApiType.MongoDB.toString(),
@@ -26,9 +26,9 @@ const defaultHeaders = {
function authHeaders() {
if (window.authType === AuthType.EncryptedToken) {
return { [HttpHeaders.guestAccessToken]: CosmosClient.accessToken() };
return { [HttpHeaders.guestAccessToken]: userContext.accessToken };
} else {
return { [HttpHeaders.authorization]: CosmosClient.authorizationToken() };
return { [HttpHeaders.authorization]: userContext.authorizationToken };
}
}
@@ -67,7 +67,7 @@ export function queryDocuments(
query: string,
continuationToken?: string
): Promise<QueryResponse> {
const databaseAccount = CosmosClient.databaseAccount();
const databaseAccount = userContext.databaseAccount;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const params = {
db: databaseId,
@@ -75,8 +75,8 @@ export function queryDocuments(
resourceUrl: `${resourceEndpoint}dbs/${databaseId}/colls/${collection.id()}/docs/`,
rid: collection.rid,
rtype: "docs",
sid: CosmosClient.subscriptionId(),
rg: CosmosClient.resourceGroup(),
sid: userContext.subscriptionId,
rg: userContext.resourceGroup,
dba: databaseAccount.name,
pk:
collection && collection.partitionKey && !collection.partitionKey.systemKey ? collection.partitionKeyProperty : ""
@@ -125,7 +125,7 @@ export function readDocument(
collection: Collection,
documentId: DocumentId
): Promise<DataModels.DocumentId> {
const databaseAccount = CosmosClient.databaseAccount();
const databaseAccount = userContext.databaseAccount;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const idComponents = documentId.self.split("/");
const path = idComponents.slice(0, 4).join("/");
@@ -136,8 +136,8 @@ export function readDocument(
resourceUrl: `${resourceEndpoint}${path}/${rid}`,
rid,
rtype: "docs",
sid: CosmosClient.subscriptionId(),
rg: CosmosClient.resourceGroup(),
sid: userContext.subscriptionId,
rg: userContext.resourceGroup,
dba: databaseAccount.name,
pk:
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey ? documentId.partitionKeyProperty : ""
@@ -169,7 +169,7 @@ export function createDocument(
partitionKeyProperty: string,
documentContent: unknown
): Promise<DataModels.DocumentId> {
const databaseAccount = CosmosClient.databaseAccount();
const databaseAccount = userContext.databaseAccount;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const params = {
db: databaseId,
@@ -177,8 +177,8 @@ export function createDocument(
resourceUrl: `${resourceEndpoint}dbs/${databaseId}/colls/${collection.id()}/docs/`,
rid: collection.rid,
rtype: "docs",
sid: CosmosClient.subscriptionId(),
rg: CosmosClient.resourceGroup(),
sid: userContext.subscriptionId,
rg: userContext.resourceGroup,
dba: databaseAccount.name,
pk: collection && collection.partitionKey && !collection.partitionKey.systemKey ? partitionKeyProperty : ""
};
@@ -208,7 +208,7 @@ export function updateDocument(
documentId: DocumentId,
documentContent: string
): Promise<DataModels.DocumentId> {
const databaseAccount = CosmosClient.databaseAccount();
const databaseAccount = userContext.databaseAccount;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const idComponents = documentId.self.split("/");
const path = idComponents.slice(0, 5).join("/");
@@ -219,8 +219,8 @@ export function updateDocument(
resourceUrl: `${resourceEndpoint}${path}/${rid}`,
rid,
rtype: "docs",
sid: CosmosClient.subscriptionId(),
rg: CosmosClient.resourceGroup(),
sid: userContext.subscriptionId,
rg: userContext.resourceGroup,
dba: databaseAccount.name,
pk:
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey ? documentId.partitionKeyProperty : ""
@@ -247,7 +247,7 @@ export function updateDocument(
}
export function deleteDocument(databaseId: string, collection: Collection, documentId: DocumentId): Promise<void> {
const databaseAccount = CosmosClient.databaseAccount();
const databaseAccount = userContext.databaseAccount;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const idComponents = documentId.self.split("/");
const path = idComponents.slice(0, 5).join("/");
@@ -258,8 +258,8 @@ export function deleteDocument(databaseId: string, collection: Collection, docum
resourceUrl: `${resourceEndpoint}${path}/${rid}`,
rid,
rtype: "docs",
sid: CosmosClient.subscriptionId(),
rg: CosmosClient.resourceGroup(),
sid: userContext.subscriptionId,
rg: userContext.resourceGroup,
dba: databaseAccount.name,
pk:
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey ? documentId.partitionKeyProperty : ""
@@ -294,7 +294,7 @@ export function createMongoCollectionWithProxy(
isSharded: boolean,
autopilotOptions?: DataModels.RpOptions
): Promise<DataModels.Collection> {
const databaseAccount = CosmosClient.databaseAccount();
const databaseAccount = userContext.databaseAccount;
const params: DataModels.MongoParameters = {
resourceUrl: databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint,
db: databaseId,
@@ -306,8 +306,8 @@ export function createMongoCollectionWithProxy(
is: isSharded,
rid: "",
rtype: "colls",
sid: CosmosClient.subscriptionId(),
rg: CosmosClient.resourceGroup(),
sid: userContext.subscriptionId,
rg: userContext.resourceGroup,
dba: databaseAccount.name,
isAutoPilot: false
};
@@ -351,7 +351,7 @@ export function createMongoCollectionWithARM(
isSharded: boolean,
additionalOptions?: DataModels.RpOptions
): Promise<DataModels.CreateCollectionWithRpResponse> {
const databaseAccount = CosmosClient.databaseAccount();
const databaseAccount = userContext.databaseAccount;
const params: DataModels.MongoParameters = {
resourceUrl: databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint,
db: databaseId,
@@ -363,8 +363,8 @@ export function createMongoCollectionWithARM(
is: isSharded,
rid: "",
rtype: "colls",
sid: CosmosClient.subscriptionId(),
rg: CosmosClient.resourceGroup(),
sid: userContext.subscriptionId,
rg: userContext.resourceGroup,
dba: databaseAccount.name,
analyticalStorageTtl
};
@@ -384,8 +384,8 @@ export function createMongoCollectionWithARM(
export function getEndpoint(databaseAccount: DataModels.DatabaseAccount): string {
const serverId = window.dataExplorer.serverId();
const extensionEndpoint = window.dataExplorer.extensionEndpoint();
let url = config.MONGO_BACKEND_ENDPOINT
? config.MONGO_BACKEND_ENDPOINT + "/api/mongo/explorer"
let url = configContext.MONGO_BACKEND_ENDPOINT
? configContext.MONGO_BACKEND_ENDPOINT + "/api/mongo/explorer"
: EnvironmentUtility.getMongoBackendEndpoint(serverId, databaseAccount.location, extensionEndpoint);
if (window.authType === AuthType.EncryptedToken) {
@@ -411,9 +411,7 @@ async function errorHandling(response: Response, action: string, params: unknown
}
export function getARMCreateCollectionEndpoint(params: DataModels.MongoParameters): string {
return `subscriptions/${params.sid}/resourceGroups/${params.rg}/providers/Microsoft.DocumentDB/databaseAccounts/${
CosmosClient.databaseAccount().name
}/mongodbDatabases/${params.db}/collections/${params.coll}`;
return `subscriptions/${params.sid}/resourceGroups/${params.rg}/providers/Microsoft.DocumentDB/databaseAccounts/${userContext.databaseAccount.name}/mongodbDatabases/${params.db}/collections/${params.coll}`;
}
export async function _createMongoCollectionWithARM(

View File

@@ -2,9 +2,8 @@ import "jquery";
import * as Q from "q";
import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
import { CosmosClient } from "./CosmosClient";
import { userContext } from "../UserContext";
export class NotificationsClientBase {
private _extensionEndpoint: string;
@@ -16,10 +15,10 @@ export class NotificationsClientBase {
public fetchNotifications(): Q.Promise<DataModels.Notification[]> {
const deferred: Q.Deferred<DataModels.Notification[]> = Q.defer<DataModels.Notification[]>();
const databaseAccount = CosmosClient.databaseAccount();
const subscriptionId: string = CosmosClient.subscriptionId();
const resourceGroup: string = CosmosClient.resourceGroup();
const url: string = `${this._extensionEndpoint}${this._notificationsApiSuffix}?accountName=${databaseAccount.name}&subscriptionId=${subscriptionId}&resourceGroup=${resourceGroup}`;
const databaseAccount = userContext.databaseAccount;
const subscriptionId = userContext.subscriptionId;
const resourceGroup = userContext.resourceGroup;
const url = `${this._extensionEndpoint}${this._notificationsApiSuffix}?accountName=${databaseAccount.name}&subscriptionId=${subscriptionId}&resourceGroup=${resourceGroup}`;
const authorizationHeader: ViewModels.AuthorizationTokenHeaderMetadata = getAuthorizationHeader();
const headers: any = {};
headers[authorizationHeader.header] = authorizationHeader.token;

View File

@@ -1,24 +1,24 @@
import { ItemDefinition, QueryIterator, Resource } from "@azure/cosmos";
import * as _ from "underscore";
import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
import DocumentId from "../Explorer/Tree/DocumentId";
import * as ErrorParserUtility from "./ErrorParserUtility";
import { BackendDefaults, HttpStatusCodes, SavedQueries } from "./Constants";
import Explorer from "../Explorer/Explorer";
import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import { CosmosClient } from "./CosmosClient";
import { ItemDefinition, QueryIterator, Resource } from "@azure/cosmos";
import * as Logger from "./Logger";
import DocumentsTab from "../Explorer/Tabs/DocumentsTab";
import DocumentId from "../Explorer/Tree/DocumentId";
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
import { QueryUtils } from "../Utils/QueryUtils";
import Explorer from "../Explorer/Explorer";
import { BackendDefaults, HttpStatusCodes, SavedQueries } from "./Constants";
import { userContext } from "../UserContext";
import {
getOrCreateDatabaseAndCollection,
createDocument,
deleteDocument,
getOrCreateDatabaseAndCollection,
queryDocuments,
queryDocumentsPage,
deleteDocument
queryDocumentsPage
} from "./DocumentClientUtilityBase";
import DocumentsTab from "../Explorer/Tabs/DocumentsTab";
import * as ErrorParserUtility from "./ErrorParserUtility";
import * as Logger from "./Logger";
export class QueriesClient {
private static readonly PartitionKey: DataModels.PartitionKey = {
@@ -249,10 +249,10 @@ export class QueriesClient {
}
public getResourceId(): string {
const databaseAccount = CosmosClient.databaseAccount();
const databaseAccountName: string = (databaseAccount && databaseAccount.name) || "";
const subscriptionId: string = CosmosClient.subscriptionId() || "";
const resourceGroup: string = CosmosClient.resourceGroup() || "";
const databaseAccount = userContext.databaseAccount;
const databaseAccountName = (databaseAccount && databaseAccount.name) || "";
const subscriptionId = userContext.subscriptionId || "";
const resourceGroup = userContext.resourceGroup || "";
return `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDb/databaseAccounts/${databaseAccountName}`;
}

View File

@@ -0,0 +1,250 @@
import * as DataModels from "../../Contracts/DataModels";
import { AuthType } from "../../AuthType";
import { DatabaseResponse } from "@azure/cosmos";
import { DatabaseRequest } from "@azure/cosmos/dist-esm/client/Database/DatabaseRequest";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { RequestOptions } from "@azure/cosmos/dist-esm";
import {
SqlDatabaseCreateUpdateParameters,
CreateUpdateOptions
} from "../../Utils/arm/generatedClients/2020-04-01/types";
import { client } from "../CosmosClient";
import { createUpdateSqlDatabase, getSqlDatabase } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources";
import {
createUpdateCassandraKeyspace,
getCassandraKeyspace
} from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources";
import {
createUpdateMongoDBDatabase,
getMongoDBDatabase
} from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources";
import {
createUpdateGremlinDatabase,
getGremlinDatabase
} from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources";
import { logConsoleProgress, logConsoleError, logConsoleInfo } from "../../Utils/NotificationConsoleUtils";
import { logError } from "../Logger";
import { refreshCachedOffers, refreshCachedResources } from "../DataAccessUtilityBase";
import { sendNotificationForError } from "./sendNotificationForError";
import { userContext } from "../../UserContext";
export async function createDatabase(params: DataModels.CreateDatabaseParams): Promise<DataModels.Database> {
let database: DataModels.Database;
const clearMessage = logConsoleProgress(`Creating a new database ${params.databaseId}`);
try {
if (
window.authType === AuthType.AAD &&
!userContext.useSDKOperations &&
userContext.defaultExperience !== DefaultAccountExperienceType.Table
) {
database = await createDatabaseWithARM(params);
} else {
database = await createDatabaseWithSDK(params);
}
} catch (error) {
logConsoleError(`Error while creating database ${params.databaseId}:\n ${error.message}`);
logError(JSON.stringify(error), "CreateDatabase", error.code);
sendNotificationForError(error);
clearMessage();
throw error;
}
logConsoleInfo(`Successfully created database ${params.databaseId}`);
await refreshCachedResources();
await refreshCachedOffers();
clearMessage();
return database;
}
async function createDatabaseWithARM(params: DataModels.CreateDatabaseParams): Promise<DataModels.Database> {
const defaultExperience = userContext.defaultExperience;
switch (defaultExperience) {
case DefaultAccountExperienceType.DocumentDB:
return createSqlDatabase(params);
case DefaultAccountExperienceType.MongoDB:
return createMongoDatabase(params);
case DefaultAccountExperienceType.Cassandra:
return createCassandraKeyspace(params);
case DefaultAccountExperienceType.Graph:
return createGremlineDatabase(params);
default:
throw new Error(`Unsupported default experience type: ${defaultExperience}`);
}
}
async function createSqlDatabase(params: DataModels.CreateDatabaseParams): Promise<DataModels.Database> {
try {
const getResponse = await getSqlDatabase(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
params.databaseId
);
if (getResponse && getResponse.properties && getResponse.properties.resource) {
throw new Error(`Create database failed: database with id ${params.databaseId} already exists`);
}
} catch (error) {
if (error.code !== "NotFound") {
throw error;
}
}
const options: CreateUpdateOptions = constructRpOptions(params);
const rpPayload: SqlDatabaseCreateUpdateParameters = {
properties: {
resource: {
id: params.databaseId
},
options
}
};
const createResponse = await createUpdateSqlDatabase(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
params.databaseId,
rpPayload
);
return createResponse && (createResponse.properties.resource as DataModels.Database);
}
async function createMongoDatabase(params: DataModels.CreateDatabaseParams): Promise<DataModels.Database> {
try {
const getResponse = await getMongoDBDatabase(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
params.databaseId
);
if (getResponse && getResponse.properties && getResponse.properties.resource) {
throw new Error(`Create database failed: database with id ${params.databaseId} already exists`);
}
} catch (error) {
if (error.code !== "NotFound") {
throw error;
}
}
const options: CreateUpdateOptions = constructRpOptions(params);
const rpPayload: SqlDatabaseCreateUpdateParameters = {
properties: {
resource: {
id: params.databaseId
},
options
}
};
const createResponse = await createUpdateMongoDBDatabase(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
params.databaseId,
rpPayload
);
return createResponse && (createResponse.properties.resource as DataModels.Database);
}
async function createCassandraKeyspace(params: DataModels.CreateDatabaseParams): Promise<DataModels.Database> {
try {
const getResponse = await getCassandraKeyspace(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
params.databaseId
);
if (getResponse?.properties?.resource) {
throw new Error(`Create database failed: database with id ${params.databaseId} already exists`);
}
} catch (error) {
if (error.code !== "NotFound") {
throw error;
}
}
const options: CreateUpdateOptions = constructRpOptions(params);
const rpPayload: SqlDatabaseCreateUpdateParameters = {
properties: {
resource: {
id: params.databaseId
},
options
}
};
const createResponse = await createUpdateCassandraKeyspace(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
params.databaseId,
rpPayload
);
return createResponse && (createResponse.properties.resource as DataModels.Database);
}
async function createGremlineDatabase(params: DataModels.CreateDatabaseParams): Promise<DataModels.Database> {
try {
const getResponse = await getGremlinDatabase(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
params.databaseId
);
if (getResponse && getResponse.properties && getResponse.properties.resource) {
throw new Error(`Create database failed: database with id ${params.databaseId} already exists`);
}
} catch (error) {
if (error.code !== "NotFound") {
throw error;
}
}
const options: CreateUpdateOptions = constructRpOptions(params);
const rpPayload: SqlDatabaseCreateUpdateParameters = {
properties: {
resource: {
id: params.databaseId
},
options
}
};
const createResponse = await createUpdateGremlinDatabase(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
params.databaseId,
rpPayload
);
return createResponse && (createResponse.properties.resource as DataModels.Database);
}
async function createDatabaseWithSDK(params: DataModels.CreateDatabaseParams): Promise<DataModels.Database> {
const createBody: DatabaseRequest = { id: params.databaseId };
const databaseOptions: RequestOptions = {};
// TODO: replace when SDK support autopilot
if (params.databaseLevelThroughput) {
if (params.autoPilotMaxThroughput) {
createBody.maxThroughput = params.autoPilotMaxThroughput;
} else {
createBody.throughput = params.offerThroughput;
}
}
const response: DatabaseResponse = await client().databases.create(createBody, databaseOptions);
return response.resource;
}
function constructRpOptions(params: DataModels.CreateDatabaseParams): CreateUpdateOptions {
if (!params.databaseLevelThroughput) {
return {};
}
if (params.autoPilotMaxThroughput) {
return {
autoscaleSettings: {
maxThroughput: params.autoPilotMaxThroughput
}
};
}
return {
throughput: params.offerThroughput
};
}

View File

@@ -1,16 +1,46 @@
jest.mock("../../Utils/arm/request");
jest.mock("../MessageHandler");
jest.mock("../CosmosClient");
import { deleteCollection } from "./deleteCollection";
import { armRequest } from "../../Utils/arm/request";
import { AuthType } from "../../AuthType";
import { client } from "../CosmosClient";
import { updateUserContext } from "../../UserContext";
import { DatabaseAccount } from "../../Contracts/DataModels";
import { sendCachedDataMessage } from "../MessageHandler";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
describe("deleteCollection", () => {
beforeAll(() => {
updateUserContext({
databaseAccount: {
name: "test"
} as DatabaseAccount,
defaultExperience: DefaultAccountExperienceType.DocumentDB
});
(sendCachedDataMessage as jest.Mock).mockResolvedValue(undefined);
});
it("should call ARM if logged in with AAD", async () => {
window.authType = AuthType.AAD;
(sendCachedDataMessage as jest.Mock).mockResolvedValue(undefined);
await deleteCollection("database", "collection");
expect(armRequest).toHaveBeenCalled();
});
// TODO: Test non-AAD case
it("should call SDK if not logged in with non-AAD method", async () => {
window.authType = AuthType.MasterKey;
(client as jest.Mock).mockReturnValue({
database: () => {
return {
container: () => {
return {
delete: (): unknown => undefined
};
}
};
}
});
await deleteCollection("database", "collection");
expect(client).toHaveBeenCalled();
});
});

View File

@@ -1,31 +1,57 @@
import { CosmosClient } from "../CosmosClient";
import { refreshCachedResources } from "../DataAccessUtilityBase";
import { logConsoleProgress, logConsoleInfo, logConsoleError } from "../../Utils/NotificationConsoleUtils";
import { AuthType } from "../../AuthType";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { deleteSqlContainer } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources";
import { deleteCassandraTable } from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources";
import { deleteMongoDBCollection } from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources";
import { deleteGremlinGraph } from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources";
import { deleteTable } from "../../Utils/arm/generatedClients/2020-04-01/tableResources";
import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logError } from "../Logger";
import { sendNotificationForError } from "./sendNotificationForError";
import { userContext } from "../../UserContext";
import { client } from "../CosmosClient";
import { refreshCachedResources } from "../DataAccessUtilityBase";
export async function deleteCollection(databaseId: string, collectionId: string): Promise<void> {
const clearMessage = logConsoleProgress(`Deleting container ${collectionId}`);
try {
if (window.authType === AuthType.AAD) {
await deleteSqlContainer(
CosmosClient.subscriptionId(),
CosmosClient.resourceGroup(),
CosmosClient.databaseAccount().name,
databaseId,
collectionId
);
if (window.authType === AuthType.AAD && !userContext.useSDKOperations) {
await deleteCollectionWithARM(databaseId, collectionId);
} else {
await CosmosClient.client()
await client()
.database(databaseId)
.container(collectionId)
.delete();
}
} catch (error) {
logConsoleError(`Error while deleting container ${collectionId}:\n ${JSON.stringify(error)}`);
logError(JSON.stringify(error), "DeleteCollection", error.code);
sendNotificationForError(error);
throw error;
}
logConsoleInfo(`Successfully deleted container ${collectionId}`);
clearMessage();
await refreshCachedResources();
}
function deleteCollectionWithARM(databaseId: string, collectionId: string): Promise<void> {
const subscriptionId = userContext.subscriptionId;
const resourceGroup = userContext.resourceGroup;
const accountName = userContext.databaseAccount.name;
const defaultExperience = userContext.defaultExperience;
switch (defaultExperience) {
case DefaultAccountExperienceType.DocumentDB:
return deleteSqlContainer(subscriptionId, resourceGroup, accountName, databaseId, collectionId);
case DefaultAccountExperienceType.MongoDB:
return deleteMongoDBCollection(subscriptionId, resourceGroup, accountName, databaseId, collectionId);
case DefaultAccountExperienceType.Cassandra:
return deleteCassandraTable(subscriptionId, resourceGroup, accountName, databaseId, collectionId);
case DefaultAccountExperienceType.Graph:
return deleteGremlinGraph(subscriptionId, resourceGroup, accountName, databaseId, collectionId);
case DefaultAccountExperienceType.Table:
return deleteTable(subscriptionId, resourceGroup, accountName, collectionId);
default:
throw new Error(`Unsupported default experience type: ${defaultExperience}`);
}
}

View File

@@ -1,16 +1,42 @@
jest.mock("../../Utils/arm/request");
jest.mock("../MessageHandler");
jest.mock("../CosmosClient");
import { deleteDatabase } from "./deleteDatabase";
import { armRequest } from "../../Utils/arm/request";
import { AuthType } from "../../AuthType";
import { client } from "../CosmosClient";
import { updateUserContext } from "../../UserContext";
import { DatabaseAccount } from "../../Contracts/DataModels";
import { sendCachedDataMessage } from "../MessageHandler";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
describe("deleteDatabase", () => {
beforeAll(() => {
updateUserContext({
databaseAccount: {
name: "test"
} as DatabaseAccount,
defaultExperience: DefaultAccountExperienceType.DocumentDB
});
(sendCachedDataMessage as jest.Mock).mockResolvedValue(undefined);
});
it("should call ARM if logged in with AAD", async () => {
window.authType = AuthType.AAD;
(sendCachedDataMessage as jest.Mock).mockResolvedValue(undefined);
await deleteDatabase("database");
expect(armRequest).toHaveBeenCalled();
});
// TODO: Test non-AAD case
it("should call SDK if not logged in with non-AAD method", async () => {
window.authType = AuthType.MasterKey;
(client as jest.Mock).mockReturnValue({
database: () => {
return {
delete: (): unknown => undefined
};
}
});
await deleteDatabase("database");
expect(client).toHaveBeenCalled();
});
});

View File

@@ -1,8 +1,13 @@
import { CosmosClient } from "../CosmosClient";
import { refreshCachedResources } from "../DataAccessUtilityBase";
import { logConsoleProgress, logConsoleError, logConsoleInfo } from "../../Utils/NotificationConsoleUtils";
import { deleteSqlDatabase } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources";
import { AuthType } from "../../AuthType";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { deleteSqlDatabase } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources";
import { deleteCassandraKeyspace } from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources";
import { deleteMongoDBDatabase } from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources";
import { deleteGremlinDatabase } from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources";
import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { userContext } from "../../UserContext";
import { client } from "../CosmosClient";
import { refreshCachedResources } from "../DataAccessUtilityBase";
import { logError } from "../Logger";
import { sendNotificationForError } from "./sendNotificationForError";
@@ -10,15 +15,14 @@ export async function deleteDatabase(databaseId: string): Promise<void> {
const clearMessage = logConsoleProgress(`Deleting database ${databaseId}`);
try {
if (window.authType === AuthType.AAD) {
await deleteSqlDatabase(
CosmosClient.subscriptionId(),
CosmosClient.resourceGroup(),
CosmosClient.databaseAccount().name,
databaseId
);
if (
window.authType === AuthType.AAD &&
userContext.defaultExperience !== DefaultAccountExperienceType.Table &&
!userContext.useSDKOperations
) {
await deleteDatabaseWithARM(databaseId);
} else {
await CosmosClient.client()
await client()
.database(databaseId)
.delete();
}
@@ -32,3 +36,23 @@ export async function deleteDatabase(databaseId: string): Promise<void> {
clearMessage();
await refreshCachedResources();
}
function deleteDatabaseWithARM(databaseId: string): Promise<void> {
const subscriptionId = userContext.subscriptionId;
const resourceGroup = userContext.resourceGroup;
const accountName = userContext.databaseAccount.name;
const defaultExperience = userContext.defaultExperience;
switch (defaultExperience) {
case DefaultAccountExperienceType.DocumentDB:
return deleteSqlDatabase(subscriptionId, resourceGroup, accountName, databaseId);
case DefaultAccountExperienceType.MongoDB:
return deleteMongoDBDatabase(subscriptionId, resourceGroup, accountName, databaseId);
case DefaultAccountExperienceType.Cassandra:
return deleteCassandraKeyspace(subscriptionId, resourceGroup, accountName, databaseId);
case DefaultAccountExperienceType.Graph:
return deleteGremlinDatabase(subscriptionId, resourceGroup, accountName, databaseId);
default:
throw new Error(`Unsupported default experience type: ${defaultExperience}`);
}
}

View File

@@ -0,0 +1,35 @@
jest.mock("../CosmosClient");
import { AuthType } from "../../AuthType";
import { DatabaseAccount } from "../../Contracts/DataModels";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { client } from "../CosmosClient";
import { readCollection } from "./readCollection";
import { updateUserContext } from "../../UserContext";
describe("readCollection", () => {
beforeAll(() => {
updateUserContext({
databaseAccount: {
name: "test"
} as DatabaseAccount,
defaultExperience: DefaultAccountExperienceType.DocumentDB
});
});
it("should call SDK if logged in with resource token", async () => {
window.authType = AuthType.ResourceToken;
(client as jest.Mock).mockReturnValue({
database: () => {
return {
container: () => {
return {
read: (): unknown => ({})
};
}
};
}
});
await readCollection("database", "collection");
expect(client).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,24 @@
import * as DataModels from "../../Contracts/DataModels";
import { client } from "../CosmosClient";
import { logConsoleProgress, logConsoleError } from "../../Utils/NotificationConsoleUtils";
import { logError } from "../Logger";
import { sendNotificationForError } from "./sendNotificationForError";
export async function readCollection(databaseId: string, collectionId: string): Promise<DataModels.Collection> {
let collection: DataModels.Collection;
const clearMessage = logConsoleProgress(`Querying container ${collectionId}`);
try {
const response = await client()
.database(databaseId)
.container(collectionId)
.read();
collection = response.resource as DataModels.Collection;
} catch (error) {
logConsoleError(`Error while querying container ${collectionId}:\n ${JSON.stringify(error)}`);
logError(JSON.stringify(error), "ReadCollection", error.code);
sendNotificationForError(error);
throw error;
}
clearMessage();
return collection;
}

View File

@@ -0,0 +1,45 @@
jest.mock("../../Utils/arm/request");
jest.mock("../CosmosClient");
import { AuthType } from "../../AuthType";
import { DatabaseAccount } from "../../Contracts/DataModels";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { armRequest } from "../../Utils/arm/request";
import { client } from "../CosmosClient";
import { readCollections } from "./readCollections";
import { updateUserContext } from "../../UserContext";
describe("readCollections", () => {
beforeAll(() => {
updateUserContext({
databaseAccount: {
name: "test"
} as DatabaseAccount,
defaultExperience: DefaultAccountExperienceType.DocumentDB
});
});
it("should call ARM if logged in with AAD", async () => {
window.authType = AuthType.AAD;
await readCollections("database");
expect(armRequest).toHaveBeenCalled();
});
it("should call SDK if not logged in with non-AAD method", async () => {
window.authType = AuthType.MasterKey;
(client as jest.Mock).mockReturnValue({
database: () => {
return {
containers: {
readAll: () => {
return {
fetchAll: (): unknown => []
};
}
}
};
}
});
await readCollections("database");
expect(client).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,71 @@
import * as DataModels from "../../Contracts/DataModels";
import { AuthType } from "../../AuthType";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { client } from "../CosmosClient";
import { listSqlContainers } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources";
import { listCassandraTables } from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources";
import { listMongoDBCollections } from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources";
import { listGremlinGraphs } from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources";
import { listTables } from "../../Utils/arm/generatedClients/2020-04-01/tableResources";
import { logConsoleProgress, logConsoleError } from "../../Utils/NotificationConsoleUtils";
import { logError } from "../Logger";
import { sendNotificationForError } from "./sendNotificationForError";
import { userContext } from "../../UserContext";
export async function readCollections(databaseId: string): Promise<DataModels.Collection[]> {
let collections: DataModels.Collection[];
const clearMessage = logConsoleProgress(`Querying containers for database ${databaseId}`);
try {
if (
window.authType === AuthType.AAD &&
!userContext.useSDKOperations &&
userContext.defaultExperience !== DefaultAccountExperienceType.MongoDB &&
userContext.defaultExperience !== DefaultAccountExperienceType.Table
) {
collections = await readCollectionsWithARM(databaseId);
} else {
const sdkResponse = await client()
.database(databaseId)
.containers.readAll()
.fetchAll();
collections = sdkResponse.resources as DataModels.Collection[];
}
} catch (error) {
logConsoleError(`Error while querying containers for database ${databaseId}:\n ${JSON.stringify(error)}`);
logError(JSON.stringify(error), "ReadCollections", error.code);
sendNotificationForError(error);
throw error;
}
clearMessage();
return collections;
}
async function readCollectionsWithARM(databaseId: string): Promise<DataModels.Collection[]> {
let rpResponse;
const subscriptionId = userContext.subscriptionId;
const resourceGroup = userContext.resourceGroup;
const accountName = userContext.databaseAccount.name;
const defaultExperience = userContext.defaultExperience;
switch (defaultExperience) {
case DefaultAccountExperienceType.DocumentDB:
rpResponse = await listSqlContainers(subscriptionId, resourceGroup, accountName, databaseId);
break;
case DefaultAccountExperienceType.MongoDB:
rpResponse = await listMongoDBCollections(subscriptionId, resourceGroup, accountName, databaseId);
break;
case DefaultAccountExperienceType.Cassandra:
rpResponse = await listCassandraTables(subscriptionId, resourceGroup, accountName, databaseId);
break;
case DefaultAccountExperienceType.Graph:
rpResponse = await listGremlinGraphs(subscriptionId, resourceGroup, accountName, databaseId);
break;
case DefaultAccountExperienceType.Table:
rpResponse = await listTables(subscriptionId, resourceGroup, accountName);
break;
default:
throw new Error(`Unsupported default experience type: ${defaultExperience}`);
}
return rpResponse?.value?.map(collection => collection.properties?.resource as DataModels.Collection);
}

View File

@@ -0,0 +1,41 @@
jest.mock("../../Utils/arm/request");
jest.mock("../CosmosClient");
import { AuthType } from "../../AuthType";
import { DatabaseAccount } from "../../Contracts/DataModels";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { armRequest } from "../../Utils/arm/request";
import { client } from "../CosmosClient";
import { readDatabases } from "./readDatabases";
import { updateUserContext } from "../../UserContext";
describe("readDatabases", () => {
beforeAll(() => {
updateUserContext({
databaseAccount: {
name: "test"
} as DatabaseAccount,
defaultExperience: DefaultAccountExperienceType.DocumentDB
});
});
it("should call ARM if logged in with AAD", async () => {
window.authType = AuthType.AAD;
await readDatabases();
expect(armRequest).toHaveBeenCalled();
});
it("should call SDK if not logged in with non-AAD method", async () => {
window.authType = AuthType.MasterKey;
(client as jest.Mock).mockReturnValue({
databases: {
readAll: () => {
return {
fetchAll: (): unknown => []
};
}
}
});
await readDatabases();
expect(client).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,67 @@
import * as DataModels from "../../Contracts/DataModels";
import { AuthType } from "../../AuthType";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { client } from "../CosmosClient";
import { listSqlDatabases } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources";
import { listCassandraKeyspaces } from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources";
import { listMongoDBDatabases } from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources";
import { listGremlinDatabases } from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources";
import { logConsoleProgress, logConsoleError } from "../../Utils/NotificationConsoleUtils";
import { logError } from "../Logger";
import { sendNotificationForError } from "./sendNotificationForError";
import { userContext } from "../../UserContext";
export async function readDatabases(): Promise<DataModels.Database[]> {
let databases: DataModels.Database[];
const clearMessage = logConsoleProgress(`Querying databases`);
try {
if (
window.authType === AuthType.AAD &&
!userContext.useSDKOperations &&
userContext.defaultExperience !== DefaultAccountExperienceType.MongoDB &&
userContext.defaultExperience !== DefaultAccountExperienceType.Table &&
userContext.defaultExperience !== DefaultAccountExperienceType.Cassandra
) {
databases = await readDatabasesWithARM();
} else {
const sdkResponse = await client()
.databases.readAll()
.fetchAll();
databases = sdkResponse.resources as DataModels.Database[];
}
} catch (error) {
logConsoleError(`Error while querying databases:\n ${JSON.stringify(error)}`);
logError(JSON.stringify(error), "ReadDatabases", error.code);
sendNotificationForError(error);
throw error;
}
clearMessage();
return databases;
}
async function readDatabasesWithARM(): Promise<DataModels.Database[]> {
let rpResponse;
const subscriptionId = userContext.subscriptionId;
const resourceGroup = userContext.resourceGroup;
const accountName = userContext.databaseAccount.name;
const defaultExperience = userContext.defaultExperience;
switch (defaultExperience) {
case DefaultAccountExperienceType.DocumentDB:
rpResponse = await listSqlDatabases(subscriptionId, resourceGroup, accountName);
break;
case DefaultAccountExperienceType.MongoDB:
rpResponse = await listMongoDBDatabases(subscriptionId, resourceGroup, accountName);
break;
case DefaultAccountExperienceType.Cassandra:
rpResponse = await listCassandraKeyspaces(subscriptionId, resourceGroup, accountName);
break;
case DefaultAccountExperienceType.Graph:
rpResponse = await listGremlinDatabases(subscriptionId, resourceGroup, accountName);
break;
default:
throw new Error(`Unsupported default experience type: ${defaultExperience}`);
}
return rpResponse?.value?.map(database => database.properties?.resource as DataModels.Database);
}

View File

@@ -0,0 +1,225 @@
import { AuthType } from "../../AuthType";
import { Collection } from "../../Contracts/DataModels";
import { ContainerDefinition } from "@azure/cosmos";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import {
ExtendedResourceProperties,
SqlContainerCreateUpdateParameters,
SqlContainerResource
} from "../../Utils/arm/generatedClients/2020-04-01/types";
import { RequestOptions } from "@azure/cosmos/dist-esm";
import { client } from "../CosmosClient";
import { createUpdateSqlContainer, getSqlContainer } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources";
import {
createUpdateCassandraTable,
getCassandraTable
} from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources";
import {
createUpdateMongoDBCollection,
getMongoDBCollection
} from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources";
import {
createUpdateGremlinGraph,
getGremlinGraph
} from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources";
import { createUpdateTable, getTable } from "../../Utils/arm/generatedClients/2020-04-01/tableResources";
import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logError } from "../Logger";
import { refreshCachedResources } from "../DataAccessUtilityBase";
import { sendNotificationForError } from "./sendNotificationForError";
import { userContext } from "../../UserContext";
export async function updateCollection(
databaseId: string,
collectionId: string,
newCollection: Collection,
options: RequestOptions = {}
): Promise<Collection> {
let collection: Collection;
const clearMessage = logConsoleProgress(`Updating container ${collectionId}`);
try {
if (
window.authType === AuthType.AAD &&
userContext.defaultExperience !== DefaultAccountExperienceType.MongoDB &&
userContext.defaultExperience !== DefaultAccountExperienceType.Table
) {
collection = await updateCollectionWithARM(databaseId, collectionId, newCollection);
} else {
const sdkResponse = await client()
.database(databaseId)
.container(collectionId)
.replace(newCollection as ContainerDefinition, options);
collection = sdkResponse.resource as Collection;
}
} catch (error) {
logConsoleError(`Failed to update container ${collectionId}: ${JSON.stringify(error)}`);
logError(JSON.stringify(error), "UpdateCollection", error.code);
sendNotificationForError(error);
throw error;
}
logConsoleInfo(`Successfully updated container ${collectionId}`);
clearMessage();
await refreshCachedResources();
return collection;
}
async function updateCollectionWithARM(
databaseId: string,
collectionId: string,
newCollection: Collection
): Promise<Collection> {
const subscriptionId = userContext.subscriptionId;
const resourceGroup = userContext.resourceGroup;
const accountName = userContext.databaseAccount.name;
const defaultExperience = userContext.defaultExperience;
switch (defaultExperience) {
case DefaultAccountExperienceType.DocumentDB:
return updateSqlContainer(databaseId, collectionId, subscriptionId, resourceGroup, accountName, newCollection);
case DefaultAccountExperienceType.MongoDB:
return updateMongoDBCollection(
databaseId,
collectionId,
subscriptionId,
resourceGroup,
accountName,
newCollection
);
case DefaultAccountExperienceType.Cassandra:
return updateCassandraTable(databaseId, collectionId, subscriptionId, resourceGroup, accountName, newCollection);
case DefaultAccountExperienceType.Graph:
return updateGremlinGraph(databaseId, collectionId, subscriptionId, resourceGroup, accountName, newCollection);
case DefaultAccountExperienceType.Table:
return updateTable(collectionId, subscriptionId, resourceGroup, accountName, newCollection);
default:
throw new Error(`Unsupported default experience type: ${defaultExperience}`);
}
}
async function updateSqlContainer(
databaseId: string,
collectionId: string,
subscriptionId: string,
resourceGroup: string,
accountName: string,
newCollection: Collection
): Promise<Collection> {
const getResponse = await getSqlContainer(subscriptionId, resourceGroup, accountName, databaseId, collectionId);
if (getResponse && getResponse.properties && getResponse.properties.resource) {
getResponse.properties.resource = newCollection as SqlContainerResource & ExtendedResourceProperties;
const updateResponse = await createUpdateSqlContainer(
subscriptionId,
resourceGroup,
accountName,
databaseId,
collectionId,
getResponse as SqlContainerCreateUpdateParameters
);
return updateResponse && (updateResponse.properties.resource as Collection);
}
throw new Error(`Sql container to update does not exist. Database id: ${databaseId} Collection id: ${collectionId}`);
}
async function updateMongoDBCollection(
databaseId: string,
collectionId: string,
subscriptionId: string,
resourceGroup: string,
accountName: string,
newCollection: Collection
): Promise<Collection> {
const getResponse = await getMongoDBCollection(subscriptionId, resourceGroup, accountName, databaseId, collectionId);
if (getResponse && getResponse.properties && getResponse.properties.resource) {
getResponse.properties.resource = newCollection as SqlContainerResource & ExtendedResourceProperties;
const updateResponse = await createUpdateMongoDBCollection(
subscriptionId,
resourceGroup,
accountName,
databaseId,
collectionId,
getResponse as SqlContainerCreateUpdateParameters
);
return updateResponse && (updateResponse.properties.resource as Collection);
}
throw new Error(
`MongoDB collection to update does not exist. Database id: ${databaseId} Collection id: ${collectionId}`
);
}
async function updateCassandraTable(
databaseId: string,
collectionId: string,
subscriptionId: string,
resourceGroup: string,
accountName: string,
newCollection: Collection
): Promise<Collection> {
const getResponse = await getCassandraTable(subscriptionId, resourceGroup, accountName, databaseId, collectionId);
if (getResponse && getResponse.properties && getResponse.properties.resource) {
getResponse.properties.resource = newCollection as SqlContainerResource & ExtendedResourceProperties;
const updateResponse = await createUpdateCassandraTable(
subscriptionId,
resourceGroup,
accountName,
databaseId,
collectionId,
getResponse as SqlContainerCreateUpdateParameters
);
return updateResponse && (updateResponse.properties.resource as Collection);
}
throw new Error(
`Cassandra table to update does not exist. Database id: ${databaseId} Collection id: ${collectionId}`
);
}
async function updateGremlinGraph(
databaseId: string,
collectionId: string,
subscriptionId: string,
resourceGroup: string,
accountName: string,
newCollection: Collection
): Promise<Collection> {
const getResponse = await getGremlinGraph(subscriptionId, resourceGroup, accountName, databaseId, collectionId);
if (getResponse && getResponse.properties && getResponse.properties.resource) {
getResponse.properties.resource = newCollection as SqlContainerResource & ExtendedResourceProperties;
const updateResponse = await createUpdateGremlinGraph(
subscriptionId,
resourceGroup,
accountName,
databaseId,
collectionId,
getResponse as SqlContainerCreateUpdateParameters
);
return updateResponse && (updateResponse.properties.resource as Collection);
}
throw new Error(`Gremlin graph to update does not exist. Database id: ${databaseId} Collection id: ${collectionId}`);
}
async function updateTable(
collectionId: string,
subscriptionId: string,
resourceGroup: string,
accountName: string,
newCollection: Collection
): Promise<Collection> {
const getResponse = await getTable(subscriptionId, resourceGroup, accountName, collectionId);
if (getResponse && getResponse.properties && getResponse.properties.resource) {
getResponse.properties.resource = newCollection as SqlContainerResource & ExtendedResourceProperties;
const updateResponse = await createUpdateTable(
subscriptionId,
resourceGroup,
accountName,
collectionId,
getResponse as SqlContainerCreateUpdateParameters
);
return updateResponse && (updateResponse.properties.resource as Collection);
}
throw new Error(`Table to update does not exist. Table id: ${collectionId}`);
}

View File

@@ -0,0 +1,26 @@
import { updateOfferThroughputBeyondLimit } from "./updateOfferThroughputBeyondLimit";
describe("updateOfferThroughputBeyondLimit", () => {
it("should call fetch", async () => {
window.fetch = jest.fn(() => {
return {
ok: true
};
});
window.dataExplorer = {
logConsoleData: jest.fn(),
deleteInProgressConsoleDataWithId: jest.fn(),
extensionEndpoint: jest.fn()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
await updateOfferThroughputBeyondLimit({
subscriptionId: "foo",
resourceGroup: "foo",
databaseAccountName: "foo",
databaseName: "foo",
throughput: 1000000000,
offerIsRUPerMinuteThroughputEnabled: false
});
expect(window.fetch).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,52 @@
import { Platform, configContext } from "../../ConfigContext";
import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils";
import { AutoPilotOfferSettings } from "../../Contracts/DataModels";
import { logConsoleProgress, logConsoleInfo, logConsoleError } from "../../Utils/NotificationConsoleUtils";
import { HttpHeaders } from "../Constants";
interface UpdateOfferThroughputRequest {
subscriptionId: string;
resourceGroup: string;
databaseAccountName: string;
databaseName: string;
collectionName?: string;
throughput: number;
offerIsRUPerMinuteThroughputEnabled: boolean;
offerAutopilotSettings?: AutoPilotOfferSettings;
}
export async function updateOfferThroughputBeyondLimit(request: UpdateOfferThroughputRequest): Promise<void> {
if (configContext.platform !== Platform.Portal) {
throw new Error("Updating throughput beyond specified limit is not supported on this platform");
}
const resourceDescriptionInfo = request.collectionName
? `database ${request.databaseName} and container ${request.collectionName}`
: `database ${request.databaseName}`;
const clearMessage = logConsoleProgress(
`Requesting increase in throughput to ${request.throughput} for ${resourceDescriptionInfo}`
);
const explorer = window.dataExplorer;
const url = `${explorer.extensionEndpoint()}/api/offerthroughputrequest/updatebeyondspecifiedlimit`;
const authorizationHeader = getAuthorizationHeader();
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(request),
headers: { [authorizationHeader.header]: authorizationHeader.token, [HttpHeaders.contentType]: "application/json" }
});
if (response.ok) {
logConsoleInfo(
`Successfully requested an increase in throughput to ${request.throughput} for ${resourceDescriptionInfo}`
);
clearMessage();
return undefined;
}
const error = await response.json();
logConsoleError(`Failed to request an increase in throughput for ${request.throughput}: ${error.message}`);
clearMessage();
throw new Error(error.message);
}

View File

@@ -4,7 +4,7 @@ export enum Platform {
Emulator = "Emulator"
}
interface Config {
interface ConfigContext {
platform: Platform;
allowedParentFrameOrigins: RegExp;
gitSha?: string;
@@ -28,7 +28,7 @@ interface Config {
}
// Default configuration
let config: Config = {
let configContext: Readonly<ConfigContext> = {
platform: Platform.Portal,
allowedParentFrameOrigins: /^https:\/\/portal\.azure\.com$|^https:\/\/portal\.azure\.us$|^https:\/\/portal\.azure\.cn$|^https:\/\/portal\.microsoftazure\.de$|^https:\/\/.+\.portal\.azure\.com$|^https:\/\/.+\.portal\.azure\.us$|^https:\/\/.+\.portal\.azure\.cn$|^https:\/\/.+\.portal\.microsoftazure\.de$|^https:\/\/main\.documentdb\.ext\.azure\.com$|^https:\/\/main\.documentdb\.ext\.microsoftazure\.de$|^https:\/\/main\.documentdb\.ext\.azure\.cn$|^https:\/\/main\.documentdb\.ext\.azure\.us$/,
// Webpack injects this at build time
@@ -46,36 +46,58 @@ let config: Config = {
JUNO_ENDPOINT: "https://tools.cosmos.azure.com"
};
export function resetConfigContext(): void {
if (process.env.NODE_ENV !== "test") {
throw new Error("resetConfigContext can only becalled in a test environment");
}
configContext = {} as ConfigContext;
}
export function updateConfigContext(newContext: Partial<ConfigContext>): void {
Object.assign(configContext, newContext);
}
// Injected for local develpment. These will be removed in the production bundle by webpack
if (process.env.NODE_ENV === "development") {
const port: string = process.env.PORT || "1234";
config.BACKEND_ENDPOINT = "https://localhost:" + port;
config.MONGO_BACKEND_ENDPOINT = "https://localhost:" + port;
config.PROXY_PATH = "/proxy";
config.EMULATOR_ENDPOINT = "https://localhost:8081";
updateConfigContext({
BACKEND_ENDPOINT: "https://localhost:" + port,
MONGO_BACKEND_ENDPOINT: "https://localhost:" + port,
PROXY_PATH: "/proxy",
EMULATOR_ENDPOINT: "https://localhost:8081"
});
}
export async function initializeConfiguration(): Promise<Config> {
export async function initializeConfiguration(): Promise<ConfigContext> {
try {
const response = await fetch("./config.json");
if (response.status === 200) {
try {
const externalConfig = await response.json();
config = Object.assign({}, config, externalConfig);
Object.assign(configContext, externalConfig);
} catch (error) {
console.error("Unable to parse json in config file");
console.error(error);
}
}
// Allow override of any config value with URL query parameters
// Allow override of platform value with URL query parameter
const params = new URLSearchParams(window.location.search);
params.forEach((value, key) => {
(config as any)[key] = value;
});
if (params.has("platform")) {
const platform = params.get("platform");
switch (platform) {
default:
console.log("Invalid platform query parameter given, ignoring");
break;
case Platform.Portal:
case Platform.Hosted:
case Platform.Emulator:
updateConfigContext({ platform });
}
}
} catch (error) {
console.log("No configuration file found using defaults");
}
return config;
return configContext;
}
export { config };
export { configContext };

View File

@@ -153,7 +153,14 @@ export interface KeyResource {
Token: string;
}
export interface IndexingPolicy {}
export interface IndexingPolicy {
automatic: boolean;
indexingMode: string;
includedPaths: any;
excludedPaths: any;
compositeIndexes?: any;
spatialIndexes?: any;
}
export interface PartitionKey {
paths: string[];
@@ -312,17 +319,6 @@ export interface Query {
query: string;
}
export interface UpdateOfferThroughputRequest {
subscriptionId: string;
resourceGroup: string;
databaseAccountName: string;
databaseName: string;
collectionName: string;
throughput: number;
offerIsRUPerMinuteThroughputEnabled: boolean;
offerAutopilotSettings?: AutoPilotOfferSettings;
}
export interface AutoPilotOfferSettings {
tier?: AutopilotTier;
maximumTierThroughput?: number;
@@ -331,12 +327,11 @@ export interface AutoPilotOfferSettings {
targetMaxThroughput?: number;
}
export interface CreateDatabaseRequest {
export interface CreateDatabaseParams {
autoPilotMaxThroughput?: number;
databaseId: string;
databaseLevelThroughput?: boolean;
offerThroughput?: number;
autoPilot?: AutoPilotCreationSettings;
hasAutoPilotV2FeatureFlag?: boolean;
}
export interface SharedThroughputRange {

View File

@@ -0,0 +1,8 @@
export enum DefaultAccountExperienceType {
DocumentDB = "DocumentDB",
Graph = "Graph",
MongoDB = "MongoDB",
Table = "Table",
Cassandra = "Cassandra",
ApiForMongoDB = "Azure Cosmos DB for MongoDB API"
}

View File

@@ -49,6 +49,12 @@ export const FeaturePanelComponent: React.FunctionComponent = () => {
{ key: "feature.hosteddataexplorerenabled", label: "Hosted Data Explorer (deprecated?)", value: "true" },
{ key: "feature.enablettl", label: "Enable TTL", value: "true" },
{ key: "feature.enablegallerypublish", label: "Enable Notebook Gallery Publishing", value: "true" },
{ key: "feature.enablecodeofconduct", label: "Enable Code Of Conduct Acknowledgement", value: "true" },
{
key: "feature.enableLinkInjection",
label: "Enable Injecting Notebook Viewer Link into the first cell",
value: "true"
},
{ key: "feature.canexceedmaximumvalue", label: "Can exceed max value", value: "true" },
{
key: "feature.enablefixedcollectionwithsharedthroughput",

View File

@@ -163,8 +163,14 @@ exports[`Feature panel renders all flags 1`] = `
/>
<StyledCheckboxBase
checked={false}
key="feature.canexceedmaximumvalue"
label="Can exceed max value"
key="feature.enablecodeofconduct"
label="Enable Code Of Conduct Acknowledgement"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enableLinkInjection"
label="Enable Injecting Notebook Viewer Link into the first cell"
onChange={[Function]}
/>
</Stack>
@@ -172,6 +178,12 @@ exports[`Feature panel renders all flags 1`] = `
className="checkboxRow"
horizontalAlign="space-between"
>
<StyledCheckboxBase
checked={false}
key="feature.canexceedmaximumvalue"
label="Can exceed max value"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enablefixedcollectionwithsharedthroughput"

View File

@@ -17,7 +17,8 @@ describe("GalleryCardComponent", () => {
isSample: false,
downloads: 0,
favorites: 0,
views: 0
views: 0,
newCellId: undefined
},
isFavorite: false,
showDownload: true,

View File

@@ -0,0 +1,43 @@
import { shallow } from "enzyme";
import * as sinon from "sinon";
import React from "react";
import { CodeOfConductComponent, CodeOfConductComponentProps } from "./CodeOfConductComponent";
import { IJunoResponse, JunoClient } from "../../../Juno/JunoClient";
import { HttpStatusCodes } from "../../../Common/Constants";
describe("CodeOfConductComponent", () => {
let sandbox: sinon.SinonSandbox;
let codeOfConductProps: CodeOfConductComponentProps;
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(JunoClient.prototype, "acceptCodeOfConduct").returns({
status: HttpStatusCodes.OK,
data: true
} as IJunoResponse<boolean>);
const junoClient = new JunoClient(undefined);
codeOfConductProps = {
junoClient: junoClient,
onAcceptCodeOfConduct: jest.fn()
};
});
afterEach(() => {
sandbox.restore();
});
it("renders", () => {
const wrapper = shallow(<CodeOfConductComponent {...codeOfConductProps} />);
expect(wrapper).toMatchSnapshot();
});
it("onAcceptedCodeOfConductCalled", async () => {
const wrapper = shallow(<CodeOfConductComponent {...codeOfConductProps} />);
wrapper
.find(".genericPaneSubmitBtn")
.first()
.simulate("click");
await Promise.resolve();
expect(codeOfConductProps.onAcceptCodeOfConduct).toBeCalled();
});
});

View File

@@ -0,0 +1,112 @@
import * as React from "react";
import { JunoClient } from "../../../Juno/JunoClient";
import { HttpStatusCodes, CodeOfConductEndpoints } from "../../../Common/Constants";
import * as Logger from "../../../Common/Logger";
import { logConsoleError } from "../../../Utils/NotificationConsoleUtils";
import { Stack, Text, Checkbox, PrimaryButton, Link } from "office-ui-fabric-react";
export interface CodeOfConductComponentProps {
junoClient: JunoClient;
onAcceptCodeOfConduct: (result: boolean) => void;
}
interface CodeOfConductComponentState {
readCodeOfConduct: boolean;
}
export class CodeOfConductComponent extends React.Component<CodeOfConductComponentProps, CodeOfConductComponentState> {
private descriptionPara1: string;
private descriptionPara2: string;
private descriptionPara3: string;
private link1: { label: string; url: string };
private link2: { label: string; url: string };
constructor(props: CodeOfConductComponentProps) {
super(props);
this.state = {
readCodeOfConduct: false
};
this.descriptionPara1 = "Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement";
this.descriptionPara2 =
"Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB.";
this.descriptionPara3 = "In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the ";
this.link1 = { label: "code of conduct", url: CodeOfConductEndpoints.codeOfConduct };
this.link2 = { label: "privacy statement", url: CodeOfConductEndpoints.privacyStatement };
}
private async acceptCodeOfConduct(): Promise<void> {
try {
const response = await this.props.junoClient.acceptCodeOfConduct();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
}
this.props.onAcceptCodeOfConduct(response.data);
} catch (error) {
const message = `Failed to accept code of conduct: ${error}`;
Logger.logError(message, "CodeOfConductComponent/acceptCodeOfConduct");
logConsoleError(message);
}
}
private onChangeCheckbox = (): void => {
this.setState({ readCodeOfConduct: !this.state.readCodeOfConduct });
};
public render(): JSX.Element {
return (
<Stack tokens={{ childrenGap: 20 }}>
<Stack.Item>
<Text style={{ fontWeight: 500, fontSize: "20px" }}>{this.descriptionPara1}</Text>
</Stack.Item>
<Stack.Item>
<Text>{this.descriptionPara2}</Text>
</Stack.Item>
<Stack.Item>
<Text>
{this.descriptionPara3}
<Link href={this.link1.url} target="_blank">
{this.link1.label}
</Link>
{" and "}
<Link href={this.link2.url} target="_blank">
{this.link2.label}
</Link>
</Text>
</Stack.Item>
<Stack.Item>
<Checkbox
styles={{
label: {
margin: 0,
padding: "2 0 2 0"
},
text: {
fontSize: 12
}
}}
label="I have read and accepted the code of conduct and privacy statement"
onChange={this.onChangeCheckbox}
/>
</Stack.Item>
<Stack.Item>
<PrimaryButton
ariaLabel="Continue"
title="Continue"
onClick={async () => await this.acceptCodeOfConduct()}
tabIndex={0}
className="genericPaneSubmitBtn"
text="Continue"
disabled={!this.state.readCodeOfConduct}
/>
</Stack.Item>
</Stack>
);
}
}

View File

@@ -15,7 +15,7 @@ import {
} from "office-ui-fabric-react";
import * as React from "react";
import * as Logger from "../../../Common/Logger";
import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient";
import { IGalleryItem, JunoClient, IJunoResponse, IPublicGalleryData } from "../../../Juno/JunoClient";
import * as GalleryUtils from "../../../Utils/GalleryUtils";
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
@@ -24,6 +24,8 @@ import { GalleryCardComponent, GalleryCardComponentProps } from "./Cards/Gallery
import "./GalleryViewerComponent.less";
import { HttpStatusCodes } from "../../../Common/Constants";
import Explorer from "../../Explorer";
import { CodeOfConductComponent } from "./CodeOfConductComponent";
import { InfoComponent } from "./InfoComponent/InfoComponent";
export interface GalleryViewerComponentProps {
container?: Explorer;
@@ -60,6 +62,7 @@ interface GalleryViewerComponentState {
sortBy: SortBy;
searchText: string;
dialogProps: DialogProps;
isCodeOfConductAccepted: boolean;
}
interface GalleryTabInfo {
@@ -86,6 +89,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
private publicNotebooks: IGalleryItem[];
private favoriteNotebooks: IGalleryItem[];
private publishedNotebooks: IGalleryItem[];
private isCodeOfConductAccepted: boolean;
private columnCount: number;
private rowCount: number;
@@ -100,7 +104,8 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
selectedTab: props.selectedTab,
sortBy: props.sortBy,
searchText: props.searchText,
dialogProps: undefined
dialogProps: undefined,
isCodeOfConductAccepted: undefined
};
this.sortingOptions = [
@@ -134,9 +139,20 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
const tabs: GalleryTabInfo[] = [this.createTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks)];
if (this.props.container?.isGalleryPublishEnabled()) {
tabs.push(this.createTab(GalleryTab.PublicGallery, this.state.publicNotebooks));
tabs.push(
this.createPublicGalleryTab(
GalleryTab.PublicGallery,
this.state.publicNotebooks,
this.state.isCodeOfConductAccepted
)
);
tabs.push(this.createTab(GalleryTab.Favorites, this.state.favoriteNotebooks));
tabs.push(this.createTab(GalleryTab.Published, this.state.publishedNotebooks));
// explicitly checking if isCodeOfConductAccepted is not false, as it is initially undefined.
// Displaying code of conduct component on gallery load should not be the default behavior.
if (this.state.isCodeOfConductAccepted !== false) {
tabs.push(this.createTab(GalleryTab.Published, this.state.publishedNotebooks));
}
}
const pivotProps: IPivotProps = {
@@ -167,6 +183,17 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
);
}
private createPublicGalleryTab(
tab: GalleryTab,
data: IGalleryItem[],
acceptedCodeOfConduct: boolean
): GalleryTabInfo {
return {
tab,
content: this.createPublicGalleryTabContent(data, acceptedCodeOfConduct)
};
}
private createTab(tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo {
return {
tab,
@@ -174,6 +201,19 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
};
}
private createPublicGalleryTabContent(data: IGalleryItem[], acceptedCodeOfConduct: boolean): JSX.Element {
return acceptedCodeOfConduct === false ? (
<CodeOfConductComponent
junoClient={this.props.junoClient}
onAcceptCodeOfConduct={(result: boolean) => {
this.setState({ isCodeOfConductAccepted: result });
}}
/>
) : (
this.createTabContent(data)
);
}
private createTabContent(data: IGalleryItem[]): JSX.Element {
return (
<Stack tokens={{ childrenGap: 10 }}>
@@ -187,8 +227,12 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
<Stack.Item styles={{ root: { minWidth: 200 } }}>
<Dropdown options={this.sortingOptions} selectedKey={this.state.sortBy} onChange={this.onDropdownChange} />
</Stack.Item>
{this.props.container?.isGalleryPublishEnabled() && (
<Stack.Item>
<InfoComponent />
</Stack.Item>
)}
</Stack>
{data && this.createCardsTabContent(data)}
</Stack>
);
@@ -254,12 +298,19 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
private async loadPublicNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
if (!offline) {
try {
const response = await this.props.junoClient.getPublicNotebooks();
let response: IJunoResponse<IPublicGalleryData> | IJunoResponse<IGalleryItem[]>;
if (this.props.container.isCodeOfConductEnabled()) {
response = await this.props.junoClient.fetchPublicNotebooks();
this.isCodeOfConductAccepted = response.data?.metadata.acceptedCodeOfConduct;
this.publicNotebooks = response.data?.notebooksData;
} else {
response = await this.props.junoClient.getPublicNotebooks();
this.publicNotebooks = response.data;
}
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when loading public notebooks`);
}
this.publicNotebooks = response.data;
} catch (error) {
const message = `Failed to load public notebooks: ${error}`;
Logger.logError(message, "GalleryViewerComponent/loadPublicNotebooks");
@@ -268,7 +319,8 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
}
this.setState({
publicNotebooks: this.publicNotebooks && [...this.sort(sortBy, this.search(searchText, this.publicNotebooks))]
publicNotebooks: this.publicNotebooks && [...this.sort(sortBy, this.search(searchText, this.publicNotebooks))],
isCodeOfConductAccepted: this.isCodeOfConductAccepted
});
}
@@ -333,12 +385,11 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
private isGalleryItemPresent(searchText: string, item: IGalleryItem): boolean {
const toSearch = searchText.trim().toUpperCase();
const searchData: string[] = [
item.author.toUpperCase(),
item.description.toUpperCase(),
item.name.toUpperCase(),
...item.tags?.map(tag => tag.toUpperCase())
];
const searchData: string[] = [item.author.toUpperCase(), item.description.toUpperCase(), item.name.toUpperCase()];
if (item.tags) {
searchData.push(...item.tags.map(tag => tag.toUpperCase()));
}
for (const data of searchData) {
if (data?.indexOf(toSearch) !== -1) {

View File

@@ -0,0 +1,26 @@
@import "../../../../../less/Common/Constants.less";
.infoPanel, .infoPanelMain {
display: flex;
align-items: center;
}
.infoPanel {
padding-left: 5px;
padding-right: 5px;
}
.infoLabel, .infoLabelMain {
padding-left: 5px
}
.infoLabel {
font-weight: 400
}
.infoIconMain {
color: @AccentMedium
}
.infoIconMain:hover {
color: @BaseMedium
}

View File

@@ -0,0 +1,10 @@
import { shallow } from "enzyme";
import React from "react";
import { InfoComponent } from "./InfoComponent";
describe("InfoComponent", () => {
it("renders", () => {
const wrapper = shallow(<InfoComponent />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,42 @@
import * as React from "react";
import { Icon, Label, Stack, HoverCard, HoverCardType, Link } from "office-ui-fabric-react";
import { CodeOfConductEndpoints } from "../../../../Common/Constants";
import "./InfoComponent.less";
export class InfoComponent extends React.Component {
private getInfoPanel = (iconName: string, labelText: string, url: string): JSX.Element => {
return (
<Link href={url} target="_blank">
<div className="infoPanel">
<Icon iconName={iconName} styles={{ root: { verticalAlign: "middle" } }} />
<Label className="infoLabel">{labelText}</Label>
</div>
</Link>
);
};
private onHover = (): JSX.Element => {
return (
<Stack tokens={{ childrenGap: 5, padding: 5 }}>
<Stack.Item>{this.getInfoPanel("Script", "Code of Conduct", CodeOfConductEndpoints.codeOfConduct)}</Stack.Item>
<Stack.Item>
{this.getInfoPanel("RedEye", "Privacy Statement", CodeOfConductEndpoints.privacyStatement)}
</Stack.Item>
<Stack.Item>
{this.getInfoPanel("KnowledgeArticle", "Microsoft Terms of Use", CodeOfConductEndpoints.termsOfUse)}
</Stack.Item>
</Stack>
);
};
public render(): JSX.Element {
return (
<HoverCard plainCardProps={{ onRenderPlainCard: this.onHover }} instantOpenOnClick type={HoverCardType.plain}>
<div className="infoPanelMain">
<Icon className="infoIconMain" iconName="Help" styles={{ root: { verticalAlign: "middle" } }} />
<Label className="infoLabelMain">Help</Label>
</div>
</HoverCard>
);
}
}

View File

@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InfoComponent renders 1`] = `
<StyledHoverCardBase
instantOpenOnClick={true}
plainCardProps={
Object {
"onRenderPlainCard": [Function],
}
}
type="PlainCard"
>
<div
className="infoPanelMain"
>
<Memo(StyledIconBase)
className="infoIconMain"
iconName="Help"
styles={
Object {
"root": Object {
"verticalAlign": "middle",
},
}
}
/>
<StyledLabelBase
className="infoLabelMain"
>
Help
</StyledLabelBase>
</div>
</StyledHoverCardBase>
`;

View File

@@ -0,0 +1,75 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CodeOfConductComponent renders 1`] = `
<Stack
tokens={
Object {
"childrenGap": 20,
}
}
>
<StackItem>
<Text
style={
Object {
"fontSize": "20px",
"fontWeight": 500,
}
}
>
Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement
</Text>
</StackItem>
<StackItem>
<Text>
Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB.
</Text>
</StackItem>
<StackItem>
<Text>
In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the
<StyledLinkBase
href="https://aka.ms/cosmos-code-of-conduct"
target="_blank"
>
code of conduct
</StyledLinkBase>
and
<StyledLinkBase
href="https://aka.ms/ms-privacy-policy"
target="_blank"
>
privacy statement
</StyledLinkBase>
</Text>
</StackItem>
<StackItem>
<StyledCheckboxBase
label="I have read and accepted the code of conduct and privacy statement"
onChange={[Function]}
styles={
Object {
"label": Object {
"margin": 0,
"padding": "2 0 2 0",
},
"text": Object {
"fontSize": 12,
},
}
}
/>
</StackItem>
<StackItem>
<CustomizedPrimaryButton
ariaLabel="Continue"
className="genericPaneSubmitBtn"
disabled={true}
onClick={[Function]}
tabIndex={0}
text="Continue"
title="Continue"
/>
</StackItem>
</Stack>
`;

View File

@@ -17,7 +17,8 @@ describe("NotebookMetadataComponent", () => {
isSample: false,
downloads: 0,
favorites: 0,
views: 0
views: 0,
newCellId: undefined
},
isFavorite: false,
downloadButtonText: "Download",
@@ -45,7 +46,8 @@ describe("NotebookMetadataComponent", () => {
isSample: false,
downloads: 0,
favorites: 0,
views: 0
views: 0,
newCellId: undefined
},
isFavorite: true,
downloadButtonText: "Download",

View File

@@ -19,6 +19,7 @@ import { DialogComponent, DialogProps } from "../DialogReactComponent/DialogComp
import { NotebookMetadataComponent } from "./NotebookMetadataComponent";
import "./NotebookViewerComponent.less";
import Explorer from "../../Explorer";
import { NotebookV4 } from "@nteract/commutable/lib/v4";
import { SessionStorageUtility } from "../../../Shared/StorageUtility";
export interface NotebookViewerComponentProps {
@@ -86,6 +87,7 @@ export class NotebookViewerComponent extends React.Component<
}
const notebook: Notebook = await response.json();
this.removeNotebookViewerLink(notebook, this.props.galleryItem?.newCellId);
this.notebookComponentBootstrapper.setContent("json", notebook);
this.setState({ content: notebook, showProgressBar: false });
@@ -105,10 +107,21 @@ export class NotebookViewerComponent extends React.Component<
}
}
private removeNotebookViewerLink = (notebook: Notebook, newCellId: string): void => {
if (!newCellId) {
return;
}
const notebookV4 = notebook as NotebookV4;
if (notebookV4 && notebookV4.cells[0].source[0].search(newCellId)) {
delete notebookV4.cells[0];
notebook = notebookV4;
}
};
public render(): JSX.Element {
return (
<div className="notebookViewerContainer">
{this.props.backNavigationText ? (
{this.props.backNavigationText !== undefined ? (
<Link onClick={this.props.onBackClick}>
<Icon iconName="Back" /> {this.props.backNavigationText}
</Link>

View File

@@ -4,10 +4,10 @@ import * as sinon from "sinon";
import * as ViewModels from "../../Contracts/ViewModels";
import Q from "q";
import { ContainerSampleGenerator } from "./ContainerSampleGenerator";
import { CosmosClient } from "../../Common/CosmosClient";
import * as DocumentClientUtility from "../../Common/DocumentClientUtilityBase";
import { GremlinClient } from "../Graph/GraphExplorerComponent/GremlinClient";
import Explorer from "../Explorer";
import { updateUserContext } from "../../UserContext";
describe("ContainerSampleGenerator", () => {
const createExplorerStub = (database: ViewModels.Database): Explorer => {
@@ -75,8 +75,21 @@ describe("ContainerSampleGenerator", () => {
sinon.stub(GremlinClient.prototype, "initialize").callsFake(() => {});
const executeStub = sinon.stub(GremlinClient.prototype, "execute").returns(Q.resolve());
sinon.stub(CosmosClient, "databaseAccount").returns({
properties: {}
updateUserContext({
databaseAccount: {
id: "foo",
name: "foo",
location: "foo",
type: "foo",
kind: "foo",
tags: [],
properties: {
documentEndpoint: "bar",
gremlinEndpoint: "foo",
tableEndpoint: "foo",
cassandraEndpoint: "foo"
}
}
});
const sampleCollectionId = "SampleCollection";

View File

@@ -3,11 +3,11 @@ import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import GraphTab from ".././Tabs/GraphTab";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import { CosmosClient } from "../../Common/CosmosClient";
import { GremlinClient } from "../Graph/GraphExplorerComponent/GremlinClient";
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
import Explorer from "../Explorer";
import { createDocument, getOrCreateDatabaseAndCollection } from "../../Common/DocumentClientUtilityBase";
import { userContext } from "../../UserContext";
interface SampleDataFile extends DataModels.CreateDatabaseAndCollectionRequest {
data: any[];
@@ -87,14 +87,14 @@ export class ContainerSampleGenerator {
if (!queries || queries.length < 1) {
return;
}
const account = CosmosClient.databaseAccount();
const account = userContext.databaseAccount;
const databaseId = collection.databaseId;
const gremlinClient = new GremlinClient();
gremlinClient.initialize({
endpoint: `wss://${GraphTab.getGremlinEndpoint(account)}`,
databaseId: databaseId,
collectionId: collection.id(),
masterKey: CosmosClient.masterKey() || "",
masterKey: userContext.masterKey || "",
maxResultSize: 100
});

View File

@@ -15,7 +15,9 @@ import CassandraAddCollectionPane from "./Panes/CassandraAddCollectionPane";
import Database from "./Tree/Database";
import DeleteCollectionConfirmationPane from "./Panes/DeleteCollectionConfirmationPane";
import DeleteDatabaseConfirmationPane from "./Panes/DeleteDatabaseConfirmationPane";
import { readDatabases, readCollection, readOffers, refreshCachedResources } from "../Common/DocumentClientUtilityBase";
import { readOffers, refreshCachedResources } from "../Common/DocumentClientUtilityBase";
import { readCollection } from "../Common/dataAccess/readCollection";
import { readDatabases } from "../Common/dataAccess/readDatabases";
import EditTableEntityPane from "./Panes/Tables/EditTableEntityPane";
import EnvironmentUtility from "../Common/EnvironmentUtility";
import GraphStylingPane from "./Panes/GraphStylingPane";
@@ -35,9 +37,8 @@ import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer
import { BrowseQueriesPane } from "./Panes/BrowseQueriesPane";
import { CassandraAPIDataClient, TableDataClient, TablesAPIDataClient } from "./Tables/TableDataClient";
import { CommandBarComponentAdapter } from "./Menus/CommandBar/CommandBarComponentAdapter";
import { config } from "../Config";
import { configContext, updateConfigContext } from "../ConfigContext";
import { ConsoleData, ConsoleDataType } from "./Menus/NotificationConsole/NotificationConsoleComponent";
import { CosmosClient } from "../Common/CosmosClient";
import { decryptJWTToken, getAuthorizationHeader } from "../Utils/AuthorizationUtils";
import { DefaultExperienceUtility } from "../Shared/DefaultExperienceUtility";
import { DialogComponentAdapter } from "./Controls/DialogReactComponent/DialogComponentAdapter";
@@ -85,6 +86,7 @@ import { NotificationsClientBase } from "../Common/NotificationsClientBase";
import { ContextualPaneBase } from "./Panes/ContextualPaneBase";
import TabsBase from "./Tabs/TabsBase";
import { CommandButtonComponentProps } from "./Controls/CommandButton/CommandButtonComponent";
import { updateUserContext, userContext } from "../UserContext";
BindingHandlersRegisterer.registerBindingHandlers();
// Hold a reference to ComponentRegisterer to prevent transpiler to ignore import
@@ -203,11 +205,15 @@ export default class Explorer {
public setupNotebooksPane: SetupNotebooksPane;
public gitHubReposPane: ContextualPaneBase;
public publishNotebookPaneAdapter: ReactAdapter;
public copyNotebookPaneAdapter: ReactAdapter;
// features
public isGalleryPublishEnabled: ko.Computed<boolean>;
public isCodeOfConductEnabled: ko.Computed<boolean>;
public isLinkInjectionEnabled: ko.Computed<boolean>;
public isGitHubPaneEnabled: ko.Observable<boolean>;
public isPublishNotebookPaneEnabled: ko.Observable<boolean>;
public isCopyNotebookPaneEnabled: ko.Observable<boolean>;
public isHostedDataExplorerEnabled: ko.Computed<boolean>;
public isRightPanelV2Enabled: ko.Computed<boolean>;
public canExceedMaximumValue: ko.Computed<boolean>;
@@ -408,8 +414,15 @@ export default class Explorer {
this.isGalleryPublishEnabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableGalleryPublish)
);
this.isCodeOfConductEnabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableCodeOfConduct)
);
this.isLinkInjectionEnabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableLinkInjection)
);
this.isGitHubPaneEnabled = ko.observable<boolean>(false);
this.isPublishNotebookPaneEnabled = ko.observable<boolean>(false);
this.isCopyNotebookPaneEnabled = ko.observable<boolean>(false);
this.canExceedMaximumValue = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.canExceedMaximumValue)
@@ -471,7 +484,13 @@ export default class Explorer {
this.notificationConsoleData = ko.observableArray<ConsoleData>([]);
this.defaultExperience = ko.observable<string>();
this.databaseAccount.subscribe(databaseAccount => {
this.defaultExperience(DefaultExperienceUtility.getDefaultExperienceFromDatabaseAccount(databaseAccount));
const defaultExperience: string = DefaultExperienceUtility.getDefaultExperienceFromDatabaseAccount(
databaseAccount
);
this.defaultExperience(defaultExperience);
updateUserContext({
defaultExperience: DefaultExperienceUtility.mapDefaultExperienceStringToEnum(defaultExperience)
});
});
this.isPreferredApiDocumentDB = ko.computed(() => {
@@ -956,6 +975,10 @@ export default class Explorer {
this.sparkClusterConnectionInfo.valueHasMutated();
}
if (this.isFeatureEnabled(Constants.Features.enableSDKoperations)) {
updateUserContext({ useSDKOperations: true });
}
featureSubcription.dispose();
});
@@ -1403,7 +1426,7 @@ export default class Explorer {
const refreshDatabases = (offers?: DataModels.Offer[]) => {
this._setLoadingStatusText("Fetching databases...");
readDatabases(null /*options*/).then(
readDatabases().then(
(databases: DataModels.Database[]) => {
this._setLoadingStatusText("Successfully fetched databases.");
TelemetryProcessor.traceSuccess(
@@ -1456,38 +1479,33 @@ export default class Explorer {
);
};
if (this.isServerlessEnabled()) {
// Serverless accounts don't support offers call
refreshDatabases();
} else {
const offerPromise: Q.Promise<DataModels.Offer[]> = readOffers();
this._setLoadingStatusText("Fetching offers...");
offerPromise.then(
(offers: DataModels.Offer[]) => {
this._setLoadingStatusText("Successfully fetched offers.");
refreshDatabases(offers);
},
error => {
this._setLoadingStatusText("Failed to fetch offers.");
this.isRefreshingExplorer(false);
deferred.reject(error);
TelemetryProcessor.traceFailure(
Action.LoadDatabases,
{
databaseAccountName: this.databaseAccount().name,
defaultExperience: this.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree,
error: JSON.stringify(error)
},
startKey
);
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error while refreshing databases: ${JSON.stringify(error)}`
);
}
);
}
const offerPromise: Q.Promise<DataModels.Offer[]> = readOffers({ isServerless: this.isServerlessEnabled() });
this._setLoadingStatusText("Fetching offers...");
offerPromise.then(
(offers: DataModels.Offer[]) => {
this._setLoadingStatusText("Successfully fetched offers.");
refreshDatabases(offers);
},
error => {
this._setLoadingStatusText("Failed to fetch offers.");
this.isRefreshingExplorer(false);
deferred.reject(error);
TelemetryProcessor.traceFailure(
Action.LoadDatabases,
{
databaseAccountName: this.databaseAccount().name,
defaultExperience: this.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree,
error: JSON.stringify(error)
},
startKey
);
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error while refreshing databases: ${JSON.stringify(error)}`
);
}
);
return deferred.promise.then(
() => {
@@ -1603,7 +1621,7 @@ export default class Explorer {
private async _getArcadiaWorkspaces(): Promise<ArcadiaWorkspaceItem[]> {
try {
const workspaces = await this._arcadiaManager.listWorkspacesAsync([CosmosClient.subscriptionId()]);
const workspaces = await this._arcadiaManager.listWorkspacesAsync([userContext.subscriptionId]);
let workspaceItems: ArcadiaWorkspaceItem[] = new Array(workspaces.length);
const sparkPromises: Promise<void>[] = [];
workspaces.forEach((workspace, i) => {
@@ -1706,7 +1724,7 @@ export default class Explorer {
}
try {
const workspaces = await this.notebookWorkspaceManager.getNotebookWorkspacesAsync(databaseAccount.id);
const workspaces = await this.notebookWorkspaceManager.getNotebookWorkspacesAsync(databaseAccount?.id);
return workspaces && workspaces.length > 0 && workspaces.some(workspace => workspace.name === "default");
} catch (error) {
Logger.logError(error, "Explorer/_containsDefaultNotebookWorkspace");
@@ -1719,6 +1737,7 @@ export default class Explorer {
return;
}
let clearMessage;
try {
const notebookWorkspace = await this.notebookWorkspaceManager.getNotebookWorkspaceAsync(
this.databaseAccount().id,
@@ -1730,10 +1749,14 @@ export default class Explorer {
notebookWorkspace.properties.status &&
notebookWorkspace.properties.status.toLowerCase() === "stopped"
) {
clearMessage = NotificationConsoleUtils.logConsoleProgress("Initializing notebook workspace");
await this.notebookWorkspaceManager.startNotebookWorkspaceAsync(this.databaseAccount().id, "default");
}
} catch (error) {
Logger.logError(error, "Explorer/ensureNotebookWorkspaceRunning");
NotificationConsoleUtils.logConsoleError(`Failed to initialize notebook workspace: ${JSON.stringify(error)}`);
} finally {
clearMessage && clearMessage();
}
}
@@ -1808,8 +1831,8 @@ export default class Explorer {
const isRunningInPortal = window.dataExplorerPlatform == PlatformType.Portal;
const isRunningInDevMode = process.env.NODE_ENV === "development";
if (inputs && config.BACKEND_ENDPOINT && isRunningInPortal && isRunningInDevMode) {
inputs.extensionEndpoint = config.PROXY_PATH;
if (inputs && configContext.BACKEND_ENDPOINT && isRunningInPortal && isRunningInDevMode) {
inputs.extensionEndpoint = configContext.PROXY_PATH;
}
const initPromise: Q.Promise<void> = inputs ? this.initDataExplorerWithFrameInputs(inputs) : Q();
@@ -1914,7 +1937,7 @@ export default class Explorer {
this.features(inputs.features);
this.serverId(inputs.serverId);
this.extensionEndpoint(inputs.extensionEndpoint || "");
this.armEndpoint(EnvironmentUtility.normalizeArmEndpointUri(inputs.csmEndpoint || config.ARM_ENDPOINT));
this.armEndpoint(EnvironmentUtility.normalizeArmEndpointUri(inputs.csmEndpoint || configContext.ARM_ENDPOINT));
this.notificationsClient.setExtensionEndpoint(this.extensionEndpoint());
this.databaseAccount(databaseAccount);
this.subscriptionType(inputs.subscriptionType);
@@ -1930,11 +1953,17 @@ export default class Explorer {
this._importExplorerConfigComplete = true;
CosmosClient.authorizationToken(authorizationToken);
CosmosClient.masterKey(masterKey);
CosmosClient.databaseAccount(databaseAccount);
CosmosClient.subscriptionId(inputs.subscriptionId);
CosmosClient.resourceGroup(inputs.resourceGroup);
updateConfigContext({
ARM_ENDPOINT: this.armEndpoint()
});
updateUserContext({
authorizationToken,
masterKey,
databaseAccount,
resourceGroup: inputs.resourceGroup,
subscriptionId: inputs.subscriptionId
});
TelemetryProcessor.traceSuccess(
Action.LoadDatabaseAccount,
{
@@ -2179,7 +2208,7 @@ export default class Explorer {
return undefined;
}
const urlPrefixWithKeyParam: string = `${config.hostedExplorerURL}?key=`;
const urlPrefixWithKeyParam: string = `${configContext.hostedExplorerURL}?key=`;
const currentActiveTab = this.tabsManager.activeTab();
return `${urlPrefixWithKeyParam}${token}#/${(currentActiveTab && currentActiveTab.hashLocation()) || ""}`;
@@ -2291,7 +2320,7 @@ export default class Explorer {
return _.find(offers, (offer: DataModels.Offer) => offer.resource === resourceId);
}
private uploadFile(name: string, content: string, parent: NotebookContentItem): Promise<NotebookContentItem> {
public uploadFile(name: string, content: string, parent: NotebookContentItem): Promise<NotebookContentItem> {
if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) {
const error = "Attempt to upload notebook, but notebook is not enabled";
Logger.logError(error, "Explorer/uploadFile");
@@ -2346,14 +2375,28 @@ export default class Explorer {
return Promise.resolve(false);
}
public publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): void {
public async publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): Promise<void> {
if (this.notebookManager) {
this.notebookManager.openPublishNotebookPane(name, content, parentDomElement);
await this.notebookManager.openPublishNotebookPane(
name,
content,
parentDomElement,
this.isCodeOfConductEnabled(),
this.isLinkInjectionEnabled()
);
this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter;
this.isPublishNotebookPaneEnabled(true);
}
}
public copyNotebook(name: string, content: string): void {
if (this.notebookManager) {
this.notebookManager.openCopyNotebookPane(name, content);
this.copyNotebookPaneAdapter = this.notebookManager.copyNotebookPaneAdapter;
this.isCopyNotebookPaneEnabled(true);
}
}
public showOkModalDialog(title: string, msg: string): void {
this._dialogProps({
isModal: true,
@@ -2455,14 +2498,14 @@ export default class Explorer {
this.tabsManager.activateTab(notebookTab);
} else {
const options: NotebookTabOptions = {
account: CosmosClient.databaseAccount(),
account: userContext.databaseAccount,
tabKind: ViewModels.CollectionTabKind.NotebookV2,
node: null,
title: notebookContentItem.name,
tabPath: notebookContentItem.path,
collection: null,
selfLink: null,
masterKey: CosmosClient.masterKey() || "",
masterKey: userContext.masterKey || "",
hashLocation: "notebooks",
isActive: ko.observable(false),
isTabsContentExpanded: ko.observable(true),
@@ -2652,7 +2695,7 @@ export default class Explorer {
}
public _refreshSparkEnabledStateForAccount = async (): Promise<void> => {
const subscriptionId = CosmosClient.subscriptionId();
const subscriptionId = userContext.subscriptionId;
const armEndpoint = this.armEndpoint();
const authType = window.authType as AuthType;
if (!subscriptionId || !armEndpoint || authType === AuthType.EncryptedToken) {
@@ -2681,7 +2724,7 @@ export default class Explorer {
};
public _isAfecFeatureRegistered = async (featureName: string): Promise<boolean> => {
const subscriptionId = CosmosClient.subscriptionId();
const subscriptionId = userContext.subscriptionId;
const armEndpoint = this.armEndpoint();
const authType = window.authType as AuthType;
if (!featureName || !subscriptionId || !armEndpoint || authType === AuthType.EncryptedToken) {
@@ -2710,6 +2753,7 @@ export default class Explorer {
}
await this.resourceTree.initialize();
this.notebookManager?.refreshPinnedRepos();
if (this.notebookToImport) {
this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content);
}
@@ -2900,7 +2944,7 @@ export default class Explorer {
this.tabsManager.activateTab(terminalTab);
} else {
const newTab = new TerminalTab({
account: CosmosClient.databaseAccount(),
account: userContext.databaseAccount,
tabKind: ViewModels.CollectionTabKind.Terminal,
node: null,
title: title,
@@ -2939,7 +2983,7 @@ export default class Explorer {
const newTab = new this.galleryTab.default({
// GalleryTabOptions
account: CosmosClient.databaseAccount(),
account: userContext.databaseAccount,
container: this,
junoClient: this.notebookManager?.junoClient,
notebookUrl,
@@ -2986,7 +3030,7 @@ export default class Explorer {
this.tabsManager.activateNewTab(notebookViewerTab);
} else {
notebookViewerTab = new this.notebookViewerTab.default({
account: CosmosClient.databaseAccount(),
account: userContext.databaseAccount,
tabKind: ViewModels.CollectionTabKind.NotebookViewer,
node: null,
title: title,

View File

@@ -87,13 +87,31 @@ describe("getPkIdFromDocumentId", () => {
expect(GraphExplorer.getPkIdFromDocumentId(doc, "mypk")).toEqual("['pkvalue', 'id']");
});
it("should create pkid pair from partitioned graph (pk as number)", () => {
const doc = createFakeDoc({ id: "id", mypk: 234 });
expect(GraphExplorer.getPkIdFromDocumentId(doc, "mypk")).toEqual("[234, 'id']");
});
it("should create pkid pair from partitioned graph (pk as boolean)", () => {
const doc = createFakeDoc({ id: "id", mypk: true });
expect(GraphExplorer.getPkIdFromDocumentId(doc, "mypk")).toEqual("[true, 'id']");
});
it("should create pkid pair from partitioned graph (pk as valid array value)", () => {
const doc = createFakeDoc({ id: "id", mypk: [{ id: "someid", _value: "pkvalue" }] });
expect(GraphExplorer.getPkIdFromDocumentId(doc, "mypk")).toEqual("['pkvalue', 'id']");
});
it("should error if id is not a string", () => {
const doc = createFakeDoc({ id: { foo: 1 } });
it("should error if id is not a string or number", () => {
let doc = createFakeDoc({ id: { foo: 1 } });
try {
GraphExplorer.getPkIdFromDocumentId(doc, undefined);
expect(true).toBe(false);
} catch (e) {
expect(true).toBe(true);
}
doc = createFakeDoc({ id: true });
try {
GraphExplorer.getPkIdFromDocumentId(doc, undefined);
expect(true).toBe(false);
@@ -102,16 +120,8 @@ describe("getPkIdFromDocumentId", () => {
}
});
it("should error if pk not string nor non-empty array", () => {
let doc = createFakeDoc({ mypk: { foo: 1 } });
try {
GraphExplorer.getPkIdFromDocumentId(doc, "mypk");
} catch (e) {
expect(true).toBe(true);
}
doc = createFakeDoc({ mypk: [] });
it("should error if pk is empty array", () => {
let doc = createFakeDoc({ mypk: [] });
try {
GraphExplorer.getPkIdFromDocumentId(doc, "mypk");
expect(true).toBe(false);

View File

@@ -1371,7 +1371,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
if (collectionPartitionKeyProperty && d.hasOwnProperty(collectionPartitionKeyProperty)) {
let pk = (d as any)[collectionPartitionKeyProperty];
if (typeof pk !== "string") {
if (typeof pk !== "string" && typeof pk !== "number" && typeof pk !== "boolean") {
if (Array.isArray(pk) && pk.length > 0) {
// pk is [{ id: 'id', _value: 'value' }]
pk = pk[0]["_value"];

View File

@@ -24,7 +24,7 @@ import NewNotebookIcon from "../../../../images/notebook/Notebook-new.svg";
import ResetWorkspaceIcon from "../../../../images/notebook/Notebook-reset-workspace.svg";
import GitHubIcon from "../../../../images/github.svg";
import SynapseIcon from "../../../../images/synapse-link.svg";
import { config, Platform } from "../../../Config";
import { configContext, Platform } from "../../../ConfigContext";
import Explorer from "../../Explorer";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
@@ -243,7 +243,7 @@ export class CommandBarComponentButtonFactory {
}
private static createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonComponentProps {
if (config.platform === Platform.Emulator) {
if (configContext.platform === Platform.Emulator) {
return null;
}
@@ -469,7 +469,7 @@ export class CommandBarComponentButtonFactory {
}
private static createEnableNotebooksButton(container: Explorer): CommandButtonComponentProps {
if (config.platform === Platform.Emulator) {
if (configContext.platform === Platform.Emulator) {
return null;
}
const label = "Enable Notebooks (Preview)";

View File

@@ -0,0 +1,464 @@
import { Channels } from "@nteract/messaging";
import * as monaco from "./monaco";
import * as React from "react";
import { completionProvider } from "./completions/completionItemProvider";
import { AppState, ContentRef } from "@nteract/core";
import { connect } from "react-redux";
import "./styles.css";
import { LightThemeName, HCLightThemeName, DarkThemeName } from "./theme";
// import { logger } from "src/common/localLogger";
import { getCellMonacoLanguage } from "./selectors";
// import { DocumentUri } from "./documentUri";
export type IModelContentChangedEvent = monaco.editor.IModelContentChangedEvent;
/**
* Initial props for Monaco received from agnostic component
*/
export interface IMonacoProps {
id: string;
contentRef: ContentRef;
modelUri?: monaco.Uri;
theme: monaco.editor.IStandaloneThemeData | monaco.editor.BuiltinTheme | string;
cellLanguageOverride?: string;
notebookLanguageOverride?: string;
readOnly?: boolean;
channels: Channels | undefined;
enableCompletion: boolean;
shouldRegisterDefaultCompletion?: boolean;
onChange: (value: string, event?: unknown) => void;
onFocusChange: (focus: boolean) => void;
onCursorPositionChange?: (selection: monaco.ISelection | null) => void;
onRegisterCompletionProvider?: (languageId: string) => void;
value: string;
editorFocused: boolean;
lineNumbers: boolean;
/** set height of editor to fit the specified number of lines in display */
numberOfLines?: number;
options?: monaco.editor.IEditorOptions;
}
/**
* Monaco specific props derived from State
*/
interface IMonacoStateProps {
language: string;
}
// Cache the custom theme data to avoid repeatly defining the custom theme
let customThemeData: monaco.editor.IStandaloneThemeData;
function getMonacoTheme(theme: monaco.editor.IStandaloneThemeData | monaco.editor.BuiltinTheme | string) {
if (typeof theme === "string") {
switch (theme) {
case "vs-dark":
return DarkThemeName;
case "hc-black":
return "hc-black";
case "vs":
return LightThemeName;
case "hc-light":
return HCLightThemeName;
default:
return LightThemeName;
}
} else if (theme === undefined || typeof theme === "undefined") {
return LightThemeName;
} else {
const themeName = "custom-vs";
// Skip redefining the same custom theme if it is the same theme data.
if (customThemeData !== theme) {
monaco.editor.defineTheme(themeName, theme);
customThemeData = theme;
}
return themeName;
}
}
const makeMapStateToProps = (initialState: AppState, initialProps: IMonacoProps) => {
const { id, contentRef } = initialProps;
const mapStateToProps = (state: AppState, ownProps: IMonacoProps & IMonacoStateProps) => {
return {
language: getCellMonacoLanguage(
state,
contentRef,
id,
ownProps.cellLanguageOverride,
ownProps.notebookLanguageOverride
)
};
};
return mapStateToProps;
};
/**
* Creates a MonacoEditor instance within the MonacoContainer div
*/
export class MonacoEditor extends React.Component<IMonacoProps & IMonacoStateProps> {
editor?: monaco.editor.IStandaloneCodeEditor;
editorContainerRef = React.createRef<HTMLDivElement>();
contentHeight?: number;
private cursorPositionListener?: monaco.IDisposable;
constructor(props: IMonacoProps & IMonacoStateProps) {
super(props);
this.onFocus = this.onFocus.bind(this);
this.onBlur = this.onBlur.bind(this);
this.calculateHeight = this.calculateHeight.bind(this);
}
onDidChangeModelContent(e: monaco.editor.IModelContentChangedEvent): void {
if (this.editor) {
if (this.props.onChange) {
this.props.onChange(this.editor.getValue(), e);
}
this.calculateHeight();
}
}
/**
* Adjust the height of editor
*
* @remarks
* The way to determine how many lines we should display in editor:
* If numberOfLines is not set or set to 0, we adjust the height to fit the content
* If numberOfLines is specified we respect that setting
*/
calculateHeight(): void {
// Make sure we have an editor
if (!this.editor) {
return;
}
// Make sure we have a model
const model = this.editor.getModel();
if (!model) {
return;
}
if (this.editorContainerRef && this.editorContainerRef.current) {
const expectedLines = this.props.numberOfLines || model.getLineCount();
// The find & replace menu takes up 2 lines, that is why 2 line is set as the minimum number of lines
// TODO: we should either disable the find/replace menu or auto expand the editor when find/replace is triggerred.
const finalizedLines = Math.max(expectedLines, 1) + 1;
const lineHeight = this.editor.getConfiguration().lineHeight;
const contentHeight = finalizedLines * lineHeight;
if (this.contentHeight !== contentHeight) {
this.editorContainerRef.current.style.height = contentHeight + "px";
this.editor.layout();
this.contentHeight = contentHeight;
}
}
}
componentDidMount(): void {
if (this.editorContainerRef && this.editorContainerRef.current) {
// Register Jupyter completion provider if needed
this.registerCompletionProvider();
// Use Monaco model uri if provided. Otherwise, create a new model uri using editor id.
const uri = this.props.modelUri ? this.props.modelUri : monaco.Uri.file(this.props.id);
// Only create a new model if it does not exist. For example, when we double click on a markdown cell,
// an editor model is created for it. Once we go back to markdown preview mode that doesn't use the editor,
// double clicking on the markdown cell will again instantiate a monaco editor. In that case, we should
// rebind the previously created editor model for the markdown instead of recreating one. Monaco does not
// allow models to be recreated with the same uri.
let model = monaco.editor.getModel(uri);
if (!model) {
model = monaco.editor.createModel(this.props.value, this.props.language, uri);
}
// Create Monaco editor backed by a Monaco model.
this.editor = monaco.editor.create(this.editorContainerRef.current, {
// Following are the default settings
minimap: {
enabled: false
},
autoIndent: true,
overviewRulerLanes: 1,
scrollbar: {
useShadows: false,
verticalHasArrows: false,
horizontalHasArrows: false,
vertical: "hidden",
horizontal: "hidden",
verticalScrollbarSize: 0,
horizontalScrollbarSize: 0,
arrowSize: 30
},
scrollBeyondLastLine: false,
find: {
// TODO Need this?
// addExtraSpaceOnTop: false, // pops the editor out of alignment if turned on
seedSearchStringFromSelection: true, // default is true
autoFindInSelection: false // default is false
},
// Disable highlight current line, too much visual noise with it on.
// VS Code also has it disabled for their notebook experience.
renderLineHighlight: "none",
// Allow editor pop up widgets such as context menus, signature help, hover tips to be able to be
// displayed outside of the editor. Without this, the pop up widgets can be clipped.
fixedOverflowWidgets: true,
// Apply custom settings from configuration
...this.props.options,
// Apply specific settings passed-in as direct props
model,
value: this.props.value,
language: this.props.language,
readOnly: this.props.readOnly,
lineNumbers: this.props.lineNumbers ? "on" : "off",
theme: getMonacoTheme(this.props.theme)
});
this.addEditorTopMargin();
// Ignore Ctrl + Enter
// tslint:disable-next-line no-bitwise
this.editor.addCommand(
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
() => {
// Do nothing. This is handled elsewhere, we just don't want the editor to put the newline.
},
undefined
);
// TODO Add right context
this.toggleEditorOptions(this.props.editorFocused);
if (this.props.editorFocused) {
if (!this.editor.hasTextFocus()) {
// Bring browser focus to the editor if text not already in focus
this.editor.focus();
}
this.registerCursorListener();
}
// TODO: Need to remove the event listener when the editor is disposed, or we have a memory leak here.
// The same applies to the other event listeners below
// Adds listener under the resize window event which calls the resize method
window.addEventListener("resize", this.resize.bind(this));
// Adds listeners for undo and redo actions emitted from the toolbar
this.editorContainerRef.current.addEventListener("undo", () => {
if (this.editor) {
this.editor.trigger("undo-event", "undo", {});
}
});
this.editorContainerRef.current.addEventListener("redo", () => {
if (this.editor) {
this.editor.trigger("redo-event", "redo", {});
}
});
this.editor.onDidChangeModelContent(this.onDidChangeModelContent.bind(this));
this.editor.onDidFocusEditorText(this.onFocus);
this.editor.onDidBlurEditorText(this.onBlur);
this.calculateHeight();
// FIXME: This might need further investigation as the props value should be respected in construction
// The following is a mitigation measure till that time
// Ensures that the source contents of the editor (value) is consistent with the state of the editor
this.editor.setValue(this.props.value);
}
}
addEditorTopMargin(): void {
if (this.editor) {
// Monaco editor doesn't have margins
// https://github.com/notable/notable/issues/551
// This is a workaround to add an editor area 12px padding at the top
// so that cursors rendered by collab decorators could be visible without being cut.
this.editor.changeViewZones(changeAccessor => {
const domNode = document.createElement("div");
changeAccessor.addZone({
afterLineNumber: 0,
heightInPx: 12,
domNode
});
});
}
}
/**
* Tells editor to check the surrounding container size and resize itself appropriately
*/
resize(): void {
if (this.editor && this.props.editorFocused) {
this.editor.layout();
}
}
componentDidUpdate(): void {
if (!this.editor) {
return;
}
const { value, channels, /* language, contentRef, id,*/ editorFocused, theme } = this.props;
// Ensures that the source contents of the editor (value) is consistent with the state of the editor
if (this.editor.getValue() !== value) {
this.editor.setValue(value);
}
completionProvider.setChannels(channels);
// Register Jupyter completion provider if needed
this.registerCompletionProvider();
/*
// Apply new model to the editor when the language is changed.
const model = this.editor.getModel();
if (model && language && model.getModeId() !== language) {
const newUri = DocumentUri.createCellUri(contentRef, id, language);
if (!monaco.editor.getModel(newUri)) {
// Save the cursor position before we set new model.
const position = this.editor.getPosition();
// Set new model targeting the changed language.
this.editor.setModel(monaco.editor.createModel(value, language, newUri));
this.addEditorTopMargin();
// Restore cursor position to new model.
if (position) {
this.editor.setPosition(position);
}
// Dispose of the old model in a seperate event. We cannot dispose of the model within the
// componentDidUpdate method or else the editor will throw an exception. Zero in the timeout field
// means execute immediately but in a seperate next event.
setTimeout(() => model.dispose(), 0);
}
}
*/
if (theme) {
monaco.editor.setTheme(getMonacoTheme(theme));
}
// In the multi-tabs scenario, when the notebook is hidden by setting "display:none",
// Any state update propagated here would cause a UI re-layout, monaco-editor will then recalculate
// and set its height to 5px.
// To work around that issue, we skip updating the UI when parent element's offsetParent is null (which
// indicate an ancient element is hidden by display set to none)
// We may revisit this when we get to refactor for multi-notebooks.
if (!this.editorContainerRef.current?.offsetParent) {
return;
}
// Set focus
if (editorFocused && !this.editor.hasTextFocus()) {
this.editor.focus();
}
// Tells the editor pane to check if its container has changed size and fill appropriately
this.editor.layout();
}
componentWillUnmount(): void {
if (this.editor) {
try {
const model = this.editor.getModel();
if (model) {
model.dispose();
}
this.editor.dispose();
} catch (err) {
console.error(`Error occurs in disposing editor: ${JSON.stringify(err)}`);
}
}
}
render(): JSX.Element {
return (
<div className="monaco-container">
<div ref={this.editorContainerRef} id={`editor-${this.props.id}`} />
</div>
);
}
/**
* Register default kernel-based completion provider.
* @param language Language
*/
registerDefaultCompletionProvider(language: string): void {
// onLanguage event is emitted only once per language when language is first time needed.
monaco.languages.onLanguage(language, () => {
monaco.languages.registerCompletionItemProvider(language, completionProvider);
});
}
private onFocus() {
this.props.onFocusChange(true);
this.toggleEditorOptions(true);
this.registerCursorListener();
}
private onBlur() {
this.props.onFocusChange(false);
this.toggleEditorOptions(false);
this.unregisterCursorListener();
}
private registerCursorListener() {
if (this.editor && this.props.onCursorPositionChange) {
const selection = this.editor.getSelection();
this.props.onCursorPositionChange(selection);
if (!this.cursorPositionListener) {
this.cursorPositionListener = this.editor.onDidChangeCursorSelection(event =>
this.props.onCursorPositionChange!(event.selection)
);
}
}
}
private unregisterCursorListener() {
if (this.cursorPositionListener) {
this.cursorPositionListener.dispose();
this.cursorPositionListener = undefined;
}
}
/**
* Toggle editor options based on if the editor is in active state (i.e. focused).
* When the editor is not active, we want to deactivate some of the visual noise.
* @param isActive Whether editor is active.
*/
private toggleEditorOptions(isActive: boolean) {
if (this.editor) {
this.editor.updateOptions({
matchBrackets: isActive,
occurrencesHighlight: isActive,
renderIndentGuides: isActive
});
}
}
/**
* Register language features for target language. Call before setting language type to model.
*/
private registerCompletionProvider() {
const { enableCompletion, language, onRegisterCompletionProvider, shouldRegisterDefaultCompletion } = this.props;
if (enableCompletion && language) {
if (onRegisterCompletionProvider) {
onRegisterCompletionProvider(language);
} else if (shouldRegisterDefaultCompletion) {
this.registerDefaultCompletionProvider(language);
}
}
}
}
export default connect<IMonacoStateProps, void, IMonacoProps, AppState>(makeMapStateToProps)(MonacoEditor);

View File

@@ -0,0 +1,239 @@
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
// import * as monaco from "../monaco";
import { Observable, Observer } from "rxjs";
import { first, map } from "rxjs/operators";
import { childOf, JupyterMessage, ofMessageType, Channels } from "@nteract/messaging";
/**
* TODO: import from nteract when the changes under editor-base.ts are ported to nteract.
*/
import { CompletionResults, CompletionMatch, completionRequest, js_idx_to_char_idx } from "../editor-base";
/**
* Jupyter to Monaco completion item kinds.
*/
const unknownJupyterKind = "<unknown>";
const jupyterToMonacoCompletionItemKind = {
[unknownJupyterKind]: monaco.languages.CompletionItemKind.Field,
class: monaco.languages.CompletionItemKind.Class,
function: monaco.languages.CompletionItemKind.Function,
keyword: monaco.languages.CompletionItemKind.Keyword,
instance: monaco.languages.CompletionItemKind.Variable,
statement: monaco.languages.CompletionItemKind.Variable
};
/**
* Completion item provider.
*/
class CompletionItemProvider implements monaco.languages.CompletionItemProvider {
private channels: Channels | undefined;
/**
* Set Channels of Jupyter kernel.
* @param channels Channels of Jupyter kernel.
*/
setChannels(channels: Channels | undefined) {
this.channels = channels;
}
/**
* Whether provider is connected to Jupyter kernel.
*/
get isConnectedToKernel() {
return !!this.channels;
}
/**
* Additional characters to trigger completion other than Ctrl+Space.
*/
get triggerCharacters() {
return [" ", "<", "/", ".", "="];
}
/**
* Get list of completion items at position of cursor.
* @param model Monaco editor text model.
* @param position Position of cursor.
*/
async provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position) {
// Convert to zero-based index
let cursorPos = model.getOffsetAt(position);
const code = model.getValue();
cursorPos = js_idx_to_char_idx(cursorPos, code);
// Get completions from Jupyter kernel if its Channels is connected
let items = [];
if (this.channels) {
try {
const message = completionRequest(code, cursorPos);
items = await this.codeCompleteObservable(this.channels, message, model).toPromise();
} catch (error) {
// Temporary log error to console until we settle on how we log in V3
// tslint:disable-next-line
console.error(error);
}
}
return Promise.resolve<monaco.languages.CompletionList>({
suggestions: items,
incomplete: false
});
}
/**
* Get list of completion items from Jupyter kernel.
* @param channels Channels of Jupyter kernel.
* @param message Jupyter message for completion request.
* @param model Text model.
*/
private codeCompleteObservable(channels: Channels, message: JupyterMessage, model: monaco.editor.ITextModel) {
// Process completion response
const completion$ = channels.pipe(
childOf(message),
ofMessageType("complete_reply"),
map(entry => entry.content),
first(),
map(results => this.adaptToMonacoCompletions(results, model))
);
// Subscribe and send completion request message
return Observable.create((observer: Observer<unknown>) => {
const subscription = completion$.subscribe(observer);
channels.next(message);
return subscription;
});
}
/**
* Converts Jupyter completion result to list of Monaco completion items.
*/
private adaptToMonacoCompletions(results: CompletionResults, model: monaco.editor.ITextModel) {
let range: monaco.IRange;
let percentCount = 0;
let matches = results ? results.matches : [];
if (results.metadata && results.metadata._jupyter_types_experimental) {
matches = results.metadata._jupyter_types_experimental as CompletionMatch[];
}
return matches.map((match: CompletionMatch, index: number) => {
if (typeof match === "string") {
const text = this.sanitizeText(match);
const filtered = this.getFilterText(text);
return {
kind: this.adaptToMonacoCompletionItemKind(unknownJupyterKind),
label: text,
insertText: text,
filterText: filtered,
sortText: this.getSortText(index)
} as monaco.languages.CompletionItem;
} else {
// We only need to get the range once as the range is the same for all completion items in the list.
if (!range) {
const start = model.getPositionAt(match.start);
const end = model.getPositionAt(match.end);
range = {
startLineNumber: start.lineNumber,
startColumn: start.column,
endLineNumber: end.lineNumber,
endColumn: end.column
};
// Get the range representing the text before the completion action was invoked.
// If the text starts with magics % indicator, we need to track how many of these indicators exist
// so that we ensure the insertion text only inserts the delta between what the user typed versus
// what is recommended by the completion. Without this, there will be extra % insertions.
// Example:
// User types %%p then suggestion list will recommend %%python, if we now commit the item then the
// final text in the editor becomes %%p%%python instead of %%python. This is why the tracking code
// below is needed. This behavior is only specific to the magics % indicators as Monaco does not
// handle % characters in their completion list well.
const rangeText = model.getValueInRange(range);
if (rangeText.startsWith("%%")) {
percentCount = 2;
} else if (rangeText.startsWith("%")) {
percentCount = 1;
}
}
const text = this.sanitizeText(match.text);
const filtered = this.getFilterText(text);
const insert = this.getInsertText(text, percentCount);
return {
kind: this.adaptToMonacoCompletionItemKind(match.type as keyof typeof jupyterToMonacoCompletionItemKind),
label: text,
insertText: percentCount > 0 ? insert : text,
filterText: filtered,
sortText: this.getSortText(index)
} as monaco.languages.CompletionItem;
}
});
}
/**
* Converts Jupyter completion item kind to Monaco completion item kind.
* @param kind Jupyter completion item kind.
*/
private adaptToMonacoCompletionItemKind(kind: keyof typeof jupyterToMonacoCompletionItemKind) {
const result = jupyterToMonacoCompletionItemKind[kind];
return result ? result : jupyterToMonacoCompletionItemKind[unknownJupyterKind];
}
/**
* Remove everything before a dot. Jupyter completion results like to include all characters before
* the trigger character. For example, if user types "myarray.", we expect the completion results to
* show "append", "pop", etc. but for the actual case, it will show "myarray.append", "myarray.pop",
* etc. so we are going to sanitize the text.
* @param text Text of Jupyter completion item
*/
private sanitizeText(text: string) {
const index = text.lastIndexOf(".");
return index > -1 && index < text.length - 1 ? text.substring(index + 1) : text;
}
/**
* Remove magics all % characters as Monaco doesn't like them for the filtering text.
* Without this, completion won't show magics match items.
* @param text Text of Jupyter completion item.
*/
private getFilterText(text: string) {
return text.replace(/%/g, "");
}
/**
* Get insertion text handling what to insert for the magics case depending on what
* has already been typed.
* @param text Text of Jupyter completion item.
* @param percentCount Number of percent characters to remove
*/
private getInsertText(text: string, percentCount: number) {
for (let i = 0; i < percentCount; i++) {
text = text.replace("%", "");
}
return text;
}
/**
* Maps numbers to strings, such that if a>b numerically, f(a)>f(b) lexicograhically.
* 1 -> "za", 26 -> "zz", 27 -> "zza", 28 -> "zzb", 52 -> "zzz", 53 ->"zzza"
* @param order Number to be converted to a sorting-string. order >= 0.
* @returns A string representing the order.
*/
private getSortText(order: number): string {
order++;
const numCharacters = 26; // "z" - "a" + 1;
const div = Math.floor(order / numCharacters);
let sortText = "z";
for (let i = 0; i < div; i++) {
sortText += "z";
}
const remainder = order % numCharacters;
if (remainder > 0) {
sortText += String.fromCharCode(96 + remainder);
}
return sortText;
}
}
const completionProvider = new CompletionItemProvider();
export { completionProvider };

View File

@@ -0,0 +1,44 @@
import Immutable from "immutable";
import * as monaco from "./monaco";
/**
* Code Mirror to Monaco constants.
*/
export enum Mode {
markdown = "markdown",
raw = "plaintext",
python = "python",
csharp = "csharp"
}
/**
* Maps Code Mirror mode to a valid Monaco Editor supported langauge
* defaults to plaintext if map not found.
* @param mode Code Mirror mode
* @returns Monaco language
*/
export function mapCodeMirrorModeToMonaco(mode: string | { name: string }): string {
let language = "";
// Parse codemirror mode object
if (typeof mode === "string") {
language = mode;
}
// Vanilla object
else if (typeof mode === "object" && mode.name) {
language = mode.name;
}
// Immutable Map
else if (Immutable.Map.isMap(mode) && mode.has("name")) {
language = mode.get("name");
}
// Need to handle "ipython" as a special case since it is not a registered language
if (language === "ipython") {
return Mode.python;
} else if (language === "text/x-csharp") {
return Mode.csharp;
} else if (monaco.languages.getEncodedLanguageId(language) > 0) {
return language;
}
return Mode.raw;
}

View File

@@ -0,0 +1,76 @@
// Disable linting on file since we will be moving the code below to nteract which have different rules configured.
// tslint:disable:variable-name
// tslint:disable:interface-name
/**
* TODO: Create new editor-base package in nteract repo and move all code below to new package.
*/
import { createMessage } from "@nteract/messaging";
/**
* Jupyter messaging protocol's _jupyter_types_experimental completion result.
*/
interface CompletionResult {
end: number;
start: number;
type: string;
text: string;
displayText?: string;
}
/**
* Juptyer completion match item.
*/
export type CompletionMatch = string | CompletionResult;
/**
* Jupyter messaging protocol's complete_reply response.
*/
export interface CompletionResults {
status: string;
cursor_start: number;
cursor_end: number;
matches: CompletionMatch[];
metadata?: {
_jupyter_types_experimental?: unknown;
};
}
/**
* Create Jupyter messaging protocol's complete_request message.
* @param code Code of editor.
* @param cursorPos cursor position represented in the Jupyter messaging protocol (character position)
*/
export const completionRequest = (code: string, cursorPos: number) =>
createMessage("complete_request", {
content: {
code,
cursor_pos: cursorPos
}
});
/**
* JavaScript stores text as utf16 and string indices use "code units",
* which stores high-codepoint characters as "surrogate pairs",
* which occupy two indices in the JavaScript string.
* We need to translate cursor_pos in the protocol (in characters)
* to js offset (with surrogate pairs taking two spots).
* @param js_idx JavaScript index
* @param text Text
*/
export const js_idx_to_char_idx: (js_idx: number, text: string) => number = (js_idx: number, text: string): number => {
let char_idx: number = js_idx;
for (let i = 0; i + 1 < text.length && i < js_idx; i++) {
const char_code: number = text.charCodeAt(i);
// check for surrogate pair
if (char_code >= 0xd800 && char_code <= 0xdbff) {
const next_char_code: number = text.charCodeAt(i + 1);
if (next_char_code >= 0xdc00 && next_char_code <= 0xdfff) {
char_idx--;
i++;
}
}
}
return char_idx;
};

View File

@@ -0,0 +1,10 @@
export * from "monaco-editor/esm/vs/editor/editor.api";
// /**
// * Set the custom worker url to workaround the cross-domain issue with creating web worker
// * See https://github.com/microsoft/monaco-editor/blob/master/docs/integrate-amd-cross.md for more details
// * This step has to be executed after a importing of monaco-editor once per chunk to make sure
// * the custom worker url overwrites the one from monaco-editor module itself.
// */
// import { setMonacoWorkerUrl } from "./workerUrl";
// setMonacoWorkerUrl();

View File

@@ -0,0 +1,67 @@
import { AppState, ContentRef, selectors as nteractSelectors } from "@nteract/core";
import { CellId } from "@nteract/commutable";
import { Mode, mapCodeMirrorModeToMonaco } from "./converter";
/**
* Returns the language to use for syntax highlighting and autocompletion in the Monaco Editor for a given cell, falling back to the notebook language if one for the cell is not defined.
*/
export const getCellMonacoLanguage = (
state: AppState,
contentRef: ContentRef,
cellId: CellId,
cellLanguageOverride?: string,
notebookLanguageOverride?: string
): string => {
const model = nteractSelectors.model(state, { contentRef });
if (!model || model.type !== "notebook") {
throw new Error("Connected Editor components should not be used with non-notebook models");
}
const cell = nteractSelectors.notebook.cellById(model, { id: cellId });
if (!cell) {
throw new Error("Invalid cell id");
}
switch (cell.cell_type) {
case "markdown":
return Mode.markdown;
case "raw":
return Mode.raw;
case "code":
if (cellLanguageOverride) {
return mapCodeMirrorModeToMonaco(cellLanguageOverride);
} else {
// Fall back to notebook language if cell language isn't present.
return getNotebookMonacoLanguage(state, contentRef, notebookLanguageOverride);
}
}
};
/**
* Returns the language to use for syntax highlighting and autocompletion in the Monaco Editor for a given notebook.
*/
export const getNotebookMonacoLanguage = (
state: AppState,
contentRef: ContentRef,
notebookLanguageOverride?: string
): string => {
const model = nteractSelectors.model(state, { contentRef });
if (!model || model.type !== "notebook") {
throw new Error("Connected Editor components should not be used with non-notebook models");
}
if (notebookLanguageOverride) {
return mapCodeMirrorModeToMonaco(notebookLanguageOverride);
}
const kernelRef = model.kernelRef;
let codeMirrorMode;
// Try to get the CodeMirror mode from the kernel.
if (kernelRef) {
codeMirrorMode = nteractSelectors.kernel(state, { kernelRef })?.info?.codemirrorMode;
}
// As a fallback, get the CodeMirror mode from the notebook itself.
codeMirrorMode = codeMirrorMode ?? nteractSelectors.notebook.codeMirrorMode(model);
return mapCodeMirrorModeToMonaco(codeMirrorMode);
};

View File

@@ -0,0 +1,22 @@
/*
For the following components, we use the inherited width values from monaco-container.
On resizing the browser, the width of monaco-container will be calculated
and we just use the calculated width for the following components
So we don't need to use Monaco editor's layout() function which is expensive operation and causes performance issues on resizing.
*/
/*
TODO: These styles below are added for resizing perf improvement.
Once the virtualization is implemented, we will revisit this later.
*/
.monaco-container .monaco-editor {
width: inherit !important;
}
.monaco-container .monaco-editor .overflow-guard {
width: inherit !important;
}
/* 26px is the left margin for .monaco-scrollable-element */
.monaco-container .monaco-editor .monaco-scrollable-element.editor-scrollable.vs {
width: calc(100% - 26px) !important;
}

View File

@@ -0,0 +1,75 @@
import * as monaco from "./monaco";
// TODO: move defineTheme calls to an initialization function
/**
* The default light theme with customized background
*/
export const LightThemeName = "vs-light";
/**
* Default monaco theme for light theme
*/
export const customMonacoLightTheme: monaco.editor.IStandaloneThemeData = {
base: "vs", // Derive from default light theme of Monaco
inherit: true,
rules: [],
colors: {
// We want Monaco background to use the same background for our themes.
// Without this, the Monaco light theme has a yellowish tone.
// Verified with UX that white meets all the accessbility requirements for light
// and high contrast light theme.
"editor.background": "#FFFFFF"
}
};
monaco.editor.defineTheme(LightThemeName, customMonacoLightTheme);
/**
* The default dark theme with customized background
*/
export const DarkThemeName = "aznb-dark";
/**
* Default monaco theme for dark theme
*/
export const customMonacoDarkTheme: monaco.editor.IStandaloneThemeData = {
base: "vs-dark", // Derive from default dark theme of Monaco
inherit: true,
rules: [],
colors: {
"editor.background": "#1b1a19"
}
};
monaco.editor.defineTheme(DarkThemeName, customMonacoDarkTheme);
/**
* The custom high contrast light theme with customized background
*/
export const HCLightThemeName = "hc-light";
/**
* Default monaco theme for light high contrast mode
*/
export const customMonacoHCLightTheme: monaco.editor.IStandaloneThemeData = {
base: "vs", // Derive from default light theme of Monaco; change all grey colors to black to comply with highcontrast rules
inherit: true,
rules: [
{ token: "annotation", foreground: "000000" },
{ token: "delimiter.html", foreground: "000000" },
{ token: "operator.scss", foreground: "000000" },
{ token: "operator.sql", foreground: "000000" },
{ token: "operator.swift", foreground: "000000" },
{ token: "predefined.sql", foreground: "000000" }
],
colors: {
// We want Monaco background to use the same background for our themes.
// Without this, the Monaco light theme has a yellowish tone.
// Verified with UX that white meets all the accessbility requirements for light
// and high contrast light theme.
"editor.background": "#FFFFFF"
}
};
monaco.editor.defineTheme(HCLightThemeName, customMonacoHCLightTheme);

View File

@@ -98,7 +98,7 @@ export class NotebookComponentBootstrapper {
actions.fetchContentFulfilled({
filepath: undefined,
model: NotebookComponentBootstrapper.wrapModelIntoContent(name, undefined, content),
kernelRef: createKernelRef(),
kernelRef: undefined, // must be undefined or it will be auto-started by the epic
contentRef: this.contentRef
})
);

View File

@@ -1,13 +1,13 @@
import * as Immutable from "immutable";
import { ActionsObservable, StateObservable } from "redux-observable";
import { Subject } from "rxjs";
import { Subject, empty } from "rxjs";
import { toArray } from "rxjs/operators";
import { makeNotebookRecord } from "@nteract/commutable";
import { actions, state } from "@nteract/core";
import * as sinon from "sinon";
import { CdbAppState, makeCdbRecord } from "./types";
import { launchWebSocketKernelEpic } from "./epics";
import { launchWebSocketKernelEpic, autoStartKernelEpic } from "./epics";
import { NotebookUtil } from "../NotebookUtil";
import { sessions } from "rx-jupyter";
@@ -74,46 +74,47 @@ describe("Extract kernel from notebook", () => {
});
});
const initialState = {
app: state.makeAppRecord({
host: state.makeJupyterHostRecord({
type: "jupyter",
token: "eh",
basePath: "/"
})
}),
comms: state.makeCommsRecord(),
config: Immutable.Map({}),
core: state.makeStateRecord({
kernelRef: "fake",
entities: state.makeEntitiesRecord({
contents: state.makeContentsRecord({
byRef: Immutable.Map({
fakeContentRef: state.makeNotebookContentRecord()
})
}),
kernels: state.makeKernelsRecord({
byRef: Immutable.Map({
fake: state.makeRemoteKernelRecord({
type: "websocket",
channels: new Subject<any>(),
kernelSpecName: "fancy",
id: "0"
})
})
})
})
}),
cdb: makeCdbRecord({
databaseAccountName: "dbAccountName",
defaultExperience: "defaultExperience"
})
};
describe("launchWebSocketKernelEpic", () => {
const createSpy = sinon.spy(sessions, "create");
const contentRef = "fakeContentRef";
const kernelRef = "fake";
const initialState = {
app: state.makeAppRecord({
host: state.makeJupyterHostRecord({
type: "jupyter",
token: "eh",
basePath: "/"
})
}),
comms: state.makeCommsRecord(),
config: Immutable.Map({}),
core: state.makeStateRecord({
kernelRef: "fake",
entities: state.makeEntitiesRecord({
contents: state.makeContentsRecord({
byRef: Immutable.Map({
fakeContentRef: state.makeNotebookContentRecord()
})
}),
kernels: state.makeKernelsRecord({
byRef: Immutable.Map({
fake: state.makeRemoteKernelRecord({
type: "websocket",
channels: new Subject<any>(),
kernelSpecName: "fancy",
id: "0"
})
})
})
})
}),
cdb: makeCdbRecord({
databaseAccountName: "dbAccountName",
defaultExperience: "defaultExperience"
})
};
it("launches remote kernels", async () => {
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
@@ -490,3 +491,55 @@ describe("launchWebSocketKernelEpic", () => {
});
});
});
describe("autoStartKernelEpic", () => {
const contentRef = "fakeContentRef";
const kernelRef = "fake";
it("automatically starts kernel when content fetch is successful if kernelRef is defined", async () => {
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
const action$ = ActionsObservable.of(
actions.fetchContentFulfilled({
contentRef,
kernelRef,
filepath: "filepath",
model: {}
})
);
const responseActions = await autoStartKernelEpic(action$, state$)
.pipe(toArray())
.toPromise();
expect(responseActions).toMatchObject([
{
type: actions.RESTART_KERNEL,
payload: {
contentRef,
kernelRef,
outputHandling: "None"
}
}
]);
});
it("Don't start kernel when content fetch is successful if kernelRef is not defined", async () => {
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
const action$ = ActionsObservable.of(
actions.fetchContentFulfilled({
contentRef,
kernelRef: undefined,
filepath: "filepath",
model: {}
})
);
const responseActions = await autoStartKernelEpic(action$, state$)
.pipe(toArray())
.toPromise();
expect(responseActions).toMatchObject([]);
});
});

View File

@@ -1,4 +1,4 @@
import { empty, merge, of, timer, concat, Subject, Subscriber, Observable, Observer } from "rxjs";
import { EMPTY, merge, of, timer, concat, Subject, Subscriber, Observable, Observer } from "rxjs";
import { webSocket } from "rxjs/webSocket";
import { ActionsObservable, StateObservable } from "redux-observable";
import { ofType } from "redux-observable";
@@ -77,7 +77,7 @@ const addInitialCodeCellEpic = (
// If it's not a notebook, we shouldn't be here
if (!model || model.type !== "notebook") {
return empty();
return EMPTY;
}
const cellOrder = selectors.notebook.cellOrder(model);
@@ -90,7 +90,40 @@ const addInitialCodeCellEpic = (
);
}
return empty();
return EMPTY;
})
);
};
/**
* Automatically start kernel if kernelRef is present.
* The kernel is normally lazy-started when a cell is being executed, but a running kernel is
* required for code completion to work.
* For notebook viewer, there is no kernel
* @param action$
* @param state$
*/
export const autoStartKernelEpic = (
action$: ActionsObservable<actions.FetchContentFulfilled>,
state$: StateObservable<AppState>
): Observable<{} | actions.CreateCellBelow> => {
return action$.pipe(
ofType(actions.FETCH_CONTENT_FULFILLED),
mergeMap(action => {
const state = state$.value;
const { contentRef, kernelRef } = action.payload;
if (!kernelRef) {
return EMPTY;
}
return of(
actions.restartKernel({
contentRef,
kernelRef,
outputHandling: "None"
})
);
})
);
};
@@ -288,7 +321,7 @@ export const launchWebSocketKernelEpic = (
const state = state$.value;
const host = selectors.currentHost(state);
if (host.type !== "jupyter") {
return empty();
return EMPTY;
}
const serverConfig: NotebookServiceConfig = selectors.serverConfig(host);
serverConfig.userPuid = getUserPuid();
@@ -299,7 +332,7 @@ export const launchWebSocketKernelEpic = (
const content = selectors.content(state, { contentRef });
if (!content || content.type !== "notebook") {
return empty();
return EMPTY;
}
let kernelSpecToLaunch = kernelSpecName;
@@ -513,26 +546,26 @@ const changeWebSocketKernelEpic = (
const state = state$.value;
const host = selectors.currentHost(state);
if (host.type !== "jupyter") {
return empty();
return EMPTY;
}
const serverConfig: NotebookServiceConfig = selectors.serverConfig(host);
if (!oldKernelRef) {
return empty();
return EMPTY;
}
const oldKernel = selectors.kernel(state, { kernelRef: oldKernelRef });
if (!oldKernel || oldKernel.type !== "websocket") {
return empty();
return EMPTY;
}
const { sessionId } = oldKernel;
if (!sessionId) {
return empty();
return EMPTY;
}
const content = selectors.content(state, { contentRef });
if (!content || content.type !== "notebook") {
return empty();
return EMPTY;
}
const {
filepath,
@@ -593,7 +626,7 @@ const focusInitialCodeCellEpic = (
// If it's not a notebook, we shouldn't be here
if (!model || model.type !== "notebook") {
return empty();
return EMPTY;
}
const cellOrder = selectors.notebook.cellOrder(model);
@@ -608,7 +641,7 @@ const focusInitialCodeCellEpic = (
);
}
return empty();
return EMPTY;
})
);
};
@@ -661,7 +694,7 @@ const notificationsToUserEpic = (
break;
}
}
return empty();
return EMPTY;
})
);
};
@@ -701,7 +734,7 @@ const handleKernelConnectionLostEpic = (
if (explorer) {
explorer.showOkModalDialog("kernel restarts", msg);
}
return of(empty());
return of(EMPTY);
}
return concat(
@@ -814,7 +847,7 @@ const closeUnsupportedMimetypesEpic = (
explorer.showOkModalDialog("File cannot be rendered", msg);
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, msg);
}
return empty();
return EMPTY;
})
);
};
@@ -842,13 +875,14 @@ const closeContentFailedToFetchEpic = (
explorer.showOkModalDialog("Failure to load", msg);
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, msg);
}
return empty();
return EMPTY;
})
);
};
export const allEpics = [
addInitialCodeCellEpic,
autoStartKernelEpic,
focusInitialCodeCellEpic,
notificationsToUserEpic,
launchWebSocketKernelEpic,

View File

@@ -89,7 +89,7 @@ export class NotebookContentClient {
throw new Error(`Parent must be a directory: ${parent}`);
}
const filepath = `${parent.path}/${name}`;
const filepath = NotebookUtil.getFilePath(parent.path, name);
if (await this.checkIfFilepathExists(filepath)) {
throw new Error(`File already exists: ${filepath}`);
}
@@ -116,12 +116,7 @@ export class NotebookContentClient {
}
private async checkIfFilepathExists(filepath: string): Promise<boolean> {
const basename = filepath.split("/").pop();
let parentDirPath = filepath
.split(basename)
.shift()
.replace(/\/$/, ""); // no trailling slash
const parentDirPath = NotebookUtil.getParentPath(filepath);
const items = await this.fetchNotebookFiles(parentDirPath);
return items.some(value => FileSystemUtil.isPathEqual(value.path, filepath));
}

View File

@@ -25,6 +25,7 @@ import { getFullName } from "../../Utils/UserUtils";
import { ImmutableNotebook } from "@nteract/commutable";
import Explorer from "../Explorer";
import { ContextualPaneBase } from "../Panes/ContextualPaneBase";
import { CopyNotebookPaneAdapter } from "../Panes/CopyNotebookPane";
export interface NotebookManagerOptions {
container: Explorer;
@@ -49,6 +50,7 @@ export default class NotebookManager {
public gitHubReposPane: ContextualPaneBase;
public publishNotebookPaneAdapter: PublishNotebookPaneAdapter;
public copyNotebookPaneAdapter: CopyNotebookPaneAdapter;
public initialize(params: NotebookManagerOptions): void {
this.params = params;
@@ -90,6 +92,12 @@ export default class NotebookManager {
this.publishNotebookPaneAdapter = new PublishNotebookPaneAdapter(this.params.container, this.junoClient);
}
this.copyNotebookPaneAdapter = new CopyNotebookPaneAdapter(
this.params.container,
this.junoClient,
this.gitHubOAuthService
);
this.gitHubOAuthService.getTokenObservable().subscribe(token => {
this.gitHubClient.setToken(token?.access_token);
@@ -108,12 +116,29 @@ export default class NotebookManager {
this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope);
}
public openPublishNotebookPane(
public refreshPinnedRepos(): void {
this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope);
}
public async openPublishNotebookPane(
name: string,
content: string | ImmutableNotebook,
parentDomElement: HTMLElement
): void {
this.publishNotebookPaneAdapter.open(name, getFullName(), content, parentDomElement);
parentDomElement: HTMLElement,
isCodeOfConductEnabled: boolean,
isLinkInjectionEnabled: boolean
): Promise<void> {
await this.publishNotebookPaneAdapter.open(
name,
getFullName(),
content,
parentDomElement,
isCodeOfConductEnabled,
isLinkInjectionEnabled
);
}
public openCopyNotebookPane(name: string, content: string): void {
this.copyNotebookPaneAdapter.open(name, content);
}
// Octokit's error handler uses any

View File

@@ -30,6 +30,7 @@ import { CellType } from "@nteract/commutable/src";
import "./NotebookRenderer.less";
import HoverableCell from "./decorators/HoverableCell";
import CellLabeler from "./decorators/CellLabeler";
import MonacoEditor from "../MonacoEditor/MonacoEditor";
import * as cdbActions from "../NotebookComponent/actions";
export interface NotebookRendererBaseProps {
@@ -116,7 +117,12 @@ class BaseNotebookRenderer extends React.Component<NotebookRendererProps> {
{{
editor: {
codemirror: (props: PassedEditorProps) => (
<CodeMirrorEditor {...props} lineNumbers={true} />
<MonacoEditor
{...props}
lineNumbers={true}
enableCompletion={true}
shouldRegisterDefaultCompletion={true}
/>
)
},
prompt: ({ id, contentRef }: { id: CellId; contentRef: ContentRef }) => (

View File

@@ -13,8 +13,10 @@ import { List, Map } from "immutable";
const fileName = "file";
const notebookName = "file.ipynb";
const filePath = `folder/${fileName}`;
const notebookPath = `folder/${notebookName}`;
const folderPath = "folder";
const filePath = `${folderPath}/${fileName}`;
const notebookPath = `${folderPath}/${notebookName}`;
const gitHubFolderUri = GitHubUtils.toContentUri("owner", "repo", "branch", folderPath);
const gitHubFileUri = GitHubUtils.toContentUri("owner", "repo", "branch", filePath);
const gitHubNotebookUri = GitHubUtils.toContentUri("owner", "repo", "branch", notebookPath);
const notebookRecord = makeNotebookRecord({
@@ -43,10 +45,8 @@ const notebookRecord = makeNotebookRecord({
source: 'display(HTML("<h1>Sample html</h1>"))',
outputs: List.of({
data: Object.freeze({
data: {
"text/html": "<h1>Sample output</h1>",
"text/plain": "<IPython.core.display.HTML object>"
}
"text/html": "<h1>Sample output</h1>",
"text/plain": "<IPython.core.display.HTML object>"
} as MediaBundle),
output_type: "display_data",
metadata: undefined
@@ -82,6 +82,26 @@ describe("NotebookUtil", () => {
});
});
describe("getFilePath", () => {
it("works for jupyter file paths", () => {
expect(NotebookUtil.getFilePath(folderPath, fileName)).toEqual(filePath);
});
it("works for github file uris", () => {
expect(NotebookUtil.getFilePath(gitHubFolderUri, fileName)).toEqual(gitHubFileUri);
});
});
describe("getParentPath", () => {
it("works for jupyter file paths", () => {
expect(NotebookUtil.getParentPath(filePath)).toEqual(folderPath);
});
it("works for github file uris", () => {
expect(NotebookUtil.getParentPath(gitHubFileUri)).toEqual(gitHubFolderUri);
});
});
describe("getName", () => {
it("works for jupyter file paths", () => {
expect(NotebookUtil.getName(filePath)).toEqual(fileName);

View File

@@ -1,5 +1,5 @@
import path from "path";
import { ImmutableNotebook, ImmutableCodeCell, ImmutableOutput } from "@nteract/commutable";
import { ImmutableNotebook, ImmutableCodeCell } from "@nteract/commutable";
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
import { StringUtils } from "../../Utils/StringUtils";
import * as GitHubUtils from "../../Utils/GitHubUtils";
@@ -70,6 +70,46 @@ export class NotebookUtil {
};
}
public static getFilePath(path: string, fileName: string): string {
const contentInfo = GitHubUtils.fromContentUri(path);
if (contentInfo) {
let path = fileName;
if (contentInfo.path) {
path = `${contentInfo.path}/${path}`;
}
return GitHubUtils.toContentUri(contentInfo.owner, contentInfo.repo, contentInfo.branch, path);
}
return `${path}/${fileName}`;
}
public static getParentPath(filepath: string): undefined | string {
const basename = NotebookUtil.getName(filepath);
if (basename) {
const contentInfo = GitHubUtils.fromContentUri(filepath);
if (contentInfo) {
const parentPath = contentInfo.path.split(basename).shift();
if (parentPath === undefined) {
return undefined;
}
return GitHubUtils.toContentUri(
contentInfo.owner,
contentInfo.repo,
contentInfo.branch,
parentPath.replace(/\/$/, "") // no trailling slash
);
}
const parentPath = filepath.split(basename).shift();
if (parentPath) {
return parentPath.replace(/\/$/, ""); // no trailling slash
}
}
return undefined;
}
public static getName(path: string): undefined | string {
let relativePath: string = path;
const contentInfo = GitHubUtils.fromContentUri(path);
@@ -102,25 +142,19 @@ export class NotebookUtil {
}
public static findFirstCodeCellWithDisplay(notebookObject: ImmutableNotebook): number {
let codeCellCount = -1;
let codeCellIndex = 0;
for (let i = 0; i < notebookObject.cellOrder.size; i++) {
const cellId = notebookObject.cellOrder.get(i);
if (cellId) {
const cell = notebookObject.cellMap.get(cellId);
if (cell && cell.cell_type === "code") {
codeCellCount++;
const codeCell = cell as ImmutableCodeCell;
if (codeCell.outputs) {
const displayOutput = codeCell.outputs.find((output: ImmutableOutput) => {
if (output.output_type === "display_data" || output.output_type === "execute_result") {
return true;
}
return false;
});
if (displayOutput) {
return codeCellCount;
}
if (cell?.cell_type === "code") {
const displayOutput = (cell as ImmutableCodeCell)?.outputs?.find(
output => output.output_type === "display_data" || output.output_type === "execute_result"
);
if (displayOutput) {
return codeCellIndex;
}
codeCellIndex++;
}
}
}

View File

@@ -13,14 +13,14 @@ import EnvironmentUtility from "../../Common/EnvironmentUtility";
import Q from "q";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { config, Platform } from "../../Config";
import { configContext, Platform } from "../../ConfigContext";
import { ContextualPaneBase } from "./ContextualPaneBase";
import { CosmosClient } from "../../Common/CosmosClient";
import { createMongoCollectionWithARM, createMongoCollectionWithProxy } from "../../Common/MongoProxyClient";
import { DynamicListItem } from "../Controls/DynamicList/DynamicListComponent";
import { HashMap } from "../../Common/HashMap";
import { PlatformType } from "../../PlatformType";
import { refreshCachedResources, getOrCreateDatabaseAndCollection } from "../../Common/DocumentClientUtilityBase";
import { userContext } from "../../UserContext";
export interface AddCollectionPaneOptions extends ViewModels.PaneOptions {
isPreferredApiTable: ko.Computed<boolean>;
@@ -599,7 +599,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
});
this.isSynapseLinkSupported = ko.computed(() => {
if (config.platform === Platform.Emulator) {
if (configContext.platform === Platform.Emulator) {
return false;
}
@@ -920,9 +920,9 @@ export default class AddCollectionPane extends ContextualPaneBase {
partitionKey.version,
databaseCreateNew,
useDatabaseSharedOffer,
CosmosClient.subscriptionId(),
CosmosClient.resourceGroup(),
CosmosClient.databaseAccount().name,
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
autopilotSettings
)
);
@@ -940,9 +940,9 @@ export default class AddCollectionPane extends ContextualPaneBase {
partitionKey.version,
databaseCreateNew,
useDatabaseSharedOffer,
CosmosClient.subscriptionId(),
CosmosClient.resourceGroup(),
CosmosClient.databaseAccount().name,
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
uniqueKeyPolicy,
autopilotSettings
)

View File

@@ -14,9 +14,9 @@ import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstan
import { AddDbUtilities } from "../../Shared/AddDatabaseUtility";
import { CassandraAPIDataClient } from "../Tables/TableDataClient";
import { ContextualPaneBase } from "./ContextualPaneBase";
import { CosmosClient } from "../../Common/CosmosClient";
import { createDatabase } from "../../Common/dataAccess/createDatabase";
import { PlatformType } from "../../PlatformType";
import { refreshCachedOffers, refreshCachedResources, createDatabase } from "../../Common/DocumentClientUtilityBase";
import { userContext } from "../../UserContext";
export default class AddDatabasePane extends ContextualPaneBase {
public defaultExperience: ko.Computed<string>;
@@ -304,76 +304,23 @@ export default class AddDatabasePane extends ContextualPaneBase {
this.formErrors("");
this.isExecuting(true);
const createDatabaseParameters: DataModels.RpParameters = {
db: addDatabasePaneStartMessage.database.id,
st: addDatabasePaneStartMessage.database.shared,
offerThroughput: addDatabasePaneStartMessage.offerThroughput,
sid: CosmosClient.subscriptionId(),
rg: CosmosClient.resourceGroup(),
dba: addDatabasePaneStartMessage.databaseAccountName
const createDatabaseParams: DataModels.CreateDatabaseParams = {
autoPilotMaxThroughput: this.maxAutoPilotThroughputSet(),
databaseId: addDatabasePaneStartMessage.database.id,
databaseLevelThroughput: addDatabasePaneStartMessage.database.shared,
offerThroughput: addDatabasePaneStartMessage.offerThroughput
};
const autopilotSettings = this._getAutopilotSettings();
if (this.container.isPreferredApiCassandra()) {
this._createKeyspace(createDatabaseParameters, autopilotSettings, startKey);
} else if (this.container.isPreferredApiMongoDB() && EnvironmentUtility.isAadUser()) {
this._createMongoDatabase(createDatabaseParameters, autopilotSettings, startKey);
} else if (this.container.isPreferredApiGraph() && EnvironmentUtility.isAadUser()) {
this._createGremlinDatabase(createDatabaseParameters, autopilotSettings, startKey);
} else if (this.container.isPreferredApiDocumentDB() && EnvironmentUtility.isAadUser()) {
this._createSqlDatabase(createDatabaseParameters, autopilotSettings, startKey);
} else {
this._createDatabase(offerThroughput, startKey);
}
}
private _createSqlDatabase(
createDatabaseParameters: DataModels.RpParameters,
autoPilotSettings: DataModels.RpOptions,
startKey: number
) {
AddDbUtilities.createSqlDatabase(this.container.armEndpoint(), createDatabaseParameters, autoPilotSettings).then(
() => {
Promise.all([refreshCachedOffers(), refreshCachedResources()]).then(() => {
this._onCreateDatabaseSuccess(createDatabaseParameters.offerThroughput, startKey);
});
createDatabase(createDatabaseParams).then(
(database: DataModels.Database) => {
this._onCreateDatabaseSuccess(offerThroughput, startKey);
},
(reason: any) => {
this._onCreateDatabaseFailure(reason, offerThroughput, reason);
}
);
}
private _createMongoDatabase(
createDatabaseParameters: DataModels.RpParameters,
autoPilotSettings: DataModels.RpOptions,
startKey: number
) {
AddDbUtilities.createMongoDatabaseWithARM(
this.container.armEndpoint(),
createDatabaseParameters,
autoPilotSettings
).then(() => {
Promise.all([refreshCachedOffers(), refreshCachedResources()]).then(() => {
this._onCreateDatabaseSuccess(createDatabaseParameters.offerThroughput, startKey);
});
});
}
private _createGremlinDatabase(
createDatabaseParameters: DataModels.RpParameters,
autoPilotSettings: DataModels.RpOptions,
startKey: number
) {
AddDbUtilities.createGremlinDatabase(
this.container.armEndpoint(),
createDatabaseParameters,
autoPilotSettings
).then(() => {
Promise.all([refreshCachedOffers(), refreshCachedResources()]).then(() => {
this._onCreateDatabaseSuccess(createDatabaseParameters.offerThroughput, startKey);
});
});
}
public resetData() {
this.databaseId("");
this.databaseCreateNewShared(this.getSharedThroughputDefault());
@@ -396,72 +343,6 @@ export default class AddDatabasePane extends ContextualPaneBase {
return true;
}
private _createDatabase(offerThroughput: number, telemetryStartKey: number): void {
const autoPilot: DataModels.AutoPilotCreationSettings = this._isAutoPilotSelectedAndWhatTier();
const createRequest: DataModels.CreateDatabaseRequest = {
databaseId: this.databaseId().trim(),
offerThroughput,
databaseLevelThroughput: this.databaseCreateNewShared(),
autoPilot,
hasAutoPilotV2FeatureFlag: this.hasAutoPilotV2FeatureFlag()
};
createDatabase(createRequest).then(
(database: DataModels.Database) => {
this._onCreateDatabaseSuccess(offerThroughput, telemetryStartKey);
},
(reason: any) => {
this._onCreateDatabaseFailure(reason, offerThroughput, reason);
}
);
}
private _createKeyspace(
createDatabaseParameters: DataModels.RpParameters,
autoPilotSettings: DataModels.RpOptions,
startKey: number
): void {
if (EnvironmentUtility.isAadUser()) {
this._createKeyspaceUsingRP(this.container.armEndpoint(), createDatabaseParameters, autoPilotSettings, startKey);
} else {
this._createKeyspaceUsingProxy(createDatabaseParameters.offerThroughput, startKey);
}
}
private _createKeyspaceUsingProxy(offerThroughput: number, telemetryStartKey: number): void {
const provisionThroughputQueryPart: string = this.databaseCreateNewShared()
? `AND cosmosdb_provisioned_throughput=${offerThroughput}`
: "";
const createKeyspaceQuery: string = `CREATE KEYSPACE ${this.databaseId().trim()} WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 3 } ${provisionThroughputQueryPart};`;
(this.container.tableDataClient as CassandraAPIDataClient)
.createKeyspace(
this.container.databaseAccount().properties.cassandraEndpoint,
this.container.databaseAccount().id,
this.container,
createKeyspaceQuery
)
.then(
() => {
this._onCreateDatabaseSuccess(offerThroughput, telemetryStartKey);
},
(reason: any) => {
this._onCreateDatabaseFailure(reason, offerThroughput, telemetryStartKey);
}
);
}
private _createKeyspaceUsingRP(
armEndpoint: string,
createKeyspaceParameters: DataModels.RpParameters,
autoPilotSettings: DataModels.RpOptions,
startKey: number
): void {
AddDbUtilities.createCassandraKeyspace(armEndpoint, createKeyspaceParameters, autoPilotSettings).then(() => {
Promise.all([refreshCachedOffers(), refreshCachedResources()]).then(() => {
this._onCreateDatabaseSuccess(createKeyspaceParameters.offerThroughput, startKey);
});
});
}
private _onCreateDatabaseSuccess(offerThroughput: number, startKey: number): void {
this.isExecuting(false);
this.close();
@@ -582,20 +463,6 @@ export default class AddDatabasePane extends ContextualPaneBase {
return undefined;
}
private _getAutopilotSettings(): DataModels.RpOptions {
if (
(!this.hasAutoPilotV2FeatureFlag() && this.isAutoPilotSelected() && this.maxAutoPilotThroughputSet()) ||
(this.hasAutoPilotV2FeatureFlag() && this.isAutoPilotSelected() && this.selectedAutoPilotTier())
) {
return !this.hasAutoPilotV2FeatureFlag()
? {
[Constants.HttpHeaders.autoPilotThroughput]: { maxThroughput: this.maxAutoPilotThroughputSet() * 1 }
}
: { [Constants.HttpHeaders.autoPilotTier]: this.selectedAutoPilotTier().toString() };
}
return undefined;
}
private _updateThroughputLimitByDatabase() {
const throughputDefaults = this.container.collectionCreationDefaults.throughput;
this.throughput(throughputDefaults.shared);

View File

@@ -0,0 +1,198 @@
import ko from "knockout";
import * as React from "react";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import * as Logger from "../../Common/Logger";
import { JunoClient, IPinnedRepo } from "../../Juno/JunoClient";
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
import Explorer from "../Explorer";
import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRightPaneComponent";
import { CopyNotebookPaneComponent, CopyNotebookPaneProps } from "./CopyNotebookPaneComponent";
import { IDropdownOption } from "office-ui-fabric-react";
import { GitHubOAuthService } from "../../GitHub/GitHubOAuthService";
import { HttpStatusCodes } from "../../Common/Constants";
import * as GitHubUtils from "../../Utils/GitHubUtils";
import { NotebookContentItemType, NotebookContentItem } from "../Notebook/NotebookContentItem";
import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter";
interface Location {
type: "MyNotebooks" | "GitHub";
// GitHub
owner?: string;
repo?: string;
branch?: string;
}
export class CopyNotebookPaneAdapter implements ReactAdapter {
private static readonly BranchNameWhiteSpace = " ";
parameters: ko.Observable<number>;
private isOpened: boolean;
private isExecuting: boolean;
private formError: string;
private formErrorDetail: string;
private name: string;
private content: string;
private pinnedRepos: IPinnedRepo[];
private selectedLocation: Location;
constructor(
private container: Explorer,
private junoClient: JunoClient,
private gitHubOAuthService: GitHubOAuthService
) {
this.parameters = ko.observable(Date.now());
this.reset();
this.triggerRender();
}
public renderComponent(): JSX.Element {
if (!this.isOpened) {
return undefined;
}
const genericPaneProps: GenericRightPaneProps = {
container: this.container,
formError: this.formError,
formErrorDetail: this.formErrorDetail,
id: "copynotebookpane",
isExecuting: this.isExecuting,
title: "Copy notebook",
submitButtonText: "OK",
onClose: () => this.close(),
onSubmit: () => this.submit()
};
const copyNotebookPaneProps: CopyNotebookPaneProps = {
name: this.name,
pinnedRepos: this.pinnedRepos,
onDropDownChange: this.onDropDownChange
};
return (
<GenericRightPaneComponent {...genericPaneProps}>
<CopyNotebookPaneComponent {...copyNotebookPaneProps} />
</GenericRightPaneComponent>
);
}
public triggerRender(): void {
window.requestAnimationFrame(() => this.parameters(Date.now()));
}
public async open(name: string, content: string): Promise<void> {
this.name = name;
this.content = content;
this.isOpened = true;
this.triggerRender();
if (this.gitHubOAuthService.isLoggedIn()) {
const response = await this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope);
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
const message = `Received HTTP ${response.status} when fetching pinned repos`;
Logger.logError(message, "CopyNotebookPaneAdapter/submit");
NotificationConsoleUtils.logConsoleError(message);
}
if (response.data?.length > 0) {
this.pinnedRepos = response.data;
this.triggerRender();
}
}
}
public close(): void {
this.reset();
this.triggerRender();
}
public async submit(): Promise<void> {
let destination: string = this.selectedLocation?.type;
let clearMessage: () => void;
this.isExecuting = true;
this.triggerRender();
try {
if (!this.selectedLocation) {
throw new Error(`No location selected`);
}
if (this.selectedLocation.type === "GitHub") {
destination = `${destination} - ${GitHubUtils.toRepoFullName(
this.selectedLocation.owner,
this.selectedLocation.repo
)} - ${this.selectedLocation.branch}`;
}
clearMessage = NotificationConsoleUtils.logConsoleProgress(`Copying ${this.name} to ${destination}`);
const notebookContentItem = await this.copyNotebook(this.selectedLocation);
if (!notebookContentItem) {
throw new Error(`Failed to upload ${this.name}`);
}
NotificationConsoleUtils.logConsoleInfo(`Successfully copied ${this.name} to ${destination}`);
} catch (error) {
this.formError = `Failed to copy ${this.name} to ${destination}`;
this.formErrorDetail = `${error}`;
const message = `${this.formError}: ${this.formErrorDetail}`;
Logger.logError(message, "CopyNotebookPaneAdapter/submit");
NotificationConsoleUtils.logConsoleError(message);
return;
} finally {
clearMessage && clearMessage();
this.isExecuting = false;
this.triggerRender();
}
this.close();
}
private copyNotebook = async (location: Location): Promise<NotebookContentItem> => {
let parent: NotebookContentItem;
switch (location.type) {
case "MyNotebooks":
parent = {
name: ResourceTreeAdapter.MyNotebooksTitle,
path: this.container.getNotebookBasePath(),
type: NotebookContentItemType.Directory
};
break;
case "GitHub":
parent = {
name: ResourceTreeAdapter.GitHubReposTitle,
path: GitHubUtils.toContentUri(
this.selectedLocation.owner,
this.selectedLocation.repo,
this.selectedLocation.branch,
""
),
type: NotebookContentItemType.Directory
};
break;
default:
throw new Error(`Unsupported location type ${location.type}`);
}
return this.container.uploadFile(this.name, this.content, parent);
};
private onDropDownChange = (_: React.FormEvent<HTMLDivElement>, option?: IDropdownOption): void => {
this.selectedLocation = option?.data;
};
private reset = (): void => {
this.isOpened = false;
this.isExecuting = false;
this.formError = undefined;
this.formErrorDetail = undefined;
this.name = undefined;
this.content = undefined;
this.pinnedRepos = undefined;
this.selectedLocation = undefined;
};
}

View File

@@ -0,0 +1,119 @@
import * as GitHubUtils from "../../Utils/GitHubUtils";
import * as React from "react";
import { IPinnedRepo } from "../../Juno/JunoClient";
import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter";
import {
Stack,
Label,
Text,
Dropdown,
IDropdownProps,
IDropdownOption,
SelectableOptionMenuItemType,
IRenderFunction,
ISelectableOption
} from "office-ui-fabric-react";
interface Location {
type: "MyNotebooks" | "GitHub";
// GitHub
owner?: string;
repo?: string;
branch?: string;
}
export interface CopyNotebookPaneProps {
name: string;
pinnedRepos: IPinnedRepo[];
onDropDownChange: (_: React.FormEvent<HTMLDivElement>, option?: IDropdownOption) => void;
}
export class CopyNotebookPaneComponent extends React.Component<CopyNotebookPaneProps> {
private static readonly BranchNameWhiteSpace = " ";
public render(): JSX.Element {
const dropDownProps: IDropdownProps = {
label: "Location",
ariaLabel: "Location",
placeholder: "Select an option",
onRenderTitle: this.onRenderDropDownTitle,
onRenderOption: this.onRenderDropDownOption,
options: this.getDropDownOptions(),
onChange: this.props.onDropDownChange
};
return (
<div className="paneMainContent">
<Stack tokens={{ childrenGap: 10 }}>
<Stack.Item>
<Label htmlFor="notebookName">Name</Label>
<Text id="notebookName">{this.props.name}</Text>
</Stack.Item>
<Dropdown {...dropDownProps} />
</Stack>
</div>
);
}
private onRenderDropDownTitle: IRenderFunction<IDropdownOption[]> = (options: IDropdownOption[]): JSX.Element => {
return <span>{options.length && options[0].title}</span>;
};
private onRenderDropDownOption: IRenderFunction<ISelectableOption> = (option: ISelectableOption): JSX.Element => {
return <span style={{ whiteSpace: "pre-wrap" }}>{option.text}</span>;
};
private getDropDownOptions = (): IDropdownOption[] => {
const options: IDropdownOption[] = [];
options.push({
key: "MyNotebooks-Item",
text: ResourceTreeAdapter.MyNotebooksTitle,
title: ResourceTreeAdapter.MyNotebooksTitle,
data: {
type: "MyNotebooks"
} as Location
});
if (this.props.pinnedRepos && this.props.pinnedRepos.length > 0) {
options.push({
key: "GitHub-Header-Divider",
text: undefined,
itemType: SelectableOptionMenuItemType.Divider
});
options.push({
key: "GitHub-Header",
text: ResourceTreeAdapter.GitHubReposTitle,
itemType: SelectableOptionMenuItemType.Header
});
this.props.pinnedRepos.forEach(pinnedRepo => {
const repoFullName = GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name);
options.push({
key: `GitHub-Repo-${repoFullName}`,
text: repoFullName,
disabled: true
});
pinnedRepo.branches.forEach(branch =>
options.push({
key: `GitHub-Repo-${repoFullName}-${branch.name}`,
text: `${CopyNotebookPaneComponent.BranchNameWhiteSpace}${branch.name}`,
title: `${repoFullName} - ${branch.name}`,
data: {
type: "GitHub",
owner: pinnedRepo.owner,
repo: pinnedRepo.name,
branch: branch.name
} as Location
})
);
});
}
return options;
};
}

View File

@@ -8,7 +8,6 @@ import Explorer from "../Explorer";
export interface GenericRightPaneProps {
container: Explorer;
content: JSX.Element;
formError: string;
formErrorDetail: string;
id: string;
@@ -17,6 +16,7 @@ export interface GenericRightPaneProps {
onSubmit: () => void;
submitButtonText: string;
title: string;
isSubmitButtonHidden?: boolean;
}
export interface GenericRightPaneState {
@@ -56,18 +56,18 @@ export class GenericRightPaneComponent extends React.Component<GenericRightPaneP
onKeyDown={this.onKeyDown}
>
<div className="panelContentWrapper">
{this.createPanelHeader()}
{this.createErrorSection()}
{this.props.content}
{this.createPanelFooter()}
{this.renderPanelHeader()}
{this.renderErrorSection()}
{this.props.children}
{this.renderPanelFooter()}
</div>
{this.createLoadingScreen()}
{this.renderLoadingScreen()}
</div>
</div>
);
}
private createPanelHeader = (): JSX.Element => {
private renderPanelHeader = (): JSX.Element => {
return (
<div className="firstdivbg headerline">
<span id="databaseTitle">{this.props.title}</span>
@@ -83,7 +83,7 @@ export class GenericRightPaneComponent extends React.Component<GenericRightPaneP
);
};
private createErrorSection = (): JSX.Element => {
private renderErrorSection = (): JSX.Element => {
return (
<div className="warningErrorContainer" aria-live="assertive" hidden={!this.props.formError}>
<div className="warningErrorContent">
@@ -103,11 +103,12 @@ export class GenericRightPaneComponent extends React.Component<GenericRightPaneP
);
};
private createPanelFooter = (): JSX.Element => {
private renderPanelFooter = (): JSX.Element => {
return (
<div className="paneFooter">
<div className="leftpanel-okbut">
<PrimaryButton
style={{ visibility: this.props.isSubmitButtonHidden ? "hidden" : "visible" }}
ariaLabel="Submit"
title="Submit"
onClick={this.props.onSubmit}
@@ -120,7 +121,7 @@ export class GenericRightPaneComponent extends React.Component<GenericRightPaneP
);
};
private createLoadingScreen = (): JSX.Element => {
private renderLoadingScreen = (): JSX.Element => {
return (
<div className="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" hidden={!this.props.isExecuting}>
<img className="dataExplorerLoader" src={LoadingIndicatorIcon} />

View File

@@ -10,6 +10,8 @@ import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRight
import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent";
import { ImmutableNotebook } from "@nteract/commutable/src";
import { toJS } from "@nteract/commutable";
import { CodeOfConductComponent } from "../Controls/NotebookGallery/CodeOfConductComponent";
import { HttpStatusCodes } from "../../Common/Constants";
export class PublishNotebookPaneAdapter implements ReactAdapter {
parameters: ko.Observable<number>;
@@ -26,6 +28,8 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
private imageSrc: string;
private notebookObject: ImmutableNotebook;
private parentDomElement: HTMLElement;
private isCodeOfConductAccepted: boolean;
private isLinkInjectionEnabled: boolean;
constructor(private container: Explorer, private junoClient: JunoClient) {
this.parameters = ko.observable(Date.now());
@@ -40,7 +44,6 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
const props: GenericRightPaneProps = {
container: this.container,
content: this.createContent(),
formError: this.formError,
formErrorDetail: this.formErrorDetail,
id: "publishnotebookpane",
@@ -48,33 +51,86 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
title: "Publish to gallery",
submitButtonText: "Publish",
onClose: () => this.close(),
onSubmit: () => this.submit()
onSubmit: () => this.submit(),
isSubmitButtonHidden: !this.isCodeOfConductAccepted
};
return <GenericRightPaneComponent {...props} />;
const publishNotebookPaneProps: PublishNotebookPaneProps = {
notebookName: this.name,
notebookDescription: "",
notebookTags: "",
notebookAuthor: this.author,
notebookCreatedDate: new Date().toISOString(),
notebookObject: this.notebookObject,
notebookParentDomElement: this.parentDomElement,
onChangeName: (newValue: string) => (this.name = newValue),
onChangeDescription: (newValue: string) => (this.description = newValue),
onChangeTags: (newValue: string) => (this.tags = newValue),
onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue),
onError: this.createFormErrorForLargeImageSelection,
clearFormError: this.clearFormError
};
return (
<GenericRightPaneComponent {...props}>
{!this.isCodeOfConductAccepted ? (
<div style={{ padding: "15px", marginTop: "10px" }}>
<CodeOfConductComponent
junoClient={this.junoClient}
onAcceptCodeOfConduct={() => {
this.isCodeOfConductAccepted = true;
this.triggerRender();
}}
/>
</div>
) : (
<PublishNotebookPaneComponent {...publishNotebookPaneProps} />
)}
</GenericRightPaneComponent>
);
}
public triggerRender(): void {
window.requestAnimationFrame(() => this.parameters(Date.now()));
}
public open(
public async open(
name: string,
author: string,
notebookContent: string | ImmutableNotebook,
parentDomElement: HTMLElement
): void {
parentDomElement: HTMLElement,
isCodeOfConductEnabled: boolean,
isLinkInjectionEnabled: boolean
): Promise<void> {
if (isCodeOfConductEnabled) {
try {
const response = await this.junoClient.isCodeOfConductAccepted();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
}
this.isCodeOfConductAccepted = response.data;
} catch (error) {
const message = `Failed to check if code of conduct was accepted: ${error}`;
Logger.logError(message, "PublishNotebookPaneAdapter/isCodeOfConductAccepted");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
}
} else {
this.isCodeOfConductAccepted = true;
}
this.name = name;
this.author = author;
if (typeof notebookContent === "string") {
this.content = notebookContent as string;
} else {
this.content = JSON.stringify(toJS(notebookContent as ImmutableNotebook));
this.content = JSON.stringify(toJS(notebookContent));
this.notebookObject = notebookContent;
}
this.parentDomElement = parentDomElement;
this.isOpened = true;
this.isLinkInjectionEnabled = isLinkInjectionEnabled;
this.triggerRender();
}
@@ -102,13 +158,12 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.tags?.split(","),
this.author,
this.imageSrc,
this.content
this.content,
this.isLinkInjectionEnabled
);
if (!response.data) {
throw new Error(`Received HTTP ${response.status} when publishing ${name} to gallery`);
if (response.data) {
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Published ${name} to gallery`);
}
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Published ${name} to gallery`);
} catch (error) {
this.formError = `Failed to publish ${this.name} to gallery`;
this.formErrorDetail = `${error}`;
@@ -142,25 +197,6 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.triggerRender();
};
private createContent = (): JSX.Element => {
const publishNotebookPaneProps: PublishNotebookPaneProps = {
notebookName: this.name,
notebookDescription: "",
notebookTags: "",
notebookAuthor: this.author,
notebookCreatedDate: new Date().toISOString(),
notebookObject: this.notebookObject,
notebookParentDomElement: this.parentDomElement,
onChangeDescription: (newValue: string) => (this.description = newValue),
onChangeTags: (newValue: string) => (this.tags = newValue),
onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue),
onError: this.createFormErrorForLargeImageSelection,
clearFormError: this.clearFormError
};
return <PublishNotebookPaneComponent {...publishNotebookPaneProps} />;
};
private reset = (): void => {
this.isOpened = false;
this.isExecuting = false;
@@ -174,5 +210,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.imageSrc = undefined;
this.notebookObject = undefined;
this.parentDomElement = undefined;
this.isCodeOfConductAccepted = undefined;
this.isLinkInjectionEnabled = undefined;
};
}

View File

@@ -12,6 +12,7 @@ describe("PublishNotebookPaneComponent", () => {
notebookCreatedDate: "2020-07-17T00:00:00Z",
notebookObject: undefined,
notebookParentDomElement: undefined,
onChangeName: undefined,
onChangeDescription: undefined,
onChangeTags: undefined,
onChangeImageSrc: undefined,

View File

@@ -15,6 +15,7 @@ export interface PublishNotebookPaneProps {
notebookCreatedDate: string;
notebookObject: ImmutableNotebook;
notebookParentDomElement: HTMLElement;
onChangeName: (newValue: string) => void;
onChangeDescription: (newValue: string) => void;
onChangeTags: (newValue: string) => void;
onChangeImageSrc: (newValue: string) => void;
@@ -24,6 +25,7 @@ export interface PublishNotebookPaneProps {
interface PublishNotebookPaneState {
type: string;
notebookName: string;
notebookDescription: string;
notebookTags: string;
imageSrc: string;
@@ -40,6 +42,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
private static readonly maxImageSizeInMib = 1.5;
private descriptionPara1: string;
private descriptionPara2: string;
private nameProps: ITextFieldProps;
private descriptionProps: ITextFieldProps;
private tagsProps: ITextFieldProps;
private thumbnailUrlProps: ITextFieldProps;
@@ -52,6 +55,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
this.state = {
type: ImageTypes.Url,
notebookName: props.notebookName,
notebookDescription: "",
notebookTags: "",
imageSrc: undefined
@@ -165,6 +169,17 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
}
};
this.nameProps = {
label: "Name",
ariaLabel: "Name",
defaultValue: this.props.notebookName,
required: true,
onChange: (event, newValue) => {
this.props.onChangeName(newValue);
this.setState({ notebookName: newValue });
}
};
this.descriptionProps = {
label: "Description",
ariaLabel: "Description",
@@ -245,6 +260,10 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
<Text>{this.descriptionPara2}</Text>
</Stack.Item>
<Stack.Item>
<TextField {...this.nameProps} />
</Stack.Item>
<Stack.Item>
<TextField {...this.descriptionProps} />
</Stack.Item>
@@ -266,7 +285,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
<GalleryCardComponent
data={{
id: undefined,
name: this.props.notebookName,
name: this.state.notebookName,
description: this.state.notebookDescription,
gitSha: undefined,
tags: this.state.notebookTags.split(","),
@@ -276,7 +295,8 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
isSample: false,
downloads: 0,
favorites: 0,
views: 0
views: 0,
newCellId: undefined
}}
isFavorite={false}
showDownload={true}

View File

@@ -6,7 +6,7 @@ import { ContextualPaneBase } from "./ContextualPaneBase";
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
import { StringUtility } from "../../Shared/StringUtility";
import { config } from "../../Config";
import { configContext } from "../../ConfigContext";
export class SettingsPane extends ContextualPaneBase {
public pageOption: ko.Observable<string>;
@@ -46,7 +46,7 @@ export class SettingsPane extends ContextualPaneBase {
: false;
this.graphAutoVizDisabled = ko.observable<string>(`${isGraphAutoVizDisabled}`);
this.explorerVersion = config.gitSha;
this.explorerVersion = configContext.gitSha;
this.shouldShowQueryPageOptions = ko.computed<boolean>(() => this.container.isPreferredApiDocumentDB());
this.shouldShowCrossPartitionOption = ko.computed<boolean>(() => !this.container.isPreferredApiGraph());
this.shouldShowParallelismOption = ko.computed<boolean>(() => !this.container.isPreferredApiGraph());

View File

@@ -1,15 +1,13 @@
import * as Constants from "../../Common/Constants";
import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
import * as ko from "knockout";
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
import * as React from "react";
import * as ViewModels from "../../Contracts/ViewModels";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import { IconButton } from "office-ui-fabric-react/lib/Button";
import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRightPaneComponent";
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import { UploadDetailsRecord, UploadDetails } from "../../workers/upload/definitions";
import InfoBubbleIcon from "../../../images/info-bubble.svg";
import { UploadItemsPaneComponent, UploadItemsPaneProps } from "./UploadItemsPaneComponent";
import Explorer from "../Explorer";
const UPLOAD_FILE_SIZE_LIMIT = 2097152;
@@ -35,9 +33,8 @@ export class UploadItemsPaneAdapter implements ReactAdapter {
return undefined;
}
const props: GenericRightPaneProps = {
const genericPaneProps: GenericRightPaneProps = {
container: this.container,
content: this.createContent(),
formError: this.formError,
formErrorDetail: this.formErrorDetail,
id: "uploaditemspane",
@@ -47,7 +44,18 @@ export class UploadItemsPaneAdapter implements ReactAdapter {
onClose: () => this.close(),
onSubmit: () => this.submit()
};
return <GenericRightPaneComponent {...props} />;
const uploadItemsPaneProps: UploadItemsPaneProps = {
selectedFilesTitle: this.selectedFilesTitle,
updateSelectedFiles: this.updateSelectedFiles,
uploadFileData: this.uploadFileData
};
return (
<GenericRightPaneComponent {...genericPaneProps}>
<UploadItemsPaneComponent {...uploadItemsPaneProps} />
</GenericRightPaneComponent>
);
}
public triggerRender(): void {
@@ -110,77 +118,6 @@ export class UploadItemsPaneAdapter implements ReactAdapter {
});
}
private createContent = (): JSX.Element => {
return <div className="panelContent">{this.createMainContentSection()}</div>;
};
private createMainContentSection = (): JSX.Element => {
return (
<div className="paneMainContent">
<div className="renewUploadItemsHeader">
<span> Select JSON Files </span>
<span className="infoTooltip" role="tooltip" tabIndex={0}>
<img className="infoImg" src={InfoBubbleIcon} alt="More information" />
<span className="tooltiptext infoTooltipWidth">
Select one or more JSON files to upload. Each file can contain a single JSON document or an array of JSON
documents. The combined size of all files in an individual upload operation must be less than 2 MB. You
can perform multiple upload operations for larger data sets.
</span>
</span>
</div>
<input
className="importFilesTitle"
type="text"
disabled
value={this.selectedFilesTitle}
aria-label="Select JSON Files"
/>
<input
type="file"
id="importDocsInput"
title="Upload Icon"
multiple
accept="application/json"
role="button"
tabIndex={0}
style={{ display: "none" }}
onChange={this.updateSelectedFiles}
/>
<IconButton
iconProps={{ iconName: "FolderHorizontal" }}
className="fileImportButton"
alt="Select JSON files to upload"
title="Select JSON files to upload"
onClick={this.onImportButtonClick}
onKeyPress={this.onImportButtonKeyPress}
/>
<div className="fileUploadSummaryContainer" hidden={this.uploadFileData.length === 0}>
<b>File upload status</b>
<table className="fileUploadSummary">
<thead>
<tr className="fileUploadSummaryHeader fileUploadSummaryTuple">
<th>FILE NAME</th>
<th>STATUS</th>
</tr>
</thead>
<tbody>
{this.uploadFileData.map(
(data: UploadDetailsRecord): JSX.Element => {
return (
<tr className="fileUploadSummaryTuple" key={data.fileName}>
<td>{data.fileName}</td>
<td>{this.fileUploadSummaryText(data.numSucceeded, data.numFailed)}</td>
</tr>
);
}
)}
</tbody>
</table>
</div>
</div>
);
};
private updateSelectedFiles = (event: React.ChangeEvent<HTMLInputElement>): void => {
this.selectedFiles = event.target.files;
this._updateSelectedFilesTitle();
@@ -212,21 +149,6 @@ export class UploadItemsPaneAdapter implements ReactAdapter {
return totalFileSize;
}
private fileUploadSummaryText = (numSucceeded: number, numFailed: number): string => {
return `${numSucceeded} items created, ${numFailed} errors`;
};
private onImportButtonClick = (): void => {
document.getElementById("importDocsInput").click();
};
private onImportButtonKeyPress = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
if (event.charCode === Constants.KeyCodes.Enter || event.charCode === Constants.KeyCodes.Space) {
this.onImportButtonClick();
event.stopPropagation();
}
};
private reset = (): void => {
this.isOpened = false;
this.isExecuting = false;

View File

@@ -0,0 +1,97 @@
import * as Constants from "../../Common/Constants";
import * as React from "react";
import { IconButton } from "office-ui-fabric-react/lib/Button";
import { UploadDetailsRecord } from "../../workers/upload/definitions";
import InfoBubbleIcon from "../../../images/info-bubble.svg";
export interface UploadItemsPaneProps {
selectedFilesTitle: string;
updateSelectedFiles: (event: React.ChangeEvent<HTMLInputElement>) => void;
uploadFileData: UploadDetailsRecord[];
}
export class UploadItemsPaneComponent extends React.Component<UploadItemsPaneProps> {
public render(): JSX.Element {
return (
<div className="panelContent">
<div className="paneMainContent">
<div className="renewUploadItemsHeader">
<span> Select JSON Files </span>
<span className="infoTooltip" role="tooltip" tabIndex={0}>
<img className="infoImg" src={InfoBubbleIcon} alt="More information" />
<span className="tooltiptext infoTooltipWidth">
Select one or more JSON files to upload. Each file can contain a single JSON document or an array of
JSON documents. The combined size of all files in an individual upload operation must be less than 2 MB.
You can perform multiple upload operations for larger data sets.
</span>
</span>
</div>
<input
className="importFilesTitle"
type="text"
disabled
value={this.props.selectedFilesTitle}
aria-label="Select JSON Files"
/>
<input
type="file"
id="importDocsInput"
title="Upload Icon"
multiple
accept="application/json"
role="button"
tabIndex={0}
style={{ display: "none" }}
onChange={this.props.updateSelectedFiles}
/>
<IconButton
iconProps={{ iconName: "FolderHorizontal" }}
className="fileImportButton"
alt="Select JSON files to upload"
title="Select JSON files to upload"
onClick={this.onImportButtonClick}
onKeyPress={this.onImportButtonKeyPress}
/>
<div className="fileUploadSummaryContainer" hidden={this.props.uploadFileData.length === 0}>
<b>File upload status</b>
<table className="fileUploadSummary">
<thead>
<tr className="fileUploadSummaryHeader fileUploadSummaryTuple">
<th>FILE NAME</th>
<th>STATUS</th>
</tr>
</thead>
<tbody>
{this.props.uploadFileData.map(
(data: UploadDetailsRecord): JSX.Element => {
return (
<tr className="fileUploadSummaryTuple" key={data.fileName}>
<td>{data.fileName}</td>
<td>{this.fileUploadSummaryText(data.numSucceeded, data.numFailed)}</td>
</tr>
);
}
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
private fileUploadSummaryText = (numSucceeded: number, numFailed: number): string => {
return `${numSucceeded} items created, ${numFailed} errors`;
};
private onImportButtonClick = (): void => {
document.getElementById("importDocsInput").click();
};
private onImportButtonKeyPress = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
if (event.charCode === Constants.KeyCodes.Enter || event.charCode === Constants.KeyCodes.Space) {
this.onImportButtonClick();
event.stopPropagation();
}
};
}

View File

@@ -22,6 +22,15 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
Would you like to publish and share "SampleNotebook" to the gallery?
</Text>
</StackItem>
<StackItem>
<StyledTextFieldBase
ariaLabel="Name"
defaultValue="SampleNotebook.ipynb"
label="Name"
onChange={[Function]}
required={true}
/>
</StackItem>
<StackItem>
<StyledTextFieldBase
ariaLabel="Description"
@@ -93,6 +102,7 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
"id": undefined,
"isSample": false,
"name": "SampleNotebook.ipynb",
"newCellId": undefined,
"tags": Array [
"",
],

View File

@@ -5,8 +5,6 @@ import * as ko from "knockout";
import * as React from "react";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import * as ViewModels from "../../Contracts/ViewModels";
import { CosmosClient } from "../../Common/CosmosClient";
import NewContainerIcon from "../../../images/Hero-new-container.svg";
import NewNotebookIcon from "../../../images/Hero-new-notebook.svg";
import NewQueryIcon from "../../../images/AddSqlQuery_16x16.svg";
@@ -19,6 +17,7 @@ import AddDatabaseIcon from "../../../images/AddDatabase.svg";
import SampleIcon from "../../../images/Hero-sample.svg";
import { DataSamplesUtil } from "../DataSamples/DataSamplesUtil";
import Explorer from "../Explorer";
import { userContext } from "../../UserContext";
/**
* TODO Remove this when fully ported to ReactJS
@@ -46,7 +45,7 @@ export class SplashScreenComponentAdapter implements ReactAdapter {
};
private clearMostRecent = (): void => {
this.container.mostRecentActivity.clear(CosmosClient.databaseAccount().id);
this.container.mostRecentActivity.clear(userContext.databaseAccount?.id);
this.forceRender();
};
@@ -195,7 +194,7 @@ export class SplashScreenComponentAdapter implements ReactAdapter {
}
private createRecentItems(): SplashScreenItem[] {
return this.container.mostRecentActivity.getItems(CosmosClient.databaseAccount().id).map(item => ({
return this.container.mostRecentActivity.getItems(userContext.databaseAccount?.id).map(item => ({
iconSrc: MostRecentActivity.MostRecentActivity.getItemIcon(item),
title: item.title,
description: item.description,

View File

@@ -1,4 +1,3 @@
import * as AddCollectionUtility from "../../Shared/AddCollectionUtility";
import * as AutoPilotUtils from "../../Utils/AutoPilotUtils";
import * as Constants from "../../Common/Constants";
import * as DataModels from "../../Contracts/DataModels";
@@ -14,12 +13,13 @@ import SaveIcon from "../../../images/save-cosmos.svg";
import TabsBase from "./TabsBase";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import { CosmosClient } from "../../Common/CosmosClient";
import { PlatformType } from "../../PlatformType";
import { RequestOptions } from "@azure/cosmos/dist-esm";
import Explorer from "../Explorer";
import { updateOfferThroughputBeyondLimit, updateOffer } from "../../Common/DocumentClientUtilityBase";
import { updateOffer } from "../../Common/DocumentClientUtilityBase";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { userContext } from "../../UserContext";
import { updateOfferThroughputBeyondLimit } from "../../Common/dataAccess/updateOfferThroughputBeyondLimit";
const updateThroughputBeyondLimitWarningMessage: string = `
You are about to request an increase in throughput beyond the pre-allocated capacity.
@@ -520,16 +520,15 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels.
this.maxRUs() <= SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
this.throughput() > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million
) {
const requestPayload: DataModels.UpdateOfferThroughputRequest = {
subscriptionId: CosmosClient.subscriptionId(),
databaseAccountName: CosmosClient.databaseAccount().name,
resourceGroup: CosmosClient.resourceGroup(),
const requestPayload = {
subscriptionId: userContext.subscriptionId,
databaseAccountName: userContext.databaseAccount.name,
resourceGroup: userContext.resourceGroup,
databaseName: this.database.id(),
collectionName: undefined,
throughput: newThroughput,
offerIsRUPerMinuteThroughputEnabled: false
};
const updateOfferBeyondLimitPromise: Q.Promise<void> = updateOfferThroughputBeyondLimit(requestPayload).then(
const updateOfferBeyondLimitPromise = updateOfferThroughputBeyondLimit(requestPayload).then(
() => {
this.database.offer().content.offerThroughput = originalThroughputValue;
this.throughput(originalThroughputValue);
@@ -553,7 +552,7 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels.
);
}
);
promises.push(updateOfferBeyondLimitPromise);
promises.push(Q(updateOfferBeyondLimitPromise));
} else {
const newOffer: DataModels.Offer = {
content: {

View File

@@ -9,11 +9,11 @@ import TabsBase from "./TabsBase";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import { CosmosClient } from "../../Common/CosmosClient";
import { HashMap } from "../../Common/HashMap";
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
import { PlatformType } from "../../PlatformType";
import Explorer from "../Explorer";
import { userContext } from "../../UserContext";
export default class MongoShellTab extends TabsBase {
public url: ko.Computed<string>;
@@ -26,8 +26,8 @@ export default class MongoShellTab extends TabsBase {
this._logTraces = new HashMap<number>();
this._container = options.collection.container;
this.url = ko.computed<string>(() => {
const account = CosmosClient.databaseAccount();
const resourceId: string = account && account.id;
const account = userContext.databaseAccount;
const resourceId = account && account.id;
const accountName = account && account.name;
const mongoEndpoint = account && (account.properties.mongoEndpoint || account.properties.documentEndpoint);
@@ -95,7 +95,7 @@ export default class MongoShellTab extends TabsBase {
return;
}
const authorization: string = CosmosClient.authorizationToken() || "";
const authorization: string = userContext.authorizationToken || "";
const resourceId = this._container.databaseAccount().id;
const accountName = this._container.databaseAccount().name;
const documentEndpoint =
@@ -111,10 +111,10 @@ export default class MongoShellTab extends TabsBase {
const collectionId = this.collection.id();
const apiEndpoint = EnvironmentUtility.getMongoBackendEndpoint(
this._container.serverId(),
CosmosClient.databaseAccount().location,
userContext.databaseAccount.location,
this._container.extensionEndpoint()
).replace("/api/mongo/explorer", "");
const encryptedAuthToken: string = CosmosClient.accessToken();
const encryptedAuthToken: string = userContext.accessToken;
shellIframe.contentWindow.postMessage(
{

View File

@@ -26,10 +26,11 @@ import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils"
import { NotebookComponentAdapter } from "../Notebook/NotebookComponent/NotebookComponentAdapter";
import { NotebookConfigurationUtils } from "../../Utils/NotebookConfigurationUtils";
import { KernelSpecsDisplay, NotebookClientV2 } from "../Notebook/NotebookClientV2";
import { config } from "../../Config";
import { configContext } from "../../ConfigContext";
import Explorer from "../Explorer";
import { NotebookContentItem } from "../Notebook/NotebookContentItem";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { toJS, stringifyNotebook } from "@nteract/commutable";
export interface NotebookTabOptions extends ViewModels.TabOptions {
account: DataModels.DatabaseAccount;
@@ -122,6 +123,7 @@ export default class NotebookTabV2 extends TabsBase {
const availableKernels = NotebookTabV2.clientManager.getAvailableKernelSpecs();
const saveLabel = "Save";
const copyToLabel = "Copy to ...";
const publishLabel = "Publish to gallery";
const workspaceLabel = "No Workspace";
const kernelLabel = "No Kernel";
@@ -145,6 +147,30 @@ export default class NotebookTabV2 extends TabsBase {
const cellCodeType = "code";
const cellMarkdownType = "markdown";
const cellRawType = "raw";
const saveButtonChildren = [];
if (this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
saveButtonChildren.push({
iconName: "Copy",
onCommandClick: () => this.copyNotebook(),
commandButtonLabel: copyToLabel,
hasPopup: false,
disabled: false,
ariaLabel: copyToLabel
});
}
if (this.container.isGalleryPublishEnabled()) {
saveButtonChildren.push({
iconName: "PublishContent",
onCommandClick: async () => await this.publishToGallery(),
commandButtonLabel: publishLabel,
hasPopup: false,
disabled: false,
ariaLabel: publishLabel
});
}
let buttons: CommandButtonComponentProps[] = [
{
iconSrc: SaveIcon,
@@ -154,26 +180,17 @@ export default class NotebookTabV2 extends TabsBase {
hasPopup: false,
disabled: false,
ariaLabel: saveLabel,
children: this.container.isGalleryPublishEnabled()
? [
{
iconName: "Save",
onCommandClick: () => this.notebookComponentAdapter.notebookSave(),
commandButtonLabel: saveLabel,
hasPopup: false,
disabled: false,
ariaLabel: saveLabel
},
{
iconName: "PublishContent",
onCommandClick: () => this.publishToGallery(),
commandButtonLabel: publishLabel,
hasPopup: false,
disabled: false,
ariaLabel: publishLabel
}
]
: undefined
children: saveButtonChildren.length && [
{
iconName: "Save",
onCommandClick: () => this.notebookComponentAdapter.notebookSave(),
commandButtonLabel: saveLabel,
hasPopup: false,
disabled: false,
ariaLabel: saveLabel
},
...saveButtonChildren
]
},
{
iconSrc: null,
@@ -423,7 +440,7 @@ export default class NotebookTabV2 extends TabsBase {
password: undefined,
endpoints: [
{
endpoint: `https://${workspace.name}.${config.ARCADIA_LIVY_ENDPOINT_DNS_ZONE}/livyApi/versions/${ArmApiVersions.arcadiaLivy}/sparkPools/${selectedPool.name}/`,
endpoint: `https://${workspace.name}.${configContext.ARCADIA_LIVY_ENDPOINT_DNS_ZONE}/livyApi/versions/${ArmApiVersions.arcadiaLivy}/sparkPools/${selectedPool.name}/`,
kind: DataModels.SparkClusterEndpointKind.Livy
}
]
@@ -456,15 +473,27 @@ export default class NotebookTabV2 extends TabsBase {
);
}
private publishToGallery = () => {
private publishToGallery = async () => {
const notebookContent = this.notebookComponentAdapter.getContent();
this.container.publishNotebook(
await this.container.publishNotebook(
notebookContent.name,
notebookContent.content,
this.notebookComponentAdapter.getNotebookParentElement()
);
};
private copyNotebook = () => {
const notebookContent = this.notebookComponentAdapter.getContent();
let content: string;
if (typeof notebookContent.content === "string") {
content = notebookContent.content;
} else {
content = stringifyNotebook(toJS(notebookContent.content));
}
this.container.copyNotebook(notebookContent.name, content);
};
private traceTelemetry(actionType: number) {
TelemetryProcessor.trace(actionType, ActionModifiers.Mark, {
databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name,

View File

@@ -7,6 +7,7 @@ import Database from "../Tree/Database";
import Explorer from "../Explorer";
import SettingsTab from "../Tabs/SettingsTab";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { IndexingPolicies } from "../../Shared/Constants";
describe("Settings tab", () => {
const baseCollection: DataModels.Collection = {
@@ -16,7 +17,7 @@ describe("Settings tab", () => {
mode: DataModels.ConflictResolutionMode.LastWriterWins,
conflictResolutionPath: "/_ts"
},
indexingPolicy: {},
indexingPolicy: IndexingPolicies.SharedDatabaseDefault,
_rid: "",
_self: "",
_etag: "",
@@ -51,7 +52,7 @@ describe("Settings tab", () => {
defaultTtl: 200,
partitionKey: null,
conflictResolutionPolicy: null,
indexingPolicy: {},
indexingPolicy: IndexingPolicies.SharedDatabaseDefault,
_rid: "",
_self: "",
_etag: "",
@@ -345,7 +346,6 @@ describe("Settings tab", () => {
const offer: DataModels.Offer = null;
const defaultTtl = 200;
const indexingPolicy = {};
const database = new Database(explorer, baseDatabase, null);
const conflictResolutionPolicy = {
mode: DataModels.ConflictResolutionMode.LastWriterWins,
@@ -367,7 +367,7 @@ describe("Settings tab", () => {
}
: null,
conflictResolutionPolicy: conflictResolutionPolicy,
indexingPolicy: indexingPolicy,
indexingPolicy: IndexingPolicies.SharedDatabaseDefault,
_rid: "",
_self: "",
_etag: "",

View File

@@ -14,16 +14,14 @@ import SaveIcon from "../../../images/save-cosmos.svg";
import TabsBase from "./TabsBase";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import { CosmosClient } from "../../Common/CosmosClient";
import { PlatformType } from "../../PlatformType";
import { RequestOptions } from "@azure/cosmos/dist-esm";
import Explorer from "../Explorer";
import {
updateOfferThroughputBeyondLimit,
updateOffer,
updateCollection
} from "../../Common/DocumentClientUtilityBase";
import { updateOffer } from "../../Common/DocumentClientUtilityBase";
import { updateCollection } from "../../Common/dataAccess/updateCollection";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { userContext } from "../../UserContext";
import { updateOfferThroughputBeyondLimit } from "../../Common/dataAccess/updateOfferThroughputBeyondLimit";
const ttlWarning: string = `
The system will automatically delete items based on the TTL value (in seconds) you provide, without needing a delete operation explicitly issued by a client application.
@@ -347,7 +345,7 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
if (!this.isAutoPilotSelected()) {
return false;
}
const originalAutoPilotSettings = this.collection.offer().content.offerAutopilotSettings;
const originalAutoPilotSettings = this.collection?.offer()?.content?.offerAutopilotSettings;
if (!originalAutoPilotSettings) {
return false;
}
@@ -1012,8 +1010,7 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
);
}
public onSaveClick = (): Q.Promise<any> => {
let promises: Q.Promise<void>[] = [];
public onSaveClick = async (): Promise<any> => {
this.isExecutionError(false);
this.isExecuting(true);
@@ -1026,50 +1023,60 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
const newCollectionAttributes: any = {};
if (this.shouldUpdateCollection()) {
let defaultTtl: number;
switch (this.timeToLive()) {
case "on":
defaultTtl = Number(this.timeToLiveSeconds());
break;
case "on-nodefault":
defaultTtl = -1;
break;
case "off":
default:
defaultTtl = undefined;
break;
}
try {
if (this.shouldUpdateCollection()) {
let defaultTtl: number;
switch (this.timeToLive()) {
case "on":
defaultTtl = Number(this.timeToLiveSeconds());
break;
case "on-nodefault":
defaultTtl = -1;
break;
case "off":
default:
defaultTtl = undefined;
break;
}
newCollectionAttributes.defaultTtl = defaultTtl;
newCollectionAttributes.defaultTtl = defaultTtl;
newCollectionAttributes.indexingPolicy = this.indexingPolicyContent();
newCollectionAttributes.indexingPolicy = this.indexingPolicyContent();
newCollectionAttributes.changeFeedPolicy =
this.changeFeedPolicyVisible() && this.changeFeedPolicyToggled() === ChangeFeedPolicyToggledState.On
? ({
retentionDuration: Constants.BackendDefaults.maxChangeFeedRetentionDuration
} as DataModels.ChangeFeedPolicy)
newCollectionAttributes.changeFeedPolicy =
this.changeFeedPolicyVisible() && this.changeFeedPolicyToggled() === ChangeFeedPolicyToggledState.On
? ({
retentionDuration: Constants.BackendDefaults.maxChangeFeedRetentionDuration
} as DataModels.ChangeFeedPolicy)
: undefined;
newCollectionAttributes.analyticalStorageTtl = this.isAnalyticalStorageEnabled
? this.analyticalStorageTtlSelection() === "on"
? Number(this.analyticalStorageTtlSeconds())
: Constants.AnalyticalStorageTtl.Infinite
: undefined;
newCollectionAttributes.analyticalStorageTtl = this.isAnalyticalStorageEnabled
? this.analyticalStorageTtlSelection() === "on"
? Number(this.analyticalStorageTtlSeconds())
: Constants.AnalyticalStorageTtl.Infinite
: undefined;
newCollectionAttributes.geospatialConfig = {
type: this.geospatialConfigType()
};
newCollectionAttributes.geospatialConfig = {
type: this.geospatialConfigType()
};
const conflictResolutionChanges: DataModels.ConflictResolutionPolicy = this.getUpdatedConflictResolutionPolicy();
if (!!conflictResolutionChanges) {
newCollectionAttributes.conflictResolutionPolicy = conflictResolutionChanges;
}
const conflictResolutionChanges: DataModels.ConflictResolutionPolicy = this.getUpdatedConflictResolutionPolicy();
if (!!conflictResolutionChanges) {
newCollectionAttributes.conflictResolutionPolicy = conflictResolutionChanges;
}
const newCollection: DataModels.Collection = _.extend(
{},
this.collection.rawDataModel,
newCollectionAttributes
);
const updatedCollection: DataModels.Collection = await updateCollection(
this.collection.databaseId,
this.collection.id(),
newCollection
);
const newCollection: DataModels.Collection = _.extend({}, this.collection.rawDataModel, newCollectionAttributes);
const updateCollectionPromise = updateCollection(this.collection.databaseId, this.collection, newCollection).then(
(updatedCollection: DataModels.Collection) => {
if (updatedCollection) {
this.collection.rawDataModel = updatedCollection;
this.collection.defaultTtl(updatedCollection.defaultTtl);
this.collection.analyticalStorageTtl(updatedCollection.analyticalStorageTtl);
@@ -1079,164 +1086,133 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
this.collection.changeFeedPolicy(updatedCollection.changeFeedPolicy);
this.collection.geospatialConfig(updatedCollection.geospatialConfig);
}
);
promises.push(updateCollectionPromise);
}
if (
this.throughput.editableIsDirty() ||
this.rupm.editableIsDirty() ||
this._isAutoPilotDirty() ||
this._hasProvisioningTypeChanged()
) {
const newThroughput = this.throughput();
const isRUPerMinuteThroughputEnabled: boolean = this.rupm() === Constants.RUPMStates.on;
let newOffer: DataModels.Offer = _.extend({}, this.collection.offer());
const originalThroughputValue: number = this.throughput.getEditableOriginalValue();
if (newOffer.content) {
newOffer.content.offerThroughput = newThroughput;
newOffer.content.offerIsRUPerMinuteThroughputEnabled = isRUPerMinuteThroughputEnabled;
} else {
newOffer = _.extend({}, newOffer, {
content: {
offerThroughput: newThroughput,
offerIsRUPerMinuteThroughputEnabled: isRUPerMinuteThroughputEnabled
}
});
}
const headerOptions: RequestOptions = { initialHeaders: {} };
if (this.isAutoPilotSelected()) {
if (!this.hasAutoPilotV2FeatureFlag()) {
newOffer.content.offerAutopilotSettings = {
maxThroughput: this.autoPilotThroughput()
};
} else {
newOffer.content.offerAutopilotSettings = {
tier: this.selectedAutoPilotTier()
};
}
// user has changed from provisioned --> autoscale
if (!this.hasAutoPilotV2FeatureFlag() && this._hasProvisioningTypeChanged()) {
headerOptions.initialHeaders[Constants.HttpHeaders.migrateOfferToAutopilot] = "true";
delete newOffer.content.offerAutopilotSettings;
} else {
delete newOffer.content.offerThroughput;
}
} else {
this.isAutoPilotSelected(false);
this.userCanChangeProvisioningTypes(false || !this.hasAutoPilotV2FeatureFlag());
// user has changed from autoscale --> provisioned
if (!this.hasAutoPilotV2FeatureFlag() && this._hasProvisioningTypeChanged()) {
headerOptions.initialHeaders[Constants.HttpHeaders.migrateOfferToManualThroughput] = "true";
} else {
delete newOffer.content.offerAutopilotSettings;
}
}
if (
this.maxRUs() <= SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
newThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
this.container != null
this.throughput.editableIsDirty() ||
this.rupm.editableIsDirty() ||
this._isAutoPilotDirty() ||
this._hasProvisioningTypeChanged()
) {
const requestPayload: DataModels.UpdateOfferThroughputRequest = {
subscriptionId: CosmosClient.subscriptionId(),
databaseAccountName: CosmosClient.databaseAccount().name,
resourceGroup: CosmosClient.resourceGroup(),
databaseName: this.collection.databaseId,
collectionName: this.collection.id(),
throughput: newThroughput,
offerIsRUPerMinuteThroughputEnabled: isRUPerMinuteThroughputEnabled
};
const updateOfferBeyondLimitPromise: Q.Promise<void> = updateOfferThroughputBeyondLimit(requestPayload).then(
() => {
this.collection.offer().content.offerThroughput = originalThroughputValue;
this.throughput(originalThroughputValue);
this.notificationStatusInfo(
throughputApplyDelayedMessage(
this.isAutoPilotSelected(),
originalThroughputValue,
this._getThroughputUnit(),
this.collection.databaseId,
this.collection.id(),
newThroughput
)
);
this.throughput.valueHasMutated(); // force component re-render
},
(error: any) => {
TelemetryProcessor.traceFailure(
Action.UpdateSettings,
{
databaseAccountName: this.container.databaseAccount().name,
databaseName: this.collection && this.collection.databaseId,
collectionName: this.collection && this.collection.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.tabTitle(),
error: error
},
startKey
);
}
);
promises.push(updateOfferBeyondLimitPromise);
} else {
const updateOfferPromise = updateOffer(this.collection.offer(), newOffer, headerOptions).then(
(updatedOffer: DataModels.Offer) => {
this.collection.offer(updatedOffer);
this.collection.offer.valueHasMutated();
}
);
const newThroughput = this.throughput();
const isRUPerMinuteThroughputEnabled: boolean = this.rupm() === Constants.RUPMStates.on;
let newOffer: DataModels.Offer = _.extend({}, this.collection.offer());
const originalThroughputValue: number = this.throughput.getEditableOriginalValue();
promises.push(updateOfferPromise);
}
}
if (promises.length === 0) {
this.isExecuting(false);
}
return Q.all(promises)
.then(
() => {
this.container.isRefreshingExplorer(false);
this._setBaseline();
this.collection.readSettings();
this._wasAutopilotOriginallySet(this.isAutoPilotSelected());
TelemetryProcessor.traceSuccess(
Action.UpdateSettings,
{
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.tabTitle()
},
startKey
);
},
(reason: any) => {
this.container.isRefreshingExplorer(false);
this.isExecutionError(true);
console.error(reason);
TelemetryProcessor.traceFailure(
Action.UpdateSettings,
{
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.tabTitle()
},
startKey
);
if (newOffer.content) {
newOffer.content.offerThroughput = newThroughput;
newOffer.content.offerIsRUPerMinuteThroughputEnabled = isRUPerMinuteThroughputEnabled;
} else {
newOffer = _.extend({}, newOffer, {
content: {
offerThroughput: newThroughput,
offerIsRUPerMinuteThroughputEnabled: isRUPerMinuteThroughputEnabled
}
});
}
)
.finally(() => this.isExecuting(false));
const headerOptions: RequestOptions = { initialHeaders: {} };
if (this.isAutoPilotSelected()) {
if (!this.hasAutoPilotV2FeatureFlag()) {
newOffer.content.offerAutopilotSettings = {
maxThroughput: this.autoPilotThroughput()
};
} else {
newOffer.content.offerAutopilotSettings = {
tier: this.selectedAutoPilotTier()
};
}
// user has changed from provisioned --> autoscale
if (!this.hasAutoPilotV2FeatureFlag() && this._hasProvisioningTypeChanged()) {
headerOptions.initialHeaders[Constants.HttpHeaders.migrateOfferToAutopilot] = "true";
delete newOffer.content.offerAutopilotSettings;
} else {
delete newOffer.content.offerThroughput;
}
} else {
this.isAutoPilotSelected(false);
this.userCanChangeProvisioningTypes(false || !this.hasAutoPilotV2FeatureFlag());
// user has changed from autoscale --> provisioned
if (!this.hasAutoPilotV2FeatureFlag() && this._hasProvisioningTypeChanged()) {
headerOptions.initialHeaders[Constants.HttpHeaders.migrateOfferToManualThroughput] = "true";
} else {
delete newOffer.content.offerAutopilotSettings;
}
}
if (
this.maxRUs() <= SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
newThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
this.container != null
) {
const requestPayload = {
subscriptionId: userContext.subscriptionId,
databaseAccountName: userContext.databaseAccount.name,
resourceGroup: userContext.resourceGroup,
databaseName: this.collection.databaseId,
collectionName: this.collection.id(),
throughput: newThroughput,
offerIsRUPerMinuteThroughputEnabled: isRUPerMinuteThroughputEnabled
};
await updateOfferThroughputBeyondLimit(requestPayload);
this.collection.offer().content.offerThroughput = originalThroughputValue;
this.throughput(originalThroughputValue);
this.notificationStatusInfo(
throughputApplyDelayedMessage(
this.isAutoPilotSelected(),
originalThroughputValue,
this._getThroughputUnit(),
this.collection.databaseId,
this.collection.id(),
newThroughput
)
);
this.throughput.valueHasMutated(); // force component re-render
} else {
const updatedOffer: DataModels.Offer = await updateOffer(this.collection.offer(), newOffer, headerOptions);
this.collection.offer(updatedOffer);
this.collection.offer.valueHasMutated();
}
}
this.container.isRefreshingExplorer(false);
this._setBaseline();
this.collection.readSettings();
this._wasAutopilotOriginallySet(this.isAutoPilotSelected());
TelemetryProcessor.traceSuccess(
Action.UpdateSettings,
{
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.tabTitle()
},
startKey
);
} catch (error) {
this.container.isRefreshingExplorer(false);
this.isExecutionError(true);
console.error(error);
TelemetryProcessor.traceFailure(
Action.UpdateSettings,
{
databaseAccountName: this.container.databaseAccount().name,
databaseName: this.collection && this.collection.databaseId,
collectionName: this.collection && this.collection.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.tabTitle(),
error: error
},
startKey
);
}
this.isExecuting(false);
};
public onRevertClick = (): Q.Promise<any> => {

View File

@@ -4,7 +4,6 @@ import * as _ from "underscore";
import UploadWorker from "worker-loader!../../workers/upload";
import { AuthType } from "../../AuthType";
import * as Constants from "../../Common/Constants";
import { CosmosClient } from "../../Common/CosmosClient";
import * as Logger from "../../Common/Logger";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
@@ -31,7 +30,7 @@ import SettingsTab from "../Tabs/SettingsTab";
import StoredProcedure from "./StoredProcedure";
import Trigger from "./Trigger";
import UserDefinedFunction from "./UserDefinedFunction";
import { config } from "../../Config";
import { configContext } from "../../ConfigContext";
import Explorer from "../Explorer";
import {
createDocument,
@@ -42,6 +41,7 @@ import {
readOffer,
readOffers
} from "../../Common/DocumentClientUtilityBase";
import { userContext } from "../../UserContext";
export default class Collection implements ViewModels.Collection {
public nodeKind: string;
@@ -472,7 +472,7 @@ export default class Collection implements ViewModels.Collection {
});
graphTab = new GraphTab({
account: CosmosClient.databaseAccount(),
account: userContext.databaseAccount,
tabKind: ViewModels.CollectionTabKind.Graph,
node: this,
title: title,
@@ -480,7 +480,7 @@ export default class Collection implements ViewModels.Collection {
collection: this,
selfLink: this.self,
masterKey: CosmosClient.masterKey() || "",
masterKey: userContext.masterKey || "",
collectionPartitionKeyProperty: this.partitionKeyProperty,
hashLocation: `${Constants.HashRoutePrefixes.collectionsWithIds(this.databaseId, this.id())}/graphs`,
collectionId: this.id(),
@@ -648,7 +648,9 @@ export default class Collection implements ViewModels.Collection {
});
// TODO: Use the collection entity cache to get quota info
const quotaInfoPromise: Q.Promise<DataModels.CollectionQuotaInfo> = readCollectionQuotaInfo(this);
const offerInfoPromise: Q.Promise<DataModels.Offer[]> = readOffers();
const offerInfoPromise: Q.Promise<DataModels.Offer[]> = readOffers({
isServerless: this.container.isServerlessEnabled()
});
Q.all([quotaInfoPromise, offerInfoPromise]).then(
() => {
this.container.isRefreshingExplorer(false);
@@ -657,9 +659,7 @@ export default class Collection implements ViewModels.Collection {
const quotaInfo = _.omit(quotaInfoWithUniqueKeyPolicy, "uniqueKeyPolicy");
const collectionOffer = this._getOfferForCollection(offerInfoPromise.valueOf(), collectionDataModel);
const isDatabaseShared = this.getDatabase() && this.getDatabase().isDatabaseShared();
const isServerless = this.container.isServerlessEnabled();
if ((isDatabaseShared || isServerless) && !collectionOffer) {
if (!collectionOffer) {
this.quotaInfo(quotaInfo);
TelemetryProcessor.traceSuccess(
Action.LoadOffers,
@@ -804,14 +804,14 @@ export default class Collection implements ViewModels.Collection {
});
const graphTab: GraphTab = new GraphTab({
account: CosmosClient.databaseAccount(),
account: userContext.databaseAccount,
tabKind: ViewModels.CollectionTabKind.Graph,
node: this,
title: title,
tabPath: "",
collection: this,
selfLink: this.self,
masterKey: CosmosClient.masterKey() || "",
masterKey: userContext.masterKey || "",
collectionPartitionKeyProperty: this.partitionKeyProperty,
hashLocation: `${Constants.HashRoutePrefixes.collectionsWithIds(this.databaseId, this.id())}/graphs`,
collectionId: this.id(),
@@ -1181,11 +1181,11 @@ export default class Collection implements ViewModels.Collection {
documentClientParams: {
databaseId: this.databaseId,
containerId: this.id(),
masterKey: CosmosClient.masterKey(),
endpoint: CosmosClient.endpoint(),
accessToken: CosmosClient.accessToken(),
platform: config.platform,
databaseAccount: CosmosClient.databaseAccount()
masterKey: userContext.masterKey,
endpoint: userContext.endpoint,
accessToken: userContext.accessToken,
platform: configContext.platform,
databaseAccount: userContext.databaseAccount
}
};

View File

@@ -12,7 +12,8 @@ import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils"
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import * as Logger from "../../Common/Logger";
import Explorer from "../Explorer";
import { readCollections, readOffers, readOffer } from "../../Common/DocumentClientUtilityBase";
import { readOffers, readOffer } from "../../Common/DocumentClientUtilityBase";
import { readCollections } from "../../Common/dataAccess/readCollections";
export default class Database implements ViewModels.Database {
public nodeKind: string;
@@ -122,10 +123,6 @@ export default class Database implements ViewModels.Database {
public readSettings(): Q.Promise<void> {
const deferred: Q.Deferred<void> = Q.defer<void>();
if (this.container.isServerlessEnabled()) {
deferred.resolve();
}
this.container.isRefreshingExplorer(true);
const databaseDataModel: DataModels.Database = <DataModels.Database>{
id: this.id(),
@@ -137,7 +134,9 @@ export default class Database implements ViewModels.Database {
defaultExperience: this.container.defaultExperience()
});
const offerInfoPromise: Q.Promise<DataModels.Offer[]> = readOffers();
const offerInfoPromise: Q.Promise<DataModels.Offer[]> = readOffers({
isServerless: this.container.isServerlessEnabled()
});
Q.all([offerInfoPromise]).then(
() => {
this.container.isRefreshingExplorer(false);
@@ -146,6 +145,11 @@ export default class Database implements ViewModels.Database {
offerInfoPromise.valueOf(),
databaseDataModel
);
if (!databaseOffer) {
return;
}
readOffer(databaseOffer).then((offerDetail: DataModels.OfferWithHeaders) => {
const offerThroughputInfo: DataModels.OfferThroughputInfo = {
minimumRUForCollection:
@@ -259,7 +263,7 @@ export default class Database implements ViewModels.Database {
let collectionVMs: Collection[] = [];
let deferred: Q.Deferred<void> = Q.defer<void>();
readCollections(this).then(
readCollections(this.id()).then(
(collections: DataModels.Collection[]) => {
let collectionsToAddVMPromises: Q.Promise<any>[] = [];
let deltaCollections = this.getDeltaCollections(collections);

View File

@@ -7,7 +7,7 @@ import * as ViewModels from "../../Contracts/ViewModels";
import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem";
import { ResourceTreeContextMenuButtonFactory } from "../ContextMenuButtonFactory";
import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity";
import { CosmosClient } from "../../Common/CosmosClient";
import CopyIcon from "../../../images/notebook/Notebook-copy.svg";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
import CollectionIcon from "../../../images/tree-collection.svg";
import DeleteIcon from "../../../images/delete.svg";
@@ -31,8 +31,12 @@ import UserDefinedFunction from "./UserDefinedFunction";
import StoredProcedure from "./StoredProcedure";
import Trigger from "./Trigger";
import TabsBase from "../Tabs/TabsBase";
import { userContext } from "../../UserContext";
export class ResourceTreeAdapter implements ReactAdapter {
public static readonly MyNotebooksTitle = "My Notebooks";
public static readonly GitHubReposTitle = "GitHub repos";
private static readonly DataTitle = "DATA";
private static readonly NotebooksTitle = "NOTEBOOKS";
private static readonly PseudoDirPath = "PsuedoDir";
@@ -104,7 +108,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
};
this.myNotebooksContentRoot = {
name: "My Notebooks",
name: ResourceTreeAdapter.MyNotebooksTitle,
path: this.container.getNotebookBasePath(),
type: NotebookContentItemType.Directory
};
@@ -118,7 +122,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
if (this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
this.gitHubNotebooksContentRoot = {
name: "GitHub repos",
name: ResourceTreeAdapter.GitHubReposTitle,
path: ResourceTreeAdapter.PseudoDirPath,
type: NotebookContentItemType.Directory
};
@@ -224,7 +228,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
onClick: () => {
collection.openTab();
// push to most recent
this.container.mostRecentActivity.addItem(CosmosClient.databaseAccount().id, {
this.container.mostRecentActivity.addItem(userContext.databaseAccount?.id, {
type: MostRecentActivity.Type.OpenCollection,
title: collection.id(),
description: "Data",
@@ -490,7 +494,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
}
private pushItemToMostRecent(item: NotebookContentItem) {
this.container.mostRecentActivity.addItem(CosmosClient.databaseAccount().id, {
this.container.mostRecentActivity.addItem(userContext.databaseAccount?.id, {
type: MostRecentActivity.Type.OpenNotebook,
title: item.name,
description: "Notebook",
@@ -542,38 +546,59 @@ export class ResourceTreeAdapter implements ReactAdapter {
(activeTab as any).notebookPath() === item.path
);
},
contextMenu: createFileContextMenu
? [
{
label: "Rename",
iconSrc: NotebookIcon,
onClick: () => this.container.renameNotebook(item)
},
{
label: "Delete",
iconSrc: DeleteIcon,
onClick: () => {
this.container.showOkCancelModalDialog(
"Confirm delete",
`Are you sure you want to delete "${item.name}"`,
"Delete",
() => this.container.deleteNotebookFile(item).then(() => this.triggerRender()),
"Cancel",
undefined
);
}
},
{
label: "Download",
iconSrc: NotebookIcon,
onClick: () => this.container.downloadFile(item)
}
]
: undefined,
contextMenu: createFileContextMenu && this.createFileContextMenu(item),
data: item
};
}
private createFileContextMenu(item: NotebookContentItem): TreeNodeMenuItem[] {
let items: TreeNodeMenuItem[] = [
{
label: "Rename",
iconSrc: NotebookIcon,
onClick: () => this.container.renameNotebook(item)
},
{
label: "Delete",
iconSrc: DeleteIcon,
onClick: () => {
this.container.showOkCancelModalDialog(
"Confirm delete",
`Are you sure you want to delete "${item.name}"`,
"Delete",
() => this.container.deleteNotebookFile(item).then(() => this.triggerRender()),
"Cancel",
undefined
);
}
},
{
label: "Copy to ...",
iconSrc: CopyIcon,
onClick: () => this.copyNotebook(item)
},
{
label: "Download",
iconSrc: NotebookIcon,
onClick: () => this.container.downloadFile(item)
}
];
// "Copy to ..." isn't needed if github locations are not available
if (!this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
items = items.filter(item => item.label !== "Copy to ...");
}
return items;
}
private copyNotebook = async (item: NotebookContentItem) => {
const content = await this.container.readFile(item);
if (content) {
this.container.copyNotebook(item.name, content);
}
};
private createDirectoryContextMenu(item: NotebookContentItem): TreeNodeMenuItem[] {
let items: TreeNodeMenuItem[] = [
{

View File

@@ -2,12 +2,12 @@ import * as ko from "knockout";
import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity";
import * as React from "react";
import * as ViewModels from "../../Contracts/ViewModels";
import { CosmosClient } from "../../Common/CosmosClient";
import { NotebookContentItem } from "../Notebook/NotebookContentItem";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import { TreeComponent, TreeNode } from "../Controls/TreeComponent/TreeComponent";
import CollectionIcon from "../../../images/tree-collection.svg";
import Explorer from "../Explorer";
import { userContext } from "../../UserContext";
export class ResourceTreeAdapterForResourceToken implements ReactAdapter {
public parameters: ko.Observable<number>;
@@ -44,7 +44,7 @@ export class ResourceTreeAdapterForResourceToken implements ReactAdapter {
onClick: () => {
collection.onDocumentDBDocumentsClick();
// push to most recent
this.container.mostRecentActivity.addItem(CosmosClient.databaseAccount().id, {
this.container.mostRecentActivity.addItem(userContext.databaseAccount?.id, {
type: MostRecentActivity.Type.OpenCollection,
title: collection.id(),
description: "Data",

View File

@@ -3,7 +3,7 @@ import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import { Text, Link } from "office-ui-fabric-react";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { initializeConfiguration } from "../Config";
import { initializeConfiguration } from "../ConfigContext";
import { GalleryHeaderComponent } from "../Explorer/Controls/Header/GalleryHeaderComponent";
import {
GalleryAndNotebookViewerComponent,

View File

@@ -317,6 +317,13 @@ export class GitHubClient {
objectExpression: `refs/heads/${branch}:${path || ""}`
} as ContentsQueryParams)) as ContentsQueryResponse;
if (!response.repository.object) {
return {
status: HttpStatusCodes.NotFound,
data: undefined
};
}
let data: IGitHubFile | IGitHubFile[];
const entries = response.repository.object.entries;
const gitHubRepo = GitHubClient.toGitHubRepo(response.repository);

View File

@@ -2,10 +2,11 @@ import { Notebook, stringifyNotebook, makeNotebookRecord, toJS } from "@nteract/
import { FileType, IContent, IContentProvider, IEmptyContent, IGetParams, ServerConfig } from "@nteract/core";
import { from, Observable, of } from "rxjs";
import { AjaxResponse } from "rxjs/ajax";
import * as Base64Utils from "../Utils/Base64Utils";
import { HttpStatusCodes } from "../Common/Constants";
import * as Logger from "../Common/Logger";
import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil";
import { GitHubClient, IGitHubFile, IGitHubResponse, IGitHubCommit, IGitHubBranch } from "./GitHubClient";
import { GitHubClient, IGitHubFile, IGitHubResponse } from "./GitHubClient";
import * as GitHubUtils from "../Utils/GitHubUtils";
import UrlUtility from "../Common/UrlUtility";
@@ -131,7 +132,7 @@ export class GitHubContentProvider implements IContentProvider {
throw new GitHubContentProviderError(`Failed to parse ${uri}`);
}
const content = btoa(stringifyNotebook(toJS(makeNotebookRecord())));
const content = Base64Utils.utf8ToB64(stringifyNotebook(toJS(makeNotebookRecord())));
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
@@ -195,34 +196,63 @@ export class GitHubContentProvider implements IContentProvider {
return from(
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
try {
const commitMsg = await this.validateContentAndGetCommitMsg(content, "Save", "Save");
let commitMsg: string;
if (content.status === HttpStatusCodes.NotFound) {
// We'll create a new file since it doesn't exist
commitMsg = await this.params.promptForCommitMsg("Save", "Save");
if (!commitMsg) {
throw new GitHubContentProviderError("Couldn't get a commit message");
}
} else {
commitMsg = await this.validateContentAndGetCommitMsg(content, "Save", "Save");
}
let updatedContent: string;
if (model.type === "notebook") {
updatedContent = btoa(stringifyNotebook(model.content as Notebook));
updatedContent = Base64Utils.utf8ToB64(stringifyNotebook(model.content as Notebook));
} else if (model.type === "file") {
updatedContent = model.content as string;
if (model.format !== "base64") {
updatedContent = btoa(updatedContent);
updatedContent = Base64Utils.utf8ToB64(updatedContent);
}
} else {
throw new GitHubContentProviderError("Unsupported content type");
}
const gitHubFile = content.data as IGitHubFile;
const response = await this.params.gitHubClient.createOrUpdateFileAsync(
gitHubFile.repo.owner,
gitHubFile.repo.name,
gitHubFile.branch.name,
gitHubFile.path,
commitMsg,
updatedContent,
gitHubFile.sha
);
if (response.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to update", response.status);
const contentInfo = GitHubUtils.fromContentUri(uri);
let gitHubFile: IGitHubFile;
if (content.data) {
gitHubFile = content.data as IGitHubFile;
}
gitHubFile.commit = response.data;
const response = await this.params.gitHubClient.createOrUpdateFileAsync(
contentInfo.owner,
contentInfo.repo,
contentInfo.branch,
contentInfo.path,
commitMsg,
updatedContent,
gitHubFile?.sha
);
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.Created) {
throw new GitHubContentProviderError("Failed to create or update", response.status);
}
if (gitHubFile) {
gitHubFile.commit = response.data;
} else {
const contentResponse = await this.params.gitHubClient.getContentsAsync(
contentInfo.owner,
contentInfo.repo,
contentInfo.branch,
contentInfo.path
);
if (contentResponse.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to get content", response.status);
}
gitHubFile = contentResponse.data as IGitHubFile;
}
return this.createSuccessAjaxResponse(
HttpStatusCodes.OK,

View File

@@ -1,7 +1,7 @@
import ko from "knockout";
import { HttpStatusCodes } from "../Common/Constants";
import * as Logger from "../Common/Logger";
import { config } from "../Config";
import { configContext } from "../ConfigContext";
import { AuthorizeAccessComponent } from "../Explorer/Controls/GitHub/AuthorizeAccessComponent";
import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import { JunoClient } from "../Juno/JunoClient";
@@ -55,7 +55,7 @@ export class GitHubOAuthService {
const params = {
scope,
client_id: config.GITHUB_CLIENT_ID,
client_id: configContext.GITHUB_CLIENT_ID,
redirect_uri: new URL("./connectToGitHub.html", window.location.href).href,
state: this.resetState()
};

View File

@@ -2,7 +2,7 @@ import ko from "knockout";
import { HttpStatusCodes } from "../Common/Constants";
import * as ViewModels from "../Contracts/ViewModels";
import { IPinnedRepo, JunoClient, IGalleryItem } from "./JunoClient";
import { config } from "../Config";
import { configContext } from "../ConfigContext";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
import { DatabaseAccount } from "../Contracts/DataModels";
@@ -47,7 +47,8 @@ const sampleGalleryItems: IGalleryItem[] = [
isSample: false,
downloads: 0,
favorites: 0,
views: 0
views: 0,
newCellId: undefined
}
];
@@ -163,7 +164,7 @@ describe("Gallery", () => {
const response = await junoClient.getSampleNotebooks();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/samples`, undefined);
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/samples`, undefined);
});
it("getPublicNotebooks", async () => {
@@ -175,7 +176,7 @@ describe("Gallery", () => {
const response = await junoClient.getPublicNotebooks();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/public`, undefined);
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/public`, undefined);
});
it("getNotebook", async () => {
@@ -185,10 +186,10 @@ describe("Gallery", () => {
json: () => undefined as any
});
const response = await junoClient.getNotebook(id);
const response = await junoClient.getNotebookInfo(id);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/${id}`);
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/${id}`);
});
it("getNotebookContent", async () => {
@@ -201,7 +202,7 @@ describe("Gallery", () => {
const response = await junoClient.getNotebookContent(id);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/${id}/content`);
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/${id}/content`);
});
it("increaseNotebookViews", async () => {
@@ -214,7 +215,7 @@ describe("Gallery", () => {
const response = await junoClient.increaseNotebookViews(id);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/${id}/views`, {
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/${id}/views`, {
method: "PATCH"
});
});
@@ -231,7 +232,7 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(
`${config.JUNO_ENDPOINT}/api/notebooks/${sampleDatabaseAccount.name}/gallery/${id}/downloads`,
`${configContext.JUNO_ENDPOINT}/api/notebooks/${sampleDatabaseAccount.name}/gallery/${id}/downloads`,
{
method: "PATCH",
headers: {
@@ -254,7 +255,7 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(
`${config.JUNO_ENDPOINT}/api/notebooks/${sampleDatabaseAccount.name}/gallery/${id}/favorite`,
`${configContext.JUNO_ENDPOINT}/api/notebooks/${sampleDatabaseAccount.name}/gallery/${id}/favorite`,
{
method: "PATCH",
headers: {
@@ -276,7 +277,7 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/${id}/unfavorite`, {
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/${id}/unfavorite`, {
method: "PATCH",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
@@ -295,7 +296,7 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/favorites`, {
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/favorites`, {
headers: {
[authorizationHeader.header]: authorizationHeader.token,
"content-type": "application/json"
@@ -313,7 +314,7 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/published`, {
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/published`, {
headers: {
[authorizationHeader.header]: authorizationHeader.token,
"content-type": "application/json"
@@ -332,7 +333,7 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/gallery/${id}`, {
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/${id}`, {
method: "DELETE",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
@@ -353,24 +354,27 @@ describe("Gallery", () => {
json: () => undefined as any
});
const response = await junoClient.publishNotebook(name, description, tags, author, thumbnailUrl, content);
const response = await junoClient.publishNotebook(name, description, tags, author, thumbnailUrl, content, false);
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${config.JUNO_ENDPOINT}/api/notebooks/${sampleDatabaseAccount.name}/gallery`, {
method: "PUT",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
"content-type": "application/json"
},
body: JSON.stringify({
name,
description,
tags,
author,
thumbnailUrl,
content: JSON.parse(content)
})
});
expect(window.fetch).toBeCalledWith(
`${configContext.JUNO_ENDPOINT}/api/notebooks/${sampleDatabaseAccount.name}/gallery`,
{
method: "PUT",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
"content-type": "application/json"
},
body: JSON.stringify({
name,
description,
tags,
author,
thumbnailUrl,
content: JSON.parse(content)
})
}
);
});
});

View File

@@ -1,6 +1,6 @@
import ko from "knockout";
import { HttpStatusCodes } from "../Common/Constants";
import { config } from "../Config";
import { configContext } from "../ConfigContext";
import * as DataModels from "../Contracts/DataModels";
import { AuthorizeAccessComponent } from "../Explorer/Controls/GitHub/AuthorizeAccessComponent";
import { IGitHubResponse } from "../GitHub/GitHubClient";
@@ -36,6 +36,16 @@ export interface IGalleryItem {
downloads: number;
favorites: number;
views: number;
newCellId: string;
}
export interface IPublicGalleryData {
metadata: IPublicGalleryMetaData;
notebooksData: IGalleryItem[];
}
export interface IPublicGalleryMetaData {
acceptedCodeOfConduct: boolean;
}
export interface IUserGallery {
@@ -162,7 +172,62 @@ export class JunoClient {
return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/public`);
}
public async getNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
// will be renamed once feature.enableCodeOfConduct flag is removed
public async fetchPublicNotebooks(): Promise<IJunoResponse<IPublicGalleryData>> {
const url = `${this.getNotebooksAccountUrl()}/gallery/public`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders()
});
let data: IPublicGalleryData;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async acceptCodeOfConduct(): Promise<IJunoResponse<boolean>> {
const url = `${this.getNotebooksAccountUrl()}/gallery/acceptCodeOfConduct`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders()
});
let data: boolean;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async isCodeOfConductAccepted(): Promise<IJunoResponse<boolean>> {
const url = `${this.getNotebooksAccountUrl()}/gallery/isCodeOfConductAccepted`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders()
});
let data: boolean;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async getNotebookInfo(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(this.getNotebookInfoUrl(id));
let data: IGalleryItem;
@@ -292,24 +357,37 @@ export class JunoClient {
tags: string[],
author: string,
thumbnailUrl: string,
content: string
content: string,
isLinkInjectionEnabled: boolean
): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery`, {
method: "PUT",
headers: JunoClient.getHeaders(),
body: JSON.stringify({
name,
description,
tags,
author,
thumbnailUrl,
content: JSON.parse(content)
} as IPublishNotebookRequest)
body: isLinkInjectionEnabled
? JSON.stringify({
name,
description,
tags,
author,
thumbnailUrl,
content: JSON.parse(content),
addLinkToNotebookViewer: isLinkInjectionEnabled
} as IPublishNotebookRequest)
: JSON.stringify({
name,
description,
tags,
author,
thumbnailUrl,
content: JSON.parse(content)
} as IPublishNotebookRequest)
});
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
} else {
throw new Error(`HTTP status ${response.status} thrown. ${(await response.json()).Message}`);
}
return {
@@ -341,11 +419,11 @@ export class JunoClient {
}
private getNotebooksUrl(): string {
return `${config.JUNO_ENDPOINT}/api/notebooks`;
return `${configContext.JUNO_ENDPOINT}/api/notebooks`;
}
private getNotebooksAccountUrl(): string {
return `${config.JUNO_ENDPOINT}/api/notebooks/${this.databaseAccount().name}`;
return `${configContext.JUNO_ENDPOINT}/api/notebooks/${this.databaseAccount().name}`;
}
private static getHeaders(): HeadersInit {
@@ -358,11 +436,11 @@ export class JunoClient {
private static getGitHubClientParams(): URLSearchParams {
const githubParams = new URLSearchParams({
client_id: config.GITHUB_CLIENT_ID
client_id: configContext.GITHUB_CLIENT_ID
});
if (config.GITHUB_CLIENT_SECRET) {
githubParams.append("client_secret", config.GITHUB_CLIENT_SECRET);
if (configContext.GITHUB_CLIENT_SECRET) {
githubParams.append("client_secret", configContext.GITHUB_CLIENT_SECRET);
}
return githubParams;

View File

@@ -73,7 +73,7 @@ import { AuthType } from "./AuthType";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import { applyExplorerBindings } from "./applyExplorerBindings";
import { initializeConfiguration, Platform } from "./Config";
import { initializeConfiguration, Platform } from "./ConfigContext";
import Explorer from "./Explorer/Explorer";
initializeIcons(/* optional base url */);

View File

@@ -2,7 +2,7 @@ import "bootstrap/dist/css/bootstrap.css";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import React from "react";
import * as ReactDOM from "react-dom";
import { initializeConfiguration } from "../Config";
import { initializeConfiguration, configContext } from "../ConfigContext";
import {
NotebookViewerComponent,
NotebookViewerComponentProps
@@ -17,28 +17,41 @@ const onInit = async () => {
await initializeConfiguration();
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search);
const notebookViewerProps = GalleryUtils.getNotebookViewerProps(window.location.search);
const backNavigationText = galleryViewerProps.selectedTab && GalleryUtils.getTabTitle(galleryViewerProps.selectedTab);
let backNavigationText: string;
let onBackClick: () => void;
if (galleryViewerProps.selectedTab !== undefined) {
backNavigationText = GalleryUtils.getTabTitle(galleryViewerProps.selectedTab);
onBackClick = () => (window.location.href = `${configContext.hostedExplorerURL}gallery.html`);
}
const hideInputs = notebookViewerProps.hideInputs;
const notebookUrl = decodeURIComponent(notebookViewerProps.notebookUrl);
render(notebookUrl, backNavigationText, hideInputs);
const galleryItemId = notebookViewerProps.galleryItemId;
let galleryItem: IGalleryItem;
if (galleryItemId) {
const junoClient = new JunoClient();
const notebook = await junoClient.getNotebook(galleryItemId);
render(notebookUrl, backNavigationText, hideInputs, notebook.data);
const galleryItemJunoResponse = await junoClient.getNotebookInfo(galleryItemId);
galleryItem = galleryItemJunoResponse.data;
}
render(notebookUrl, backNavigationText, hideInputs, galleryItem, onBackClick);
};
const render = (notebookUrl: string, backNavigationText: string, hideInputs: boolean, galleryItem?: IGalleryItem) => {
const render = (
notebookUrl: string,
backNavigationText: string,
hideInputs: boolean,
galleryItem?: IGalleryItem,
onBackClick?: () => void
) => {
const props: NotebookViewerComponentProps = {
junoClient: galleryItem ? new JunoClient() : undefined,
notebookUrl,
galleryItem,
backNavigationText,
hideInputs,
onBackClick: undefined,
onBackClick: onBackClick,
onTagClick: undefined
};

View File

@@ -2,13 +2,13 @@ import AuthHeadersUtil from "./Authorization";
import * as Constants from "../../Common/Constants";
import * as Logger from "../../Common/Logger";
import { Tenant, Subscription, DatabaseAccount, AccountKeys } from "../../Contracts/DataModels";
import { config } from "../../Config";
import { configContext } from "../../ConfigContext";
// TODO: 421864 - add a fetch wrapper
export abstract class ArmResourceUtils {
private static readonly _armEndpoint: string = config.ARM_ENDPOINT;
private static readonly _armApiVersion: string = config.ARM_API_VERSION;
private static readonly _armAuthArea: string = config.ARM_AUTH_AREA;
private static readonly _armEndpoint: string = configContext.ARM_ENDPOINT;
private static readonly _armApiVersion: string = configContext.ARM_API_VERSION;
private static readonly _armAuthArea: string = configContext.ARM_AUTH_AREA;
// TODO: 422867 - return continuation token instead of read through
public static async listTenants(): Promise<Array<Tenant>> {

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