mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-28 13:21:42 +00:00
Compare commits
29 Commits
users/srna
...
v-yiq
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f82ed3749 | ||
|
|
3112cf5573 | ||
|
|
0ad5fb465b | ||
|
|
4fe2098730 | ||
|
|
fc722e87be | ||
|
|
4ecdfe60eb | ||
|
|
0c7a73e716 | ||
|
|
b2c24fab4f | ||
|
|
aa369760ad | ||
|
|
23c5d2d7e0 | ||
|
|
f582887fd8 | ||
|
|
4b0b63b56b | ||
|
|
70c7d84bdb | ||
|
|
dcc2036793 | ||
|
|
987368fe58 | ||
|
|
91aa91d860 | ||
|
|
2e747a1a07 | ||
|
|
290ca4aba5 | ||
|
|
28ceb18d73 | ||
|
|
666a378b3b | ||
|
|
13dafb9581 | ||
|
|
7c5c8ddb7a | ||
|
|
3f2c67af23 | ||
|
|
3ae1f97ccc | ||
|
|
92c4440d38 | ||
|
|
1ccffab911 | ||
|
|
dc56f7e154 | ||
|
|
e62184a1f2 | ||
|
|
26c832437b |
@@ -27,7 +27,7 @@ module.exports = {
|
||||
plugins: ["react"]
|
||||
},
|
||||
{
|
||||
files: ["**/*.test.{ts,tsx}"],
|
||||
files: ["**/*.{test,spec}.{ts,tsx}"],
|
||||
env: {
|
||||
jest: true
|
||||
},
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -3,8 +3,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- hotfix/*
|
||||
- release/*
|
||||
- hotfix/**
|
||||
- release/**
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
@@ -216,6 +216,8 @@ jobs:
|
||||
env:
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: 0
|
||||
PORTAL_RUNNER_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_SQL }}
|
||||
MONGO_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_MONGO }}
|
||||
CASSANDRA_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_CASSANDRA }}
|
||||
nuget:
|
||||
name: Publish Nuget
|
||||
if: github.ref == 'refs/heads/master' || contains(github.ref, 'hotfix/') || contains(github.ref, 'release/')
|
||||
|
||||
@@ -3,8 +3,9 @@ const isCI = require("is-ci");
|
||||
module.exports = {
|
||||
launch: {
|
||||
headless: isCI,
|
||||
slowMo: 50,
|
||||
slowMo: 30,
|
||||
defaultViewport: null,
|
||||
ignoreHTTPSErrors: true
|
||||
ignoreHTTPSErrors: true,
|
||||
args: ["--disable-web-security"]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
@NotificationLow: #FFF4CE;
|
||||
@NotificationHigh: #F9E9B0;
|
||||
@Purple1: #8A2DA5;
|
||||
@Dirty: #9b4f96;
|
||||
|
||||
@BaseLow: #F2F2F2;
|
||||
@BaseMediumLow: #E6E6E6;
|
||||
@@ -104,6 +105,7 @@
|
||||
@newCollectionPaneInputWidth: 300px;
|
||||
@tooltipTextWidth: 280px;
|
||||
@sharedCollectionThroughputTooltipTextWidth: 150px;
|
||||
@mongoWildcardIndexTooltipWidth: 150px;
|
||||
@addContainerPaneThroughputInfoWidth: 370px;
|
||||
@optionsInfoWidth: 210px;
|
||||
@noFixedCollectionsTooltipWidth: 196px;
|
||||
|
||||
@@ -1565,6 +1565,10 @@ p {
|
||||
min-width: @tooltipTextWidth;
|
||||
}
|
||||
|
||||
.mongoWildcardIndexTooltipWidth {
|
||||
min-width: @mongoWildcardIndexTooltipWidth;
|
||||
}
|
||||
|
||||
.sharedCollectionThroughputTooltipWidth {
|
||||
min-width: @sharedCollectionThroughputTooltipTextWidth;
|
||||
}
|
||||
@@ -1743,7 +1747,7 @@ input::-webkit-calendar-picker-indicator {
|
||||
padding-right: 34px;
|
||||
color: @BaseDark;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-x: auto;
|
||||
margin: (2 * @MediumSpace) 0px;
|
||||
}
|
||||
|
||||
@@ -2078,7 +2082,7 @@ a:link {
|
||||
.resourceTreeAndTabs {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
16369
package-lock.json
generated
16369
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
47
package.json
47
package.json
@@ -6,26 +6,26 @@
|
||||
"dependencies": {
|
||||
"@azure/cosmos": "3.9.0",
|
||||
"@azure/cosmos-language-service": "0.0.4",
|
||||
"@jupyterlab/services": "4.2.0",
|
||||
"@jupyterlab/terminal": "1.2.1",
|
||||
"@jupyterlab/services": "6.0.0-rc.2",
|
||||
"@jupyterlab/terminal": "3.0.0-rc.2",
|
||||
"@microsoft/applicationinsights-web": "2.5.8",
|
||||
"@nteract/commutable": "7.1.4",
|
||||
"@nteract/connected-components": "6.7.8",
|
||||
"@nteract/core": "13.0.0",
|
||||
"@nteract/commutable": "7.3.2",
|
||||
"@nteract/connected-components": "6.8.2",
|
||||
"@nteract/core": "15.1.0",
|
||||
"@nteract/data-explorer": "8.0.3",
|
||||
"@nteract/directory-listing": "2.0.6",
|
||||
"@nteract/dropdown-menu": "1.0.1",
|
||||
"@nteract/editor": "9.6.6",
|
||||
"@nteract/editor": "10.1.2",
|
||||
"@nteract/fixtures": "2.3.0",
|
||||
"@nteract/iron-icons": "1.0.0",
|
||||
"@nteract/jupyter-widgets": "2.0.0",
|
||||
"@nteract/logos": "1.0.0",
|
||||
"@nteract/markdown": "4.4.0",
|
||||
"@nteract/monaco-editor": "3.0.3",
|
||||
"@nteract/monaco-editor": "3.2.0",
|
||||
"@nteract/octicons": "2.0.0",
|
||||
"@nteract/outputs": "3.0.9",
|
||||
"@nteract/presentational-components": "3.0.7",
|
||||
"@nteract/stateful-components": "1.4.0",
|
||||
"@nteract/stateful-components": "1.7.0",
|
||||
"@nteract/styles": "2.0.2",
|
||||
"@nteract/transform-geojson": "5.1.8",
|
||||
"@nteract/transform-model-debug": "5.0.1",
|
||||
@@ -47,6 +47,7 @@
|
||||
"copy-webpack-plugin": "6.0.2",
|
||||
"crossroads": "0.12.2",
|
||||
"css-element-queries": "1.1.1",
|
||||
"d3": "6.1.1",
|
||||
"datatables.net-colreorder-dt": "1.5.1",
|
||||
"datatables.net-dt": "1.10.19",
|
||||
"date-fns": "1.29.0",
|
||||
@@ -55,6 +56,7 @@
|
||||
"es6-object-assign": "1.1.0",
|
||||
"es6-symbol": "3.1.3",
|
||||
"eslint-plugin-jest": "23.13.2",
|
||||
"eslint-plugin-react": "7.20.0",
|
||||
"hasher": "1.2.0",
|
||||
"html2canvas": "1.0.0-rc.5",
|
||||
"immutable": "4.0.0-rc.12",
|
||||
@@ -72,7 +74,7 @@
|
||||
"promise-polyfill": "8.1.0",
|
||||
"promise.prototype.finally": "3.1.0",
|
||||
"q": "1.5.1",
|
||||
"react": "16.9.0",
|
||||
"react": "16.13.1",
|
||||
"react-animate-height": "2.0.8",
|
||||
"react-dnd": "9.4.0",
|
||||
"react-dnd-html5-backend": "9.4.0",
|
||||
@@ -81,11 +83,12 @@
|
||||
"react-notification-system": "0.2.17",
|
||||
"react-redux": "7.1.3",
|
||||
"redux": "4.0.4",
|
||||
"rx-jupyter": "5.5.2",
|
||||
"rxjs": "6.5.3",
|
||||
"rx-jupyter": "5.5.12",
|
||||
"rxjs": "6.6.3",
|
||||
"styled-components": "4.3.2",
|
||||
"text-encoding": "0.7.0",
|
||||
"underscore": "1.9.1",
|
||||
"utility-types": "3.10.0",
|
||||
"url-polyfill": "1.1.7",
|
||||
"webcrypto-liner": "1.1.4",
|
||||
"webfontloader": "1.6.28",
|
||||
@@ -99,9 +102,9 @@
|
||||
"@types/applicationinsights-js": "1.0.7",
|
||||
"@types/codemirror": "0.0.56",
|
||||
"@types/crossroads": "0.0.30",
|
||||
"@types/d3": "4.13.2",
|
||||
"@types/enzyme": "3.10.3",
|
||||
"@types/enzyme-adapter-react-16": "1.0.5",
|
||||
"@types/d3": "5.9.2",
|
||||
"@types/enzyme": "3.10.7",
|
||||
"@types/enzyme-adapter-react-16": "1.0.6",
|
||||
"@types/expect-puppeteer": "4.4.3",
|
||||
"@types/hasher": "0.0.31",
|
||||
"@types/jest": "23.3.10",
|
||||
@@ -112,7 +115,7 @@
|
||||
"@types/prop-types": "15.5.8",
|
||||
"@types/puppeteer": "3.0.1",
|
||||
"@types/q": "1.5.1",
|
||||
"@types/react": "16.8.25",
|
||||
"@types/react": "16.9.49",
|
||||
"@types/react-dom": "16.0.7",
|
||||
"@types/react-notification-system": "0.2.39",
|
||||
"@types/react-redux": "7.1.7",
|
||||
@@ -131,15 +134,13 @@
|
||||
"case-sensitive-paths-webpack-plugin": "2.3.0",
|
||||
"create-file-webpack": "1.0.2",
|
||||
"css-loader": "1.0.0",
|
||||
"d3": "4.13.0",
|
||||
"enzyme": "3.10.0",
|
||||
"enzyme-adapter-react-16": "1.15.1",
|
||||
"enzyme-to-json": "3.4.3",
|
||||
"enzyme": "3.11.0",
|
||||
"enzyme-adapter-react-16": "1.15.5",
|
||||
"enzyme-to-json": "3.6.1",
|
||||
"eslint": "7.8.1",
|
||||
"eslint-cli": "1.1.1",
|
||||
"eslint-plugin-no-null": "1.0.2",
|
||||
"eslint-plugin-prefer-arrow": "1.2.2",
|
||||
"eslint-plugin-react": "7.20.0",
|
||||
"expose-loader": "0.7.5",
|
||||
"file-loader": "2.0.0",
|
||||
"fs-extra": "7.0.0",
|
||||
@@ -156,7 +157,7 @@
|
||||
"less-vars-loader": "1.1.0",
|
||||
"mini-css-extract-plugin": "0.4.3",
|
||||
"monaco-editor-webpack-plugin": "1.7.0",
|
||||
"node-fetch": "2.6.0",
|
||||
"node-fetch": "2.6.1",
|
||||
"prettier": "1.19.1",
|
||||
"puppeteer": "4.0.0",
|
||||
"raw-loader": "0.5.1",
|
||||
@@ -193,8 +194,8 @@
|
||||
"compile": "tsc",
|
||||
"compile:contracts": "tsc -p ./tsconfig.contracts.json",
|
||||
"compile:strict": "tsc -p ./tsconfig.strict.json",
|
||||
"format": "prettier --write \"{src,cypress}/**/*.{ts,tsx,html}\" \"*.{js,html}\"",
|
||||
"format:check": "prettier --check \"{src,cypress}/**/*.{ts,tsx,html}\" \"*.{js,html}\"",
|
||||
"format": "prettier --write \"{src,cypress,test}/**/*.{ts,tsx,html}\" \"*.{js,html}\"",
|
||||
"format:check": "prettier --check \"{src,cypress,test}/**/*.{ts,tsx,html}\" \"*.{js,html}\"",
|
||||
"lint": "tslint --project tsconfig.json && eslint \"**/*.{ts,tsx}\"",
|
||||
"build:contracts": "npm run compile:contracts",
|
||||
"strictEligibleFiles": "node ./strict-migration-tools/index.js",
|
||||
|
||||
@@ -124,6 +124,7 @@ export class Features {
|
||||
public static readonly enableGalleryPublish = "enablegallerypublish";
|
||||
public static readonly enableCodeOfConduct = "enablecodeofconduct";
|
||||
public static readonly enableLinkInjection = "enablelinkinjection";
|
||||
public static readonly enableSettingsV2 = "enablesettingsv2";
|
||||
public static readonly enableSpark = "enablespark";
|
||||
public static readonly livyEndpoint = "livyendpoint";
|
||||
public static readonly notebookServerUrl = "notebookserverurl";
|
||||
@@ -170,89 +171,8 @@ export enum MongoBackendEndpointType {
|
||||
remote
|
||||
}
|
||||
|
||||
export class MongoBackend {
|
||||
public static localhostEndpoint: string = "/api/mongo/explorer";
|
||||
public static centralUsEndpoint: string = "https://main.documentdb.ext.azure.com/api/mongo/explorer";
|
||||
public static northEuropeEndpoint: string = "https://main.documentdb.ext.azure.com/api/mongo/explorer";
|
||||
public static southEastAsiaEndpoint: string = "https://main.documentdb.ext.azure.com/api/mongo/explorer";
|
||||
|
||||
public static endpointsByRegion: any = {
|
||||
default: MongoBackend.centralUsEndpoint,
|
||||
northeurope: MongoBackend.northEuropeEndpoint,
|
||||
ukwest: MongoBackend.northEuropeEndpoint,
|
||||
uksouth: MongoBackend.northEuropeEndpoint,
|
||||
westeurope: MongoBackend.northEuropeEndpoint,
|
||||
australiaeast: MongoBackend.southEastAsiaEndpoint,
|
||||
australiasoutheast: MongoBackend.southEastAsiaEndpoint,
|
||||
centralindia: MongoBackend.southEastAsiaEndpoint,
|
||||
eastasia: MongoBackend.southEastAsiaEndpoint,
|
||||
japaneast: MongoBackend.southEastAsiaEndpoint,
|
||||
japanwest: MongoBackend.southEastAsiaEndpoint,
|
||||
koreacentral: MongoBackend.southEastAsiaEndpoint,
|
||||
koreasouth: MongoBackend.southEastAsiaEndpoint,
|
||||
southeastasia: MongoBackend.southEastAsiaEndpoint,
|
||||
southindia: MongoBackend.southEastAsiaEndpoint,
|
||||
westindia: MongoBackend.southEastAsiaEndpoint
|
||||
};
|
||||
|
||||
public static endpointsByEnvironment: any = {
|
||||
default: MongoBackendEndpointType.local,
|
||||
localhost: MongoBackendEndpointType.local,
|
||||
prod1: MongoBackendEndpointType.remote,
|
||||
prod2: MongoBackendEndpointType.remote
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: 435619 Add default endpoints per cloud and use regional only when available
|
||||
export class CassandraBackend {
|
||||
public static readonly localhostEndpoint: string = "https://localhost:12901/";
|
||||
public static readonly devEndpoint: string = "https://platformproxycassandradev.azurewebsites.net/";
|
||||
|
||||
public static readonly centralUsEndpoint: string = "https://main.documentdb.ext.azure.com/";
|
||||
public static readonly northEuropeEndpoint: string = "https://main.documentdb.ext.azure.com/";
|
||||
public static readonly southEastAsiaEndpoint: string = "https://main.documentdb.ext.azure.com/";
|
||||
|
||||
public static readonly bf_default: string = "https://main.documentdb.ext.microsoftazure.de/";
|
||||
public static readonly mc_default: string = "https://main.documentdb.ext.azure.cn/";
|
||||
public static readonly ff_default: string = "https://main.documentdb.ext.azure.us/";
|
||||
|
||||
public static readonly endpointsByRegion: any = {
|
||||
default: CassandraBackend.centralUsEndpoint,
|
||||
northeurope: CassandraBackend.northEuropeEndpoint,
|
||||
ukwest: CassandraBackend.northEuropeEndpoint,
|
||||
uksouth: CassandraBackend.northEuropeEndpoint,
|
||||
westeurope: CassandraBackend.northEuropeEndpoint,
|
||||
australiaeast: CassandraBackend.southEastAsiaEndpoint,
|
||||
australiasoutheast: CassandraBackend.southEastAsiaEndpoint,
|
||||
centralindia: CassandraBackend.southEastAsiaEndpoint,
|
||||
eastasia: CassandraBackend.southEastAsiaEndpoint,
|
||||
japaneast: CassandraBackend.southEastAsiaEndpoint,
|
||||
japanwest: CassandraBackend.southEastAsiaEndpoint,
|
||||
koreacentral: CassandraBackend.southEastAsiaEndpoint,
|
||||
koreasouth: CassandraBackend.southEastAsiaEndpoint,
|
||||
southeastasia: CassandraBackend.southEastAsiaEndpoint,
|
||||
southindia: CassandraBackend.southEastAsiaEndpoint,
|
||||
westindia: CassandraBackend.southEastAsiaEndpoint,
|
||||
|
||||
// Black Forest
|
||||
germanycentral: CassandraBackend.bf_default,
|
||||
germanynortheast: CassandraBackend.bf_default,
|
||||
|
||||
// Fairfax
|
||||
usdodeast: CassandraBackend.ff_default,
|
||||
usdodcentral: CassandraBackend.ff_default,
|
||||
usgovarizona: CassandraBackend.ff_default,
|
||||
usgoviowa: CassandraBackend.ff_default,
|
||||
usgovtexas: CassandraBackend.ff_default,
|
||||
usgovvirginia: CassandraBackend.ff_default,
|
||||
|
||||
// Mooncake
|
||||
chinaeast: CassandraBackend.mc_default,
|
||||
chinaeast2: CassandraBackend.mc_default,
|
||||
chinanorth: CassandraBackend.mc_default,
|
||||
chinanorth2: CassandraBackend.mc_default
|
||||
};
|
||||
|
||||
public static readonly createOrDeleteApi: string = "api/cassandra/createordelete";
|
||||
public static readonly guestCreateOrDeleteApi: string = "api/guest/cassandra/createordelete";
|
||||
public static readonly queryApi: string = "api/cassandra";
|
||||
@@ -562,3 +482,11 @@ export class AnalyticalStorageTtl {
|
||||
public static readonly Infinite: number = -1;
|
||||
public static readonly Disabled: number = 0;
|
||||
}
|
||||
|
||||
export class TerminalQueryParams {
|
||||
public static readonly Terminal = "terminal";
|
||||
public static readonly Server = "server";
|
||||
public static readonly Token = "token";
|
||||
public static readonly SubscriptionId = "subscriptionId";
|
||||
public static readonly TerminalEndpoint = "terminalEndpoint";
|
||||
}
|
||||
|
||||
@@ -184,86 +184,6 @@ export function deleteConflict(
|
||||
);
|
||||
}
|
||||
|
||||
export function readCollectionQuotaInfo(
|
||||
collection: ViewModels.Collection,
|
||||
options: any
|
||||
): Q.Promise<DataModels.CollectionQuotaInfo> {
|
||||
options = options || {};
|
||||
options.populateQuotaInfo = true;
|
||||
options.initialHeaders = options.initialHeaders || {};
|
||||
options.initialHeaders[Constants.HttpHeaders.populatePartitionStatistics] = true;
|
||||
|
||||
return Q(
|
||||
client()
|
||||
.database(collection.databaseId)
|
||||
.container(collection.id())
|
||||
.read(options)
|
||||
// TODO any needed because SDK does not properly type response.resource.statistics
|
||||
.then((response: any) => {
|
||||
let quota: DataModels.CollectionQuotaInfo = HeadersUtility.getQuota(response.headers);
|
||||
quota["usageSizeInKB"] = response.resource.statistics.reduce(
|
||||
(
|
||||
previousValue: number,
|
||||
currentValue: DataModels.Statistic,
|
||||
currentIndex: number,
|
||||
array: DataModels.Statistic[]
|
||||
) => {
|
||||
return previousValue + currentValue.sizeInKB;
|
||||
},
|
||||
0
|
||||
);
|
||||
quota["numPartitions"] = response.resource.statistics.length;
|
||||
quota["uniqueKeyPolicy"] = collection.uniqueKeyPolicy; // TODO: Remove after refactoring (#119617)
|
||||
return quota;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function readOffers(options: any): Q.Promise<DataModels.Offer[]> {
|
||||
if (options.isServerless) {
|
||||
return Q([]); // Reading offers is not supported for serverless accounts
|
||||
}
|
||||
|
||||
try {
|
||||
if (configContext.platform === Platform.Portal) {
|
||||
return sendCachedDataMessage<DataModels.Offer[]>(MessageTypes.AllOffers, [
|
||||
(<any>window).dataExplorer.databaseAccount().id,
|
||||
Constants.ClientDefaults.portalCacheTimeoutMs
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
// If error getting cached Offers, continue on and read via SDK
|
||||
}
|
||||
return Q(
|
||||
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;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function readOffer(requestedResource: DataModels.Offer, options: any): Q.Promise<DataModels.OfferWithHeaders> {
|
||||
options = options || {};
|
||||
options.initialHeaders = options.initialHeaders || {};
|
||||
if (!OfferUtils.isOfferV1(requestedResource)) {
|
||||
options.initialHeaders[Constants.HttpHeaders.populateCollectionThroughputInfo] = true;
|
||||
}
|
||||
|
||||
return Q(
|
||||
client()
|
||||
.offer(requestedResource.id)
|
||||
.read(options)
|
||||
.then(response => ({ ...response.resource, headers: response.headers }))
|
||||
);
|
||||
}
|
||||
|
||||
export function refreshCachedOffers(): Q.Promise<void> {
|
||||
if (configContext.platform === Platform.Portal) {
|
||||
return sendCachedDataMessage(MessageTypes.RefreshOffers, []);
|
||||
|
||||
@@ -277,78 +277,3 @@ export function refreshCachedResources(options: any = {}): Q.Promise<void> {
|
||||
export function refreshCachedOffers(): Q.Promise<void> {
|
||||
return DataAccessUtilityBase.refreshCachedOffers();
|
||||
}
|
||||
|
||||
export function readCollectionQuotaInfo(
|
||||
collection: ViewModels.Collection,
|
||||
options?: any
|
||||
): Q.Promise<DataModels.CollectionQuotaInfo> {
|
||||
var deferred = Q.defer<DataModels.CollectionQuotaInfo>();
|
||||
|
||||
const clearMessage = logConsoleProgress(`Querying quota info for container ${collection.id}`);
|
||||
DataAccessUtilityBase.readCollectionQuotaInfo(collection, options)
|
||||
.then(
|
||||
(quota: DataModels.CollectionQuotaInfo) => {
|
||||
deferred.resolve(quota);
|
||||
},
|
||||
(error: any) => {
|
||||
logConsoleError(`Error while querying quota info for container ${collection.id}:\n ${JSON.stringify(error)}`);
|
||||
Logger.logError(JSON.stringify(error), "ReadCollectionQuotaInfo", error.code);
|
||||
sendNotificationForError(error);
|
||||
deferred.reject(error);
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
clearMessage();
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
export function readOffers(options: any = {}): Q.Promise<DataModels.Offer[]> {
|
||||
var deferred = Q.defer<DataModels.Offer[]>();
|
||||
|
||||
const clearMessage = logConsoleProgress("Querying offers");
|
||||
DataAccessUtilityBase.readOffers(options)
|
||||
.then(
|
||||
(offers: DataModels.Offer[]) => {
|
||||
deferred.resolve(offers);
|
||||
},
|
||||
(error: any) => {
|
||||
logConsoleError(`Error while querying offers:\n ${JSON.stringify(error)}`);
|
||||
Logger.logError(JSON.stringify(error), "ReadOffers", error.code);
|
||||
sendNotificationForError(error);
|
||||
deferred.reject(error);
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
clearMessage();
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
export function readOffer(
|
||||
requestedResource: DataModels.Offer,
|
||||
options: any = {}
|
||||
): Q.Promise<DataModels.OfferWithHeaders> {
|
||||
var deferred = Q.defer<DataModels.OfferWithHeaders>();
|
||||
|
||||
const clearMessage = logConsoleProgress("Querying offer");
|
||||
DataAccessUtilityBase.readOffer(requestedResource, options)
|
||||
.then(
|
||||
(offer: DataModels.OfferWithHeaders) => {
|
||||
deferred.resolve(offer);
|
||||
},
|
||||
(error: any) => {
|
||||
logConsoleError(`Error while querying offer:\n ${JSON.stringify(error)}`);
|
||||
Logger.logError(JSON.stringify(error), "ReadOffer", error.code);
|
||||
sendNotificationForError(error);
|
||||
deferred.reject(error);
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
clearMessage();
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
@@ -1,49 +1,8 @@
|
||||
import * as Constants from "../Common/Constants";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
import { AuthType } from "../AuthType";
|
||||
import { StringUtils } from "../Utils/StringUtils";
|
||||
import Explorer from "../Explorer/Explorer";
|
||||
|
||||
export default class EnvironmentUtility {
|
||||
public static getMongoBackendEndpoint(serverId: string, location: string, extensionEndpoint: string = ""): string {
|
||||
const defaultEnvironment: string = "default";
|
||||
const defaultLocation: string = "default";
|
||||
let environment: string = serverId;
|
||||
const endpointType: Constants.MongoBackendEndpointType =
|
||||
Constants.MongoBackend.endpointsByEnvironment[environment] ||
|
||||
Constants.MongoBackend.endpointsByEnvironment[defaultEnvironment];
|
||||
if (endpointType === Constants.MongoBackendEndpointType.local) {
|
||||
return `${extensionEndpoint}${Constants.MongoBackend.localhostEndpoint}`;
|
||||
}
|
||||
|
||||
const normalizedLocation = EnvironmentUtility.normalizeRegionName(location);
|
||||
return (
|
||||
Constants.MongoBackend.endpointsByRegion[normalizedLocation] ||
|
||||
Constants.MongoBackend.endpointsByRegion[defaultLocation]
|
||||
);
|
||||
}
|
||||
|
||||
public static isAadUser(): boolean {
|
||||
return window.authType === AuthType.AAD;
|
||||
}
|
||||
|
||||
public static getCassandraBackendEndpoint(explorer: Explorer): string {
|
||||
const defaultLocation: string = "default";
|
||||
const location: string = EnvironmentUtility.normalizeRegionName(explorer.databaseAccount().location);
|
||||
return (
|
||||
Constants.CassandraBackend.endpointsByRegion[location] ||
|
||||
Constants.CassandraBackend.endpointsByRegion[defaultLocation]
|
||||
);
|
||||
}
|
||||
|
||||
public static normalizeArmEndpointUri(uri: string): string {
|
||||
if (uri && uri.slice(-1) !== "/") {
|
||||
return `${uri}/`;
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
private static normalizeRegionName(region: string): string {
|
||||
return region && StringUtils.stripSpacesFromString(region.toLocaleLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,34 +25,4 @@ describe("Message Handler", () => {
|
||||
MessageHandler.runGarbageCollector();
|
||||
expect(MessageHandler.RequestMap["123"]).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("getDataExplorerWindow", () => {
|
||||
it("should return current window if current window has dataExplorerPlatform property", () => {
|
||||
const currentWindow: Window = { dataExplorerPlatform: 0 } as any;
|
||||
|
||||
expect(MessageHandler.getDataExplorerWindow(currentWindow)).toEqual(currentWindow);
|
||||
});
|
||||
|
||||
it("should return current window's parent if current window's parent has dataExplorerPlatform property", () => {
|
||||
const parentWindow: Window = { dataExplorerPlatform: 0 } as any;
|
||||
const currentWindow: Window = { parent: parentWindow } as any;
|
||||
|
||||
expect(MessageHandler.getDataExplorerWindow(currentWindow)).toEqual(parentWindow);
|
||||
});
|
||||
|
||||
it("should return undefined if none of the windows in the hierarchy have dataExplorerPlatform property and window's parent is reference to itself", () => {
|
||||
const parentWindow: Window = {} as any;
|
||||
(parentWindow as any).parent = parentWindow; // If a window does not have a parent, its parent property is a reference to itself.
|
||||
const currentWindow: Window = { parent: parentWindow } as any;
|
||||
|
||||
expect(MessageHandler.getDataExplorerWindow(currentWindow)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined if none of the windows in the hierarchy have dataExplorerPlatform property and window's parent is not defined", () => {
|
||||
const parentWindow: Window = {} as any;
|
||||
const currentWindow: Window = { parent: parentWindow } as any;
|
||||
|
||||
expect(MessageHandler.getDataExplorerWindow(currentWindow)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MessageTypes } from "../Contracts/ExplorerContracts";
|
||||
import Q from "q";
|
||||
import * as _ from "underscore";
|
||||
import * as Constants from "./Constants";
|
||||
import { getDataExplorerWindow } from "../Utils/WindowUtils";
|
||||
|
||||
export interface CachedDataPromise<T> {
|
||||
deferred: Q.Deferred<T>;
|
||||
@@ -48,38 +49,18 @@ export function sendCachedDataMessage<TResponseDataModel>(
|
||||
|
||||
export function sendMessage(data: any): void {
|
||||
if (canSendMessage()) {
|
||||
const dataExplorerWindow = getDataExplorerWindow(window);
|
||||
if (dataExplorerWindow) {
|
||||
dataExplorerWindow.parent.postMessage(
|
||||
{
|
||||
signature: "pcIframe",
|
||||
data: data
|
||||
},
|
||||
dataExplorerWindow.document.referrer
|
||||
);
|
||||
}
|
||||
// We try to find data explorer window first, then fallback to current window
|
||||
const portalChildWindow = getDataExplorerWindow(window) || window;
|
||||
portalChildWindow.parent.postMessage(
|
||||
{
|
||||
signature: "pcIframe",
|
||||
data: data
|
||||
},
|
||||
portalChildWindow.document.referrer
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only exported for unit tests
|
||||
export const getDataExplorerWindow = (currentWindow: Window): Window | undefined => {
|
||||
// Start with the current window and traverse up the parent hierarchy to find a window
|
||||
// with `dataExplorerPlatform` property
|
||||
let dataExplorerWindow: Window | undefined = currentWindow;
|
||||
// TODO: Need to `any` here since the window imports Explorer which can't be in strict mode yet
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
while (dataExplorerWindow && (dataExplorerWindow as any).dataExplorerPlatform == undefined) {
|
||||
// If a window does not have a parent, its parent property is a reference to itself.
|
||||
if (dataExplorerWindow.parent == dataExplorerWindow) {
|
||||
dataExplorerWindow = undefined;
|
||||
} else {
|
||||
dataExplorerWindow = dataExplorerWindow.parent;
|
||||
}
|
||||
}
|
||||
|
||||
return dataExplorerWindow;
|
||||
};
|
||||
|
||||
export function canSendMessage(): boolean {
|
||||
return window.parent !== window;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { AuthType } from "../AuthType";
|
||||
import { configContext, resetConfigContext, updateConfigContext } from "../ConfigContext";
|
||||
import { 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 { deleteDocument, getEndpoint, queryDocuments, readDocument, updateDocument } from "./MongoProxyClient";
|
||||
jest.mock("../ResourceProvider/ResourceProviderClient.ts");
|
||||
@@ -237,19 +236,19 @@ describe("MongoProxyClient", () => {
|
||||
});
|
||||
|
||||
it("returns a production endpoint", () => {
|
||||
const endpoint = getEndpoint(databaseAccount as DatabaseAccount);
|
||||
const endpoint = getEndpoint();
|
||||
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/mongo/explorer");
|
||||
});
|
||||
|
||||
it("returns a development endpoint", () => {
|
||||
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
|
||||
const endpoint = getEndpoint(databaseAccount as DatabaseAccount);
|
||||
const endpoint = getEndpoint();
|
||||
expect(endpoint).toEqual("https://localhost:1234/api/mongo/explorer");
|
||||
});
|
||||
|
||||
it("returns a guest endpoint", () => {
|
||||
window.authType = AuthType.EncryptedToken;
|
||||
const endpoint = getEndpoint(databaseAccount as DatabaseAccount);
|
||||
const endpoint = getEndpoint();
|
||||
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/guest/mongo/explorer");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import DocumentId from "../Explorer/Tree/DocumentId";
|
||||
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";
|
||||
|
||||
@@ -78,7 +77,7 @@ export function queryDocuments(
|
||||
collection && collection.partitionKey && !collection.partitionKey.systemKey ? collection.partitionKeyProperty : ""
|
||||
};
|
||||
|
||||
const endpoint = getEndpoint(databaseAccount) || "";
|
||||
const endpoint = getEndpoint() || "";
|
||||
|
||||
const headers = {
|
||||
...defaultHeaders,
|
||||
@@ -139,7 +138,7 @@ export function readDocument(
|
||||
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey ? documentId.partitionKeyProperty : ""
|
||||
};
|
||||
|
||||
const endpoint = getEndpoint(databaseAccount);
|
||||
const endpoint = getEndpoint();
|
||||
return window
|
||||
.fetch(`${endpoint}?${queryString.stringify(params)}`, {
|
||||
method: "GET",
|
||||
@@ -179,7 +178,7 @@ export function createDocument(
|
||||
pk: collection && collection.partitionKey && !collection.partitionKey.systemKey ? partitionKeyProperty : ""
|
||||
};
|
||||
|
||||
const endpoint = getEndpoint(databaseAccount);
|
||||
const endpoint = getEndpoint();
|
||||
|
||||
return window
|
||||
.fetch(`${endpoint}/resourcelist?${queryString.stringify(params)}`, {
|
||||
@@ -221,7 +220,7 @@ export function updateDocument(
|
||||
pk:
|
||||
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey ? documentId.partitionKeyProperty : ""
|
||||
};
|
||||
const endpoint = getEndpoint(databaseAccount);
|
||||
const endpoint = getEndpoint();
|
||||
|
||||
return window
|
||||
.fetch(`${endpoint}?${queryString.stringify(params)}`, {
|
||||
@@ -260,7 +259,7 @@ export function deleteDocument(databaseId: string, collection: Collection, docum
|
||||
pk:
|
||||
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey ? documentId.partitionKeyProperty : ""
|
||||
};
|
||||
const endpoint = getEndpoint(databaseAccount);
|
||||
const endpoint = getEndpoint();
|
||||
|
||||
return window
|
||||
.fetch(`${endpoint}?${queryString.stringify(params)}`, {
|
||||
@@ -303,7 +302,7 @@ export function createMongoCollectionWithProxy(
|
||||
autoPilotThroughput: params.autoPilotMaxThroughput?.toString()
|
||||
};
|
||||
|
||||
const endpoint = getEndpoint(databaseAccount);
|
||||
const endpoint = getEndpoint();
|
||||
|
||||
return window
|
||||
.fetch(
|
||||
@@ -327,12 +326,9 @@ export function createMongoCollectionWithProxy(
|
||||
});
|
||||
}
|
||||
|
||||
export function getEndpoint(databaseAccount: DataModels.DatabaseAccount): string {
|
||||
const serverId = window.dataExplorer.serverId();
|
||||
export function getEndpoint(): string {
|
||||
const extensionEndpoint = window.dataExplorer.extensionEndpoint();
|
||||
let url = configContext.MONGO_BACKEND_ENDPOINT
|
||||
? configContext.MONGO_BACKEND_ENDPOINT + "/api/mongo/explorer"
|
||||
: EnvironmentUtility.getMongoBackendEndpoint(serverId, databaseAccount.location, extensionEndpoint);
|
||||
let url = (configContext.MONGO_BACKEND_ENDPOINT || extensionEndpoint) + "/api/mongo/explorer";
|
||||
|
||||
if (window.authType === AuthType.EncryptedToken) {
|
||||
url = url.replace("api/mongo", "api/guest/mongo");
|
||||
|
||||
@@ -29,6 +29,8 @@ import { refreshCachedResources } from "../DataAccessUtilityBase";
|
||||
import { sendNotificationForError } from "./sendNotificationForError";
|
||||
import { userContext } from "../../UserContext";
|
||||
import { createDatabase } from "./createDatabase";
|
||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
|
||||
export const createCollection = async (params: DataModels.CreateCollectionParams): Promise<DataModels.Collection> => {
|
||||
let collection: DataModels.Collection;
|
||||
@@ -138,6 +140,7 @@ const createSqlContainer = async (params: DataModels.CreateCollectionParams): Pr
|
||||
};
|
||||
|
||||
const createMongoCollection = async (params: DataModels.CreateCollectionParams): Promise<DataModels.Collection> => {
|
||||
const mongoWildcardIndexOnAllFields: ARMTypes.MongoIndex[] = [{ key: { keys: ["$**"] } }, { key: { keys: ["_id"] } }];
|
||||
try {
|
||||
const getResponse = await getMongoDBCollection(
|
||||
userContext.subscriptionId,
|
||||
@@ -166,6 +169,9 @@ const createMongoCollection = async (params: DataModels.CreateCollectionParams):
|
||||
const partitionKeyPath: string = params.partitionKey.paths[0];
|
||||
resource.shardKey = { [partitionKeyPath]: "Hash" };
|
||||
}
|
||||
if (params.createMongoWildcardIndex) {
|
||||
resource.indexes = mongoWildcardIndexOnAllFields;
|
||||
}
|
||||
|
||||
const rpPayload: ARMTypes.MongoDBCollectionCreateUpdateParameters = {
|
||||
properties: {
|
||||
@@ -182,6 +188,13 @@ const createMongoCollection = async (params: DataModels.CreateCollectionParams):
|
||||
params.collectionId,
|
||||
rpPayload
|
||||
);
|
||||
|
||||
if (params.createMongoWildcardIndex) {
|
||||
TelemetryProcessor.trace(Action.CreateMongoCollectionWithWildcardIndex, ActionModifiers.Mark, {
|
||||
message: "Mongo Collection created with wildcard index on all fields."
|
||||
});
|
||||
}
|
||||
|
||||
return createResponse && (createResponse.properties.resource as DataModels.Collection);
|
||||
};
|
||||
|
||||
|
||||
@@ -34,11 +34,10 @@ export async function createDatabase(params: DataModels.CreateDatabaseParams): P
|
||||
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
|
||||
) {
|
||||
if (userContext.defaultExperience === DefaultAccountExperienceType.Table) {
|
||||
throw new Error("Creating database resources is not allowed for tables accounts");
|
||||
}
|
||||
if (window.authType === AuthType.AAD && !userContext.useSDKOperations) {
|
||||
database = await createDatabaseWithARM(params);
|
||||
} else {
|
||||
database = await createDatabaseWithSDK(params);
|
||||
|
||||
@@ -15,11 +15,10 @@ export async function deleteDatabase(databaseId: string): Promise<void> {
|
||||
const clearMessage = logConsoleProgress(`Deleting database ${databaseId}`);
|
||||
|
||||
try {
|
||||
if (
|
||||
window.authType === AuthType.AAD &&
|
||||
userContext.defaultExperience !== DefaultAccountExperienceType.Table &&
|
||||
!userContext.useSDKOperations
|
||||
) {
|
||||
if (userContext.defaultExperience === DefaultAccountExperienceType.Table) {
|
||||
throw new Error("Deleting database resources is not allowed for tables accounts");
|
||||
}
|
||||
if (window.authType === AuthType.AAD && !userContext.useSDKOperations) {
|
||||
await deleteDatabaseWithARM(databaseId);
|
||||
} else {
|
||||
await client()
|
||||
|
||||
126
src/Common/dataAccess/readCollectionOffer.ts
Normal file
126
src/Common/dataAccess/readCollectionOffer.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
|
||||
import { HttpHeaders } from "../Constants";
|
||||
import { RequestOptions } from "@azure/cosmos/dist-esm";
|
||||
import { client } from "../CosmosClient";
|
||||
import { getSqlContainerThroughput } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources";
|
||||
import { getMongoDBCollectionThroughput } from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources";
|
||||
import { getCassandraTableThroughput } from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources";
|
||||
import { getGremlinGraphThroughput } from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources";
|
||||
import { getTableThroughput } from "../../Utils/arm/generatedClients/2020-04-01/tableResources";
|
||||
import { logConsoleProgress, logConsoleError } from "../../Utils/NotificationConsoleUtils";
|
||||
import { logError } from "../Logger";
|
||||
import { readOffers } from "./readOffers";
|
||||
import { sendNotificationForError } from "./sendNotificationForError";
|
||||
import { userContext } from "../../UserContext";
|
||||
|
||||
export const readCollectionOffer = async (
|
||||
params: DataModels.ReadCollectionOfferParams
|
||||
): Promise<DataModels.OfferWithHeaders> => {
|
||||
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
|
||||
let offerId = params.offerId;
|
||||
if (!offerId) {
|
||||
if (window.authType === AuthType.AAD && !userContext.useSDKOperations) {
|
||||
try {
|
||||
offerId = await getCollectionOfferIdWithARM(params.databaseId, params.collectionId);
|
||||
} catch (error) {
|
||||
clearMessage();
|
||||
if (error.code !== "NotFound") {
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
offerId = await getCollectionOfferIdWithSDK(params.collectionResourceId);
|
||||
if (!offerId) {
|
||||
clearMessage();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const options: RequestOptions = {
|
||||
initialHeaders: {
|
||||
[HttpHeaders.populateCollectionThroughputInfo]: true
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await client()
|
||||
.offer(offerId)
|
||||
.read(options);
|
||||
return (
|
||||
response && {
|
||||
...response.resource,
|
||||
headers: response.headers
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logConsoleError(`Error while querying offer for collection ${params.collectionId}:\n ${JSON.stringify(error)}`);
|
||||
logError(JSON.stringify(error), "ReadCollectionOffer", error.code);
|
||||
sendNotificationForError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
clearMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const getCollectionOfferIdWithARM = async (databaseId: string, collectionId: string): Promise<string> => {
|
||||
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 getSqlContainerThroughput(
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
accountName,
|
||||
databaseId,
|
||||
collectionId
|
||||
);
|
||||
break;
|
||||
case DefaultAccountExperienceType.MongoDB:
|
||||
rpResponse = await getMongoDBCollectionThroughput(
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
accountName,
|
||||
databaseId,
|
||||
collectionId
|
||||
);
|
||||
break;
|
||||
case DefaultAccountExperienceType.Cassandra:
|
||||
rpResponse = await getCassandraTableThroughput(
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
accountName,
|
||||
databaseId,
|
||||
collectionId
|
||||
);
|
||||
break;
|
||||
case DefaultAccountExperienceType.Graph:
|
||||
rpResponse = await getGremlinGraphThroughput(
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
accountName,
|
||||
databaseId,
|
||||
collectionId
|
||||
);
|
||||
break;
|
||||
case DefaultAccountExperienceType.Table:
|
||||
rpResponse = await getTableThroughput(subscriptionId, resourceGroup, accountName, collectionId);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported default experience type: ${defaultExperience}`);
|
||||
}
|
||||
|
||||
return rpResponse?.name;
|
||||
};
|
||||
|
||||
const getCollectionOfferIdWithSDK = async (collectionResourceId: string): Promise<string> => {
|
||||
const offers = await readOffers();
|
||||
const offer = offers.find(offer => offer.resource === collectionResourceId);
|
||||
return offer?.id;
|
||||
};
|
||||
48
src/Common/dataAccess/readCollectionQuotaInfo.ts
Normal file
48
src/Common/dataAccess/readCollectionQuotaInfo.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as HeadersUtility from "../HeadersUtility";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { ContainerDefinition, Resource } from "@azure/cosmos";
|
||||
import { HttpHeaders } from "../Constants";
|
||||
import { RequestOptions } from "@azure/cosmos/dist-esm";
|
||||
import { client } from "../CosmosClient";
|
||||
import { logConsoleProgress, logConsoleError } from "../../Utils/NotificationConsoleUtils";
|
||||
import { logError } from "../Logger";
|
||||
import { sendNotificationForError } from "./sendNotificationForError";
|
||||
|
||||
interface ResourceWithStatistics {
|
||||
statistics: DataModels.Statistic[];
|
||||
}
|
||||
|
||||
export const readCollectionQuotaInfo = async (
|
||||
collection: ViewModels.Collection
|
||||
): Promise<DataModels.CollectionQuotaInfo> => {
|
||||
const clearMessage = logConsoleProgress(`Querying containers for database ${collection.id}`);
|
||||
const options: RequestOptions = {};
|
||||
options.populateQuotaInfo = true;
|
||||
options.initialHeaders = options.initialHeaders || {};
|
||||
options.initialHeaders[HttpHeaders.populatePartitionStatistics] = true;
|
||||
|
||||
try {
|
||||
const response = await client()
|
||||
.database(collection.databaseId)
|
||||
.container(collection.id())
|
||||
.read(options);
|
||||
const quota: DataModels.CollectionQuotaInfo = HeadersUtility.getQuota(response.headers);
|
||||
const resource = response.resource as ContainerDefinition & Resource & ResourceWithStatistics;
|
||||
quota["usageSizeInKB"] = resource.statistics.reduce(
|
||||
(previousValue: number, currentValue: DataModels.Statistic) => previousValue + currentValue.sizeInKB,
|
||||
0
|
||||
);
|
||||
quota["numPartitions"] = resource.statistics.length;
|
||||
quota["uniqueKeyPolicy"] = collection.uniqueKeyPolicy; // TODO: Remove after refactoring (#119617)
|
||||
|
||||
return quota;
|
||||
} catch (error) {
|
||||
logConsoleError(`Error while querying quota info for container ${collection.id}:\n ${JSON.stringify(error)}`);
|
||||
logError(JSON.stringify(error), "ReadCollectionQuotaInfo", error.code);
|
||||
sendNotificationForError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
clearMessage();
|
||||
}
|
||||
};
|
||||
102
src/Common/dataAccess/readDatabaseOffer.ts
Normal file
102
src/Common/dataAccess/readDatabaseOffer.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
|
||||
import { HttpHeaders } from "../Constants";
|
||||
import { RequestOptions } from "@azure/cosmos/dist-esm";
|
||||
import { client } from "../CosmosClient";
|
||||
import { getSqlDatabaseThroughput } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources";
|
||||
import { getMongoDBDatabaseThroughput } from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources";
|
||||
import { getCassandraKeyspaceThroughput } from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources";
|
||||
import { getGremlinDatabaseThroughput } from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources";
|
||||
import { logConsoleProgress, logConsoleError } from "../../Utils/NotificationConsoleUtils";
|
||||
import { logError } from "../Logger";
|
||||
import { readOffers } from "./readOffers";
|
||||
import { sendNotificationForError } from "./sendNotificationForError";
|
||||
import { userContext } from "../../UserContext";
|
||||
|
||||
export const readDatabaseOffer = async (
|
||||
params: DataModels.ReadDatabaseOfferParams
|
||||
): Promise<DataModels.OfferWithHeaders> => {
|
||||
const clearMessage = logConsoleProgress(`Querying offer for database ${params.databaseId}`);
|
||||
let offerId = params.offerId;
|
||||
if (!offerId) {
|
||||
if (
|
||||
window.authType === AuthType.AAD &&
|
||||
!userContext.useSDKOperations &&
|
||||
userContext.defaultExperience !== DefaultAccountExperienceType.Table
|
||||
) {
|
||||
try {
|
||||
offerId = await getDatabaseOfferIdWithARM(params.databaseId);
|
||||
} catch (error) {
|
||||
clearMessage();
|
||||
if (error.code !== "NotFound") {
|
||||
throw new error();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
offerId = await getDatabaseOfferIdWithSDK(params.databaseResourceId);
|
||||
if (!offerId) {
|
||||
clearMessage();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const options: RequestOptions = {
|
||||
initialHeaders: {
|
||||
[HttpHeaders.populateCollectionThroughputInfo]: true
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await client()
|
||||
.offer(offerId)
|
||||
.read(options);
|
||||
return (
|
||||
response && {
|
||||
...response.resource,
|
||||
headers: response.headers
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logConsoleError(`Error while querying offer for database ${params.databaseId}:\n ${JSON.stringify(error)}`);
|
||||
logError(JSON.stringify(error), "ReadDatabaseOffer", error.code);
|
||||
sendNotificationForError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
clearMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const getDatabaseOfferIdWithARM = async (databaseId: string): Promise<string> => {
|
||||
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 getSqlDatabaseThroughput(subscriptionId, resourceGroup, accountName, databaseId);
|
||||
break;
|
||||
case DefaultAccountExperienceType.MongoDB:
|
||||
rpResponse = await getMongoDBDatabaseThroughput(subscriptionId, resourceGroup, accountName, databaseId);
|
||||
break;
|
||||
case DefaultAccountExperienceType.Cassandra:
|
||||
rpResponse = await getCassandraKeyspaceThroughput(subscriptionId, resourceGroup, accountName, databaseId);
|
||||
break;
|
||||
case DefaultAccountExperienceType.Graph:
|
||||
rpResponse = await getGremlinDatabaseThroughput(subscriptionId, resourceGroup, accountName, databaseId);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported default experience type: ${defaultExperience}`);
|
||||
}
|
||||
|
||||
return rpResponse?.name;
|
||||
};
|
||||
|
||||
const getDatabaseOfferIdWithSDK = async (databaseResourceId: string): Promise<string> => {
|
||||
const offers = await readOffers();
|
||||
const offer = offers.find(offer => offer.resource === databaseResourceId);
|
||||
return offer?.id;
|
||||
};
|
||||
43
src/Common/dataAccess/readOffers.ts
Normal file
43
src/Common/dataAccess/readOffers.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Offer } from "../../Contracts/DataModels";
|
||||
import { ClientDefaults } from "../Constants";
|
||||
import { MessageTypes } from "../../Contracts/ExplorerContracts";
|
||||
import { Platform, configContext } from "../../ConfigContext";
|
||||
import { client } from "../CosmosClient";
|
||||
import { logConsoleProgress, logConsoleError } from "../../Utils/NotificationConsoleUtils";
|
||||
import { logError } from "../Logger";
|
||||
import { sendCachedDataMessage } from "../MessageHandler";
|
||||
import { sendNotificationForError } from "./sendNotificationForError";
|
||||
import { userContext } from "../../UserContext";
|
||||
|
||||
export const readOffers = async (): Promise<Offer[]> => {
|
||||
const clearMessage = logConsoleProgress(`Querying offers`);
|
||||
try {
|
||||
if (configContext.platform === Platform.Portal) {
|
||||
return sendCachedDataMessage<Offer[]>(MessageTypes.AllOffers, [
|
||||
userContext.databaseAccount.id,
|
||||
ClientDefaults.portalCacheTimeoutMs
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
// If error getting cached Offers, continue on and read via SDK
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client()
|
||||
.offers.readAll()
|
||||
.fetchAll();
|
||||
return 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 [];
|
||||
}
|
||||
|
||||
logConsoleError(`Error while querying offers:\n ${JSON.stringify(error)}`);
|
||||
logError(JSON.stringify(error), "ReadOffers", error.code);
|
||||
sendNotificationForError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
clearMessage();
|
||||
}
|
||||
};
|
||||
@@ -25,16 +25,18 @@ interface ConfigContext {
|
||||
GITHUB_CLIENT_ID: string;
|
||||
GITHUB_CLIENT_SECRET?: string; // No need to inject secret for prod. Juno already knows it.
|
||||
hostedExplorerURL: string;
|
||||
armAPIVersion?: string;
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
let configContext: Readonly<ConfigContext> = {
|
||||
platform: Platform.Portal,
|
||||
allowedParentFrameOrigins: [
|
||||
`^https:\\/\\/cosmos.azure.(com|cn|us)$`,
|
||||
`^https:\\/\\/[\\.\\w]+.portal.azure.(com|cn|us)$`,
|
||||
`^https:\\/\\/[\\.\\w]+.ext.azure.(com|cn|us)$`,
|
||||
`^https:\\/\\/[\\.\\w]+microsoftazure.de$`
|
||||
`^https:\\/\\/cosmos\\.azure\\.(com|cn|us)$`,
|
||||
`^https:\\/\\/[\\.\\w]*portal\\.azure\\.(com|cn|us)$`,
|
||||
`^https:\\/\\/[\\.\\w]*ext\\.azure\\.(com|cn|us)$`,
|
||||
`^https:\\/\\/[\\.\\w]*\\.ext\\.microsoftazure\\.de$`,
|
||||
`^https://cosmos-db-dataexplorer-germanycentral.azurewebsites.de$`
|
||||
],
|
||||
// Webpack injects this at build time
|
||||
gitSha: process.env.GIT_SHA,
|
||||
@@ -92,6 +94,10 @@ export async function initializeConfiguration(): Promise<ConfigContext> {
|
||||
}
|
||||
// Allow override of platform value with URL query parameter
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("armAPIVersion")) {
|
||||
const armAPIVersion = params.get("armAPIVersion") || "";
|
||||
updateConfigContext({ armAPIVersion });
|
||||
}
|
||||
if (params.has("platform")) {
|
||||
const platform = params.get("platform");
|
||||
switch (platform) {
|
||||
|
||||
@@ -287,6 +287,20 @@ export interface CreateCollectionParams {
|
||||
indexingPolicy?: IndexingPolicy;
|
||||
partitionKey?: PartitionKey;
|
||||
uniqueKeyPolicy?: UniqueKeyPolicy;
|
||||
createMongoWildcardIndex?: boolean;
|
||||
}
|
||||
|
||||
export interface ReadDatabaseOfferParams {
|
||||
databaseId: string;
|
||||
databaseResourceId?: string;
|
||||
offerId?: string;
|
||||
}
|
||||
|
||||
export interface ReadCollectionOfferParams {
|
||||
collectionId: string;
|
||||
databaseId: string;
|
||||
collectionResourceId?: string;
|
||||
offerId?: string;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
|
||||
@@ -81,15 +81,15 @@ export interface Database extends TreeNode {
|
||||
selectedSubnodeKind: ko.Observable<CollectionTabKind>;
|
||||
|
||||
selectDatabase(): void;
|
||||
expandDatabase(): void;
|
||||
expandDatabase(): Promise<void>;
|
||||
collapseDatabase(): void;
|
||||
|
||||
loadCollections(): Q.Promise<void>;
|
||||
loadCollections(): Promise<void>;
|
||||
findCollectionWithId(collectionRid: string): Collection;
|
||||
openAddCollection(database: Database, event: MouseEvent): void;
|
||||
onDeleteDatabaseContextMenuClick(source: Database, event: MouseEvent | KeyboardEvent): void;
|
||||
readSettings(): void;
|
||||
onSettingsClick: () => void;
|
||||
loadOffer(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CollectionBase extends TreeNode {
|
||||
@@ -133,8 +133,7 @@ export interface Collection extends CollectionBase {
|
||||
onMongoDBDocumentsClick(): void;
|
||||
openTab(): void;
|
||||
|
||||
onSettingsClick: () => void;
|
||||
readSettings(): Q.Promise<void>;
|
||||
onSettingsClick: () => Promise<void>;
|
||||
onDeleteCollectionContextMenuClick(source: Collection, event: MouseEvent): void;
|
||||
|
||||
onNewGraphClick(): void;
|
||||
@@ -162,6 +161,7 @@ export interface Collection extends CollectionBase {
|
||||
loadUserDefinedFunctions(): Promise<any>;
|
||||
loadStoredProcedures(): Promise<any>;
|
||||
loadTriggers(): Promise<any>;
|
||||
loadOffer(): Promise<void>;
|
||||
|
||||
createStoredProcedureNode(data: StoredProcedureDefinition & Resource): StoredProcedure;
|
||||
createUserDefinedFunctionNode(data: UserDefinedFunctionDefinition & Resource): UserDefinedFunction;
|
||||
@@ -309,10 +309,6 @@ export interface ScriptTabOption extends TabOptions {
|
||||
partitionKey?: DataModels.PartitionKey;
|
||||
}
|
||||
|
||||
export interface WaitsForTemplate {
|
||||
isTemplateReady: ko.Observable<boolean>;
|
||||
}
|
||||
|
||||
export interface EditorPosition {
|
||||
line: number;
|
||||
column: number;
|
||||
@@ -359,7 +355,8 @@ export enum CollectionTabKind {
|
||||
NotebookV2 = 15,
|
||||
SparkMasterTab = 16,
|
||||
Gallery = 17,
|
||||
NotebookViewer = 18
|
||||
NotebookViewer = 18,
|
||||
SettingsV2 = 19
|
||||
}
|
||||
|
||||
export enum TerminalKind {
|
||||
|
||||
@@ -48,6 +48,10 @@ describe("Component Registerer", () => {
|
||||
expect(ko.components.isRegistered("settings-tab")).toBe(true);
|
||||
});
|
||||
|
||||
it("should register settings-tab-v2 component", () => {
|
||||
expect(ko.components.isRegistered("settings-tab-v2")).toBe(true);
|
||||
});
|
||||
|
||||
it("should register query-tab component", () => {
|
||||
expect(ko.components.isRegistered("query-tab")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ ko.components.register("stored-procedure-tab", new TabComponents.StoredProcedure
|
||||
ko.components.register("trigger-tab", new TabComponents.TriggerTab());
|
||||
ko.components.register("user-defined-function-tab", new TabComponents.UserDefinedFunctionTab());
|
||||
ko.components.register("settings-tab", new TabComponents.SettingsTab());
|
||||
ko.components.register("settings-tab-v2", new TabComponents.SettingsTabV2());
|
||||
ko.components.register("query-tab", new TabComponents.QueryTab());
|
||||
ko.components.register("tables-query-tab", new TabComponents.QueryTablesTab());
|
||||
ko.components.register("graph-tab", new TabComponents.GraphTab());
|
||||
|
||||
@@ -16,6 +16,8 @@ import Explorer from "./Explorer";
|
||||
import UserDefinedFunction from "./Tree/UserDefinedFunction";
|
||||
import StoredProcedure from "./Tree/StoredProcedure";
|
||||
import Trigger from "./Tree/Trigger";
|
||||
import { userContext } from "../UserContext";
|
||||
import { DefaultAccountExperienceType } from "../DefaultAccountExperienceType";
|
||||
|
||||
export interface CollectionContextMenuButtonParams {
|
||||
databaseId: string;
|
||||
@@ -29,22 +31,24 @@ export interface DatabaseContextMenuButtonParams {
|
||||
* New resource tree (in ReactJS)
|
||||
*/
|
||||
export class ResourceTreeContextMenuButtonFactory {
|
||||
public static createDatabaseContextMenu(
|
||||
container: Explorer,
|
||||
selectedDatabase: ViewModels.Database
|
||||
): TreeNodeMenuItem[] {
|
||||
const newCollectionMenuItem: TreeNodeMenuItem = {
|
||||
iconSrc: AddCollectionIcon,
|
||||
onClick: () => container.onNewCollectionClicked(),
|
||||
label: container.addCollectionText()
|
||||
};
|
||||
public static createDatabaseContextMenu(container: Explorer): TreeNodeMenuItem[] {
|
||||
const items: TreeNodeMenuItem[] = [
|
||||
{
|
||||
iconSrc: AddCollectionIcon,
|
||||
onClick: () => container.onNewCollectionClicked(),
|
||||
label: container.addCollectionText()
|
||||
}
|
||||
];
|
||||
|
||||
const deleteDatabaseMenuItem = {
|
||||
iconSrc: DeleteDatabaseIcon,
|
||||
onClick: () => container.deleteDatabaseConfirmationPane.open(),
|
||||
label: container.deleteDatabaseText()
|
||||
};
|
||||
return [newCollectionMenuItem, deleteDatabaseMenuItem];
|
||||
if (userContext.defaultExperience !== DefaultAccountExperienceType.Table) {
|
||||
items.push({
|
||||
iconSrc: DeleteDatabaseIcon,
|
||||
onClick: () => container.deleteDatabaseConfirmationPane.open(),
|
||||
label: container.deleteDatabaseText(),
|
||||
styleClass: "deleteDatabaseMenuItem"
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
public static createCollectionContextMenuButton(
|
||||
@@ -112,7 +116,8 @@ export class ResourceTreeContextMenuButtonFactory {
|
||||
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
|
||||
selectedCollection && selectedCollection.onDeleteCollectionContextMenuClick(selectedCollection, null);
|
||||
},
|
||||
label: container.deleteCollectionText()
|
||||
label: container.deleteCollectionText(),
|
||||
styleClass: "deleteCollectionMenuItem"
|
||||
});
|
||||
|
||||
return items;
|
||||
|
||||
@@ -55,6 +55,7 @@ export const FeaturePanelComponent: React.FunctionComponent = () => {
|
||||
label: "Enable Injecting Notebook Viewer Link into the first cell",
|
||||
value: "true"
|
||||
},
|
||||
{ key: "feature.enablesettingsv2", label: "Enable SettingsV2 Tab", value: "true" },
|
||||
{ key: "feature.canexceedmaximumvalue", label: "Can exceed max value", value: "true" },
|
||||
{
|
||||
key: "feature.enablefixedcollectionwithsharedthroughput",
|
||||
|
||||
@@ -178,6 +178,12 @@ exports[`Feature panel renders all flags 1`] = `
|
||||
className="checkboxRow"
|
||||
horizontalAlign="space-between"
|
||||
>
|
||||
<StyledCheckboxBase
|
||||
checked={false}
|
||||
key="feature.enablesettingsv2"
|
||||
label="Enable SettingsV2 Tab"
|
||||
onChange={[Function]}
|
||||
/>
|
||||
<StyledCheckboxBase
|
||||
checked={false}
|
||||
key="feature.canexceedmaximumvalue"
|
||||
|
||||
@@ -8,6 +8,8 @@ import * as Logger from "../../../Common/Logger";
|
||||
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
|
||||
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { StringUtils } from "../../../Utils/StringUtils";
|
||||
import { userContext } from "../../../UserContext";
|
||||
import { TerminalQueryParams } from "../../../Common/Constants";
|
||||
|
||||
export interface NotebookTerminalComponentProps {
|
||||
notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo;
|
||||
@@ -32,11 +34,11 @@ export class NotebookTerminalComponent extends React.Component<NotebookTerminalC
|
||||
|
||||
public getTerminalParams(): Map<string, string> {
|
||||
let params: Map<string, string> = new Map<string, string>();
|
||||
params.set("terminal", "true");
|
||||
params.set(TerminalQueryParams.Terminal, "true");
|
||||
|
||||
const terminalEndpoint: string = this.tryGetTerminalEndpoint();
|
||||
if (terminalEndpoint) {
|
||||
params.set("terminalEndpoint", terminalEndpoint);
|
||||
params.set(TerminalQueryParams.TerminalEndpoint, terminalEndpoint);
|
||||
}
|
||||
|
||||
return params;
|
||||
@@ -75,11 +77,13 @@ export class NotebookTerminalComponent extends React.Component<NotebookTerminalC
|
||||
return "";
|
||||
}
|
||||
|
||||
params.set("server", serverInfo.notebookServerEndpoint);
|
||||
params.set(TerminalQueryParams.Server, serverInfo.notebookServerEndpoint);
|
||||
if (serverInfo.authToken && serverInfo.authToken.length > 0) {
|
||||
params.set("token", serverInfo.authToken);
|
||||
params.set(TerminalQueryParams.Token, serverInfo.authToken);
|
||||
}
|
||||
|
||||
params.set(TerminalQueryParams.SubscriptionId, userContext.subscriptionId);
|
||||
|
||||
let result: string = "terminal.html?";
|
||||
for (let key of params.keys()) {
|
||||
result += `${key}=${encodeURIComponent(params.get(key))}&`;
|
||||
|
||||
@@ -26,7 +26,7 @@ exports[`GalleryCardComponent renders 1`] = `
|
||||
/>
|
||||
</CardItem>
|
||||
<CardItem>
|
||||
<Memo(StyledImageBase)
|
||||
<StyledImageBase
|
||||
alt="name cover image"
|
||||
height={144}
|
||||
imageFit={2}
|
||||
@@ -95,7 +95,7 @@ exports[`GalleryCardComponent renders 1`] = `
|
||||
}
|
||||
variant="tiny"
|
||||
>
|
||||
<Memo(StyledIconBase)
|
||||
<StyledIconBase
|
||||
iconName="RedEye"
|
||||
styles={
|
||||
Object {
|
||||
@@ -119,7 +119,7 @@ exports[`GalleryCardComponent renders 1`] = `
|
||||
}
|
||||
variant="tiny"
|
||||
>
|
||||
<Memo(StyledIconBase)
|
||||
<StyledIconBase
|
||||
iconName="Download"
|
||||
styles={
|
||||
Object {
|
||||
@@ -143,7 +143,7 @@ exports[`GalleryCardComponent renders 1`] = `
|
||||
}
|
||||
variant="tiny"
|
||||
>
|
||||
<Memo(StyledIconBase)
|
||||
<StyledIconBase
|
||||
iconName="Heart"
|
||||
styles={
|
||||
Object {
|
||||
|
||||
@@ -13,7 +13,7 @@ exports[`InfoComponent renders 1`] = `
|
||||
<div
|
||||
className="infoPanelMain"
|
||||
>
|
||||
<Memo(StyledIconBase)
|
||||
<StyledIconBase
|
||||
className="infoIconMain"
|
||||
iconName="Help"
|
||||
styles={
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface NotebookViewerComponentProps {
|
||||
isFavorite?: boolean;
|
||||
backNavigationText: string;
|
||||
hideInputs?: boolean;
|
||||
hidePrompts?: boolean;
|
||||
onBackClick: () => void;
|
||||
onTagClick: (tag: string) => void;
|
||||
}
|
||||
@@ -57,7 +58,7 @@ export class NotebookViewerComponent extends React.Component<
|
||||
databaseAccountName: undefined,
|
||||
defaultExperience: "NotebookViewer",
|
||||
isReadOnly: true,
|
||||
cellEditorType: "codemirror",
|
||||
cellEditorType: "monaco",
|
||||
autoSaveInterval: 365 * 24 * 3600 * 1000, // There is no way to turn off auto-save, set to 1 year
|
||||
contentProvider: contents.JupyterContentProvider // NotebookViewer only knows how to talk to Jupyter contents API
|
||||
});
|
||||
@@ -148,7 +149,8 @@ export class NotebookViewerComponent extends React.Component<
|
||||
{this.state.showProgressBar && <ProgressIndicator />}
|
||||
|
||||
{this.notebookComponentBootstrapper.renderComponent(NotebookReadOnlyRenderer, {
|
||||
hideInputs: this.props.hideInputs
|
||||
hideInputs: this.props.hideInputs,
|
||||
hidePrompts: this.props.hidePrompts
|
||||
})}
|
||||
|
||||
{this.state.dialogProps && <DialogComponent {...this.state.dialogProps} />}
|
||||
|
||||
@@ -56,14 +56,14 @@ exports[`NotebookMetadataComponent renders liked notebook 1`] = `
|
||||
Invalid Date
|
||||
</Text>
|
||||
<Text>
|
||||
<Memo(StyledIconBase)
|
||||
<StyledIconBase
|
||||
iconName="RedEye"
|
||||
/>
|
||||
|
||||
0
|
||||
</Text>
|
||||
<Text>
|
||||
<Memo(StyledIconBase)
|
||||
<StyledIconBase
|
||||
iconName="Download"
|
||||
/>
|
||||
0
|
||||
@@ -156,14 +156,14 @@ exports[`NotebookMetadataComponent renders un-liked notebook 1`] = `
|
||||
Invalid Date
|
||||
</Text>
|
||||
<Text>
|
||||
<Memo(StyledIconBase)
|
||||
<StyledIconBase
|
||||
iconName="RedEye"
|
||||
/>
|
||||
|
||||
0
|
||||
</Text>
|
||||
<Text>
|
||||
<Memo(StyledIconBase)
|
||||
<StyledIconBase
|
||||
iconName="Download"
|
||||
/>
|
||||
0
|
||||
|
||||
31
src/Explorer/Controls/Settings/SettingsComponent.less
Normal file
31
src/Explorer/Controls/Settings/SettingsComponent.less
Normal file
@@ -0,0 +1,31 @@
|
||||
@import "../../../../less/Common/Constants";
|
||||
|
||||
.settingsV2MainContainer {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settingsV2ToolTip {
|
||||
padding: 10px;
|
||||
font: 12px @DataExplorerFont;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.autoPilotSelector span {
|
||||
height: 25px;
|
||||
font: 14px @DataExplorerFont;
|
||||
}
|
||||
|
||||
.settingsV2TabsContainer {
|
||||
padding: @LargeSpace @LargeSpace 30px @LargeSpace;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
font-family: @DataExplorerFont;
|
||||
}
|
||||
|
||||
.settingsV2IndexingPolicyEditor {
|
||||
width: 100%;
|
||||
height: 60vh;
|
||||
}
|
||||
269
src/Explorer/Controls/Settings/SettingsComponent.test.tsx
Normal file
269
src/Explorer/Controls/Settings/SettingsComponent.test.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import { SettingsComponentProps, SettingsComponent, SettingsComponentState } from "./SettingsComponent";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import SettingsTabV2 from "../../Tabs/SettingsTabV2";
|
||||
import { collection } from "./TestUtils";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import ko from "knockout";
|
||||
import { TtlType, isDirty, TtlOnNoDefault, TtlOn, TtlOff } from "./SettingsUtils";
|
||||
import Explorer from "../../Explorer";
|
||||
import { updateCollection } from "../../../Common/dataAccess/updateCollection";
|
||||
jest.mock("../../../Common/dataAccess/updateCollection", () => ({
|
||||
updateCollection: jest.fn().mockReturnValue({
|
||||
id: undefined,
|
||||
defaultTtl: undefined,
|
||||
indexingPolicy: undefined,
|
||||
conflictResolutionPolicy: undefined,
|
||||
changeFeedPolicy: undefined,
|
||||
analyticalStorageTtl: undefined,
|
||||
geospatialConfig: undefined
|
||||
} as DataModels.Collection)
|
||||
}));
|
||||
import { updateOffer } from "../../../Common/DocumentClientUtilityBase";
|
||||
jest.mock("../../../Common/DocumentClientUtilityBase", () => ({
|
||||
updateOffer: jest.fn().mockReturnValue({} as DataModels.Offer)
|
||||
}));
|
||||
|
||||
describe("SettingsComponent", () => {
|
||||
const baseProps: SettingsComponentProps = {
|
||||
settingsTab: new SettingsTabV2({
|
||||
collection: collection,
|
||||
tabKind: ViewModels.CollectionTabKind.SettingsV2,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
node: undefined,
|
||||
selfLink: undefined,
|
||||
hashLocation: "settings",
|
||||
isActive: ko.observable(false),
|
||||
onUpdateTabsButtons: undefined
|
||||
})
|
||||
};
|
||||
|
||||
it("renders", () => {
|
||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("dirty value enables save button and discard button", () => {
|
||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toEqual(false);
|
||||
expect(settingsComponentInstance.isDiscardSettingsButtonEnabled()).toEqual(false);
|
||||
wrapper.setState({ isScaleSaveable: true, isScaleDiscardable: true });
|
||||
wrapper.update();
|
||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toEqual(true);
|
||||
expect(settingsComponentInstance.isDiscardSettingsButtonEnabled()).toEqual(true);
|
||||
wrapper.setState({
|
||||
isScaleSaveable: false,
|
||||
isScaleDiscardable: false,
|
||||
isSubSettingsSaveable: true,
|
||||
isSubSettingsDiscardable: true
|
||||
});
|
||||
wrapper.update();
|
||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toEqual(true);
|
||||
expect(settingsComponentInstance.isDiscardSettingsButtonEnabled()).toEqual(true);
|
||||
wrapper.setState({ isSubSettingsSaveable: false, isSubSettingsDiscardable: false, isIndexingPolicyDirty: true });
|
||||
wrapper.update();
|
||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toEqual(true);
|
||||
expect(settingsComponentInstance.isDiscardSettingsButtonEnabled()).toEqual(true);
|
||||
wrapper.setState({ isIndexingPolicyDirty: false, isConflictResolutionDirty: true });
|
||||
wrapper.update();
|
||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toEqual(true);
|
||||
expect(settingsComponentInstance.isDiscardSettingsButtonEnabled()).toEqual(true);
|
||||
});
|
||||
|
||||
it("auto pilot helper functions pass on correct value", () => {
|
||||
const newCollection = { ...collection };
|
||||
newCollection.offer = ko.observable<DataModels.Offer>({
|
||||
content: {
|
||||
offerAutopilotSettings: {
|
||||
maxThroughput: 10000
|
||||
}
|
||||
}
|
||||
} as DataModels.Offer);
|
||||
|
||||
const props = { ...baseProps };
|
||||
props.settingsTab.collection = newCollection;
|
||||
|
||||
const wrapper = shallow(<SettingsComponent {...props} />);
|
||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||
expect(settingsComponentInstance.hasProvisioningTypeChanged()).toEqual(false);
|
||||
wrapper.setState({
|
||||
userCanChangeProvisioningTypes: true,
|
||||
isAutoPilotSelected: true,
|
||||
wasAutopilotOriginallySet: false,
|
||||
autoPilotThroughput: 1000
|
||||
});
|
||||
wrapper.update();
|
||||
expect(settingsComponentInstance.hasProvisioningTypeChanged()).toEqual(true);
|
||||
});
|
||||
|
||||
it("shouldShowKeyspaceSharedThroughputMessage", () => {
|
||||
let settingsComponentInstance = new SettingsComponent(baseProps);
|
||||
expect(settingsComponentInstance.shouldShowKeyspaceSharedThroughputMessage()).toEqual(false);
|
||||
|
||||
const newContainer = new Explorer({
|
||||
notificationsClient: undefined,
|
||||
isEmulator: false
|
||||
});
|
||||
newContainer.isPreferredApiCassandra = ko.computed(() => true);
|
||||
|
||||
const newCollection = { ...collection };
|
||||
newCollection.container = newContainer;
|
||||
const newDatabase = {
|
||||
nodeKind: undefined,
|
||||
rid: undefined,
|
||||
container: newContainer,
|
||||
self: undefined,
|
||||
id: undefined,
|
||||
collections: undefined,
|
||||
offer: undefined,
|
||||
isDatabaseExpanded: undefined,
|
||||
isDatabaseShared: ko.computed(() => true),
|
||||
selectedSubnodeKind: undefined,
|
||||
selectDatabase: undefined,
|
||||
expandDatabase: undefined,
|
||||
collapseDatabase: undefined,
|
||||
loadCollections: undefined,
|
||||
findCollectionWithId: undefined,
|
||||
openAddCollection: undefined,
|
||||
onDeleteDatabaseContextMenuClick: undefined,
|
||||
readSettings: undefined,
|
||||
onSettingsClick: undefined,
|
||||
loadOffer: undefined
|
||||
} as ViewModels.Database;
|
||||
newCollection.getDatabase = () => newDatabase;
|
||||
newCollection.offer = ko.observable(undefined);
|
||||
|
||||
const props = { ...baseProps };
|
||||
props.settingsTab.collection = newCollection;
|
||||
|
||||
settingsComponentInstance = new SettingsComponent(props);
|
||||
expect(settingsComponentInstance.shouldShowKeyspaceSharedThroughputMessage()).toEqual(true);
|
||||
});
|
||||
|
||||
it("hasConflictResolution", () => {
|
||||
let settingsComponentInstance = new SettingsComponent(baseProps);
|
||||
expect(settingsComponentInstance.hasConflictResolution()).toEqual(undefined);
|
||||
|
||||
const newContainer = new Explorer({
|
||||
notificationsClient: undefined,
|
||||
isEmulator: false
|
||||
});
|
||||
newContainer.databaseAccount = ko.observable({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
location: undefined,
|
||||
type: undefined,
|
||||
kind: undefined,
|
||||
tags: undefined,
|
||||
properties: {
|
||||
documentEndpoint: undefined,
|
||||
tableEndpoint: undefined,
|
||||
gremlinEndpoint: undefined,
|
||||
cassandraEndpoint: undefined,
|
||||
enableMultipleWriteLocations: true
|
||||
}
|
||||
});
|
||||
const newCollection = { ...collection };
|
||||
newCollection.container = newContainer;
|
||||
newCollection.conflictResolutionPolicy = ko.observable({
|
||||
mode: DataModels.ConflictResolutionMode.Custom,
|
||||
conflictResolutionProcedure: undefined
|
||||
} as DataModels.ConflictResolutionPolicy);
|
||||
|
||||
const props = { ...baseProps };
|
||||
props.settingsTab.collection = newCollection;
|
||||
|
||||
settingsComponentInstance = new SettingsComponent(props);
|
||||
expect(settingsComponentInstance.hasConflictResolution()).toEqual(true);
|
||||
});
|
||||
|
||||
it("isOfferReplacePending", () => {
|
||||
let settingsComponentInstance = new SettingsComponent(baseProps);
|
||||
expect(settingsComponentInstance.isOfferReplacePending()).toEqual(undefined);
|
||||
|
||||
const newCollection = { ...collection };
|
||||
newCollection.offer = ko.observable({
|
||||
headers: { "x-ms-offer-replace-pending": true }
|
||||
} as DataModels.OfferWithHeaders);
|
||||
const props = { ...baseProps };
|
||||
props.settingsTab.collection = newCollection;
|
||||
|
||||
settingsComponentInstance = new SettingsComponent(props);
|
||||
expect(settingsComponentInstance.isOfferReplacePending()).toEqual(true);
|
||||
});
|
||||
|
||||
it("save calls updateCollection and updateOffer", async () => {
|
||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||
wrapper.setState({ isSubSettingsSaveable: true, isScaleSaveable: true });
|
||||
wrapper.update();
|
||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||
await settingsComponentInstance.onSaveClick();
|
||||
expect(updateCollection).toBeCalled();
|
||||
expect(updateOffer).toBeCalled();
|
||||
});
|
||||
|
||||
it("revert resets state values", async () => {
|
||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||
wrapper.setState({ timeToLive: TtlType.OnNoDefault, throughput: 10 });
|
||||
wrapper.update();
|
||||
let state = wrapper.state() as SettingsComponentState;
|
||||
expect(isDirty(state.timeToLive, state.timeToLiveBaseline)).toEqual(true);
|
||||
expect(isDirty(state.throughput, state.throughputBaseline)).toEqual(true);
|
||||
|
||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||
settingsComponentInstance.onRevertClick();
|
||||
state = wrapper.state() as SettingsComponentState;
|
||||
expect(isDirty(state.timeToLive, state.timeToLiveBaseline)).toEqual(false);
|
||||
expect(isDirty(state.throughput, state.throughputBaseline)).toEqual(false);
|
||||
});
|
||||
|
||||
it("getTtlValue", async () => {
|
||||
const settingsComponentInstance = new SettingsComponent(baseProps);
|
||||
expect(settingsComponentInstance.getTtlValue(TtlType.OnNoDefault)).toEqual(TtlOnNoDefault);
|
||||
expect(settingsComponentInstance.getTtlValue(TtlType.On)).toEqual(TtlOn);
|
||||
expect(settingsComponentInstance.getTtlValue(TtlType.Off)).toEqual(TtlOff);
|
||||
});
|
||||
|
||||
it("getAnalyticalStorageTtl", () => {
|
||||
const newCollection = { ...collection };
|
||||
newCollection.analyticalStorageTtl = ko.observable(10);
|
||||
const props = { ...baseProps };
|
||||
props.settingsTab.collection = newCollection;
|
||||
const wrapper = shallow(<SettingsComponent {...props} />);
|
||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||
expect(settingsComponentInstance.getAnalyticalStorageTtl()).toEqual(10);
|
||||
wrapper.setState({ analyticalStorageTtlSelection: TtlType.Off });
|
||||
wrapper.update();
|
||||
expect(settingsComponentInstance.getAnalyticalStorageTtl()).toEqual(-1);
|
||||
});
|
||||
|
||||
it("getUpdatedConflictResolutionPolicy", () => {
|
||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||
const conflictResolutionPolicyPath = "_ts";
|
||||
const conflictResolutionPolicyProcedure = "sample_sproc";
|
||||
const expectSprocPath =
|
||||
"/dbs/" + collection.databaseId + "/colls/" + collection.id() + "/sprocs/" + conflictResolutionPolicyProcedure;
|
||||
|
||||
wrapper.setState({
|
||||
conflictResolutionPolicyMode: DataModels.ConflictResolutionMode.LastWriterWins,
|
||||
conflictResolutionPolicyPath: conflictResolutionPolicyPath
|
||||
});
|
||||
wrapper.update();
|
||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||
let conflictResolutionPolicy = settingsComponentInstance.getUpdatedConflictResolutionPolicy();
|
||||
expect(conflictResolutionPolicy.mode).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
||||
expect(conflictResolutionPolicy.conflictResolutionPath).toEqual(conflictResolutionPolicyPath);
|
||||
|
||||
wrapper.setState({
|
||||
conflictResolutionPolicyMode: DataModels.ConflictResolutionMode.Custom,
|
||||
conflictResolutionPolicyProcedure: conflictResolutionPolicyProcedure
|
||||
});
|
||||
wrapper.update();
|
||||
conflictResolutionPolicy = settingsComponentInstance.getUpdatedConflictResolutionPolicy();
|
||||
expect(conflictResolutionPolicy.mode).toEqual(DataModels.ConflictResolutionMode.Custom);
|
||||
expect(conflictResolutionPolicy.conflictResolutionProcedure).toEqual(expectSprocPath);
|
||||
});
|
||||
});
|
||||
912
src/Explorer/Controls/Settings/SettingsComponent.tsx
Normal file
912
src/Explorer/Controls/Settings/SettingsComponent.tsx
Normal file
@@ -0,0 +1,912 @@
|
||||
import * as React from "react";
|
||||
import * as AutoPilotUtils from "../../../Utils/AutoPilotUtils";
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import * as SharedConstants from "../../../Shared/Constants";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import DiscardIcon from "../../../../images/discard.svg";
|
||||
import SaveIcon from "../../../../images/save-cosmos.svg";
|
||||
import { traceStart, traceFailure, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
import { RequestOptions } from "@azure/cosmos/dist-esm";
|
||||
import Explorer from "../../Explorer";
|
||||
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";
|
||||
import SettingsTab from "../../Tabs/SettingsTabV2";
|
||||
import { throughputUnit } from "./SettingsRenderUtils";
|
||||
import { ScaleComponent, ScaleComponentProps } from "./SettingsSubComponents/ScaleComponent";
|
||||
import {
|
||||
getMaxRUs,
|
||||
hasDatabaseSharedThroughput,
|
||||
GeospatialConfigType,
|
||||
TtlType,
|
||||
ChangeFeedPolicyState,
|
||||
SettingsV2TabTypes,
|
||||
getTabTitle,
|
||||
isDirty,
|
||||
TtlOff,
|
||||
TtlOn,
|
||||
TtlOnNoDefault,
|
||||
parseConflictResolutionMode,
|
||||
parseConflictResolutionProcedure
|
||||
} from "./SettingsUtils";
|
||||
import {
|
||||
ConflictResolutionComponent,
|
||||
ConflictResolutionComponentProps
|
||||
} from "./SettingsSubComponents/ConflictResolutionComponent";
|
||||
import { SubSettingsComponent, SubSettingsComponentProps } from "./SettingsSubComponents/SubSettingsComponent";
|
||||
import { Pivot, PivotItem, IPivotProps, IPivotItemProps, IChoiceGroupOption } from "office-ui-fabric-react";
|
||||
import "./SettingsComponent.less";
|
||||
import { IndexingPolicyComponent, IndexingPolicyComponentProps } from "./SettingsSubComponents/IndexingPolicyComponent";
|
||||
|
||||
interface SettingsV2TabInfo {
|
||||
tab: SettingsV2TabTypes;
|
||||
content: JSX.Element;
|
||||
}
|
||||
|
||||
interface ButtonV2 {
|
||||
isVisible: () => boolean;
|
||||
isEnabled: () => boolean;
|
||||
isSelected?: () => boolean;
|
||||
}
|
||||
|
||||
export interface SettingsComponentProps {
|
||||
settingsTab: SettingsTab;
|
||||
}
|
||||
|
||||
export interface SettingsComponentState {
|
||||
throughput: number;
|
||||
throughputBaseline: number;
|
||||
autoPilotThroughput: number;
|
||||
autoPilotThroughputBaseline: number;
|
||||
isAutoPilotSelected: boolean;
|
||||
wasAutopilotOriginallySet: boolean;
|
||||
isScaleSaveable: boolean;
|
||||
isScaleDiscardable: boolean;
|
||||
|
||||
timeToLive: TtlType;
|
||||
timeToLiveBaseline: TtlType;
|
||||
timeToLiveSeconds: number;
|
||||
timeToLiveSecondsBaseline: number;
|
||||
geospatialConfigType: GeospatialConfigType;
|
||||
geospatialConfigTypeBaseline: GeospatialConfigType;
|
||||
analyticalStorageTtlSelection: TtlType;
|
||||
analyticalStorageTtlSelectionBaseline: TtlType;
|
||||
analyticalStorageTtlSeconds: number;
|
||||
analyticalStorageTtlSecondsBaseline: number;
|
||||
changeFeedPolicy: ChangeFeedPolicyState;
|
||||
changeFeedPolicyBaseline: ChangeFeedPolicyState;
|
||||
isSubSettingsSaveable: boolean;
|
||||
isSubSettingsDiscardable: boolean;
|
||||
|
||||
indexingPolicyContent: DataModels.IndexingPolicy;
|
||||
indexingPolicyContentBaseline: DataModels.IndexingPolicy;
|
||||
shouldDiscardIndexingPolicy: boolean;
|
||||
indexingPolicyElementFocussed: boolean;
|
||||
isIndexingPolicyDirty: boolean;
|
||||
|
||||
conflictResolutionPolicyMode: DataModels.ConflictResolutionMode;
|
||||
conflictResolutionPolicyModeBaseline: DataModels.ConflictResolutionMode;
|
||||
conflictResolutionPolicyPath: string;
|
||||
conflictResolutionPolicyPathBaseline: string;
|
||||
conflictResolutionPolicyProcedure: string;
|
||||
conflictResolutionPolicyProcedureBaseline: string;
|
||||
isConflictResolutionDirty: boolean;
|
||||
|
||||
initialNotification: DataModels.Notification;
|
||||
selectedTab: SettingsV2TabTypes;
|
||||
}
|
||||
|
||||
export class SettingsComponent extends React.Component<SettingsComponentProps, SettingsComponentState> {
|
||||
private static readonly sixMonthsInSeconds = 15768000;
|
||||
private static readonly zeroSeconds = 0;
|
||||
|
||||
public saveSettingsButton: ButtonV2;
|
||||
public discardSettingsChangesButton: ButtonV2;
|
||||
|
||||
public isAnalyticalStorageEnabled: boolean;
|
||||
private collection: ViewModels.Collection;
|
||||
private container: Explorer;
|
||||
private changeFeedPolicyVisible: boolean;
|
||||
private isFixedContainer: boolean;
|
||||
private autoPilotTiersList: ViewModels.DropdownOption<DataModels.AutopilotTier>[];
|
||||
private shouldShowIndexingPolicyEditor: boolean;
|
||||
|
||||
constructor(props: SettingsComponentProps) {
|
||||
super(props);
|
||||
|
||||
this.collection = this.props.settingsTab.collection as ViewModels.Collection;
|
||||
this.container = this.collection?.container;
|
||||
this.isAnalyticalStorageEnabled = !!this.collection?.analyticalStorageTtl();
|
||||
this.shouldShowIndexingPolicyEditor =
|
||||
this.container && !this.container.isPreferredApiCassandra() && !this.container.isPreferredApiMongoDB();
|
||||
|
||||
this.changeFeedPolicyVisible = this.collection?.container.isFeatureEnabled(
|
||||
Constants.Features.enableChangeFeedPolicy
|
||||
);
|
||||
// Mongo container with system partition key still treat as "Fixed"
|
||||
|
||||
this.isFixedContainer =
|
||||
!this.collection.partitionKey ||
|
||||
(this.container.isPreferredApiMongoDB() && this.collection.partitionKey.systemKey);
|
||||
|
||||
this.state = {
|
||||
throughput: undefined,
|
||||
throughputBaseline: undefined,
|
||||
autoPilotThroughput: undefined,
|
||||
autoPilotThroughputBaseline: undefined,
|
||||
isAutoPilotSelected: false,
|
||||
wasAutopilotOriginallySet: false,
|
||||
isScaleSaveable: false,
|
||||
isScaleDiscardable: false,
|
||||
|
||||
timeToLive: undefined,
|
||||
timeToLiveBaseline: undefined,
|
||||
timeToLiveSeconds: undefined,
|
||||
timeToLiveSecondsBaseline: undefined,
|
||||
geospatialConfigType: undefined,
|
||||
geospatialConfigTypeBaseline: undefined,
|
||||
analyticalStorageTtlSelection: undefined,
|
||||
analyticalStorageTtlSelectionBaseline: undefined,
|
||||
analyticalStorageTtlSeconds: undefined,
|
||||
analyticalStorageTtlSecondsBaseline: undefined,
|
||||
changeFeedPolicy: undefined,
|
||||
changeFeedPolicyBaseline: undefined,
|
||||
isSubSettingsSaveable: false,
|
||||
isSubSettingsDiscardable: false,
|
||||
|
||||
indexingPolicyContent: undefined,
|
||||
indexingPolicyContentBaseline: undefined,
|
||||
indexingPolicyElementFocussed: false,
|
||||
shouldDiscardIndexingPolicy: false,
|
||||
isIndexingPolicyDirty: false,
|
||||
|
||||
conflictResolutionPolicyMode: undefined,
|
||||
conflictResolutionPolicyModeBaseline: undefined,
|
||||
conflictResolutionPolicyPath: undefined,
|
||||
conflictResolutionPolicyPathBaseline: undefined,
|
||||
conflictResolutionPolicyProcedure: undefined,
|
||||
conflictResolutionPolicyProcedureBaseline: undefined,
|
||||
isConflictResolutionDirty: false,
|
||||
|
||||
initialNotification: undefined,
|
||||
selectedTab: SettingsV2TabTypes.ScaleTab
|
||||
};
|
||||
|
||||
this.saveSettingsButton = {
|
||||
isEnabled: this.isSaveSettingsButtonEnabled,
|
||||
isVisible: () => {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
this.discardSettingsChangesButton = {
|
||||
isEnabled: this.isDiscardSettingsButtonEnabled,
|
||||
isVisible: () => {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.setAutoPilotStates();
|
||||
this.setBaseline();
|
||||
if (this.props.settingsTab.isActive()) {
|
||||
this.props.settingsTab.getSettingsTabContainer().onUpdateTabsButtons(this.getTabsButtons());
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(): void {
|
||||
if (this.props.settingsTab.isActive()) {
|
||||
this.props.settingsTab.getSettingsTabContainer().onUpdateTabsButtons(this.getTabsButtons());
|
||||
}
|
||||
}
|
||||
|
||||
public isSaveSettingsButtonEnabled = (): boolean => {
|
||||
if (this.isOfferReplacePending()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
this.state.isScaleSaveable ||
|
||||
this.state.isSubSettingsSaveable ||
|
||||
this.state.isIndexingPolicyDirty ||
|
||||
this.state.isConflictResolutionDirty
|
||||
);
|
||||
};
|
||||
|
||||
public isDiscardSettingsButtonEnabled = (): boolean => {
|
||||
return (
|
||||
this.state.isScaleDiscardable ||
|
||||
this.state.isSubSettingsDiscardable ||
|
||||
this.state.isIndexingPolicyDirty ||
|
||||
this.state.isConflictResolutionDirty
|
||||
);
|
||||
};
|
||||
|
||||
private setAutoPilotStates = (): void => {
|
||||
const offer = this.collection?.offer && this.collection.offer();
|
||||
const offerAutopilotSettings = offer?.content?.offerAutopilotSettings;
|
||||
|
||||
if (
|
||||
offerAutopilotSettings &&
|
||||
offerAutopilotSettings.maxThroughput &&
|
||||
AutoPilotUtils.isValidAutoPilotThroughput(offerAutopilotSettings.maxThroughput)
|
||||
) {
|
||||
this.setState({
|
||||
isAutoPilotSelected: true,
|
||||
wasAutopilotOriginallySet: true,
|
||||
autoPilotThroughput: offerAutopilotSettings.maxThroughput,
|
||||
autoPilotThroughputBaseline: offerAutopilotSettings.maxThroughput
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public hasProvisioningTypeChanged = (): boolean =>
|
||||
this.state.wasAutopilotOriginallySet !== this.state.isAutoPilotSelected;
|
||||
|
||||
public shouldShowKeyspaceSharedThroughputMessage = (): boolean =>
|
||||
this.container && this.container.isPreferredApiCassandra() && hasDatabaseSharedThroughput(this.collection);
|
||||
|
||||
public hasConflictResolution = (): boolean =>
|
||||
this.container?.databaseAccount &&
|
||||
this.container.databaseAccount()?.properties?.enableMultipleWriteLocations &&
|
||||
this.collection.conflictResolutionPolicy &&
|
||||
!!this.collection.conflictResolutionPolicy();
|
||||
|
||||
public isOfferReplacePending = (): boolean => {
|
||||
const offer = this.collection?.offer && this.collection.offer();
|
||||
return (
|
||||
offer &&
|
||||
Object.keys(offer).find(value => value === "headers") &&
|
||||
!!(offer as DataModels.OfferWithHeaders).headers[Constants.HttpHeaders.offerReplacePending]
|
||||
);
|
||||
};
|
||||
|
||||
public onSaveClick = async (): Promise<void> => {
|
||||
this.props.settingsTab.isExecutionError(false);
|
||||
|
||||
this.props.settingsTab.isExecuting(true);
|
||||
const startKey: number = traceStart(Action.UpdateSettings, {
|
||||
databaseAccountName: this.container.databaseAccount()?.name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.props.settingsTab.tabTitle()
|
||||
});
|
||||
|
||||
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
|
||||
|
||||
try {
|
||||
if (
|
||||
this.state.isSubSettingsSaveable ||
|
||||
this.state.isIndexingPolicyDirty ||
|
||||
this.state.isConflictResolutionDirty
|
||||
) {
|
||||
let defaultTtl: number;
|
||||
switch (this.state.timeToLive) {
|
||||
case TtlType.On:
|
||||
defaultTtl = Number(this.state.timeToLiveSeconds);
|
||||
break;
|
||||
case TtlType.OnNoDefault:
|
||||
defaultTtl = -1;
|
||||
break;
|
||||
case TtlType.Off:
|
||||
default:
|
||||
defaultTtl = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
newCollection.defaultTtl = defaultTtl;
|
||||
|
||||
newCollection.indexingPolicy = this.state.indexingPolicyContent;
|
||||
|
||||
newCollection.changeFeedPolicy =
|
||||
this.changeFeedPolicyVisible && this.state.changeFeedPolicy === ChangeFeedPolicyState.On
|
||||
? {
|
||||
retentionDuration: Constants.BackendDefaults.maxChangeFeedRetentionDuration
|
||||
}
|
||||
: undefined;
|
||||
|
||||
newCollection.analyticalStorageTtl = this.getAnalyticalStorageTtl();
|
||||
|
||||
newCollection.geospatialConfig = {
|
||||
type: this.state.geospatialConfigType
|
||||
};
|
||||
|
||||
const conflictResolutionChanges: DataModels.ConflictResolutionPolicy = this.getUpdatedConflictResolutionPolicy();
|
||||
if (conflictResolutionChanges) {
|
||||
newCollection.conflictResolutionPolicy = conflictResolutionChanges;
|
||||
}
|
||||
|
||||
const updatedCollection: DataModels.Collection = await updateCollection(
|
||||
this.collection.databaseId,
|
||||
this.collection.id(),
|
||||
newCollection
|
||||
);
|
||||
this.collection.rawDataModel = updatedCollection;
|
||||
this.collection.defaultTtl(updatedCollection.defaultTtl);
|
||||
this.collection.analyticalStorageTtl(updatedCollection.analyticalStorageTtl);
|
||||
this.collection.id(updatedCollection.id);
|
||||
this.collection.indexingPolicy(updatedCollection.indexingPolicy);
|
||||
this.collection.conflictResolutionPolicy(updatedCollection.conflictResolutionPolicy);
|
||||
this.collection.changeFeedPolicy(updatedCollection.changeFeedPolicy);
|
||||
this.collection.geospatialConfig(updatedCollection.geospatialConfig);
|
||||
this.setState({
|
||||
isSubSettingsSaveable: false,
|
||||
isSubSettingsDiscardable: false,
|
||||
isIndexingPolicyDirty: false,
|
||||
isConflictResolutionDirty: false
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state.isScaleSaveable) {
|
||||
const newThroughput = this.state.throughput;
|
||||
const newOffer: DataModels.Offer = { ...this.collection.offer() };
|
||||
const originalThroughputValue: number = this.state.throughput;
|
||||
|
||||
if (newOffer.content) {
|
||||
newOffer.content.offerThroughput = newThroughput;
|
||||
} else {
|
||||
newOffer.content = {
|
||||
offerThroughput: newThroughput,
|
||||
offerIsRUPerMinuteThroughputEnabled: false
|
||||
};
|
||||
}
|
||||
|
||||
const headerOptions: RequestOptions = { initialHeaders: {} };
|
||||
|
||||
if (this.state.isAutoPilotSelected) {
|
||||
newOffer.content.offerAutopilotSettings = {
|
||||
maxThroughput: this.state.autoPilotThroughput
|
||||
};
|
||||
|
||||
// user has changed from provisioned --> autoscale
|
||||
if (this.hasProvisioningTypeChanged()) {
|
||||
headerOptions.initialHeaders[Constants.HttpHeaders.migrateOfferToAutopilot] = "true";
|
||||
delete newOffer.content.offerAutopilotSettings;
|
||||
} else {
|
||||
delete newOffer.content.offerThroughput;
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
isAutoPilotSelected: false
|
||||
});
|
||||
|
||||
// user has changed from autoscale --> provisioned
|
||||
if (this.hasProvisioningTypeChanged()) {
|
||||
headerOptions.initialHeaders[Constants.HttpHeaders.migrateOfferToManualThroughput] = "true";
|
||||
} else {
|
||||
delete newOffer.content.offerAutopilotSettings;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
getMaxRUs(this.collection, this.container) <=
|
||||
SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
|
||||
newThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
|
||||
this.container
|
||||
) {
|
||||
const requestPayload = {
|
||||
subscriptionId: userContext.subscriptionId,
|
||||
databaseAccountName: userContext.databaseAccount.name,
|
||||
resourceGroup: userContext.resourceGroup,
|
||||
databaseName: this.collection.databaseId,
|
||||
collectionName: this.collection.id(),
|
||||
throughput: newThroughput,
|
||||
offerIsRUPerMinuteThroughputEnabled: false
|
||||
};
|
||||
try {
|
||||
await updateOfferThroughputBeyondLimit(requestPayload);
|
||||
this.collection.offer().content.offerThroughput = originalThroughputValue;
|
||||
this.setState({
|
||||
throughput: originalThroughputValue,
|
||||
throughputBaseline: originalThroughputValue,
|
||||
initialNotification: {
|
||||
description: `Throughput update for ${newThroughput} ${throughputUnit}`
|
||||
} as DataModels.Notification
|
||||
});
|
||||
this.setState({ isScaleSaveable: false, isScaleDiscardable: false });
|
||||
} catch (error) {
|
||||
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.props.settingsTab.tabTitle(),
|
||||
error: error
|
||||
},
|
||||
startKey
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const updatedOffer: DataModels.Offer = await updateOffer(this.collection.offer(), newOffer, headerOptions);
|
||||
this.collection.offer(updatedOffer);
|
||||
this.setState({ isScaleSaveable: false, isScaleDiscardable: false });
|
||||
if (this.state.isAutoPilotSelected) {
|
||||
this.setState({
|
||||
autoPilotThroughput: updatedOffer.content.offerAutopilotSettings.maxThroughput,
|
||||
autoPilotThroughputBaseline: updatedOffer.content.offerAutopilotSettings.maxThroughput
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
throughput: updatedOffer.content.offerThroughput,
|
||||
throughputBaseline: updatedOffer.content.offerThroughput
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.container.isRefreshingExplorer(false);
|
||||
this.setBaseline();
|
||||
this.setState({ wasAutopilotOriginallySet: this.state.isAutoPilotSelected });
|
||||
traceSuccess(
|
||||
Action.UpdateSettings,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount()?.name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.props.settingsTab.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
} catch (reason) {
|
||||
this.container.isRefreshingExplorer(false);
|
||||
this.props.settingsTab.isExecutionError(true);
|
||||
console.error(reason);
|
||||
traceFailure(
|
||||
Action.UpdateSettings,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount()?.name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.props.settingsTab.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
this.props.settingsTab.isExecuting(false);
|
||||
};
|
||||
|
||||
public onRevertClick = (): void => {
|
||||
this.setState({
|
||||
throughput: this.state.throughputBaseline,
|
||||
timeToLive: this.state.timeToLiveBaseline,
|
||||
timeToLiveSeconds: this.state.timeToLiveSecondsBaseline,
|
||||
geospatialConfigType: this.state.geospatialConfigTypeBaseline,
|
||||
indexingPolicyContent: this.state.indexingPolicyContentBaseline,
|
||||
conflictResolutionPolicyMode: this.state.conflictResolutionPolicyModeBaseline,
|
||||
conflictResolutionPolicyPath: this.state.conflictResolutionPolicyPathBaseline,
|
||||
conflictResolutionPolicyProcedure: this.state.conflictResolutionPolicyProcedureBaseline,
|
||||
analyticalStorageTtlSelection: this.state.analyticalStorageTtlSelectionBaseline,
|
||||
analyticalStorageTtlSeconds: this.state.analyticalStorageTtlSecondsBaseline,
|
||||
changeFeedPolicy: this.state.changeFeedPolicyBaseline,
|
||||
autoPilotThroughput: this.state.autoPilotThroughputBaseline,
|
||||
isAutoPilotSelected: this.state.wasAutopilotOriginallySet,
|
||||
shouldDiscardIndexingPolicy: true,
|
||||
isScaleSaveable: false,
|
||||
isScaleDiscardable: false,
|
||||
isSubSettingsSaveable: false,
|
||||
isSubSettingsDiscardable: false,
|
||||
isIndexingPolicyDirty: false,
|
||||
isConflictResolutionDirty: false
|
||||
});
|
||||
};
|
||||
|
||||
private onScaleSaveableChange = (isScaleSaveable: boolean): void =>
|
||||
this.setState({ isScaleSaveable: isScaleSaveable });
|
||||
|
||||
private onScaleDiscardableChange = (isScaleDiscardable: boolean): void =>
|
||||
this.setState({ isScaleDiscardable: isScaleDiscardable });
|
||||
|
||||
private onIndexingPolicyElementFocusChange = (indexingPolicyElementFocussed: boolean): void =>
|
||||
this.setState({ indexingPolicyElementFocussed: indexingPolicyElementFocussed });
|
||||
|
||||
private onIndexingPolicyContentChange = (newIndexingPolicy: DataModels.IndexingPolicy): void =>
|
||||
this.setState({ indexingPolicyContent: newIndexingPolicy });
|
||||
|
||||
private resetShouldDiscardIndexingPolicy = (): void => this.setState({ shouldDiscardIndexingPolicy: false });
|
||||
|
||||
private logIndexingPolicySuccessMessage = (): void => {
|
||||
if (this.props.settingsTab.onLoadStartKey) {
|
||||
traceSuccess(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
databaseName: this.collection.databaseId,
|
||||
collectionName: this.collection.id(),
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.props.settingsTab.tabTitle()
|
||||
},
|
||||
this.props.settingsTab.onLoadStartKey
|
||||
);
|
||||
this.props.settingsTab.onLoadStartKey = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
private onConflictResolutionPolicyModeChange = (
|
||||
event?: React.FormEvent<HTMLElement | HTMLInputElement>,
|
||||
option?: IChoiceGroupOption
|
||||
): void =>
|
||||
this.setState({
|
||||
conflictResolutionPolicyMode:
|
||||
DataModels.ConflictResolutionMode[option.key as keyof typeof DataModels.ConflictResolutionMode]
|
||||
});
|
||||
|
||||
private onConflictResolutionPolicyPathChange = (
|
||||
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
newValue?: string
|
||||
): void => this.setState({ conflictResolutionPolicyPath: newValue });
|
||||
|
||||
private onConflictResolutionPolicyProcedureChange = (
|
||||
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
newValue?: string
|
||||
): void => this.setState({ conflictResolutionPolicyProcedure: newValue });
|
||||
|
||||
private onConflictResolutionDirtyChange = (isConflictResolutionDirty: boolean): void =>
|
||||
this.setState({ isConflictResolutionDirty: isConflictResolutionDirty });
|
||||
|
||||
public getTtlValue = (value: string): TtlType => {
|
||||
switch (value) {
|
||||
case TtlOn:
|
||||
return TtlType.On;
|
||||
case TtlOff:
|
||||
return TtlType.Off;
|
||||
case TtlOnNoDefault:
|
||||
return TtlType.OnNoDefault;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
private onTtlChange = (ev?: React.FormEvent<HTMLElement | HTMLInputElement>, option?: IChoiceGroupOption): void =>
|
||||
this.setState({ timeToLive: this.getTtlValue(option.key) });
|
||||
|
||||
private onTimeToLiveSecondsChange = (
|
||||
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
newValue?: string
|
||||
): void => {
|
||||
let newTimeToLiveSeconds = parseInt(newValue);
|
||||
newTimeToLiveSeconds = isNaN(newTimeToLiveSeconds) ? SettingsComponent.zeroSeconds : newTimeToLiveSeconds;
|
||||
this.setState({ timeToLiveSeconds: newTimeToLiveSeconds });
|
||||
};
|
||||
|
||||
private onGeoSpatialConfigTypeChange = (
|
||||
ev?: React.FormEvent<HTMLElement | HTMLInputElement>,
|
||||
option?: IChoiceGroupOption
|
||||
): void =>
|
||||
this.setState({ geospatialConfigType: GeospatialConfigType[option.key as keyof typeof GeospatialConfigType] });
|
||||
|
||||
private onAnalyticalStorageTtlSelectionChange = (
|
||||
ev?: React.FormEvent<HTMLElement | HTMLInputElement>,
|
||||
option?: IChoiceGroupOption
|
||||
): void => this.setState({ analyticalStorageTtlSelection: this.getTtlValue(option.key) });
|
||||
|
||||
private onAnalyticalStorageTtlSecondsChange = (
|
||||
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
newValue?: string
|
||||
): void => {
|
||||
let newAnalyticalStorageTtlSeconds = parseInt(newValue);
|
||||
newAnalyticalStorageTtlSeconds = isNaN(newAnalyticalStorageTtlSeconds)
|
||||
? SettingsComponent.zeroSeconds
|
||||
: newAnalyticalStorageTtlSeconds;
|
||||
this.setState({ analyticalStorageTtlSeconds: newAnalyticalStorageTtlSeconds });
|
||||
};
|
||||
|
||||
private onChangeFeedPolicyChange = (
|
||||
ev?: React.FormEvent<HTMLElement | HTMLInputElement>,
|
||||
option?: IChoiceGroupOption
|
||||
): void =>
|
||||
this.setState({ changeFeedPolicy: ChangeFeedPolicyState[option.key as keyof typeof ChangeFeedPolicyState] });
|
||||
|
||||
private onSubSettingsSaveableChange = (isSubSettingsSaveable: boolean): void =>
|
||||
this.setState({ isSubSettingsSaveable: isSubSettingsSaveable });
|
||||
|
||||
private onSubSettingsDiscardableChange = (isSubSettingsDiscardable: boolean): void =>
|
||||
this.setState({ isSubSettingsDiscardable: isSubSettingsDiscardable });
|
||||
|
||||
private onIndexingPolicyDirtyChange = (isIndexingPolicyDirty: boolean): void =>
|
||||
this.setState({ isIndexingPolicyDirty: isIndexingPolicyDirty });
|
||||
|
||||
public getAnalyticalStorageTtl = (): number => {
|
||||
if (this.isAnalyticalStorageEnabled) {
|
||||
if (this.state.analyticalStorageTtlSelection === TtlType.On) {
|
||||
return Number(this.state.analyticalStorageTtlSeconds);
|
||||
} else {
|
||||
return Constants.AnalyticalStorageTtl.Infinite;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
public getUpdatedConflictResolutionPolicy = (): DataModels.ConflictResolutionPolicy => {
|
||||
if (
|
||||
!isDirty(this.state.conflictResolutionPolicyMode, this.state.conflictResolutionPolicyModeBaseline) &&
|
||||
!isDirty(this.state.conflictResolutionPolicyPath, this.state.conflictResolutionPolicyPathBaseline) &&
|
||||
!isDirty(this.state.conflictResolutionPolicyProcedure, this.state.conflictResolutionPolicyProcedureBaseline)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const policy: DataModels.ConflictResolutionPolicy = {
|
||||
mode: parseConflictResolutionMode(this.state.conflictResolutionPolicyMode)
|
||||
};
|
||||
|
||||
if (
|
||||
policy.mode === DataModels.ConflictResolutionMode.Custom &&
|
||||
this.state.conflictResolutionPolicyProcedure?.length > 0
|
||||
) {
|
||||
policy.conflictResolutionProcedure = Constants.HashRoutePrefixes.sprocWithIds(
|
||||
this.collection.databaseId,
|
||||
this.collection.id(),
|
||||
this.state.conflictResolutionPolicyProcedure,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (policy.mode === DataModels.ConflictResolutionMode.LastWriterWins) {
|
||||
policy.conflictResolutionPath = this.state.conflictResolutionPolicyPath;
|
||||
if (policy.conflictResolutionPath?.startsWith("/")) {
|
||||
policy.conflictResolutionPath = "/" + policy.conflictResolutionPath;
|
||||
}
|
||||
}
|
||||
|
||||
return policy;
|
||||
};
|
||||
|
||||
public setBaseline = (): void => {
|
||||
const defaultTtl = this.collection.defaultTtl();
|
||||
|
||||
let timeToLive: TtlType = this.state.timeToLive;
|
||||
let timeToLiveSeconds = this.state.timeToLiveSeconds;
|
||||
switch (defaultTtl) {
|
||||
case undefined:
|
||||
case 0:
|
||||
timeToLive = TtlType.Off;
|
||||
timeToLiveSeconds = SettingsComponent.sixMonthsInSeconds;
|
||||
break;
|
||||
case -1:
|
||||
timeToLive = TtlType.OnNoDefault;
|
||||
timeToLiveSeconds = SettingsComponent.sixMonthsInSeconds;
|
||||
break;
|
||||
default:
|
||||
timeToLive = TtlType.On;
|
||||
timeToLiveSeconds = defaultTtl;
|
||||
break;
|
||||
}
|
||||
|
||||
let analyticalStorageTtlSelection: TtlType;
|
||||
let analyticalStorageTtlSeconds: number;
|
||||
if (this.isAnalyticalStorageEnabled) {
|
||||
const analyticalStorageTtl: number = this.collection.analyticalStorageTtl();
|
||||
if (analyticalStorageTtl === Constants.AnalyticalStorageTtl.Infinite) {
|
||||
analyticalStorageTtlSelection = TtlType.OnNoDefault;
|
||||
} else {
|
||||
analyticalStorageTtlSelection = TtlType.On;
|
||||
analyticalStorageTtlSeconds = analyticalStorageTtl;
|
||||
}
|
||||
}
|
||||
|
||||
const offerThroughput = this.collection?.offer && this.collection.offer()?.content?.offerThroughput;
|
||||
const changeFeedPolicy = this.collection.rawDataModel?.changeFeedPolicy
|
||||
? ChangeFeedPolicyState.On
|
||||
: ChangeFeedPolicyState.Off;
|
||||
const indexingPolicyContent = this.collection.indexingPolicy();
|
||||
const conflictResolutionPolicy: DataModels.ConflictResolutionPolicy =
|
||||
this.collection.conflictResolutionPolicy && this.collection.conflictResolutionPolicy();
|
||||
|
||||
const conflictResolutionPolicyMode = parseConflictResolutionMode(conflictResolutionPolicy?.mode);
|
||||
const conflictResolutionPolicyPath = conflictResolutionPolicy?.conflictResolutionPath;
|
||||
const conflictResolutionPolicyProcedure = parseConflictResolutionProcedure(
|
||||
conflictResolutionPolicy?.conflictResolutionProcedure
|
||||
);
|
||||
const geospatialConfigTypeString: string =
|
||||
(this.collection.geospatialConfig && this.collection.geospatialConfig()?.type) || GeospatialConfigType.Geometry;
|
||||
const geoSpatialConfigType = GeospatialConfigType[geospatialConfigTypeString as keyof typeof GeospatialConfigType];
|
||||
|
||||
this.setState({
|
||||
throughput: offerThroughput,
|
||||
throughputBaseline: offerThroughput,
|
||||
changeFeedPolicy: changeFeedPolicy,
|
||||
changeFeedPolicyBaseline: changeFeedPolicy,
|
||||
timeToLive: timeToLive,
|
||||
timeToLiveBaseline: timeToLive,
|
||||
timeToLiveSeconds: timeToLiveSeconds,
|
||||
timeToLiveSecondsBaseline: timeToLiveSeconds,
|
||||
analyticalStorageTtlSelection: analyticalStorageTtlSelection,
|
||||
analyticalStorageTtlSelectionBaseline: analyticalStorageTtlSelection,
|
||||
analyticalStorageTtlSeconds: analyticalStorageTtlSeconds,
|
||||
analyticalStorageTtlSecondsBaseline: analyticalStorageTtlSeconds,
|
||||
indexingPolicyContent: indexingPolicyContent,
|
||||
indexingPolicyContentBaseline: indexingPolicyContent,
|
||||
conflictResolutionPolicyMode: conflictResolutionPolicyMode,
|
||||
conflictResolutionPolicyModeBaseline: conflictResolutionPolicyMode,
|
||||
conflictResolutionPolicyPath: conflictResolutionPolicyPath,
|
||||
conflictResolutionPolicyPathBaseline: conflictResolutionPolicyPath,
|
||||
conflictResolutionPolicyProcedure: conflictResolutionPolicyProcedure,
|
||||
conflictResolutionPolicyProcedureBaseline: conflictResolutionPolicyProcedure,
|
||||
geospatialConfigType: geoSpatialConfigType,
|
||||
geospatialConfigTypeBaseline: geoSpatialConfigType
|
||||
});
|
||||
};
|
||||
|
||||
private getTabsButtons = (): CommandButtonComponentProps[] => {
|
||||
const buttons: CommandButtonComponentProps[] = [];
|
||||
if (this.saveSettingsButton.isVisible()) {
|
||||
const label = "Save";
|
||||
buttons.push({
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: async () => await this.onSaveClick(),
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.saveSettingsButton.isEnabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.discardSettingsChangesButton.isVisible()) {
|
||||
const label = "Discard";
|
||||
buttons.push({
|
||||
iconSrc: DiscardIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onRevertClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.discardSettingsChangesButton.isEnabled()
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
};
|
||||
|
||||
private onMaxAutoPilotThroughputChange = (newThroughput: number): void =>
|
||||
this.setState({ autoPilotThroughput: newThroughput });
|
||||
|
||||
private onThroughputChange = (newThroughput: number): void => this.setState({ throughput: newThroughput });
|
||||
|
||||
private onAutoPilotSelected = (isAutoPilotSelected: boolean): void =>
|
||||
this.setState({ isAutoPilotSelected: isAutoPilotSelected });
|
||||
|
||||
private onPivotChange = (item: PivotItem): void => {
|
||||
const selectedTab = SettingsV2TabTypes[item.props.itemKey as keyof typeof SettingsV2TabTypes];
|
||||
this.setState({ selectedTab: selectedTab });
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const scaleComponentProps: ScaleComponentProps = {
|
||||
collection: this.collection,
|
||||
container: this.container,
|
||||
isFixedContainer: this.isFixedContainer,
|
||||
autoPilotTiersList: this.autoPilotTiersList,
|
||||
onThroughputChange: this.onThroughputChange,
|
||||
throughput: this.state.throughput,
|
||||
throughputBaseline: this.state.throughputBaseline,
|
||||
autoPilotThroughput: this.state.autoPilotThroughput,
|
||||
autoPilotThroughputBaseline: this.state.autoPilotThroughputBaseline,
|
||||
isAutoPilotSelected: this.state.isAutoPilotSelected,
|
||||
wasAutopilotOriginallySet: this.state.wasAutopilotOriginallySet,
|
||||
onAutoPilotSelected: this.onAutoPilotSelected,
|
||||
onMaxAutoPilotThroughputChange: this.onMaxAutoPilotThroughputChange,
|
||||
onScaleSaveableChange: this.onScaleSaveableChange,
|
||||
onScaleDiscardableChange: this.onScaleDiscardableChange,
|
||||
initialNotification: this.props.settingsTab.pendingNotification()
|
||||
};
|
||||
|
||||
const subSettingsComponentProps: SubSettingsComponentProps = {
|
||||
collection: this.collection,
|
||||
container: this.container,
|
||||
isAnalyticalStorageEnabled: this.isAnalyticalStorageEnabled,
|
||||
changeFeedPolicyVisible: this.changeFeedPolicyVisible,
|
||||
timeToLive: this.state.timeToLive,
|
||||
timeToLiveBaseline: this.state.timeToLiveBaseline,
|
||||
onTtlChange: this.onTtlChange,
|
||||
timeToLiveSeconds: this.state.timeToLiveSeconds,
|
||||
timeToLiveSecondsBaseline: this.state.timeToLiveSecondsBaseline,
|
||||
onTimeToLiveSecondsChange: this.onTimeToLiveSecondsChange,
|
||||
geospatialConfigType: this.state.geospatialConfigType,
|
||||
geospatialConfigTypeBaseline: this.state.geospatialConfigTypeBaseline,
|
||||
onGeoSpatialConfigTypeChange: this.onGeoSpatialConfigTypeChange,
|
||||
analyticalStorageTtlSelection: this.state.analyticalStorageTtlSelection,
|
||||
analyticalStorageTtlSelectionBaseline: this.state.analyticalStorageTtlSelectionBaseline,
|
||||
onAnalyticalStorageTtlSelectionChange: this.onAnalyticalStorageTtlSelectionChange,
|
||||
analyticalStorageTtlSeconds: this.state.analyticalStorageTtlSeconds,
|
||||
analyticalStorageTtlSecondsBaseline: this.state.analyticalStorageTtlSecondsBaseline,
|
||||
onAnalyticalStorageTtlSecondsChange: this.onAnalyticalStorageTtlSecondsChange,
|
||||
changeFeedPolicy: this.state.changeFeedPolicy,
|
||||
changeFeedPolicyBaseline: this.state.changeFeedPolicyBaseline,
|
||||
onChangeFeedPolicyChange: this.onChangeFeedPolicyChange,
|
||||
onSubSettingsSaveableChange: this.onSubSettingsSaveableChange,
|
||||
onSubSettingsDiscardableChange: this.onSubSettingsDiscardableChange
|
||||
};
|
||||
|
||||
const indexingPolicyComponentProps: IndexingPolicyComponentProps = {
|
||||
shouldDiscardIndexingPolicy: this.state.shouldDiscardIndexingPolicy,
|
||||
resetShouldDiscardIndexingPolicy: this.resetShouldDiscardIndexingPolicy,
|
||||
indexingPolicyContent: this.state.indexingPolicyContent,
|
||||
indexingPolicyContentBaseline: this.state.indexingPolicyContentBaseline,
|
||||
onIndexingPolicyElementFocusChange: this.onIndexingPolicyElementFocusChange,
|
||||
onIndexingPolicyContentChange: this.onIndexingPolicyContentChange,
|
||||
logIndexingPolicySuccessMessage: this.logIndexingPolicySuccessMessage,
|
||||
onIndexingPolicyDirtyChange: this.onIndexingPolicyDirtyChange
|
||||
};
|
||||
|
||||
const conflictResolutionPolicyComponentProps: ConflictResolutionComponentProps = {
|
||||
collection: this.collection,
|
||||
container: this.container,
|
||||
conflictResolutionPolicyMode: this.state.conflictResolutionPolicyMode,
|
||||
conflictResolutionPolicyModeBaseline: this.state.conflictResolutionPolicyModeBaseline,
|
||||
onConflictResolutionPolicyModeChange: this.onConflictResolutionPolicyModeChange,
|
||||
conflictResolutionPolicyPath: this.state.conflictResolutionPolicyPath,
|
||||
conflictResolutionPolicyPathBaseline: this.state.conflictResolutionPolicyPathBaseline,
|
||||
onConflictResolutionPolicyPathChange: this.onConflictResolutionPolicyPathChange,
|
||||
conflictResolutionPolicyProcedure: this.state.conflictResolutionPolicyProcedure,
|
||||
conflictResolutionPolicyProcedureBaseline: this.state.conflictResolutionPolicyProcedureBaseline,
|
||||
onConflictResolutionPolicyProcedureChange: this.onConflictResolutionPolicyProcedureChange,
|
||||
onConflictResolutionDirtyChange: this.onConflictResolutionDirtyChange
|
||||
};
|
||||
|
||||
const tabs: SettingsV2TabInfo[] = [];
|
||||
if (!hasDatabaseSharedThroughput(this.collection)) {
|
||||
tabs.push({
|
||||
tab: SettingsV2TabTypes.ScaleTab,
|
||||
content: <ScaleComponent {...scaleComponentProps} />
|
||||
});
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
tab: SettingsV2TabTypes.SubSettingsTab,
|
||||
content: <SubSettingsComponent {...subSettingsComponentProps} />
|
||||
});
|
||||
|
||||
if (this.shouldShowIndexingPolicyEditor) {
|
||||
tabs.push({
|
||||
tab: SettingsV2TabTypes.IndexingPolicyTab,
|
||||
content: <IndexingPolicyComponent {...indexingPolicyComponentProps} />
|
||||
});
|
||||
}
|
||||
|
||||
if (this.hasConflictResolution()) {
|
||||
tabs.push({
|
||||
tab: SettingsV2TabTypes.ConflictResolutionTab,
|
||||
content: <ConflictResolutionComponent {...conflictResolutionPolicyComponentProps} />
|
||||
});
|
||||
}
|
||||
|
||||
const pivotProps: IPivotProps = {
|
||||
onLinkClick: this.onPivotChange,
|
||||
selectedKey: SettingsV2TabTypes[this.state.selectedTab]
|
||||
};
|
||||
|
||||
const pivotItems = tabs.map(tab => {
|
||||
const pivotItemProps: IPivotItemProps = {
|
||||
itemKey: SettingsV2TabTypes[tab.tab],
|
||||
style: { marginTop: 20 },
|
||||
headerText: getTabTitle(tab.tab)
|
||||
};
|
||||
|
||||
return (
|
||||
<PivotItem key={pivotItemProps.itemKey} {...pivotItemProps}>
|
||||
{tab.content}
|
||||
</PivotItem>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="settingsV2MainContainer">
|
||||
{this.shouldShowKeyspaceSharedThroughputMessage() && (
|
||||
<div>This table shared throughput is configured at the keyspace</div>
|
||||
)}
|
||||
|
||||
<div className="settingsV2TabsContainer">
|
||||
<Pivot {...pivotProps}>{pivotItems}</Pivot>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
20
src/Explorer/Controls/Settings/SettingsComponentAdapter.tsx
Normal file
20
src/Explorer/Controls/Settings/SettingsComponentAdapter.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import ko from "knockout";
|
||||
import * as React from "react";
|
||||
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
|
||||
import { SettingsComponent, SettingsComponentProps } from "./SettingsComponent";
|
||||
|
||||
export class SettingsComponentAdapter implements ReactAdapter {
|
||||
public parameters: ko.Observable<number>;
|
||||
|
||||
constructor(private props: SettingsComponentProps) {
|
||||
this.parameters = ko.observable<number>(Date.now());
|
||||
}
|
||||
|
||||
public renderComponent(): JSX.Element {
|
||||
return <SettingsComponent {...this.props} />;
|
||||
}
|
||||
|
||||
public triggerRender(): void {
|
||||
window.requestAnimationFrame(() => this.parameters(Date.now()));
|
||||
}
|
||||
}
|
||||
58
src/Explorer/Controls/Settings/SettingsRenderUtils.test.tsx
Normal file
58
src/Explorer/Controls/Settings/SettingsRenderUtils.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import {
|
||||
getAutoPilotV3SpendElement,
|
||||
getEstimatedSpendElement,
|
||||
getEstimatedAutoscaleSpendElement,
|
||||
manualToAutoscaleDisclaimerElement,
|
||||
ttlWarning,
|
||||
indexingPolicyTTLWarningMessage,
|
||||
updateThroughputBeyondLimitWarningMessage,
|
||||
updateThroughputDelayedApplyWarningMessage,
|
||||
getThroughputApplyDelayedMessage,
|
||||
getThroughputApplyShortDelayMessage,
|
||||
getThroughputApplyLongDelayMessage,
|
||||
getToolTipContainer,
|
||||
conflictResolutionCustomToolTip,
|
||||
changeFeedPolicyToolTip,
|
||||
conflictResolutionLwwTooltip
|
||||
} from "./SettingsRenderUtils";
|
||||
|
||||
class SettingsRenderUtilsTestComponent extends React.Component {
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{getAutoPilotV3SpendElement(1000, false)}
|
||||
{getAutoPilotV3SpendElement(undefined, false)}
|
||||
{getAutoPilotV3SpendElement(1000, true)}
|
||||
{getAutoPilotV3SpendElement(undefined, true)}
|
||||
|
||||
{getEstimatedSpendElement(1000, "mooncake", 2, false, true)}
|
||||
|
||||
{getEstimatedAutoscaleSpendElement(1000, "mooncake", 2, false)}
|
||||
|
||||
{manualToAutoscaleDisclaimerElement}
|
||||
{ttlWarning}
|
||||
{indexingPolicyTTLWarningMessage}
|
||||
{updateThroughputBeyondLimitWarningMessage}
|
||||
{updateThroughputDelayedApplyWarningMessage}
|
||||
|
||||
{getThroughputApplyDelayedMessage(false, 1000, "RU/s", "sampleDb", "sampleCollection", 2000)}
|
||||
{getThroughputApplyShortDelayMessage(false, 1000, "RU/s", "sampleDb", "sampleCollection", 2000)}
|
||||
{getThroughputApplyLongDelayMessage(false, 1000, "RU/s", "sampleDb", "sampleCollection", 2000)}
|
||||
|
||||
{getToolTipContainer(<span>Sample Text</span>)}
|
||||
{conflictResolutionLwwTooltip}
|
||||
{conflictResolutionCustomToolTip}
|
||||
{changeFeedPolicyToolTip}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe("SettingsUtils functions", () => {
|
||||
it("render", () => {
|
||||
const wrapper = shallow(<SettingsRenderUtilsTestComponent />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
349
src/Explorer/Controls/Settings/SettingsRenderUtils.tsx
Normal file
349
src/Explorer/Controls/Settings/SettingsRenderUtils.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import * as React from "react";
|
||||
import * as AutoPilotUtils from "../../../Utils/AutoPilotUtils";
|
||||
import { AutopilotDocumentation, hoursInAMonth } from "../../../Shared/Constants";
|
||||
import { Urls, StyleConstants } from "../../../Common/Constants";
|
||||
import {
|
||||
computeAutoscaleUsagePriceHourly,
|
||||
getPriceCurrency,
|
||||
getCurrencySign,
|
||||
getAutoscalePricePerRu,
|
||||
getMultimasterMultiplier,
|
||||
computeRUUsagePriceHourly,
|
||||
getPricePerRu,
|
||||
calculateEstimateNumber
|
||||
} from "../../../Utils/PricingUtils";
|
||||
import {
|
||||
ITextFieldStyles,
|
||||
ICheckboxStyles,
|
||||
IStackProps,
|
||||
IStackTokens,
|
||||
IChoiceGroupStyles,
|
||||
Link,
|
||||
Text,
|
||||
IMessageBarStyles,
|
||||
ITextStyles
|
||||
} from "office-ui-fabric-react";
|
||||
import { isDirtyTypes, isDirty } from "./SettingsUtils";
|
||||
|
||||
const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 12 } };
|
||||
|
||||
export const spendAckCheckBoxStyle: ICheckboxStyles = {
|
||||
label: {
|
||||
margin: 0,
|
||||
padding: "2 0 2 0"
|
||||
},
|
||||
text: {
|
||||
fontSize: 12
|
||||
}
|
||||
};
|
||||
|
||||
export const subComponentStackProps: Partial<IStackProps> = {
|
||||
tokens: { childrenGap: 20 }
|
||||
};
|
||||
|
||||
export const titleAndInputStackProps: Partial<IStackProps> = {
|
||||
tokens: { childrenGap: 5 }
|
||||
};
|
||||
|
||||
export const checkBoxAndInputStackProps: Partial<IStackProps> = {
|
||||
tokens: { childrenGap: 10 }
|
||||
};
|
||||
|
||||
export const toolTipLabelStackTokens: IStackTokens = {
|
||||
childrenGap: 6
|
||||
};
|
||||
|
||||
export const messageBarStyles: Partial<IMessageBarStyles> = { root: { marginTop: "5px" } };
|
||||
|
||||
export const throughputUnit = "RU/s";
|
||||
|
||||
export const getAutoPilotV3SpendElement = (
|
||||
maxAutoPilotThroughputSet: number,
|
||||
isDatabaseThroughput: boolean,
|
||||
requestUnitsUsageCostElement?: JSX.Element
|
||||
): JSX.Element => {
|
||||
if (!maxAutoPilotThroughputSet) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const resource: string = isDatabaseThroughput ? "database" : "container";
|
||||
return (
|
||||
<>
|
||||
<Text>
|
||||
Your {resource} throughput will automatically scale from{" "}
|
||||
<b>
|
||||
{AutoPilotUtils.getMinRUsBasedOnUserInput(maxAutoPilotThroughputSet)} RU/s (10% of max RU/s) -{" "}
|
||||
{maxAutoPilotThroughputSet} RU/s
|
||||
</b>{" "}
|
||||
based on usage.
|
||||
<br />
|
||||
</Text>
|
||||
{requestUnitsUsageCostElement}
|
||||
<Text>
|
||||
After the first {AutoPilotUtils.getStorageBasedOnUserInput(maxAutoPilotThroughputSet)} GB of data stored, the
|
||||
max RU/s will be automatically upgraded based on the new storage value.
|
||||
<Link href={AutopilotDocumentation.Url} target="_blank">
|
||||
{" "}
|
||||
Learn more
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const getEstimatedAutoscaleSpendElement = (
|
||||
throughput: number,
|
||||
serverId: string,
|
||||
regions: number,
|
||||
multimaster: boolean
|
||||
): JSX.Element => {
|
||||
const hourlyPrice: number = computeAutoscaleUsagePriceHourly(serverId, throughput, regions, multimaster);
|
||||
const monthlyPrice: number = hourlyPrice * hoursInAMonth;
|
||||
const currency: string = getPriceCurrency(serverId);
|
||||
const currencySign: string = getCurrencySign(serverId);
|
||||
const pricePerRu =
|
||||
getAutoscalePricePerRu(serverId, getMultimasterMultiplier(regions, multimaster)) *
|
||||
getMultimasterMultiplier(regions, multimaster);
|
||||
|
||||
return (
|
||||
<Text id="autoscaleSpendElement">
|
||||
Estimated monthly cost ({currency}) is{" "}
|
||||
<b>
|
||||
{currencySign}
|
||||
{calculateEstimateNumber(monthlyPrice / 10)}
|
||||
{` - `}
|
||||
{currencySign}
|
||||
{calculateEstimateNumber(monthlyPrice)}{" "}
|
||||
</b>
|
||||
({"regions: "} {regions}, {throughput / 10} - {throughput} RU/s, {currencySign}
|
||||
{pricePerRu}/RU)
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export const getEstimatedSpendElement = (
|
||||
throughput: number,
|
||||
serverId: string,
|
||||
regions: number,
|
||||
multimaster: boolean,
|
||||
rupmEnabled: boolean
|
||||
): JSX.Element => {
|
||||
const hourlyPrice: number = computeRUUsagePriceHourly(serverId, rupmEnabled, throughput, regions, multimaster);
|
||||
const dailyPrice: number = hourlyPrice * 24;
|
||||
const monthlyPrice: number = hourlyPrice * hoursInAMonth;
|
||||
const currency: string = getPriceCurrency(serverId);
|
||||
const currencySign: string = getCurrencySign(serverId);
|
||||
const pricePerRu = getPricePerRu(serverId) * getMultimasterMultiplier(regions, multimaster);
|
||||
|
||||
return (
|
||||
<Text id="throughputSpendElement">
|
||||
Estimated cost ({currency}):{" "}
|
||||
<b>
|
||||
{currencySign}
|
||||
{calculateEstimateNumber(hourlyPrice)} hourly {` / `}
|
||||
{currencySign}
|
||||
{calculateEstimateNumber(dailyPrice)} daily {` / `}
|
||||
{currencySign}
|
||||
{calculateEstimateNumber(monthlyPrice)} monthly{" "}
|
||||
</b>
|
||||
({"regions: "} {regions}, {throughput}RU/s, {currencySign}
|
||||
{pricePerRu}/RU)
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export const manualToAutoscaleDisclaimerElement: JSX.Element = (
|
||||
<Text styles={infoAndToolTipTextStyle} id="manualToAutoscaleDisclaimerElement">
|
||||
The starting autoscale max RU/s will be determined by the system, based on the current manual throughput settings
|
||||
and storage of your resource. After autoscale has been enabled, you can change the max RU/s.{" "}
|
||||
<a href={Urls.autoscaleMigration}>Learn more</a>
|
||||
</Text>
|
||||
);
|
||||
|
||||
export const ttlWarning: JSX.Element = (
|
||||
<Text styles={infoAndToolTipTextStyle}>
|
||||
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. For more information see,{" "}
|
||||
<Link target="_blank" href="https://aka.ms/cosmos-db-ttl">
|
||||
Time to Live (TTL) in Azure Cosmos DB
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
);
|
||||
|
||||
export const indexingPolicyTTLWarningMessage: JSX.Element = (
|
||||
<Text styles={infoAndToolTipTextStyle}>
|
||||
Changing the Indexing Policy impacts query results while the index transformation occurs. When a change is made and
|
||||
the indexing mode is set to consistent or lazy, queries return eventual results until the operation completes. For
|
||||
more information see,{" "}
|
||||
<Link target="_blank" href="https://aka.ms/cosmosdb/modify-index-policy">
|
||||
Modifying Indexing Policies
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
);
|
||||
|
||||
export const updateThroughputBeyondLimitWarningMessage: JSX.Element = (
|
||||
<Text styles={infoAndToolTipTextStyle} id="updateThroughputBeyondLimitWarningMessage">
|
||||
You are about to request an increase in throughput beyond the pre-allocated capacity. The service will scale out and
|
||||
increase throughput for the selected container. This operation will take 1-3 business days to complete. You can
|
||||
track the status of this request in Notifications.
|
||||
</Text>
|
||||
);
|
||||
|
||||
export const updateThroughputDelayedApplyWarningMessage: JSX.Element = (
|
||||
<Text styles={infoAndToolTipTextStyle} id="updateThroughputDelayedApplyWarningMessage">
|
||||
You are about to request an increase in throughput beyond the pre-allocated capacity. This operation will take some
|
||||
time to complete.
|
||||
</Text>
|
||||
);
|
||||
|
||||
const getCurrentThroughput = (
|
||||
isAutoscale: boolean,
|
||||
throughput: number,
|
||||
throughputUnit: string,
|
||||
targetThroughput?: number
|
||||
): string => {
|
||||
if (targetThroughput) {
|
||||
if (throughput) {
|
||||
return isAutoscale
|
||||
? `, Current autoscale throughput: ${Math.round(
|
||||
throughput / 10
|
||||
)} - ${throughput} ${throughputUnit}, Target autoscale throughput: ${Math.round(
|
||||
targetThroughput / 10
|
||||
)} - ${targetThroughput} ${throughputUnit}`
|
||||
: `, Current manual throughput: ${throughput} ${throughputUnit}, Target manual throughput: ${targetThroughput}`;
|
||||
} else {
|
||||
return isAutoscale
|
||||
? `, Target autoscale throughput: ${Math.round(targetThroughput / 10)} - ${targetThroughput} ${throughputUnit}`
|
||||
: `, Target manual throughput: ${targetThroughput} ${throughputUnit}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetThroughput && throughput) {
|
||||
return isAutoscale
|
||||
? `, Current autoscale throughput: ${Math.round(throughput / 10)} - ${throughput} ${throughputUnit}`
|
||||
: `, Current manual throughput: ${throughput} ${throughputUnit}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
export const getThroughputApplyDelayedMessage = (
|
||||
isAutoscale: boolean,
|
||||
throughput: number,
|
||||
throughputUnit: string,
|
||||
databaseName: string,
|
||||
collectionName: string,
|
||||
requestedThroughput: number
|
||||
): JSX.Element => (
|
||||
<Text styles={infoAndToolTipTextStyle}>
|
||||
The request to increase the throughput has successfully been submitted. This operation will take 1-3 business days
|
||||
to complete. View the latest status in Notifications.
|
||||
<br />
|
||||
Database: {databaseName}, Container: {collectionName}{" "}
|
||||
{getCurrentThroughput(isAutoscale, throughput, throughputUnit, requestedThroughput)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
export const getThroughputApplyShortDelayMessage = (
|
||||
isAutoscale: boolean,
|
||||
throughput: number,
|
||||
throughputUnit: string,
|
||||
databaseName: string,
|
||||
collectionName: string,
|
||||
targetThroughput: number
|
||||
): JSX.Element => (
|
||||
<Text styles={infoAndToolTipTextStyle} id="throughputApplyShortDelayMessage">
|
||||
A request to increase the throughput is currently in progress. This operation will take some time to complete.
|
||||
<br />
|
||||
Database: {databaseName}, Container: {collectionName}{" "}
|
||||
{getCurrentThroughput(isAutoscale, throughput, throughputUnit, targetThroughput)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
export const getThroughputApplyLongDelayMessage = (
|
||||
isAutoscale: boolean,
|
||||
throughput: number,
|
||||
throughputUnit: string,
|
||||
databaseName: string,
|
||||
collectionName: string,
|
||||
requestedThroughput: number
|
||||
): JSX.Element => (
|
||||
<Text styles={infoAndToolTipTextStyle} id="throughputApplyLongDelayMessage">
|
||||
A request to increase the throughput is currently in progress. This operation will take 1-3 business days to
|
||||
complete. View the latest status in Notifications.
|
||||
<br />
|
||||
Database: {databaseName}, Container: {collectionName}{" "}
|
||||
{getCurrentThroughput(isAutoscale, throughput, throughputUnit, requestedThroughput)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
export const getToolTipContainer = (content: string | JSX.Element): JSX.Element =>
|
||||
content ? <Text styles={infoAndToolTipTextStyle}>{content}</Text> : undefined;
|
||||
|
||||
export const conflictResolutionLwwTooltip: JSX.Element = (
|
||||
<Text styles={infoAndToolTipTextStyle}>
|
||||
Gets or sets the name of a integer property in your documents which is used for the Last Write Wins (LWW) based
|
||||
conflict resolution scheme. By default, the system uses the system defined timestamp property, _ts to decide the
|
||||
winner for the conflicting versions of the document. Specify your own integer property if you want to override the
|
||||
default timestamp based conflict resolution.
|
||||
</Text>
|
||||
);
|
||||
|
||||
export const conflictResolutionCustomToolTip: JSX.Element = (
|
||||
<Text styles={infoAndToolTipTextStyle}>
|
||||
Gets or sets the name of a stored procedure (aka merge procedure) for resolving the conflicts. You can write
|
||||
application defined logic to determine the winner of the conflicting versions of a document. The stored procedure
|
||||
will get executed transactionally, exactly once, on the server side. If you do not provide a stored procedure, the
|
||||
conflicts will be populated in the
|
||||
<Link className="linkDarkBackground" href="https://aka.ms/dataexplorerconflics" target="_blank">
|
||||
{` conflicts feed`}
|
||||
</Link>
|
||||
. You can update/re-register the stored procedure at any time.
|
||||
</Text>
|
||||
);
|
||||
|
||||
export const changeFeedPolicyToolTip: JSX.Element = (
|
||||
<Text styles={infoAndToolTipTextStyle}>
|
||||
Enable change feed log retention policy to retain last 10 minutes of history for items in the container by default.
|
||||
To support this, the request unit (RU) charge for this container will be multiplied by a factor of two for writes.
|
||||
Reads are unaffected.
|
||||
</Text>
|
||||
);
|
||||
|
||||
export const getTextFieldStyles = (current: isDirtyTypes, baseline: isDirtyTypes): Partial<ITextFieldStyles> => ({
|
||||
fieldGroup: {
|
||||
height: 25,
|
||||
width: 300,
|
||||
borderColor: isDirty(current, baseline) ? StyleConstants.Dirty : "",
|
||||
selectors: {
|
||||
":disabled": {
|
||||
backgroundColor: StyleConstants.BaseMedium,
|
||||
borderColor: StyleConstants.BaseMediumHigh
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const getChoiceGroupStyles = (current: isDirtyTypes, baseline: isDirtyTypes): Partial<IChoiceGroupStyles> => ({
|
||||
flexContainer: [
|
||||
{
|
||||
selectors: {
|
||||
".ms-ChoiceField-field.is-checked::before": {
|
||||
borderColor: isDirty(current, baseline) ? StyleConstants.Dirty : ""
|
||||
},
|
||||
".ms-ChoiceField-field.is-checked::after": {
|
||||
borderColor: isDirty(current, baseline) ? StyleConstants.Dirty : ""
|
||||
},
|
||||
".ms-ChoiceField-wrapper label": {
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: 14,
|
||||
fontFamily: StyleConstants.DataExplorerFont,
|
||||
padding: "2px 5px"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import { ConflictResolutionComponentProps, ConflictResolutionComponent } from "./ConflictResolutionComponent";
|
||||
import { container, collection } from "../TestUtils";
|
||||
import * as DataModels from "../../../../Contracts/DataModels";
|
||||
|
||||
describe("ConflictResolutionComponent", () => {
|
||||
const baseProps: ConflictResolutionComponentProps = {
|
||||
collection: collection,
|
||||
container: container,
|
||||
conflictResolutionPolicyMode: DataModels.ConflictResolutionMode.Custom,
|
||||
conflictResolutionPolicyModeBaseline: DataModels.ConflictResolutionMode.Custom,
|
||||
onConflictResolutionPolicyModeChange: () => {
|
||||
return;
|
||||
},
|
||||
conflictResolutionPolicyPath: "",
|
||||
conflictResolutionPolicyPathBaseline: "",
|
||||
onConflictResolutionPolicyPathChange: () => {
|
||||
return;
|
||||
},
|
||||
conflictResolutionPolicyProcedure: "",
|
||||
conflictResolutionPolicyProcedureBaseline: "",
|
||||
onConflictResolutionPolicyProcedureChange: () => {
|
||||
return;
|
||||
},
|
||||
onConflictResolutionDirtyChange: () => {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
it("Sproc text field displayed", () => {
|
||||
const wrapper = shallow(<ConflictResolutionComponent {...baseProps} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists("#conflictResolutionCustomTextField")).toEqual(true);
|
||||
expect(wrapper.exists("#conflictResolutionLwwTextField")).toEqual(false);
|
||||
});
|
||||
|
||||
it("Path text field displayed", () => {
|
||||
const props = { ...baseProps, conflictResolutionPolicyMode: DataModels.ConflictResolutionMode.LastWriterWins };
|
||||
const wrapper = shallow(<ConflictResolutionComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists("#conflictResolutionCustomTextField")).toEqual(false);
|
||||
expect(wrapper.exists("#conflictResolutionLwwTextField")).toEqual(true);
|
||||
});
|
||||
|
||||
it("conflict resolution policy dirty is set", () => {
|
||||
let conflictRsolutionComponent = new ConflictResolutionComponent(baseProps);
|
||||
expect(conflictRsolutionComponent.IsComponentDirty()).toEqual(false);
|
||||
|
||||
const newProps = { ...baseProps, conflictResolutionPolicyMode: DataModels.ConflictResolutionMode.LastWriterWins };
|
||||
conflictRsolutionComponent = new ConflictResolutionComponent(newProps);
|
||||
expect(conflictRsolutionComponent.IsComponentDirty()).toEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import * as React from "react";
|
||||
import * as ViewModels from "../../../../Contracts/ViewModels";
|
||||
import * as DataModels from "../../../../Contracts/DataModels";
|
||||
import Explorer from "../../../Explorer";
|
||||
import {
|
||||
getTextFieldStyles,
|
||||
conflictResolutionLwwTooltip,
|
||||
conflictResolutionCustomToolTip,
|
||||
subComponentStackProps,
|
||||
getChoiceGroupStyles
|
||||
} from "../SettingsRenderUtils";
|
||||
import { TextField, ITextFieldProps, Stack, IChoiceGroupOption, ChoiceGroup } from "office-ui-fabric-react";
|
||||
import { ToolTipLabelComponent } from "./ToolTipLabelComponent";
|
||||
import { isDirty } from "../SettingsUtils";
|
||||
|
||||
export interface ConflictResolutionComponentProps {
|
||||
collection: ViewModels.Collection;
|
||||
container: Explorer;
|
||||
conflictResolutionPolicyMode: DataModels.ConflictResolutionMode;
|
||||
conflictResolutionPolicyModeBaseline: DataModels.ConflictResolutionMode;
|
||||
onConflictResolutionPolicyModeChange: (
|
||||
event?: React.FormEvent<HTMLElement | HTMLInputElement>,
|
||||
option?: IChoiceGroupOption
|
||||
) => void;
|
||||
conflictResolutionPolicyPath: string;
|
||||
conflictResolutionPolicyPathBaseline: string;
|
||||
|
||||
onConflictResolutionPolicyPathChange: (
|
||||
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
newValue?: string
|
||||
) => void;
|
||||
conflictResolutionPolicyProcedure: string;
|
||||
conflictResolutionPolicyProcedureBaseline: string;
|
||||
|
||||
onConflictResolutionPolicyProcedureChange: (
|
||||
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
newValue?: string
|
||||
) => void;
|
||||
onConflictResolutionDirtyChange: (isConflictResolutionDirty: boolean) => void;
|
||||
}
|
||||
|
||||
export class ConflictResolutionComponent extends React.Component<ConflictResolutionComponentProps> {
|
||||
private shouldCheckComponentIsDirty = true;
|
||||
private conflictResolutionChoiceGroupOptions: IChoiceGroupOption[] = [
|
||||
{
|
||||
key: DataModels.ConflictResolutionMode.LastWriterWins,
|
||||
text: "Last Write Wins (default)"
|
||||
},
|
||||
{ key: DataModels.ConflictResolutionMode.Custom, text: "Merge Procedure (custom)" }
|
||||
];
|
||||
|
||||
componentDidMount(): void {
|
||||
this.onComponentUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate(): void {
|
||||
this.onComponentUpdate();
|
||||
}
|
||||
|
||||
private onComponentUpdate = (): void => {
|
||||
if (!this.shouldCheckComponentIsDirty) {
|
||||
this.shouldCheckComponentIsDirty = true;
|
||||
return;
|
||||
}
|
||||
this.props.onConflictResolutionDirtyChange(this.IsComponentDirty());
|
||||
this.shouldCheckComponentIsDirty = false;
|
||||
};
|
||||
|
||||
public IsComponentDirty = (): boolean => {
|
||||
if (
|
||||
isDirty(this.props.conflictResolutionPolicyMode, this.props.conflictResolutionPolicyModeBaseline) ||
|
||||
isDirty(this.props.conflictResolutionPolicyPath, this.props.conflictResolutionPolicyPathBaseline) ||
|
||||
isDirty(this.props.conflictResolutionPolicyProcedure, this.props.conflictResolutionPolicyProcedureBaseline)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
private getConflictResolutionModeComponent = (): JSX.Element => (
|
||||
<ChoiceGroup
|
||||
label="Mode"
|
||||
selectedKey={this.props.conflictResolutionPolicyMode}
|
||||
options={this.conflictResolutionChoiceGroupOptions}
|
||||
onChange={this.props.onConflictResolutionPolicyModeChange}
|
||||
styles={getChoiceGroupStyles(
|
||||
this.props.conflictResolutionPolicyMode,
|
||||
this.props.conflictResolutionPolicyModeBaseline
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
private onRenderLwwComponentTextField = (props: ITextFieldProps) => (
|
||||
<ToolTipLabelComponent label={props.label} toolTipElement={conflictResolutionLwwTooltip} />
|
||||
);
|
||||
|
||||
private getConflictResolutionLWWComponent = (): JSX.Element => (
|
||||
<TextField
|
||||
id="conflictResolutionLwwTextField"
|
||||
label={"Conflict Resolver Property"}
|
||||
onRenderLabel={this.onRenderLwwComponentTextField}
|
||||
styles={getTextFieldStyles(
|
||||
this.props.conflictResolutionPolicyPath,
|
||||
this.props.conflictResolutionPolicyPathBaseline
|
||||
)}
|
||||
value={this.props.conflictResolutionPolicyPath}
|
||||
onChange={this.props.onConflictResolutionPolicyPathChange}
|
||||
/>
|
||||
);
|
||||
|
||||
private onRenderCustomComponentTextField = (props: ITextFieldProps) => (
|
||||
<ToolTipLabelComponent label={props.label} toolTipElement={conflictResolutionCustomToolTip} />
|
||||
);
|
||||
|
||||
private getConflictResolutionCustomComponent = (): JSX.Element => (
|
||||
<TextField
|
||||
id="conflictResolutionCustomTextField"
|
||||
label="Stored procedure"
|
||||
onRenderLabel={this.onRenderCustomComponentTextField}
|
||||
styles={getTextFieldStyles(
|
||||
this.props.conflictResolutionPolicyProcedure,
|
||||
this.props.conflictResolutionPolicyProcedureBaseline
|
||||
)}
|
||||
value={this.props.conflictResolutionPolicyProcedure}
|
||||
onChange={this.props.onConflictResolutionPolicyProcedureChange}
|
||||
/>
|
||||
);
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<Stack {...subComponentStackProps}>
|
||||
{this.getConflictResolutionModeComponent()}
|
||||
|
||||
{this.props.conflictResolutionPolicyMode === DataModels.ConflictResolutionMode.LastWriterWins &&
|
||||
this.getConflictResolutionLWWComponent()}
|
||||
|
||||
{this.props.conflictResolutionPolicyMode === DataModels.ConflictResolutionMode.Custom &&
|
||||
this.getConflictResolutionCustomComponent()}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import { IndexingPolicyComponent, IndexingPolicyComponentProps } from "./IndexingPolicyComponent";
|
||||
import * as DataModels from "../../../../Contracts/DataModels";
|
||||
|
||||
describe("IndexingPolicyComponent", () => {
|
||||
const initialIndexingPolicyContent: DataModels.IndexingPolicy = {
|
||||
automatic: false,
|
||||
indexingMode: "",
|
||||
includedPaths: [],
|
||||
excludedPaths: []
|
||||
};
|
||||
const baseProps: IndexingPolicyComponentProps = {
|
||||
shouldDiscardIndexingPolicy: false,
|
||||
resetShouldDiscardIndexingPolicy: () => {
|
||||
return;
|
||||
},
|
||||
indexingPolicyContent: initialIndexingPolicyContent,
|
||||
indexingPolicyContentBaseline: initialIndexingPolicyContent,
|
||||
onIndexingPolicyElementFocusChange: () => {
|
||||
return;
|
||||
},
|
||||
onIndexingPolicyContentChange: () => {
|
||||
return;
|
||||
},
|
||||
logIndexingPolicySuccessMessage: () => {
|
||||
return;
|
||||
},
|
||||
onIndexingPolicyDirtyChange: () => {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
it("renders", () => {
|
||||
const wrapper = shallow(<IndexingPolicyComponent {...baseProps} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("indexing policy is reset", () => {
|
||||
const wrapper = shallow(<IndexingPolicyComponent {...baseProps} />);
|
||||
|
||||
const indexingPolicyComponentInstance = wrapper.instance() as IndexingPolicyComponent;
|
||||
const resetIndexingPolicyEditorMockFn = jest.fn();
|
||||
indexingPolicyComponentInstance.resetIndexingPolicyEditor = resetIndexingPolicyEditorMockFn;
|
||||
|
||||
wrapper.setProps({ shouldDiscardIndexingPolicy: true });
|
||||
wrapper.update();
|
||||
expect(resetIndexingPolicyEditorMockFn.mock.calls.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("conflict resolution policy dirty is set", () => {
|
||||
let indexingPolicyComponent = new IndexingPolicyComponent(baseProps);
|
||||
expect(indexingPolicyComponent.IsComponentDirty()).toEqual(false);
|
||||
|
||||
const newProps = { ...baseProps, indexingPolicyContent: undefined as DataModels.IndexingPolicy };
|
||||
indexingPolicyComponent = new IndexingPolicyComponent(newProps);
|
||||
expect(indexingPolicyComponent.IsComponentDirty()).toEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import * as React from "react";
|
||||
import * as DataModels from "../../../../Contracts/DataModels";
|
||||
import * as monaco from "monaco-editor";
|
||||
import { isDirty } from "../SettingsUtils";
|
||||
import { MessageBar, MessageBarType, Stack } from "office-ui-fabric-react";
|
||||
import { indexingPolicyTTLWarningMessage, titleAndInputStackProps } from "../SettingsRenderUtils";
|
||||
|
||||
export interface IndexingPolicyComponentProps {
|
||||
shouldDiscardIndexingPolicy: boolean;
|
||||
resetShouldDiscardIndexingPolicy: () => void;
|
||||
indexingPolicyContent: DataModels.IndexingPolicy;
|
||||
indexingPolicyContentBaseline: DataModels.IndexingPolicy;
|
||||
onIndexingPolicyElementFocusChange: (indexingPolicyContentFocussed: boolean) => void;
|
||||
onIndexingPolicyContentChange: (newIndexingPolicy: DataModels.IndexingPolicy) => void;
|
||||
logIndexingPolicySuccessMessage: () => void;
|
||||
onIndexingPolicyDirtyChange: (isIndexingPolicyDirty: boolean) => void;
|
||||
}
|
||||
|
||||
interface IndexingPolicyComponentState {
|
||||
indexingPolicyContentIsValid: boolean;
|
||||
}
|
||||
|
||||
export class IndexingPolicyComponent extends React.Component<
|
||||
IndexingPolicyComponentProps,
|
||||
IndexingPolicyComponentState
|
||||
> {
|
||||
private shouldCheckComponentIsDirty = true;
|
||||
private indexingPolicyDiv = React.createRef<HTMLDivElement>();
|
||||
private indexingPolicyEditor: monaco.editor.IStandaloneCodeEditor;
|
||||
|
||||
constructor(props: IndexingPolicyComponentProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
indexingPolicyContentIsValid: true
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(): void {
|
||||
if (this.props.shouldDiscardIndexingPolicy) {
|
||||
this.resetIndexingPolicyEditor();
|
||||
this.props.resetShouldDiscardIndexingPolicy();
|
||||
}
|
||||
this.onComponentUpdate();
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.resetIndexingPolicyEditor();
|
||||
this.onComponentUpdate();
|
||||
}
|
||||
|
||||
public resetIndexingPolicyEditor = (): void => {
|
||||
if (!this.indexingPolicyEditor) {
|
||||
this.createIndexingPolicyEditor();
|
||||
} else {
|
||||
const indexingPolicyEditorModel = this.indexingPolicyEditor.getModel();
|
||||
const value: string = JSON.stringify(this.props.indexingPolicyContent, undefined, 4);
|
||||
indexingPolicyEditorModel.setValue(value);
|
||||
}
|
||||
this.onComponentUpdate();
|
||||
};
|
||||
|
||||
private onComponentUpdate = (): void => {
|
||||
if (!this.shouldCheckComponentIsDirty) {
|
||||
this.shouldCheckComponentIsDirty = true;
|
||||
return;
|
||||
}
|
||||
this.props.onIndexingPolicyDirtyChange(this.IsComponentDirty());
|
||||
this.shouldCheckComponentIsDirty = false;
|
||||
};
|
||||
|
||||
public IsComponentDirty = (): boolean => {
|
||||
if (
|
||||
isDirty(this.props.indexingPolicyContent, this.props.indexingPolicyContentBaseline) &&
|
||||
this.state.indexingPolicyContentIsValid
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
private createIndexingPolicyEditor = (): void => {
|
||||
const value: string = JSON.stringify(this.props.indexingPolicyContent, undefined, 4);
|
||||
|
||||
this.indexingPolicyEditor = monaco.editor.create(this.indexingPolicyDiv.current, {
|
||||
value: value,
|
||||
language: "json",
|
||||
readOnly: false,
|
||||
ariaLabel: "Indexing Policy"
|
||||
});
|
||||
if (this.indexingPolicyEditor) {
|
||||
this.indexingPolicyEditor.onDidFocusEditorText(() => this.props.onIndexingPolicyElementFocusChange(true));
|
||||
this.indexingPolicyEditor.onDidBlurEditorText(() => this.props.onIndexingPolicyElementFocusChange(false));
|
||||
const indexingPolicyEditorModel = this.indexingPolicyEditor.getModel();
|
||||
indexingPolicyEditorModel.onDidChangeContent(this.onEditorContentChange.bind(this));
|
||||
this.props.logIndexingPolicySuccessMessage();
|
||||
}
|
||||
};
|
||||
|
||||
private onEditorContentChange = (): void => {
|
||||
const indexingPolicyEditorModel = this.indexingPolicyEditor.getModel();
|
||||
try {
|
||||
const newIndexingPolicyContent = JSON.parse(indexingPolicyEditorModel.getValue()) as DataModels.IndexingPolicy;
|
||||
this.props.onIndexingPolicyContentChange(newIndexingPolicyContent);
|
||||
this.setState({ indexingPolicyContentIsValid: true });
|
||||
} catch (e) {
|
||||
this.setState({ indexingPolicyContentIsValid: false });
|
||||
}
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<Stack {...titleAndInputStackProps}>
|
||||
{isDirty(this.props.indexingPolicyContent, this.props.indexingPolicyContentBaseline) && (
|
||||
<MessageBar messageBarType={MessageBarType.warning}>{indexingPolicyTTLWarningMessage}</MessageBar>
|
||||
)}
|
||||
<div className="settingsV2IndexingPolicyEditor" tabIndex={0} ref={this.indexingPolicyDiv}></div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import { ScaleComponent, ScaleComponentProps } from "./ScaleComponent";
|
||||
import { container, collection } from "../TestUtils";
|
||||
import { ThroughputInputAutoPilotV3Component } from "./ThroughputInputComponents/ThroughputInputAutoPilotV3Component";
|
||||
import Explorer from "../../../Explorer";
|
||||
import * as Constants from "../../../../Common/Constants";
|
||||
import { PlatformType } from "../../../../PlatformType";
|
||||
import * as DataModels from "../../../../Contracts/DataModels";
|
||||
import { throughputUnit } from "../SettingsRenderUtils";
|
||||
import * as SharedConstants from "../../../../Shared/Constants";
|
||||
import ko from "knockout";
|
||||
|
||||
describe("ScaleComponent", () => {
|
||||
const nonNationalCloudContainer = new Explorer({
|
||||
notificationsClient: undefined,
|
||||
isEmulator: false
|
||||
});
|
||||
nonNationalCloudContainer.getPlatformType = () => PlatformType.Portal;
|
||||
nonNationalCloudContainer.isRunningOnNationalCloud = () => false;
|
||||
|
||||
const targetThroughput = 6000;
|
||||
|
||||
const baseProps: ScaleComponentProps = {
|
||||
collection: collection,
|
||||
container: container,
|
||||
isFixedContainer: false,
|
||||
autoPilotTiersList: [],
|
||||
onThroughputChange: () => {
|
||||
return;
|
||||
},
|
||||
throughput: 1000,
|
||||
throughputBaseline: 1000,
|
||||
autoPilotThroughput: 4000,
|
||||
autoPilotThroughputBaseline: 4000,
|
||||
isAutoPilotSelected: false,
|
||||
wasAutopilotOriginallySet: true,
|
||||
onAutoPilotSelected: () => false,
|
||||
onMaxAutoPilotThroughputChange: () => {
|
||||
return;
|
||||
},
|
||||
onScaleSaveableChange: () => {
|
||||
return;
|
||||
},
|
||||
onScaleDiscardableChange: () => {
|
||||
return;
|
||||
},
|
||||
initialNotification: {
|
||||
description: `Throughput update for ${targetThroughput} ${throughputUnit}`
|
||||
} as DataModels.Notification
|
||||
};
|
||||
|
||||
it("renders with correct intiial notification", () => {
|
||||
let wrapper = shallow(<ScaleComponent {...baseProps} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists(ThroughputInputAutoPilotV3Component)).toEqual(true);
|
||||
expect(wrapper.exists("#throughputApplyLongDelayMessage")).toEqual(true);
|
||||
expect(wrapper.exists("#throughputApplyShortDelayMessage")).toEqual(false);
|
||||
expect(wrapper.find("#throughputApplyLongDelayMessage").html()).toContain(targetThroughput);
|
||||
|
||||
const newCollection = { ...collection };
|
||||
const maxThroughput = 5000;
|
||||
const targetMaxThroughput = 50000;
|
||||
newCollection.offer = ko.observable({
|
||||
content: {
|
||||
offerAutopilotSettings: {
|
||||
maxThroughput: maxThroughput,
|
||||
targetMaxThroughput: targetMaxThroughput
|
||||
}
|
||||
},
|
||||
headers: { "x-ms-offer-replace-pending": true }
|
||||
} as DataModels.OfferWithHeaders);
|
||||
const newProps = {
|
||||
...baseProps,
|
||||
initialNotification: undefined as DataModels.Notification,
|
||||
collection: newCollection
|
||||
};
|
||||
wrapper = shallow(<ScaleComponent {...newProps} />);
|
||||
expect(wrapper.exists("#throughputApplyShortDelayMessage")).toEqual(true);
|
||||
expect(wrapper.exists("#throughputApplyLongDelayMessage")).toEqual(false);
|
||||
expect(wrapper.find("#throughputApplyShortDelayMessage").html()).toContain(maxThroughput);
|
||||
expect(wrapper.find("#throughputApplyShortDelayMessage").html()).toContain(targetMaxThroughput);
|
||||
});
|
||||
|
||||
it("autoScale disabled", () => {
|
||||
const scaleComponent = new ScaleComponent(baseProps);
|
||||
expect(scaleComponent.isAutoScaleEnabled()).toEqual(false);
|
||||
});
|
||||
|
||||
it("autoScale enabled", () => {
|
||||
const newContainer = new Explorer({
|
||||
notificationsClient: undefined,
|
||||
isEmulator: false
|
||||
});
|
||||
|
||||
newContainer.databaseAccount({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
location: undefined,
|
||||
type: undefined,
|
||||
kind: "documentdb",
|
||||
tags: undefined,
|
||||
properties: {
|
||||
documentEndpoint: undefined,
|
||||
tableEndpoint: undefined,
|
||||
gremlinEndpoint: undefined,
|
||||
cassandraEndpoint: undefined,
|
||||
capabilities: [
|
||||
{
|
||||
name: Constants.CapabilityNames.EnableAutoScale.toLowerCase(),
|
||||
description: undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
const props = { ...baseProps, container: newContainer };
|
||||
const scaleComponent = new ScaleComponent(props);
|
||||
expect(scaleComponent.isAutoScaleEnabled()).toEqual(true);
|
||||
});
|
||||
|
||||
it("getMaxRUThroughputInputLimit", () => {
|
||||
const scaleComponent = new ScaleComponent(baseProps);
|
||||
expect(scaleComponent.getMaxRUThroughputInputLimit()).toEqual(40000);
|
||||
});
|
||||
|
||||
it("getThroughputTitle", () => {
|
||||
let scaleComponent = new ScaleComponent(baseProps);
|
||||
expect(scaleComponent.getThroughputTitle()).toEqual("Throughput (6,000 - 40,000 RU/s)");
|
||||
|
||||
let newProps = { ...baseProps, container: nonNationalCloudContainer };
|
||||
scaleComponent = new ScaleComponent(newProps);
|
||||
expect(scaleComponent.getThroughputTitle()).toEqual("Throughput (6,000 - unlimited RU/s)");
|
||||
|
||||
newProps = { ...baseProps, isAutoPilotSelected: true };
|
||||
scaleComponent = new ScaleComponent(newProps);
|
||||
expect(scaleComponent.getThroughputTitle()).toEqual("Throughput (autoscale)");
|
||||
});
|
||||
|
||||
it("canThroughputExceedMaximumValue", () => {
|
||||
let scaleComponent = new ScaleComponent(baseProps);
|
||||
expect(scaleComponent.canThroughputExceedMaximumValue()).toEqual(false);
|
||||
|
||||
const newProps = { ...baseProps, container: nonNationalCloudContainer };
|
||||
scaleComponent = new ScaleComponent(newProps);
|
||||
expect(scaleComponent.canThroughputExceedMaximumValue()).toEqual(true);
|
||||
});
|
||||
|
||||
it("getThroughputWarningMessage", () => {
|
||||
const throughputBeyondLimit = SharedConstants.CollectionCreation.DefaultCollectionRUs1Million + 1000;
|
||||
const throughputBeyondMaxRus = SharedConstants.CollectionCreation.DefaultCollectionRUs1Million - 1000;
|
||||
|
||||
const newProps = { ...baseProps, container: nonNationalCloudContainer, throughput: throughputBeyondLimit };
|
||||
let scaleComponent = new ScaleComponent(newProps);
|
||||
expect(scaleComponent.getThroughputWarningMessage().props.id).toEqual("updateThroughputBeyondLimitWarningMessage");
|
||||
|
||||
newProps.throughput = throughputBeyondMaxRus;
|
||||
scaleComponent = new ScaleComponent(newProps);
|
||||
expect(scaleComponent.getThroughputWarningMessage().props.id).toEqual("updateThroughputDelayedApplyWarningMessage");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import * as React from "react";
|
||||
import * as Constants from "../../../../Common/Constants";
|
||||
import { ThroughputInputAutoPilotV3Component } from "./ThroughputInputComponents/ThroughputInputAutoPilotV3Component";
|
||||
import * as ViewModels from "../../../../Contracts/ViewModels";
|
||||
import * as DataModels from "../../../../Contracts/DataModels";
|
||||
import * as SharedConstants from "../../../../Shared/Constants";
|
||||
import Explorer from "../../../Explorer";
|
||||
import { PlatformType } from "../../../../PlatformType";
|
||||
import {
|
||||
getTextFieldStyles,
|
||||
subComponentStackProps,
|
||||
titleAndInputStackProps,
|
||||
throughputUnit,
|
||||
getThroughputApplyLongDelayMessage,
|
||||
getThroughputApplyShortDelayMessage,
|
||||
updateThroughputBeyondLimitWarningMessage,
|
||||
updateThroughputDelayedApplyWarningMessage
|
||||
} from "../SettingsRenderUtils";
|
||||
import { getMaxRUs, getMinRUs, hasDatabaseSharedThroughput } from "../SettingsUtils";
|
||||
import * as AutoPilotUtils from "../../../../Utils/AutoPilotUtils";
|
||||
import { Text, TextField, Stack, Label, MessageBar, MessageBarType } from "office-ui-fabric-react";
|
||||
|
||||
export interface ScaleComponentProps {
|
||||
collection: ViewModels.Collection;
|
||||
container: Explorer;
|
||||
isFixedContainer: boolean;
|
||||
autoPilotTiersList: ViewModels.DropdownOption<DataModels.AutopilotTier>[];
|
||||
onThroughputChange: (newThroughput: number) => void;
|
||||
throughput: number;
|
||||
throughputBaseline: number;
|
||||
autoPilotThroughput: number;
|
||||
autoPilotThroughputBaseline: number;
|
||||
isAutoPilotSelected: boolean;
|
||||
wasAutopilotOriginallySet: boolean;
|
||||
onAutoPilotSelected: (isAutoPilotSelected: boolean) => void;
|
||||
onMaxAutoPilotThroughputChange: (newThroughput: number) => void;
|
||||
onScaleSaveableChange: (isScaleSaveable: boolean) => void;
|
||||
onScaleDiscardableChange: (isScaleDiscardable: boolean) => void;
|
||||
initialNotification: DataModels.Notification;
|
||||
}
|
||||
|
||||
export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
||||
private isEmulator: boolean;
|
||||
constructor(props: ScaleComponentProps) {
|
||||
super(props);
|
||||
this.isEmulator = this.props.container.isEmulator;
|
||||
}
|
||||
|
||||
public isAutoScaleEnabled = (): boolean => {
|
||||
const accountCapabilities: DataModels.Capability[] = this.props.container?.databaseAccount()?.properties
|
||||
?.capabilities;
|
||||
const enableAutoScaleCapability =
|
||||
accountCapabilities &&
|
||||
accountCapabilities.find((capability: DataModels.Capability) => {
|
||||
return (
|
||||
capability &&
|
||||
capability.name &&
|
||||
capability.name.toLowerCase() === Constants.CapabilityNames.EnableAutoScale.toLowerCase()
|
||||
);
|
||||
});
|
||||
|
||||
return !!enableAutoScaleCapability;
|
||||
};
|
||||
|
||||
private getStorageCapacityTitle = (): JSX.Element => {
|
||||
// Mongo container with system partition key still treat as "Fixed"
|
||||
const isFixed =
|
||||
!this.props.collection.partitionKey ||
|
||||
(this.props.container.isPreferredApiMongoDB() && this.props.collection.partitionKey.systemKey);
|
||||
const capacity: string = isFixed ? "Fixed" : "Unlimited";
|
||||
return (
|
||||
<Stack {...titleAndInputStackProps}>
|
||||
<Label>Storage capacity</Label>
|
||||
<Text>{capacity}</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
public getMaxRUThroughputInputLimit = (): number => {
|
||||
if (this.props.container?.getPlatformType() === PlatformType.Hosted && this.props.collection.partitionKey) {
|
||||
return SharedConstants.CollectionCreation.DefaultCollectionRUs1Million;
|
||||
}
|
||||
|
||||
return getMaxRUs(this.props.collection, this.props.container);
|
||||
};
|
||||
|
||||
public getThroughputTitle = (): string => {
|
||||
if (this.props.isAutoPilotSelected) {
|
||||
return AutoPilotUtils.getAutoPilotHeaderText(false);
|
||||
}
|
||||
|
||||
const minThroughput: string = getMinRUs(this.props.collection, this.props.container).toLocaleString();
|
||||
const maxThroughput: string =
|
||||
this.canThroughputExceedMaximumValue() && !this.props.isFixedContainer
|
||||
? "unlimited"
|
||||
: getMaxRUs(this.props.collection, this.props.container).toLocaleString();
|
||||
return `Throughput (${minThroughput} - ${maxThroughput} RU/s)`;
|
||||
};
|
||||
|
||||
public canThroughputExceedMaximumValue = (): boolean => {
|
||||
const isPublicAzurePortal: boolean =
|
||||
this.props.container.getPlatformType() === PlatformType.Portal &&
|
||||
!this.props.container.isRunningOnNationalCloud();
|
||||
const hasPartitionKey = !!this.props.collection.partitionKey;
|
||||
|
||||
return isPublicAzurePortal && hasPartitionKey;
|
||||
};
|
||||
|
||||
public getInitialNotificationElement = (): JSX.Element => {
|
||||
if (this.props.initialNotification) {
|
||||
return this.getLongDelayMessage();
|
||||
}
|
||||
|
||||
const offer = this.props.collection?.offer && this.props.collection.offer();
|
||||
if (
|
||||
offer &&
|
||||
Object.keys(offer).find(value => {
|
||||
return value === "headers";
|
||||
}) &&
|
||||
!!(offer as DataModels.OfferWithHeaders).headers[Constants.HttpHeaders.offerReplacePending]
|
||||
) {
|
||||
const throughput = offer?.content?.offerAutopilotSettings?.maxThroughput;
|
||||
|
||||
const targetThroughput =
|
||||
offer.content?.offerAutopilotSettings?.targetMaxThroughput || offer?.content?.offerThroughput;
|
||||
|
||||
return getThroughputApplyShortDelayMessage(
|
||||
this.props.isAutoPilotSelected,
|
||||
throughput,
|
||||
throughputUnit,
|
||||
this.props.collection.databaseId,
|
||||
this.props.collection.id(),
|
||||
targetThroughput
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
public getThroughputWarningMessage = (): JSX.Element => {
|
||||
const throughputExceedsBackendLimits: boolean =
|
||||
this.canThroughputExceedMaximumValue() &&
|
||||
getMaxRUs(this.props.collection, this.props.container) <=
|
||||
SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
|
||||
this.props.throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million;
|
||||
|
||||
if (throughputExceedsBackendLimits && !!this.props.collection.partitionKey && !this.props.isFixedContainer) {
|
||||
return updateThroughputBeyondLimitWarningMessage;
|
||||
}
|
||||
|
||||
const throughputExceedsMaxValue: boolean =
|
||||
!this.isEmulator && this.props.throughput > getMaxRUs(this.props.collection, this.props.container);
|
||||
|
||||
if (throughputExceedsMaxValue && !!this.props.collection.partitionKey && !this.props.isFixedContainer) {
|
||||
return updateThroughputDelayedApplyWarningMessage;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
public getLongDelayMessage = (): JSX.Element => {
|
||||
const matches: string[] = this.props.initialNotification?.description.match(
|
||||
`Throughput update for (.*) ${throughputUnit}`
|
||||
);
|
||||
|
||||
const throughput = this.props.throughputBaseline;
|
||||
const targetThroughput: number = matches.length > 1 && Number(matches[1]);
|
||||
if (targetThroughput) {
|
||||
return getThroughputApplyLongDelayMessage(
|
||||
this.props.wasAutopilotOriginallySet,
|
||||
throughput,
|
||||
throughputUnit,
|
||||
this.props.collection.databaseId,
|
||||
this.props.collection.id(),
|
||||
targetThroughput
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
};
|
||||
|
||||
private getThroughputInputComponent = (): JSX.Element => (
|
||||
<ThroughputInputAutoPilotV3Component
|
||||
databaseAccount={this.props.container.databaseAccount()}
|
||||
serverId={this.props.container.serverId()}
|
||||
throughput={this.props.throughput}
|
||||
throughputBaseline={this.props.throughputBaseline}
|
||||
onThroughputChange={this.props.onThroughputChange}
|
||||
minimum={getMinRUs(this.props.collection, this.props.container)}
|
||||
maximum={this.getMaxRUThroughputInputLimit()}
|
||||
isEnabled={!hasDatabaseSharedThroughput(this.props.collection)}
|
||||
canExceedMaximumValue={this.canThroughputExceedMaximumValue()}
|
||||
label={this.getThroughputTitle()}
|
||||
isEmulator={this.isEmulator}
|
||||
isFixed={this.props.isFixedContainer}
|
||||
isAutoPilotSelected={this.props.isAutoPilotSelected}
|
||||
onAutoPilotSelected={this.props.onAutoPilotSelected}
|
||||
wasAutopilotOriginallySet={this.props.wasAutopilotOriginallySet}
|
||||
maxAutoPilotThroughput={this.props.autoPilotThroughput}
|
||||
maxAutoPilotThroughputBaseline={this.props.autoPilotThroughputBaseline}
|
||||
onMaxAutoPilotThroughputChange={this.props.onMaxAutoPilotThroughputChange}
|
||||
spendAckChecked={false}
|
||||
onScaleSaveableChange={this.props.onScaleSaveableChange}
|
||||
onScaleDiscardableChange={this.props.onScaleDiscardableChange}
|
||||
getThroughputWarningMessage={this.getThroughputWarningMessage}
|
||||
/>
|
||||
);
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<Stack {...subComponentStackProps}>
|
||||
{this.getInitialNotificationElement() && (
|
||||
<MessageBar messageBarType={MessageBarType.warning}>{this.getInitialNotificationElement()}</MessageBar>
|
||||
)}
|
||||
{!this.isAutoScaleEnabled() && (
|
||||
<Stack {...subComponentStackProps}>
|
||||
{this.getThroughputInputComponent()}
|
||||
{this.getStorageCapacityTitle()}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* TODO: Replace link with call to the Azure Support blade */}
|
||||
{this.isAutoScaleEnabled() && (
|
||||
<Stack {...titleAndInputStackProps}>
|
||||
<Text>Throughput (RU/s)</Text>
|
||||
<TextField disabled styles={getTextFieldStyles(undefined, undefined)} />
|
||||
<Text>
|
||||
Your account has custom settings that prevents setting throughput at the container level. Please work with
|
||||
your Cosmos DB engineering team point of contact to make changes.
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import { SubSettingsComponent, SubSettingsComponentProps } from "./SubSettingsComponent";
|
||||
import { container, collection } from "../TestUtils";
|
||||
import { TtlType, GeospatialConfigType, ChangeFeedPolicyState } from "../SettingsUtils";
|
||||
import ko from "knockout";
|
||||
import Explorer from "../../../Explorer";
|
||||
|
||||
describe("SubSettingsComponent", () => {
|
||||
container.isPreferredApiDocumentDB = ko.computed(() => true);
|
||||
|
||||
const baseProps: SubSettingsComponentProps = {
|
||||
collection: collection,
|
||||
container: container,
|
||||
|
||||
timeToLive: TtlType.On,
|
||||
timeToLiveBaseline: TtlType.On,
|
||||
onTtlChange: () => {
|
||||
return;
|
||||
},
|
||||
timeToLiveSeconds: 1000,
|
||||
timeToLiveSecondsBaseline: 1000,
|
||||
onTimeToLiveSecondsChange: () => {
|
||||
return;
|
||||
},
|
||||
|
||||
geospatialConfigType: GeospatialConfigType.Geography,
|
||||
geospatialConfigTypeBaseline: GeospatialConfigType.Geography,
|
||||
onGeoSpatialConfigTypeChange: () => {
|
||||
return;
|
||||
},
|
||||
|
||||
isAnalyticalStorageEnabled: true,
|
||||
analyticalStorageTtlSelection: TtlType.On,
|
||||
analyticalStorageTtlSelectionBaseline: TtlType.On,
|
||||
onAnalyticalStorageTtlSelectionChange: () => {
|
||||
return;
|
||||
},
|
||||
analyticalStorageTtlSeconds: 2000,
|
||||
analyticalStorageTtlSecondsBaseline: 2000,
|
||||
onAnalyticalStorageTtlSecondsChange: () => {
|
||||
return;
|
||||
},
|
||||
|
||||
changeFeedPolicyVisible: true,
|
||||
changeFeedPolicy: ChangeFeedPolicyState.On,
|
||||
changeFeedPolicyBaseline: ChangeFeedPolicyState.On,
|
||||
|
||||
onChangeFeedPolicyChange: () => {
|
||||
return;
|
||||
},
|
||||
onSubSettingsSaveableChange: () => {
|
||||
return;
|
||||
},
|
||||
onSubSettingsDiscardableChange: () => {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
it("renders", () => {
|
||||
const wrapper = shallow(<SubSettingsComponent {...baseProps} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists("#timeToLive")).toEqual(true);
|
||||
expect(wrapper.exists("#timeToLiveSeconds")).toEqual(true);
|
||||
expect(wrapper.exists("#geoSpatialConfig")).toEqual(true);
|
||||
expect(wrapper.exists("#analyticalStorageTimeToLive")).toEqual(true);
|
||||
expect(wrapper.exists("#analyticalStorageTimeToLiveSeconds")).toEqual(true);
|
||||
expect(wrapper.exists("#changeFeedPolicy")).toEqual(true);
|
||||
});
|
||||
|
||||
it("timeToLiveSeconds hidden", () => {
|
||||
const props = { ...baseProps, timeToLive: TtlType.Off };
|
||||
const wrapper = shallow(<SubSettingsComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists("#timeToLive")).toEqual(true);
|
||||
expect(wrapper.exists("#timeToLiveSeconds")).toEqual(false);
|
||||
});
|
||||
|
||||
it("analyticalTimeToLive hidden", () => {
|
||||
const props = { ...baseProps, isAnalyticalStorageEnabled: false };
|
||||
const wrapper = shallow(<SubSettingsComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists("#analyticalStorageTimeToLive")).toEqual(false);
|
||||
expect(wrapper.exists("#analyticalStorageTimeToLiveSeconds")).toEqual(false);
|
||||
});
|
||||
|
||||
it("analyticalTimeToLiveSeconds hidden", () => {
|
||||
const props = { ...baseProps, analyticalStorageTtlSelection: TtlType.OnNoDefault };
|
||||
const wrapper = shallow(<SubSettingsComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists("#analyticalStorageTimeToLive")).toEqual(true);
|
||||
expect(wrapper.exists("#analyticalStorageTimeToLiveSeconds")).toEqual(false);
|
||||
});
|
||||
|
||||
it("changeFeedPolicy hidden", () => {
|
||||
const props = { ...baseProps, changeFeedPolicyVisible: false };
|
||||
const wrapper = shallow(<SubSettingsComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists("#changeFeedPolicy")).toEqual(false);
|
||||
});
|
||||
|
||||
it("partitionKey is visible", () => {
|
||||
const subSettingsComponent = new SubSettingsComponent(baseProps);
|
||||
expect(subSettingsComponent.getPartitionKeyVisible()).toEqual(true);
|
||||
});
|
||||
|
||||
it("partitionKey not visible", () => {
|
||||
const newContainer = new Explorer({
|
||||
notificationsClient: undefined,
|
||||
isEmulator: false
|
||||
});
|
||||
|
||||
newContainer.isPreferredApiCassandra = ko.computed(() => true);
|
||||
const props = { ...baseProps, container: newContainer };
|
||||
const subSettingsComponent = new SubSettingsComponent(props);
|
||||
expect(subSettingsComponent.getPartitionKeyVisible()).toEqual(false);
|
||||
});
|
||||
|
||||
it("largePartitionKey is enabled", () => {
|
||||
const subSettingsComponent = new SubSettingsComponent(baseProps);
|
||||
expect(subSettingsComponent.isLargePartitionKeyEnabled()).toEqual(true);
|
||||
});
|
||||
|
||||
it("sub settings saveable and discardable are set", () => {
|
||||
let subSettingsComponent = new SubSettingsComponent(baseProps);
|
||||
let isComponentDirtyResult = subSettingsComponent.IsComponentDirty();
|
||||
expect(isComponentDirtyResult.isSaveable).toEqual(false);
|
||||
expect(isComponentDirtyResult.isDiscardable).toEqual(false);
|
||||
|
||||
const newProps = { ...baseProps, timeToLive: TtlType.OnNoDefault };
|
||||
subSettingsComponent = new SubSettingsComponent(newProps);
|
||||
isComponentDirtyResult = subSettingsComponent.IsComponentDirty();
|
||||
expect(isComponentDirtyResult.isSaveable).toEqual(true);
|
||||
expect(isComponentDirtyResult.isDiscardable).toEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import * as React from "react";
|
||||
import * as ViewModels from "../../../../Contracts/ViewModels";
|
||||
import {
|
||||
GeospatialConfigType,
|
||||
TtlType,
|
||||
ChangeFeedPolicyState,
|
||||
isDirty,
|
||||
IsComponentDirtyResult
|
||||
} from "../SettingsUtils";
|
||||
import Explorer from "../../../Explorer";
|
||||
import { Int32 } from "../../../Panes/Tables/Validators/EntityPropertyValidationCommon";
|
||||
import {
|
||||
Label,
|
||||
Text,
|
||||
TextField,
|
||||
Stack,
|
||||
IChoiceGroupOption,
|
||||
ChoiceGroup,
|
||||
MessageBar,
|
||||
MessageBarType
|
||||
} from "office-ui-fabric-react";
|
||||
import {
|
||||
getTextFieldStyles,
|
||||
changeFeedPolicyToolTip,
|
||||
subComponentStackProps,
|
||||
titleAndInputStackProps,
|
||||
getChoiceGroupStyles,
|
||||
ttlWarning,
|
||||
messageBarStyles
|
||||
} from "../SettingsRenderUtils";
|
||||
import { ToolTipLabelComponent } from "./ToolTipLabelComponent";
|
||||
|
||||
export interface SubSettingsComponentProps {
|
||||
collection: ViewModels.Collection;
|
||||
container: Explorer;
|
||||
|
||||
timeToLive: TtlType;
|
||||
timeToLiveBaseline: TtlType;
|
||||
|
||||
onTtlChange: (ev?: React.FormEvent<HTMLElement | HTMLInputElement>, option?: IChoiceGroupOption) => void;
|
||||
timeToLiveSeconds: number;
|
||||
timeToLiveSecondsBaseline: number;
|
||||
onTimeToLiveSecondsChange: (
|
||||
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
newValue?: string
|
||||
) => void;
|
||||
|
||||
geospatialConfigType: GeospatialConfigType;
|
||||
geospatialConfigTypeBaseline: GeospatialConfigType;
|
||||
onGeoSpatialConfigTypeChange: (
|
||||
ev?: React.FormEvent<HTMLElement | HTMLInputElement>,
|
||||
option?: IChoiceGroupOption
|
||||
) => void;
|
||||
|
||||
isAnalyticalStorageEnabled: boolean;
|
||||
analyticalStorageTtlSelection: TtlType;
|
||||
analyticalStorageTtlSelectionBaseline: TtlType;
|
||||
onAnalyticalStorageTtlSelectionChange: (
|
||||
ev?: React.FormEvent<HTMLElement | HTMLInputElement>,
|
||||
option?: IChoiceGroupOption
|
||||
) => void;
|
||||
|
||||
analyticalStorageTtlSeconds: number;
|
||||
analyticalStorageTtlSecondsBaseline: number;
|
||||
onAnalyticalStorageTtlSecondsChange: (
|
||||
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
newValue?: string
|
||||
) => void;
|
||||
|
||||
changeFeedPolicyVisible: boolean;
|
||||
changeFeedPolicy: ChangeFeedPolicyState;
|
||||
changeFeedPolicyBaseline: ChangeFeedPolicyState;
|
||||
onChangeFeedPolicyChange: (ev?: React.FormEvent<HTMLElement | HTMLInputElement>, option?: IChoiceGroupOption) => void;
|
||||
onSubSettingsSaveableChange: (isSubSettingsSaveable: boolean) => void;
|
||||
onSubSettingsDiscardableChange: (isSubSettingsDiscardable: boolean) => void;
|
||||
}
|
||||
|
||||
export class SubSettingsComponent extends React.Component<SubSettingsComponentProps> {
|
||||
private shouldCheckComponentIsDirty = true;
|
||||
private ttlVisible: boolean;
|
||||
private geospatialVisible: boolean;
|
||||
private partitionKeyValue: string;
|
||||
private partitionKeyName: string;
|
||||
|
||||
constructor(props: SubSettingsComponentProps) {
|
||||
super(props);
|
||||
this.ttlVisible = (this.props.container && !this.props.container.isPreferredApiCassandra()) || false;
|
||||
this.geospatialVisible = this.props.container.isPreferredApiDocumentDB();
|
||||
this.partitionKeyValue = "/" + this.props.collection.partitionKeyProperty;
|
||||
this.partitionKeyName = this.props.container.isPreferredApiMongoDB() ? "Shard key" : "Partition key";
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.onComponentUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate(): void {
|
||||
this.onComponentUpdate();
|
||||
}
|
||||
|
||||
private onComponentUpdate = (): void => {
|
||||
if (!this.shouldCheckComponentIsDirty) {
|
||||
this.shouldCheckComponentIsDirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const isComponentDirtyResult = this.IsComponentDirty();
|
||||
this.props.onSubSettingsSaveableChange(isComponentDirtyResult.isSaveable);
|
||||
this.props.onSubSettingsDiscardableChange(isComponentDirtyResult.isDiscardable);
|
||||
|
||||
this.shouldCheckComponentIsDirty = false;
|
||||
};
|
||||
|
||||
public IsComponentDirty = (): IsComponentDirtyResult => {
|
||||
if (
|
||||
(this.props.timeToLive === TtlType.On && !this.props.timeToLiveSeconds) ||
|
||||
(this.props.analyticalStorageTtlSelection === TtlType.On && !this.props.analyticalStorageTtlSeconds)
|
||||
) {
|
||||
return { isSaveable: false, isDiscardable: true };
|
||||
} else if (
|
||||
isDirty(this.props.timeToLive, this.props.timeToLiveBaseline) ||
|
||||
(this.props.timeToLive === TtlType.On &&
|
||||
isDirty(this.props.timeToLiveSeconds, this.props.timeToLiveSecondsBaseline)) ||
|
||||
isDirty(this.props.analyticalStorageTtlSelection, this.props.analyticalStorageTtlSelectionBaseline) ||
|
||||
(this.props.analyticalStorageTtlSelection === TtlType.On &&
|
||||
isDirty(this.props.analyticalStorageTtlSeconds, this.props.analyticalStorageTtlSecondsBaseline)) ||
|
||||
isDirty(this.props.geospatialConfigType, this.props.geospatialConfigTypeBaseline) ||
|
||||
isDirty(this.props.changeFeedPolicy, this.props.changeFeedPolicyBaseline)
|
||||
) {
|
||||
return { isSaveable: true, isDiscardable: true };
|
||||
}
|
||||
|
||||
return { isSaveable: false, isDiscardable: false };
|
||||
};
|
||||
|
||||
private ttlChoiceGroupOptions: IChoiceGroupOption[] = [
|
||||
{ key: TtlType.Off, text: "Off" },
|
||||
{ key: TtlType.OnNoDefault, text: "On (no default)" },
|
||||
{ key: TtlType.On, text: "On" }
|
||||
];
|
||||
|
||||
private getTtlComponent = (): JSX.Element => (
|
||||
<Stack {...titleAndInputStackProps}>
|
||||
<ChoiceGroup
|
||||
id="timeToLive"
|
||||
label="Time to Live"
|
||||
selectedKey={this.props.timeToLive}
|
||||
options={this.ttlChoiceGroupOptions}
|
||||
onChange={this.props.onTtlChange}
|
||||
styles={getChoiceGroupStyles(this.props.timeToLive, this.props.timeToLiveBaseline)}
|
||||
/>
|
||||
{isDirty(this.props.timeToLive, this.props.timeToLiveBaseline) && this.props.timeToLive === TtlType.On && (
|
||||
<MessageBar messageBarType={MessageBarType.warning} styles={messageBarStyles}>
|
||||
{ttlWarning}
|
||||
</MessageBar>
|
||||
)}
|
||||
{this.props.timeToLive === TtlType.On && (
|
||||
<TextField
|
||||
id="timeToLiveSeconds"
|
||||
styles={getTextFieldStyles(this.props.timeToLiveSeconds, this.props.timeToLiveSecondsBaseline)}
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
max={Int32.Max}
|
||||
value={this.props.timeToLiveSeconds?.toString()}
|
||||
onChange={this.props.onTimeToLiveSecondsChange}
|
||||
suffix="second(s)"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
private analyticalTtlChoiceGroupOptions: IChoiceGroupOption[] = [
|
||||
{ key: TtlType.Off, text: "Off", disabled: true },
|
||||
{ key: TtlType.OnNoDefault, text: "On (no default)" },
|
||||
{ key: TtlType.On, text: "On" }
|
||||
];
|
||||
|
||||
private getAnalyticalStorageTtlComponent = (): JSX.Element => (
|
||||
<Stack {...titleAndInputStackProps}>
|
||||
<ChoiceGroup
|
||||
id="analyticalStorageTimeToLive"
|
||||
label="Analytical Storage Time to Live"
|
||||
selectedKey={this.props.analyticalStorageTtlSelection}
|
||||
options={this.analyticalTtlChoiceGroupOptions}
|
||||
onChange={this.props.onAnalyticalStorageTtlSelectionChange}
|
||||
styles={getChoiceGroupStyles(
|
||||
this.props.analyticalStorageTtlSelection,
|
||||
this.props.analyticalStorageTtlSelectionBaseline
|
||||
)}
|
||||
/>
|
||||
{this.props.analyticalStorageTtlSelection === TtlType.On && (
|
||||
<TextField
|
||||
id="analyticalStorageTimeToLiveSeconds"
|
||||
styles={getTextFieldStyles(
|
||||
this.props.analyticalStorageTtlSeconds,
|
||||
this.props.analyticalStorageTtlSecondsBaseline
|
||||
)}
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
max={Int32.Max}
|
||||
value={this.props.analyticalStorageTtlSeconds?.toString()}
|
||||
suffix="second(s)"
|
||||
onChange={this.props.onAnalyticalStorageTtlSecondsChange}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
private geoSpatialConfigTypeChoiceGroupOptions: IChoiceGroupOption[] = [
|
||||
{ key: GeospatialConfigType.Geography, text: "Geography" },
|
||||
{ key: GeospatialConfigType.Geometry, text: "Geometry" }
|
||||
];
|
||||
|
||||
private getGeoSpatialComponent = (): JSX.Element => (
|
||||
<ChoiceGroup
|
||||
id="geoSpatialConfig"
|
||||
label="Geospatial Configuration"
|
||||
selectedKey={this.props.geospatialConfigType}
|
||||
options={this.geoSpatialConfigTypeChoiceGroupOptions}
|
||||
onChange={this.props.onGeoSpatialConfigTypeChange}
|
||||
styles={getChoiceGroupStyles(this.props.geospatialConfigType, this.props.geospatialConfigTypeBaseline)}
|
||||
/>
|
||||
);
|
||||
|
||||
private changeFeedChoiceGroupOptions: IChoiceGroupOption[] = [
|
||||
{ key: ChangeFeedPolicyState.Off, text: "Off" },
|
||||
{ key: ChangeFeedPolicyState.On, text: "On" }
|
||||
];
|
||||
|
||||
private getChangeFeedComponent = (): JSX.Element => {
|
||||
const labelId = "settingsV2ChangeFeedLabelId";
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Label id={labelId}>
|
||||
<ToolTipLabelComponent label="Change feed log retention policy" toolTipElement={changeFeedPolicyToolTip} />
|
||||
</Label>
|
||||
<ChoiceGroup
|
||||
id="changeFeedPolicy"
|
||||
selectedKey={this.props.changeFeedPolicy}
|
||||
options={this.changeFeedChoiceGroupOptions}
|
||||
onChange={this.props.onChangeFeedPolicyChange}
|
||||
styles={getChoiceGroupStyles(this.props.changeFeedPolicy, this.props.changeFeedPolicyBaseline)}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
private getPartitionKeyComponent = (): JSX.Element => (
|
||||
<Stack {...titleAndInputStackProps}>
|
||||
{this.getPartitionKeyVisible() && (
|
||||
<TextField
|
||||
label={this.partitionKeyName}
|
||||
disabled
|
||||
styles={getTextFieldStyles(undefined, undefined)}
|
||||
defaultValue={this.partitionKeyValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{this.isLargePartitionKeyEnabled() && <Text>Large {this.partitionKeyName.toLowerCase()} has been enabled</Text>}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
public getPartitionKeyVisible = (): boolean => {
|
||||
if (
|
||||
this.props.container.isPreferredApiCassandra() ||
|
||||
this.props.container.isPreferredApiTable() ||
|
||||
!this.props.collection.partitionKeyProperty ||
|
||||
(this.props.container.isPreferredApiMongoDB() && this.props.collection.partitionKey.systemKey)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
public isLargePartitionKeyEnabled = (): boolean => this.props.collection.partitionKey?.version >= 2;
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<Stack {...subComponentStackProps}>
|
||||
{this.ttlVisible && this.getTtlComponent()}
|
||||
|
||||
{this.geospatialVisible && this.getGeoSpatialComponent()}
|
||||
|
||||
{this.props.isAnalyticalStorageEnabled && this.getAnalyticalStorageTtlComponent()}
|
||||
|
||||
{this.props.changeFeedPolicyVisible && this.getChangeFeedComponent()}
|
||||
|
||||
{this.getPartitionKeyComponent()}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import {
|
||||
ThroughputInputAutoPilotV3Component,
|
||||
ThroughputInputAutoPilotV3Props
|
||||
} from "./ThroughputInputAutoPilotV3Component";
|
||||
import * as DataModels from "../../../../../Contracts/DataModels";
|
||||
|
||||
describe("ThroughputInputAutoPilotV3Component", () => {
|
||||
const baseProps: ThroughputInputAutoPilotV3Props = {
|
||||
databaseAccount: {} as DataModels.DatabaseAccount,
|
||||
serverId: undefined,
|
||||
wasAutopilotOriginallySet: false,
|
||||
throughput: 100,
|
||||
throughputBaseline: 100,
|
||||
onThroughputChange: undefined,
|
||||
minimum: 10000,
|
||||
maximum: 400,
|
||||
step: 100,
|
||||
isEnabled: true,
|
||||
isEmulator: false,
|
||||
spendAckChecked: false,
|
||||
spendAckId: "spendAckId",
|
||||
spendAckText: "spendAckText",
|
||||
spendAckVisible: false,
|
||||
showAsMandatory: true,
|
||||
isFixed: false,
|
||||
label: "label",
|
||||
infoBubbleText: "infoBubbleText",
|
||||
canExceedMaximumValue: true,
|
||||
onAutoPilotSelected: undefined,
|
||||
isAutoPilotSelected: false,
|
||||
maxAutoPilotThroughput: 4000,
|
||||
maxAutoPilotThroughputBaseline: 4000,
|
||||
onMaxAutoPilotThroughputChange: undefined,
|
||||
onScaleSaveableChange: () => {
|
||||
return;
|
||||
},
|
||||
onScaleDiscardableChange: () => {
|
||||
return;
|
||||
},
|
||||
getThroughputWarningMessage: () => undefined
|
||||
};
|
||||
|
||||
it("throughput input visible", () => {
|
||||
const wrapper = shallow(<ThroughputInputAutoPilotV3Component {...baseProps} />);
|
||||
const throughputComponent = wrapper.instance() as ThroughputInputAutoPilotV3Component;
|
||||
expect(throughputComponent.hasProvisioningTypeChanged()).toEqual(false);
|
||||
expect(throughputComponent.overrideWithProvisionedThroughputSettings()).toEqual(false);
|
||||
expect(throughputComponent.overrideWithAutoPilotSettings()).toEqual(false);
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists("#throughputInput")).toEqual(true);
|
||||
expect(wrapper.exists("#autopilotInput")).toEqual(false);
|
||||
expect(wrapper.exists("#throughputSpendElement")).toEqual(true);
|
||||
expect(wrapper.exists("#autoscaleSpendElement")).toEqual(false);
|
||||
});
|
||||
|
||||
it("autopilot input visible", () => {
|
||||
const newProps = { ...baseProps, isAutoPilotSelected: true };
|
||||
const wrapper = shallow(<ThroughputInputAutoPilotV3Component {...newProps} />);
|
||||
const throughputComponent = wrapper.instance() as ThroughputInputAutoPilotV3Component;
|
||||
expect(throughputComponent.hasProvisioningTypeChanged()).toEqual(true);
|
||||
expect(throughputComponent.overrideWithProvisionedThroughputSettings()).toEqual(true);
|
||||
expect(throughputComponent.overrideWithAutoPilotSettings()).toEqual(false);
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists("#autopilotInput")).toEqual(true);
|
||||
expect(wrapper.exists("#throughputInput")).toEqual(false);
|
||||
expect(wrapper.exists("#manualToAutoscaleDisclaimerElement")).toEqual(true);
|
||||
|
||||
wrapper.setProps({ wasAutopilotOriginallySet: true });
|
||||
wrapper.update();
|
||||
expect(wrapper.exists("#autoscaleSpendElement")).toEqual(true);
|
||||
expect(wrapper.exists("#throughputSpendElement")).toEqual(false);
|
||||
});
|
||||
|
||||
it("spendAck checkbox visible", () => {
|
||||
const newProps = { ...baseProps, spendAckVisible: true };
|
||||
const wrapper = shallow(<ThroughputInputAutoPilotV3Component {...newProps} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists("#spendAckCheckBox")).toEqual(true);
|
||||
});
|
||||
|
||||
it("scale saveable and discardable are set", () => {
|
||||
let throughputComponent = new ThroughputInputAutoPilotV3Component(baseProps);
|
||||
let isComponentDirtyResult = throughputComponent.IsComponentDirty();
|
||||
expect(isComponentDirtyResult.isSaveable).toEqual(false);
|
||||
expect(isComponentDirtyResult.isDiscardable).toEqual(false);
|
||||
|
||||
const newProps = { ...baseProps, throughput: 1000000 };
|
||||
throughputComponent = new ThroughputInputAutoPilotV3Component(newProps);
|
||||
isComponentDirtyResult = throughputComponent.IsComponentDirty();
|
||||
expect(isComponentDirtyResult.isSaveable).toEqual(true);
|
||||
expect(isComponentDirtyResult.isDiscardable).toEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import React from "react";
|
||||
import * as AutoPilotUtils from "../../../../../Utils/AutoPilotUtils";
|
||||
import {
|
||||
getTextFieldStyles,
|
||||
getToolTipContainer,
|
||||
spendAckCheckBoxStyle,
|
||||
titleAndInputStackProps,
|
||||
checkBoxAndInputStackProps,
|
||||
getChoiceGroupStyles,
|
||||
messageBarStyles,
|
||||
getEstimatedSpendElement,
|
||||
getEstimatedAutoscaleSpendElement,
|
||||
getAutoPilotV3SpendElement,
|
||||
manualToAutoscaleDisclaimerElement
|
||||
} from "../../SettingsRenderUtils";
|
||||
import {
|
||||
Text,
|
||||
TextField,
|
||||
ChoiceGroup,
|
||||
IChoiceGroupOption,
|
||||
Checkbox,
|
||||
Stack,
|
||||
Label,
|
||||
Link,
|
||||
MessageBar,
|
||||
MessageBarType
|
||||
} from "office-ui-fabric-react";
|
||||
import { ToolTipLabelComponent } from "../ToolTipLabelComponent";
|
||||
import { IsComponentDirtyResult, isDirty } from "../../SettingsUtils";
|
||||
import * as SharedConstants from "../../../../../Shared/Constants";
|
||||
import * as DataModels from "../../../../../Contracts/DataModels";
|
||||
|
||||
export interface ThroughputInputAutoPilotV3Props {
|
||||
databaseAccount: DataModels.DatabaseAccount;
|
||||
serverId: string;
|
||||
throughput: number;
|
||||
throughputBaseline: number;
|
||||
onThroughputChange: (newThroughput: number) => void;
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
step?: number;
|
||||
isEnabled?: boolean;
|
||||
spendAckChecked?: boolean;
|
||||
spendAckId?: string;
|
||||
spendAckText?: string;
|
||||
spendAckVisible?: boolean;
|
||||
showAsMandatory?: boolean;
|
||||
isFixed: boolean;
|
||||
isEmulator: boolean;
|
||||
label: string;
|
||||
infoBubbleText?: string;
|
||||
canExceedMaximumValue?: boolean;
|
||||
onAutoPilotSelected: (isAutoPilotSelected: boolean) => void;
|
||||
isAutoPilotSelected: boolean;
|
||||
wasAutopilotOriginallySet: boolean;
|
||||
maxAutoPilotThroughput: number;
|
||||
maxAutoPilotThroughputBaseline: number;
|
||||
onMaxAutoPilotThroughputChange: (newThroughput: number) => void;
|
||||
onScaleSaveableChange: (isScaleSaveable: boolean) => void;
|
||||
onScaleDiscardableChange: (isScaleDiscardable: boolean) => void;
|
||||
getThroughputWarningMessage: () => JSX.Element;
|
||||
}
|
||||
|
||||
interface ThroughputInputAutoPilotV3State {
|
||||
spendAckChecked: boolean;
|
||||
}
|
||||
|
||||
export class ThroughputInputAutoPilotV3Component extends React.Component<
|
||||
ThroughputInputAutoPilotV3Props,
|
||||
ThroughputInputAutoPilotV3State
|
||||
> {
|
||||
private shouldCheckComponentIsDirty = true;
|
||||
private static readonly defaultStep = 100;
|
||||
private static readonly zeroThroughput = 0;
|
||||
private step: number;
|
||||
private choiceGroupFixedStyle = getChoiceGroupStyles(undefined, undefined);
|
||||
private options: IChoiceGroupOption[] = [
|
||||
{ key: "true", text: "Autoscale" },
|
||||
{ key: "false", text: "Manual" }
|
||||
];
|
||||
|
||||
componentDidMount(): void {
|
||||
this.onComponentUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate(): void {
|
||||
this.onComponentUpdate();
|
||||
}
|
||||
|
||||
private onComponentUpdate = (): void => {
|
||||
if (!this.shouldCheckComponentIsDirty) {
|
||||
this.shouldCheckComponentIsDirty = true;
|
||||
return;
|
||||
}
|
||||
const isComponentDirtyResult = this.IsComponentDirty();
|
||||
this.props.onScaleSaveableChange(isComponentDirtyResult.isSaveable);
|
||||
this.props.onScaleDiscardableChange(isComponentDirtyResult.isDiscardable);
|
||||
|
||||
this.shouldCheckComponentIsDirty = false;
|
||||
};
|
||||
|
||||
public IsComponentDirty = (): IsComponentDirtyResult => {
|
||||
let isSaveable = false;
|
||||
let isDiscardable = false;
|
||||
|
||||
if (this.props.isEnabled) {
|
||||
if (this.hasProvisioningTypeChanged()) {
|
||||
isSaveable = true;
|
||||
isDiscardable = true;
|
||||
} else if (this.props.isAutoPilotSelected) {
|
||||
if (isDirty(this.props.maxAutoPilotThroughput, this.props.maxAutoPilotThroughputBaseline)) {
|
||||
isDiscardable = true;
|
||||
if (AutoPilotUtils.isValidAutoPilotThroughput(this.props.maxAutoPilotThroughput)) {
|
||||
isSaveable = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isDirty(this.props.throughput, this.props.throughputBaseline)) {
|
||||
isDiscardable = true;
|
||||
isSaveable = true;
|
||||
if (
|
||||
!this.props.throughput ||
|
||||
this.props.throughput < this.props.minimum ||
|
||||
(this.props.throughput > this.props.maximum && (this.props.isEmulator || this.props.isFixed)) ||
|
||||
(this.props.throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
|
||||
!this.props.canExceedMaximumValue)
|
||||
) {
|
||||
isSaveable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { isSaveable, isDiscardable };
|
||||
};
|
||||
|
||||
public constructor(props: ThroughputInputAutoPilotV3Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
spendAckChecked: this.props.spendAckChecked
|
||||
};
|
||||
|
||||
this.step = this.props.step ?? ThroughputInputAutoPilotV3Component.defaultStep;
|
||||
}
|
||||
|
||||
public hasProvisioningTypeChanged = (): boolean =>
|
||||
this.props.wasAutopilotOriginallySet !== this.props.isAutoPilotSelected;
|
||||
|
||||
public overrideWithAutoPilotSettings = (): boolean =>
|
||||
this.hasProvisioningTypeChanged() && this.props.wasAutopilotOriginallySet;
|
||||
|
||||
public overrideWithProvisionedThroughputSettings = (): boolean =>
|
||||
this.hasProvisioningTypeChanged() && !this.props.wasAutopilotOriginallySet;
|
||||
|
||||
private getRequestUnitsUsageCost = (): JSX.Element => {
|
||||
const account = this.props.databaseAccount;
|
||||
if (!account) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const serverId: string = this.props.serverId;
|
||||
const offerThroughput: number = this.props.throughput;
|
||||
|
||||
const regions = account?.properties?.readLocations?.length || 1;
|
||||
const multimaster = account?.properties?.enableMultipleWriteLocations || false;
|
||||
|
||||
let estimatedSpend: JSX.Element;
|
||||
|
||||
if (!this.props.isAutoPilotSelected) {
|
||||
estimatedSpend = getEstimatedSpendElement(
|
||||
// if migrating from autoscale to manual, we use the autoscale RUs value as that is what will be set...
|
||||
this.overrideWithAutoPilotSettings() ? this.props.maxAutoPilotThroughput : offerThroughput,
|
||||
serverId,
|
||||
regions,
|
||||
multimaster,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
estimatedSpend = getEstimatedAutoscaleSpendElement(
|
||||
this.props.maxAutoPilotThroughput,
|
||||
serverId,
|
||||
regions,
|
||||
multimaster
|
||||
);
|
||||
}
|
||||
return estimatedSpend;
|
||||
};
|
||||
|
||||
private getAutoPilotUsageCost = (): JSX.Element => {
|
||||
if (!this.props.maxAutoPilotThroughput) {
|
||||
return <></>;
|
||||
}
|
||||
return getAutoPilotV3SpendElement(
|
||||
this.props.maxAutoPilotThroughput,
|
||||
false /* isDatabaseThroughput */,
|
||||
!this.props.isEmulator ? this.getRequestUnitsUsageCost() : <></>
|
||||
);
|
||||
};
|
||||
|
||||
private onAutoPilotThroughputChange = (
|
||||
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
newValue?: string
|
||||
): void => {
|
||||
let newThroughput = parseInt(newValue);
|
||||
newThroughput = isNaN(newThroughput) ? ThroughputInputAutoPilotV3Component.zeroThroughput : newThroughput;
|
||||
this.props.onMaxAutoPilotThroughputChange(newThroughput);
|
||||
};
|
||||
|
||||
private onThroughputChange = (
|
||||
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
newValue?: string
|
||||
): void => {
|
||||
let newThroughput = parseInt(newValue);
|
||||
newThroughput = isNaN(newThroughput) ? ThroughputInputAutoPilotV3Component.zeroThroughput : newThroughput;
|
||||
|
||||
if (this.overrideWithAutoPilotSettings()) {
|
||||
this.props.onMaxAutoPilotThroughputChange(newThroughput);
|
||||
} else {
|
||||
this.props.onThroughputChange(newThroughput);
|
||||
}
|
||||
};
|
||||
|
||||
private onChoiceGroupChange = (
|
||||
event?: React.FormEvent<HTMLElement | HTMLInputElement>,
|
||||
option?: IChoiceGroupOption
|
||||
): void => this.props.onAutoPilotSelected(option.key === "true");
|
||||
|
||||
private renderThroughputModeChoices = (): JSX.Element => {
|
||||
const labelId = "settingsV2RadioButtonLabelId";
|
||||
return (
|
||||
<Stack>
|
||||
<Label id={labelId}>
|
||||
<ToolTipLabelComponent
|
||||
label={this.props.label}
|
||||
toolTipElement={getToolTipContainer(this.props.infoBubbleText)}
|
||||
/>
|
||||
</Label>
|
||||
{this.overrideWithProvisionedThroughputSettings() && (
|
||||
<MessageBar messageBarType={MessageBarType.warning} styles={messageBarStyles}>
|
||||
{manualToAutoscaleDisclaimerElement}
|
||||
</MessageBar>
|
||||
)}
|
||||
<ChoiceGroup
|
||||
selectedKey={this.props.isAutoPilotSelected.toString()}
|
||||
options={this.options}
|
||||
onChange={this.onChoiceGroupChange}
|
||||
required={this.props.showAsMandatory}
|
||||
ariaLabelledBy={labelId}
|
||||
styles={this.choiceGroupFixedStyle}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
private onSpendAckChecked = (ev?: React.FormEvent<HTMLElement | HTMLInputElement>, checked?: boolean): void =>
|
||||
this.setState({ spendAckChecked: checked });
|
||||
|
||||
private renderAutoPilotInput = (): JSX.Element => (
|
||||
<>
|
||||
<Text>
|
||||
Provision maximum RU/s required by this resource. Estimate your required RU/s with
|
||||
<Link target="_blank" href="https://cosmos.azure.com/capacitycalculator/">
|
||||
{` capacity calculator`}
|
||||
</Link>
|
||||
</Text>
|
||||
<TextField
|
||||
label="Max RU/s"
|
||||
required
|
||||
type="number"
|
||||
id="autopilotInput"
|
||||
key="auto pilot throughput input"
|
||||
styles={getTextFieldStyles(this.props.maxAutoPilotThroughput, this.props.maxAutoPilotThroughputBaseline)}
|
||||
disabled={this.overrideWithProvisionedThroughputSettings()}
|
||||
step={this.step}
|
||||
min={AutoPilotUtils.minAutoPilotThroughput}
|
||||
value={this.overrideWithProvisionedThroughputSettings() ? "" : this.props.maxAutoPilotThroughput?.toString()}
|
||||
onChange={this.onAutoPilotThroughputChange}
|
||||
/>
|
||||
{!this.overrideWithProvisionedThroughputSettings() && this.getAutoPilotUsageCost()}
|
||||
{this.props.spendAckVisible && (
|
||||
<Checkbox
|
||||
id="spendAckCheckBox"
|
||||
styles={spendAckCheckBoxStyle}
|
||||
label={this.props.spendAckText}
|
||||
checked={this.state.spendAckChecked}
|
||||
onChange={this.onSpendAckChecked}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
private renderThroughputInput = (): JSX.Element => (
|
||||
<Stack {...titleAndInputStackProps}>
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
id="throughputInput"
|
||||
key="provisioned throughput input"
|
||||
styles={getTextFieldStyles(this.props.throughput, this.props.throughputBaseline)}
|
||||
disabled={this.overrideWithAutoPilotSettings()}
|
||||
step={this.step}
|
||||
min={this.props.minimum}
|
||||
max={this.props.canExceedMaximumValue ? undefined : this.props.maximum}
|
||||
value={
|
||||
this.overrideWithAutoPilotSettings()
|
||||
? this.props.maxAutoPilotThroughputBaseline?.toString()
|
||||
: this.props.throughput?.toString()
|
||||
}
|
||||
onChange={this.onThroughputChange}
|
||||
/>
|
||||
|
||||
{this.props.getThroughputWarningMessage() && (
|
||||
<MessageBar messageBarType={MessageBarType.warning} styles={messageBarStyles}>
|
||||
{this.props.getThroughputWarningMessage()}
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{!this.props.isEmulator && this.getRequestUnitsUsageCost()}
|
||||
|
||||
{this.props.spendAckVisible && (
|
||||
<Checkbox
|
||||
id="spendAckCheckBox"
|
||||
styles={spendAckCheckBoxStyle}
|
||||
label={this.props.spendAckText}
|
||||
checked={this.state.spendAckChecked}
|
||||
onChange={this.onSpendAckChecked}
|
||||
/>
|
||||
)}
|
||||
|
||||
{this.props.isFixed && <p>Choose unlimited storage capacity for more than 10,000 RU/s.</p>}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<Stack {...checkBoxAndInputStackProps}>
|
||||
{!this.props.isFixed && this.renderThroughputModeChoices()}
|
||||
|
||||
{this.props.isAutoPilotSelected ? this.renderAutoPilotInput() : this.renderThroughputInput()}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
|
||||
<Stack
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack>
|
||||
<StyledLabelBase
|
||||
id="settingsV2RadioButtonLabelId"
|
||||
>
|
||||
<ToolTipLabelComponent
|
||||
label="label"
|
||||
toolTipElement={
|
||||
<Text
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
infoBubbleText
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</StyledLabelBase>
|
||||
<StyledMessageBarBase
|
||||
messageBarType={5}
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"marginTop": "5px",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
id="manualToAutoscaleDisclaimerElement"
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
The starting autoscale max RU/s will be determined by the system, based on the current manual throughput settings and storage of your resource. After autoscale has been enabled, you can change the max RU/s.
|
||||
|
||||
<a
|
||||
href="https://aka.ms/cosmos-autoscale-migration"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</Text>
|
||||
</StyledMessageBarBase>
|
||||
<StyledChoiceGroupBase
|
||||
ariaLabelledBy="settingsV2RadioButtonLabelId"
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"key": "true",
|
||||
"text": "Autoscale",
|
||||
},
|
||||
Object {
|
||||
"key": "false",
|
||||
"text": "Manual",
|
||||
},
|
||||
]
|
||||
}
|
||||
required={true}
|
||||
selectedKey="true"
|
||||
styles={
|
||||
Object {
|
||||
"flexContainer": Array [
|
||||
Object {
|
||||
"selectors": Object {
|
||||
".ms-ChoiceField-field.is-checked::after": Object {
|
||||
"borderColor": "",
|
||||
},
|
||||
".ms-ChoiceField-field.is-checked::before": Object {
|
||||
"borderColor": "",
|
||||
},
|
||||
".ms-ChoiceField-wrapper label": Object {
|
||||
"fontFamily": undefined,
|
||||
"fontSize": 14,
|
||||
"padding": "2px 5px",
|
||||
"whiteSpace": "nowrap",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
<Text>
|
||||
Provision maximum RU/s required by this resource. Estimate your required RU/s with
|
||||
<StyledLinkBase
|
||||
href="https://cosmos.azure.com/capacitycalculator/"
|
||||
target="_blank"
|
||||
>
|
||||
capacity calculator
|
||||
</StyledLinkBase>
|
||||
</Text>
|
||||
<StyledTextFieldBase
|
||||
disabled={true}
|
||||
id="autopilotInput"
|
||||
key="auto pilot throughput input"
|
||||
label="Max RU/s"
|
||||
min={4000}
|
||||
onChange={[Function]}
|
||||
required={true}
|
||||
step={100}
|
||||
styles={
|
||||
Object {
|
||||
"fieldGroup": Object {
|
||||
"borderColor": "",
|
||||
"height": 25,
|
||||
"selectors": Object {
|
||||
":disabled": Object {
|
||||
"backgroundColor": undefined,
|
||||
"borderColor": undefined,
|
||||
},
|
||||
},
|
||||
"width": 300,
|
||||
},
|
||||
}
|
||||
}
|
||||
type="number"
|
||||
value=""
|
||||
/>
|
||||
</Stack>
|
||||
`;
|
||||
|
||||
exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
|
||||
<Stack
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack>
|
||||
<StyledLabelBase
|
||||
id="settingsV2RadioButtonLabelId"
|
||||
>
|
||||
<ToolTipLabelComponent
|
||||
label="label"
|
||||
toolTipElement={
|
||||
<Text
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
infoBubbleText
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</StyledLabelBase>
|
||||
<StyledChoiceGroupBase
|
||||
ariaLabelledBy="settingsV2RadioButtonLabelId"
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"key": "true",
|
||||
"text": "Autoscale",
|
||||
},
|
||||
Object {
|
||||
"key": "false",
|
||||
"text": "Manual",
|
||||
},
|
||||
]
|
||||
}
|
||||
required={true}
|
||||
selectedKey="false"
|
||||
styles={
|
||||
Object {
|
||||
"flexContainer": Array [
|
||||
Object {
|
||||
"selectors": Object {
|
||||
".ms-ChoiceField-field.is-checked::after": Object {
|
||||
"borderColor": "",
|
||||
},
|
||||
".ms-ChoiceField-field.is-checked::before": Object {
|
||||
"borderColor": "",
|
||||
},
|
||||
".ms-ChoiceField-wrapper label": Object {
|
||||
"fontFamily": undefined,
|
||||
"fontSize": 14,
|
||||
"padding": "2px 5px",
|
||||
"whiteSpace": "nowrap",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StyledTextFieldBase
|
||||
disabled={false}
|
||||
id="throughputInput"
|
||||
key="provisioned throughput input"
|
||||
min={10000}
|
||||
onChange={[Function]}
|
||||
required={true}
|
||||
step={100}
|
||||
styles={
|
||||
Object {
|
||||
"fieldGroup": Object {
|
||||
"borderColor": "",
|
||||
"height": 25,
|
||||
"selectors": Object {
|
||||
":disabled": Object {
|
||||
"backgroundColor": undefined,
|
||||
"borderColor": undefined,
|
||||
},
|
||||
},
|
||||
"width": 300,
|
||||
},
|
||||
}
|
||||
}
|
||||
type="number"
|
||||
value="100"
|
||||
/>
|
||||
<Text
|
||||
id="throughputSpendElement"
|
||||
>
|
||||
Estimated cost (
|
||||
USD
|
||||
):
|
||||
|
||||
<b>
|
||||
$
|
||||
0.0080
|
||||
hourly
|
||||
/
|
||||
$
|
||||
0.19
|
||||
daily
|
||||
/
|
||||
$
|
||||
5.84
|
||||
monthly
|
||||
|
||||
</b>
|
||||
(
|
||||
regions:
|
||||
|
||||
1
|
||||
,
|
||||
100
|
||||
RU/s,
|
||||
$
|
||||
0.00008
|
||||
/RU)
|
||||
</Text>
|
||||
<StyledCheckboxBase
|
||||
checked={false}
|
||||
id="spendAckCheckBox"
|
||||
label="spendAckText"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
Object {
|
||||
"label": Object {
|
||||
"margin": 0,
|
||||
"padding": "2 0 2 0",
|
||||
},
|
||||
"text": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
`;
|
||||
|
||||
exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
|
||||
<Stack
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack>
|
||||
<StyledLabelBase
|
||||
id="settingsV2RadioButtonLabelId"
|
||||
>
|
||||
<ToolTipLabelComponent
|
||||
label="label"
|
||||
toolTipElement={
|
||||
<Text
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
infoBubbleText
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</StyledLabelBase>
|
||||
<StyledChoiceGroupBase
|
||||
ariaLabelledBy="settingsV2RadioButtonLabelId"
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"key": "true",
|
||||
"text": "Autoscale",
|
||||
},
|
||||
Object {
|
||||
"key": "false",
|
||||
"text": "Manual",
|
||||
},
|
||||
]
|
||||
}
|
||||
required={true}
|
||||
selectedKey="false"
|
||||
styles={
|
||||
Object {
|
||||
"flexContainer": Array [
|
||||
Object {
|
||||
"selectors": Object {
|
||||
".ms-ChoiceField-field.is-checked::after": Object {
|
||||
"borderColor": "",
|
||||
},
|
||||
".ms-ChoiceField-field.is-checked::before": Object {
|
||||
"borderColor": "",
|
||||
},
|
||||
".ms-ChoiceField-wrapper label": Object {
|
||||
"fontFamily": undefined,
|
||||
"fontSize": 14,
|
||||
"padding": "2px 5px",
|
||||
"whiteSpace": "nowrap",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StyledTextFieldBase
|
||||
disabled={false}
|
||||
id="throughputInput"
|
||||
key="provisioned throughput input"
|
||||
min={10000}
|
||||
onChange={[Function]}
|
||||
required={true}
|
||||
step={100}
|
||||
styles={
|
||||
Object {
|
||||
"fieldGroup": Object {
|
||||
"borderColor": "",
|
||||
"height": 25,
|
||||
"selectors": Object {
|
||||
":disabled": Object {
|
||||
"backgroundColor": undefined,
|
||||
"borderColor": undefined,
|
||||
},
|
||||
},
|
||||
"width": 300,
|
||||
},
|
||||
}
|
||||
}
|
||||
type="number"
|
||||
value="100"
|
||||
/>
|
||||
<Text
|
||||
id="throughputSpendElement"
|
||||
>
|
||||
Estimated cost (
|
||||
USD
|
||||
):
|
||||
|
||||
<b>
|
||||
$
|
||||
0.0080
|
||||
hourly
|
||||
/
|
||||
$
|
||||
0.19
|
||||
daily
|
||||
/
|
||||
$
|
||||
5.84
|
||||
monthly
|
||||
|
||||
</b>
|
||||
(
|
||||
regions:
|
||||
|
||||
1
|
||||
,
|
||||
100
|
||||
RU/s,
|
||||
$
|
||||
0.00008
|
||||
/RU)
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
`;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import { ToolTipLabelComponent, ToolTipLabelComponentProps } from "./ToolTipLabelComponent";
|
||||
|
||||
describe("ToolTipLabelComponent", () => {
|
||||
const props: ToolTipLabelComponentProps = {
|
||||
label: "sample tool tip label",
|
||||
toolTipElement: <span>sample tool tip text</span>
|
||||
};
|
||||
|
||||
it("renders", () => {
|
||||
const wrapper = shallow(<ToolTipLabelComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import { Stack, Text, IIconStyles, Icon, TooltipHost, DirectionalHint } from "office-ui-fabric-react";
|
||||
import { toolTipLabelStackTokens } from "../SettingsRenderUtils";
|
||||
|
||||
export interface ToolTipLabelComponentProps {
|
||||
label: string;
|
||||
toolTipElement: JSX.Element;
|
||||
}
|
||||
|
||||
const iconButtonStyles: Partial<IIconStyles> = { root: { marginBottom: -3 } };
|
||||
|
||||
export class ToolTipLabelComponent extends React.Component<ToolTipLabelComponentProps> {
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Stack horizontal verticalAlign="center" tokens={toolTipLabelStackTokens}>
|
||||
{this.props.label && <Text style={{ fontWeight: 600 }}>{this.props.label}</Text>}
|
||||
{this.props.toolTipElement && (
|
||||
<TooltipHost
|
||||
content={this.props.toolTipElement}
|
||||
directionalHint={DirectionalHint.rightCenter}
|
||||
calloutProps={{ gapSpace: 0 }}
|
||||
styles={{ root: { display: "inline-block", float: "right" } }}
|
||||
>
|
||||
<Icon iconName="Info" ariaLabel="Info" styles={iconButtonStyles} />
|
||||
</TooltipHost>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ConflictResolutionComponent Path text field displayed 1`] = `
|
||||
<Stack
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StyledChoiceGroupBase
|
||||
label="Mode"
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"key": "LastWriterWins",
|
||||
"text": "Last Write Wins (default)",
|
||||
},
|
||||
Object {
|
||||
"key": "Custom",
|
||||
"text": "Merge Procedure (custom)",
|
||||
},
|
||||
]
|
||||
}
|
||||
selectedKey="LastWriterWins"
|
||||
styles={
|
||||
Object {
|
||||
"flexContainer": Array [
|
||||
Object {
|
||||
"selectors": Object {
|
||||
".ms-ChoiceField-field.is-checked::after": Object {
|
||||
"borderColor": undefined,
|
||||
},
|
||||
".ms-ChoiceField-field.is-checked::before": Object {
|
||||
"borderColor": undefined,
|
||||
},
|
||||
".ms-ChoiceField-wrapper label": Object {
|
||||
"fontFamily": undefined,
|
||||
"fontSize": 14,
|
||||
"padding": "2px 5px",
|
||||
"whiteSpace": "nowrap",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
/>
|
||||
<StyledTextFieldBase
|
||||
id="conflictResolutionLwwTextField"
|
||||
label="Conflict Resolver Property"
|
||||
onChange={[Function]}
|
||||
onRenderLabel={[Function]}
|
||||
styles={
|
||||
Object {
|
||||
"fieldGroup": Object {
|
||||
"borderColor": "",
|
||||
"height": 25,
|
||||
"selectors": Object {
|
||||
":disabled": Object {
|
||||
"backgroundColor": undefined,
|
||||
"borderColor": undefined,
|
||||
},
|
||||
},
|
||||
"width": 300,
|
||||
},
|
||||
}
|
||||
}
|
||||
value=""
|
||||
/>
|
||||
</Stack>
|
||||
`;
|
||||
|
||||
exports[`ConflictResolutionComponent Sproc text field displayed 1`] = `
|
||||
<Stack
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StyledChoiceGroupBase
|
||||
label="Mode"
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"key": "LastWriterWins",
|
||||
"text": "Last Write Wins (default)",
|
||||
},
|
||||
Object {
|
||||
"key": "Custom",
|
||||
"text": "Merge Procedure (custom)",
|
||||
},
|
||||
]
|
||||
}
|
||||
selectedKey="Custom"
|
||||
styles={
|
||||
Object {
|
||||
"flexContainer": Array [
|
||||
Object {
|
||||
"selectors": Object {
|
||||
".ms-ChoiceField-field.is-checked::after": Object {
|
||||
"borderColor": "",
|
||||
},
|
||||
".ms-ChoiceField-field.is-checked::before": Object {
|
||||
"borderColor": "",
|
||||
},
|
||||
".ms-ChoiceField-wrapper label": Object {
|
||||
"fontFamily": undefined,
|
||||
"fontSize": 14,
|
||||
"padding": "2px 5px",
|
||||
"whiteSpace": "nowrap",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
/>
|
||||
<StyledTextFieldBase
|
||||
id="conflictResolutionCustomTextField"
|
||||
label="Stored procedure"
|
||||
onChange={[Function]}
|
||||
onRenderLabel={[Function]}
|
||||
styles={
|
||||
Object {
|
||||
"fieldGroup": Object {
|
||||
"borderColor": "",
|
||||
"height": 25,
|
||||
"selectors": Object {
|
||||
":disabled": Object {
|
||||
"backgroundColor": undefined,
|
||||
"borderColor": undefined,
|
||||
},
|
||||
},
|
||||
"width": 300,
|
||||
},
|
||||
}
|
||||
}
|
||||
value=""
|
||||
/>
|
||||
</Stack>
|
||||
`;
|
||||
@@ -0,0 +1,16 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`IndexingPolicyComponent renders 1`] = `
|
||||
<Stack
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="settingsV2IndexingPolicyEditor"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</Stack>
|
||||
`;
|
||||
@@ -0,0 +1,79 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ScaleComponent renders with correct intiial notification 1`] = `
|
||||
<Stack
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StyledMessageBarBase
|
||||
messageBarType={5}
|
||||
>
|
||||
<Text
|
||||
id="throughputApplyLongDelayMessage"
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
A request to increase the throughput is currently in progress. This operation will take 1-3 business days to complete. View the latest status in Notifications.
|
||||
<br />
|
||||
Database:
|
||||
test
|
||||
, Container:
|
||||
test
|
||||
|
||||
, Current autoscale throughput: 100 - 1000 RU/s, Target autoscale throughput: 600 - 6000 RU/s
|
||||
</Text>
|
||||
</StyledMessageBarBase>
|
||||
<Stack
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<ThroughputInputAutoPilotV3Component
|
||||
canExceedMaximumValue={false}
|
||||
getThroughputWarningMessage={[Function]}
|
||||
isAutoPilotSelected={false}
|
||||
isEmulator={false}
|
||||
isEnabled={true}
|
||||
isFixed={false}
|
||||
label="Throughput (6,000 - 40,000 RU/s)"
|
||||
maxAutoPilotThroughput={4000}
|
||||
maxAutoPilotThroughputBaseline={4000}
|
||||
maximum={40000}
|
||||
minimum={6000}
|
||||
onAutoPilotSelected={[Function]}
|
||||
onMaxAutoPilotThroughputChange={[Function]}
|
||||
onScaleDiscardableChange={[Function]}
|
||||
onScaleSaveableChange={[Function]}
|
||||
onThroughputChange={[Function]}
|
||||
spendAckChecked={false}
|
||||
throughput={1000}
|
||||
throughputBaseline={1000}
|
||||
wasAutopilotOriginallySet={true}
|
||||
/>
|
||||
<Stack
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StyledLabelBase>
|
||||
Storage capacity
|
||||
</StyledLabelBase>
|
||||
<Text>
|
||||
Unlimited
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ToolTipLabelComponent renders 1`] = `
|
||||
<Fragment>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 6,
|
||||
}
|
||||
}
|
||||
verticalAlign="center"
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"fontWeight": 600,
|
||||
}
|
||||
}
|
||||
>
|
||||
sample tool tip label
|
||||
</Text>
|
||||
<StyledTooltipHostBase
|
||||
calloutProps={
|
||||
Object {
|
||||
"gapSpace": 0,
|
||||
}
|
||||
}
|
||||
content={
|
||||
<span>
|
||||
sample tool tip text
|
||||
</span>
|
||||
}
|
||||
directionalHint={12}
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"display": "inline-block",
|
||||
"float": "right",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<StyledIconBase
|
||||
ariaLabel="Info"
|
||||
iconName="Info"
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"marginBottom": -3,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</StyledTooltipHostBase>
|
||||
</Stack>
|
||||
</Fragment>
|
||||
`;
|
||||
89
src/Explorer/Controls/Settings/SettingsUtils.test.tsx
Normal file
89
src/Explorer/Controls/Settings/SettingsUtils.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { collection, container } from "./TestUtils";
|
||||
import {
|
||||
getMaxRUs,
|
||||
getMinRUs,
|
||||
hasDatabaseSharedThroughput,
|
||||
isDirty,
|
||||
isDirtyTypes,
|
||||
parseConflictResolutionMode,
|
||||
parseConflictResolutionProcedure
|
||||
} from "./SettingsUtils";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import ko from "knockout";
|
||||
|
||||
describe("SettingsUtils", () => {
|
||||
it("getMaxRUs", () => {
|
||||
expect(collection.offer().content.collectionThroughputInfo.numPhysicalPartitions).toEqual(4);
|
||||
expect(getMaxRUs(collection, container)).toEqual(40000);
|
||||
});
|
||||
|
||||
it("getMinRUs", () => {
|
||||
expect(collection.offer().content.collectionThroughputInfo.numPhysicalPartitions).toEqual(4);
|
||||
expect(getMinRUs(collection, container)).toEqual(6000);
|
||||
});
|
||||
|
||||
it("hasDatabaseSharedThroughput", () => {
|
||||
expect(hasDatabaseSharedThroughput(collection)).toEqual(undefined);
|
||||
|
||||
const newCollection = { ...collection };
|
||||
newCollection.getDatabase = () => {
|
||||
return {
|
||||
nodeKind: undefined,
|
||||
rid: undefined,
|
||||
container: undefined,
|
||||
self: "",
|
||||
id: ko.observable(""),
|
||||
collections: ko.observableArray(undefined),
|
||||
offer: ko.observable(undefined),
|
||||
isDatabaseExpanded: ko.observable(false),
|
||||
isDatabaseShared: ko.computed(() => true),
|
||||
selectedSubnodeKind: ko.observable(undefined),
|
||||
selectDatabase: undefined,
|
||||
expandDatabase: undefined,
|
||||
collapseDatabase: undefined,
|
||||
loadCollections: undefined,
|
||||
findCollectionWithId: undefined,
|
||||
openAddCollection: undefined,
|
||||
onDeleteDatabaseContextMenuClick: undefined,
|
||||
readSettings: undefined,
|
||||
onSettingsClick: undefined,
|
||||
loadOffer: undefined
|
||||
} as ViewModels.Database;
|
||||
};
|
||||
newCollection.offer(undefined);
|
||||
expect(hasDatabaseSharedThroughput(newCollection)).toEqual(true);
|
||||
});
|
||||
|
||||
it("parseConflictResolutionMode", () => {
|
||||
expect(parseConflictResolutionMode("custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
||||
expect(parseConflictResolutionMode("lastwriterwins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
||||
});
|
||||
|
||||
it("parseConflictResolutionProcedure", () => {
|
||||
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/sprocs/conflictResSproc")).toEqual("conflictResSproc");
|
||||
expect(parseConflictResolutionProcedure("conflictResSproc")).toEqual("conflictResSproc");
|
||||
});
|
||||
|
||||
describe("isDirty", () => {
|
||||
const indexingPolicy = {
|
||||
automatic: true,
|
||||
indexingMode: "consistent",
|
||||
includedPaths: [],
|
||||
excludedPaths: []
|
||||
} as DataModels.IndexingPolicy;
|
||||
|
||||
const cases = [
|
||||
["baseline", "current"],
|
||||
[0, 1],
|
||||
[true, false],
|
||||
[undefined, indexingPolicy],
|
||||
[indexingPolicy, { ...indexingPolicy, automatic: false }]
|
||||
];
|
||||
|
||||
test.each(cases)("", (baseline: isDirtyTypes, current: isDirtyTypes) => {
|
||||
expect(isDirty(baseline, baseline)).toEqual(false);
|
||||
expect(isDirty(baseline, current)).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
171
src/Explorer/Controls/Settings/SettingsUtils.tsx
Normal file
171
src/Explorer/Controls/Settings/SettingsUtils.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
import * as SharedConstants from "../../../Shared/Constants";
|
||||
import * as PricingUtils from "../../../Utils/PricingUtils";
|
||||
|
||||
import Explorer from "../../Explorer";
|
||||
|
||||
export type isDirtyTypes = boolean | string | number | DataModels.IndexingPolicy;
|
||||
export const TtlOff = "off";
|
||||
export const TtlOn = "on";
|
||||
export const TtlOnNoDefault = "on-nodefault";
|
||||
|
||||
export enum ChangeFeedPolicyState {
|
||||
Off = "Off",
|
||||
On = "On"
|
||||
}
|
||||
|
||||
export enum TtlType {
|
||||
Off = "off",
|
||||
On = "on",
|
||||
OnNoDefault = "on-nodefault"
|
||||
}
|
||||
|
||||
export enum GeospatialConfigType {
|
||||
Geography = "Geography",
|
||||
Geometry = "Geometry"
|
||||
}
|
||||
|
||||
export enum SettingsV2TabTypes {
|
||||
ScaleTab,
|
||||
ConflictResolutionTab,
|
||||
SubSettingsTab,
|
||||
IndexingPolicyTab
|
||||
}
|
||||
|
||||
export interface IsComponentDirtyResult {
|
||||
isSaveable: boolean;
|
||||
isDiscardable: boolean;
|
||||
}
|
||||
|
||||
export const hasDatabaseSharedThroughput = (collection: ViewModels.Collection): boolean => {
|
||||
const database: ViewModels.Database = collection.getDatabase();
|
||||
return database?.isDatabaseShared() && !collection.offer();
|
||||
};
|
||||
|
||||
export const getMaxRUs = (collection: ViewModels.Collection, container: Explorer): number => {
|
||||
const isTryCosmosDBSubscription = container?.isTryCosmosDBSubscription() || false;
|
||||
if (isTryCosmosDBSubscription) {
|
||||
return Constants.TryCosmosExperience.maxRU;
|
||||
}
|
||||
|
||||
const numPartitionsFromOffer: number =
|
||||
collection?.offer && collection.offer()?.content?.collectionThroughputInfo?.numPhysicalPartitions;
|
||||
|
||||
const numPartitionsFromQuotaInfo: number = collection?.quotaInfo().numPartitions;
|
||||
|
||||
const numPartitions = numPartitionsFromOffer ?? numPartitionsFromQuotaInfo ?? 1;
|
||||
|
||||
return SharedConstants.CollectionCreation.MaxRUPerPartition * numPartitions;
|
||||
};
|
||||
|
||||
export const getMinRUs = (collection: ViewModels.Collection, container: Explorer): number => {
|
||||
const isTryCosmosDBSubscription = container?.isTryCosmosDBSubscription();
|
||||
if (isTryCosmosDBSubscription) {
|
||||
return SharedConstants.CollectionCreation.DefaultCollectionRUs400;
|
||||
}
|
||||
|
||||
const offerContent = collection?.offer && collection.offer()?.content;
|
||||
|
||||
if (offerContent?.offerAutopilotSettings) {
|
||||
return SharedConstants.CollectionCreation.DefaultCollectionRUs400;
|
||||
}
|
||||
|
||||
const collectionThroughputInfo: DataModels.OfferThroughputInfo = offerContent?.collectionThroughputInfo;
|
||||
|
||||
if (collectionThroughputInfo?.minimumRUForCollection > 0) {
|
||||
return collectionThroughputInfo.minimumRUForCollection;
|
||||
}
|
||||
|
||||
const numPartitions = collectionThroughputInfo?.numPhysicalPartitions ?? collection.quotaInfo().numPartitions;
|
||||
|
||||
if (!numPartitions || numPartitions === 1) {
|
||||
return SharedConstants.CollectionCreation.DefaultCollectionRUs400;
|
||||
}
|
||||
|
||||
const baseRU = SharedConstants.CollectionCreation.DefaultCollectionRUs400;
|
||||
|
||||
const quotaInKb = collection.quotaInfo().collectionSize;
|
||||
const quotaInGb = PricingUtils.usageInGB(quotaInKb);
|
||||
|
||||
const perPartitionGBQuota: number = Math.max(10, quotaInGb / numPartitions);
|
||||
const baseRUbyPartitions: number = ((numPartitions * perPartitionGBQuota) / 10) * 100;
|
||||
|
||||
return Math.max(baseRU, baseRUbyPartitions);
|
||||
};
|
||||
|
||||
export const parseConflictResolutionMode = (modeFromBackend: string): DataModels.ConflictResolutionMode => {
|
||||
// Backend can contain different casing as it does case-insensitive comparisson
|
||||
if (!modeFromBackend) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const modeAsLowerCase: string = modeFromBackend.toLowerCase();
|
||||
if (modeAsLowerCase === DataModels.ConflictResolutionMode.Custom.toLowerCase()) {
|
||||
return DataModels.ConflictResolutionMode.Custom;
|
||||
}
|
||||
|
||||
// Default is LWW
|
||||
return DataModels.ConflictResolutionMode.LastWriterWins;
|
||||
};
|
||||
|
||||
export const parseConflictResolutionProcedure = (procedureFromBackEnd: string): string => {
|
||||
// Backend data comes in /dbs/xxxx/colls/xxxx/sprocs/{name}, to make it easier for users, we just use the name
|
||||
if (!procedureFromBackEnd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (procedureFromBackEnd.indexOf("/") >= 0) {
|
||||
const sprocsIndex: number = procedureFromBackEnd.indexOf(Constants.HashRoutePrefixes.sprocHash);
|
||||
if (sprocsIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return procedureFromBackEnd.substr(sprocsIndex + Constants.HashRoutePrefixes.sprocHash.length);
|
||||
}
|
||||
|
||||
// No path, just a name, in case backend returns just the name
|
||||
return procedureFromBackEnd;
|
||||
};
|
||||
|
||||
export const isDirty = (current: isDirtyTypes, baseline: isDirtyTypes): boolean => {
|
||||
const currentType = typeof current;
|
||||
const baselineType = typeof baseline;
|
||||
|
||||
if (currentType !== "undefined" && baselineType !== "undefined" && currentType !== baselineType) {
|
||||
throw new Error("current and baseline values are not of the same type.");
|
||||
}
|
||||
const currentStringValue = getStringValue(current, currentType);
|
||||
const baselineStringValue = getStringValue(baseline, baselineType);
|
||||
|
||||
return currentStringValue !== baselineStringValue;
|
||||
};
|
||||
|
||||
const getStringValue = (value: isDirtyTypes, type: string): string => {
|
||||
switch (type) {
|
||||
case "string":
|
||||
case "undefined":
|
||||
case "number":
|
||||
case "boolean":
|
||||
return value?.toString();
|
||||
|
||||
default:
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
};
|
||||
|
||||
export const getTabTitle = (tab: SettingsV2TabTypes): string => {
|
||||
switch (tab) {
|
||||
case SettingsV2TabTypes.ScaleTab:
|
||||
return "Scale";
|
||||
case SettingsV2TabTypes.ConflictResolutionTab:
|
||||
return "Conflict Resolution";
|
||||
case SettingsV2TabTypes.SubSettingsTab:
|
||||
return "Settings";
|
||||
case SettingsV2TabTypes.IndexingPolicyTab:
|
||||
return "Indexing Policy";
|
||||
default:
|
||||
throw new Error(`Unknown tab ${tab}`);
|
||||
}
|
||||
};
|
||||
52
src/Explorer/Controls/Settings/TestUtils.tsx
Normal file
52
src/Explorer/Controls/Settings/TestUtils.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import Explorer from "../../Explorer";
|
||||
import ko from "knockout";
|
||||
|
||||
export const container = new Explorer({
|
||||
notificationsClient: undefined,
|
||||
isEmulator: false
|
||||
});
|
||||
|
||||
export const collection = ({
|
||||
container: container,
|
||||
databaseId: "test",
|
||||
id: ko.observable<string>("test"),
|
||||
defaultTtl: ko.observable<number>(5),
|
||||
analyticalStorageTtl: ko.observable<number>(undefined),
|
||||
indexingPolicy: ko.observable<DataModels.IndexingPolicy>({
|
||||
automatic: true,
|
||||
indexingMode: "default",
|
||||
includedPaths: [],
|
||||
excludedPaths: []
|
||||
}),
|
||||
uniqueKeyPolicy: {} as DataModels.UniqueKeyPolicy,
|
||||
quotaInfo: ko.observable<DataModels.CollectionQuotaInfo>({} as DataModels.CollectionQuotaInfo),
|
||||
offer: ko.observable<DataModels.Offer>({
|
||||
content: {
|
||||
offerThroughput: 10000,
|
||||
offerIsRUPerMinuteThroughputEnabled: false,
|
||||
collectionThroughputInfo: {
|
||||
minimumRUForCollection: 6000,
|
||||
numPhysicalPartitions: 4
|
||||
} as DataModels.OfferThroughputInfo
|
||||
}
|
||||
} as DataModels.Offer),
|
||||
conflictResolutionPolicy: ko.observable<DataModels.ConflictResolutionPolicy>(
|
||||
{} as DataModels.ConflictResolutionPolicy
|
||||
),
|
||||
changeFeedPolicy: ko.observable<DataModels.ChangeFeedPolicy>({} as DataModels.ChangeFeedPolicy),
|
||||
geospatialConfig: ko.observable<DataModels.GeospatialConfig>({} as DataModels.GeospatialConfig),
|
||||
getDatabase: () => {
|
||||
return;
|
||||
},
|
||||
partitionKey: {
|
||||
paths: [],
|
||||
kind: "hash",
|
||||
version: 2
|
||||
},
|
||||
partitionKeyProperty: "partitionKey",
|
||||
readSettings: () => {
|
||||
return;
|
||||
}
|
||||
} as unknown) as ViewModels.Collection;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,314 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SettingsUtils functions render 1`] = `
|
||||
<Fragment>
|
||||
<Text>
|
||||
Your
|
||||
container
|
||||
throughput will automatically scale from
|
||||
|
||||
<b>
|
||||
100
|
||||
RU/s (10% of max RU/s) -
|
||||
|
||||
1000
|
||||
RU/s
|
||||
</b>
|
||||
|
||||
based on usage.
|
||||
<br />
|
||||
</Text>
|
||||
<Text>
|
||||
After the first
|
||||
10
|
||||
GB of data stored, the max RU/s will be automatically upgraded based on the new storage value.
|
||||
<StyledLinkBase
|
||||
href="https://aka.ms/cosmos-autoscale-info"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
Learn more
|
||||
</StyledLinkBase>
|
||||
.
|
||||
</Text>
|
||||
<Text>
|
||||
Your
|
||||
database
|
||||
throughput will automatically scale from
|
||||
|
||||
<b>
|
||||
100
|
||||
RU/s (10% of max RU/s) -
|
||||
|
||||
1000
|
||||
RU/s
|
||||
</b>
|
||||
|
||||
based on usage.
|
||||
<br />
|
||||
</Text>
|
||||
<Text>
|
||||
After the first
|
||||
10
|
||||
GB of data stored, the max RU/s will be automatically upgraded based on the new storage value.
|
||||
<StyledLinkBase
|
||||
href="https://aka.ms/cosmos-autoscale-info"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
Learn more
|
||||
</StyledLinkBase>
|
||||
.
|
||||
</Text>
|
||||
<Text
|
||||
id="throughputSpendElement"
|
||||
>
|
||||
Estimated cost (
|
||||
RMB
|
||||
):
|
||||
|
||||
<b>
|
||||
¥
|
||||
1.29
|
||||
hourly
|
||||
/
|
||||
¥
|
||||
31.06
|
||||
daily
|
||||
/
|
||||
¥
|
||||
944.60
|
||||
monthly
|
||||
|
||||
</b>
|
||||
(
|
||||
regions:
|
||||
|
||||
2
|
||||
,
|
||||
1000
|
||||
RU/s,
|
||||
¥
|
||||
0.00051
|
||||
/RU)
|
||||
</Text>
|
||||
<Text
|
||||
id="autoscaleSpendElement"
|
||||
>
|
||||
Estimated monthly cost (
|
||||
RMB
|
||||
) is
|
||||
|
||||
<b>
|
||||
¥
|
||||
111.69
|
||||
-
|
||||
¥
|
||||
1116.90
|
||||
|
||||
</b>
|
||||
(
|
||||
regions:
|
||||
|
||||
2
|
||||
,
|
||||
100
|
||||
-
|
||||
1000
|
||||
RU/s,
|
||||
¥
|
||||
0.000765
|
||||
/RU)
|
||||
</Text>
|
||||
<Text
|
||||
id="manualToAutoscaleDisclaimerElement"
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
The starting autoscale max RU/s will be determined by the system, based on the current manual throughput settings and storage of your resource. After autoscale has been enabled, you can change the max RU/s.
|
||||
|
||||
<a
|
||||
href="https://aka.ms/cosmos-autoscale-migration"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</Text>
|
||||
<Text
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
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. For more information see,
|
||||
|
||||
<StyledLinkBase
|
||||
href="https://aka.ms/cosmos-db-ttl"
|
||||
target="_blank"
|
||||
>
|
||||
Time to Live (TTL) in Azure Cosmos DB
|
||||
</StyledLinkBase>
|
||||
.
|
||||
</Text>
|
||||
<Text
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Changing the Indexing Policy impacts query results while the index transformation occurs. When a change is made and the indexing mode is set to consistent or lazy, queries return eventual results until the operation completes. For more information see,
|
||||
|
||||
<StyledLinkBase
|
||||
href="https://aka.ms/cosmosdb/modify-index-policy"
|
||||
target="_blank"
|
||||
>
|
||||
Modifying Indexing Policies
|
||||
</StyledLinkBase>
|
||||
.
|
||||
</Text>
|
||||
<Text
|
||||
id="updateThroughputBeyondLimitWarningMessage"
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
You are about to request an increase in throughput beyond the pre-allocated capacity. The service will scale out and increase throughput for the selected container. This operation will take 1-3 business days to complete. You can track the status of this request in Notifications.
|
||||
</Text>
|
||||
<Text
|
||||
id="updateThroughputDelayedApplyWarningMessage"
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
You are about to request an increase in throughput beyond the pre-allocated capacity. This operation will take some time to complete.
|
||||
</Text>
|
||||
<Text
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
The request to increase the throughput has successfully been submitted. This operation will take 1-3 business days to complete. View the latest status in Notifications.
|
||||
<br />
|
||||
Database:
|
||||
sampleDb
|
||||
, Container:
|
||||
sampleCollection
|
||||
|
||||
, Current manual throughput: 1000 RU/s, Target manual throughput: 2000
|
||||
</Text>
|
||||
<Text
|
||||
id="throughputApplyShortDelayMessage"
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
A request to increase the throughput is currently in progress. This operation will take some time to complete.
|
||||
<br />
|
||||
Database:
|
||||
sampleDb
|
||||
, Container:
|
||||
sampleCollection
|
||||
|
||||
, Current manual throughput: 1000 RU/s, Target manual throughput: 2000
|
||||
</Text>
|
||||
<Text
|
||||
id="throughputApplyLongDelayMessage"
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
A request to increase the throughput is currently in progress. This operation will take 1-3 business days to complete. View the latest status in Notifications.
|
||||
<br />
|
||||
Database:
|
||||
sampleDb
|
||||
, Container:
|
||||
sampleCollection
|
||||
|
||||
, Current manual throughput: 1000 RU/s, Target manual throughput: 2000
|
||||
</Text>
|
||||
<Text
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<span>
|
||||
Sample Text
|
||||
</span>
|
||||
</Text>
|
||||
<Text
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Gets or sets the name of a integer property in your documents which is used for the Last Write Wins (LWW) based conflict resolution scheme. By default, the system uses the system defined timestamp property, _ts to decide the winner for the conflicting versions of the document. Specify your own integer property if you want to override the default timestamp based conflict resolution.
|
||||
</Text>
|
||||
<Text
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Gets or sets the name of a stored procedure (aka merge procedure) for resolving the conflicts. You can write application defined logic to determine the winner of the conflicting versions of a document. The stored procedure will get executed transactionally, exactly once, on the server side. If you do not provide a stored procedure, the conflicts will be populated in the
|
||||
<StyledLinkBase
|
||||
className="linkDarkBackground"
|
||||
href="https://aka.ms/dataexplorerconflics"
|
||||
target="_blank"
|
||||
>
|
||||
conflicts feed
|
||||
</StyledLinkBase>
|
||||
. You can update/re-register the stored procedure at any time.
|
||||
</Text>
|
||||
<Text
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Enable change feed log retention policy to retain last 10 minutes of history for items in the container by default. To support this, the request unit (RU) charge for this container will be multiplied by a factor of two for writes. Reads are unaffected.
|
||||
</Text>
|
||||
</Fragment>
|
||||
`;
|
||||
@@ -159,4 +159,20 @@ describe("TreeNodeComponent", () => {
|
||||
const wrapper = shallow(<TreeNodeComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders loading icon", () => {
|
||||
const node: TreeNode = {
|
||||
label: "label",
|
||||
children: [],
|
||||
isExpanded: true
|
||||
};
|
||||
|
||||
const props = {
|
||||
node,
|
||||
generation: 2,
|
||||
paddingLeft: 9
|
||||
};
|
||||
const wrapper = shallow(<TreeNodeComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,12 +17,14 @@ import {
|
||||
|
||||
import TriangleDownIcon from "../../../../images/Triangle-down.svg";
|
||||
import TriangleRightIcon from "../../../../images/Triangle-right.svg";
|
||||
import LoadingIndicator_3Squares from "../../../../images/LoadingIndicator_3Squares.gif";
|
||||
|
||||
export interface TreeNodeMenuItem {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
iconSrc?: string;
|
||||
isDisabled?: boolean;
|
||||
styleClass?: string;
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
@@ -37,6 +39,7 @@ export interface TreeNode {
|
||||
data?: any; // Piece of data corresponding to this node
|
||||
timestamp?: number;
|
||||
isLeavesParentsSeparate?: boolean; // Display parents together first, then leaves
|
||||
isLoading?: boolean;
|
||||
isSelected?: () => boolean;
|
||||
onClick?: (isExpanded: boolean) => void; // Only if a leaf, other click will expand/collapse
|
||||
onExpanded?: () => void;
|
||||
@@ -183,6 +186,9 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
|
||||
)}
|
||||
{node.contextMenu && this.renderContextMenuButton(node)}
|
||||
</div>
|
||||
<div className="loadingIconContainer">
|
||||
<img className="loadingIcon" src={LoadingIndicator_3Squares} hidden={!this.props.node.isLoading} />
|
||||
</div>
|
||||
{node.children && (
|
||||
<AnimateHeight duration={TreeNodeComponent.transitionDurationMS} height={this.state.isExpanded ? "auto" : 0}>
|
||||
<div className="nodeChildren" data-test={node.label}>
|
||||
@@ -256,13 +262,20 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
|
||||
onContextMenu={e => e.target.dispatchEvent(TreeNodeComponent.createClickEvent())}
|
||||
>
|
||||
{props.item.onRenderIcon()}
|
||||
<span className="treeComponentMenuItemLabel">{props.item.text}</span>
|
||||
<span
|
||||
className={
|
||||
"treeComponentMenuItemLabel" + (props.item.className ? ` ${props.item.className}Label` : "")
|
||||
}
|
||||
>
|
||||
{props.item.text}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
items: node.contextMenu.map((menuItem: TreeNodeMenuItem) => ({
|
||||
key: menuItem.label,
|
||||
text: menuItem.label,
|
||||
disabled: menuItem.isDisabled,
|
||||
className: menuItem.styleClass,
|
||||
onClick: menuItem.onClick,
|
||||
onRenderIcon: (props: any) => <img src={menuItem.iconSrc} alt="" />
|
||||
}))
|
||||
|
||||
@@ -63,6 +63,15 @@ exports[`TreeNodeComponent does not render children by default 1`] = `
|
||||
label
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="loadingIconContainer"
|
||||
>
|
||||
<img
|
||||
className="loadingIcon"
|
||||
hidden={true}
|
||||
src=""
|
||||
/>
|
||||
</div>
|
||||
<AnimateHeight
|
||||
animateOpacity={false}
|
||||
animationStateClasses={
|
||||
@@ -179,6 +188,7 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
|
||||
"isBeakVisible": false,
|
||||
"items": Array [
|
||||
Object {
|
||||
"className": undefined,
|
||||
"disabled": true,
|
||||
"key": "menuLabel",
|
||||
"onClick": undefined,
|
||||
@@ -201,6 +211,15 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="loadingIconContainer"
|
||||
>
|
||||
<img
|
||||
className="loadingIcon"
|
||||
hidden={true}
|
||||
src=""
|
||||
/>
|
||||
</div>
|
||||
<AnimateHeight
|
||||
animateOpacity={false}
|
||||
animationStateClasses={
|
||||
@@ -261,6 +280,77 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TreeNodeComponent renders loading icon 1`] = `
|
||||
<div
|
||||
className=" main2 nodeItem "
|
||||
onClick={[Function]}
|
||||
onKeyPress={[Function]}
|
||||
>
|
||||
<div
|
||||
className="treeNodeHeader "
|
||||
data-test="label"
|
||||
style={
|
||||
Object {
|
||||
"paddingLeft": 9,
|
||||
}
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
alt="label branch is expanded"
|
||||
className="expandCollapseIcon"
|
||||
onKeyPress={[Function]}
|
||||
role="button"
|
||||
src=""
|
||||
tabIndex={0}
|
||||
/>
|
||||
<span
|
||||
className="nodeLabel"
|
||||
title="label"
|
||||
>
|
||||
label
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="loadingIconContainer"
|
||||
>
|
||||
<img
|
||||
className="loadingIcon"
|
||||
hidden={true}
|
||||
src=""
|
||||
/>
|
||||
</div>
|
||||
<AnimateHeight
|
||||
animateOpacity={false}
|
||||
animationStateClasses={
|
||||
Object {
|
||||
"animating": "rah-animating",
|
||||
"animatingDown": "rah-animating--down",
|
||||
"animatingToHeightAuto": "rah-animating--to-height-auto",
|
||||
"animatingToHeightSpecific": "rah-animating--to-height-specific",
|
||||
"animatingToHeightZero": "rah-animating--to-height-zero",
|
||||
"animatingUp": "rah-animating--up",
|
||||
"static": "rah-static",
|
||||
"staticHeightAuto": "rah-static--height-auto",
|
||||
"staticHeightSpecific": "rah-static--height-specific",
|
||||
"staticHeightZero": "rah-static--height-zero",
|
||||
}
|
||||
}
|
||||
applyInlineTransitions={true}
|
||||
delay={0}
|
||||
duration={200}
|
||||
easing="ease"
|
||||
height="auto"
|
||||
style={Object {}}
|
||||
>
|
||||
<div
|
||||
className="nodeChildren"
|
||||
data-test="label"
|
||||
/>
|
||||
</AnimateHeight>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents separated 1`] = `
|
||||
<div
|
||||
className="nodeClassname main12 nodeItem "
|
||||
@@ -331,6 +421,15 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="loadingIconContainer"
|
||||
>
|
||||
<img
|
||||
className="loadingIcon"
|
||||
hidden={true}
|
||||
src=""
|
||||
/>
|
||||
</div>
|
||||
<AnimateHeight
|
||||
animateOpacity={false}
|
||||
animationStateClasses={
|
||||
@@ -450,6 +549,15 @@ exports[`TreeNodeComponent renders unsorted children by default 1`] = `
|
||||
label
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="loadingIconContainer"
|
||||
>
|
||||
<img
|
||||
className="loadingIcon"
|
||||
hidden={true}
|
||||
src=""
|
||||
/>
|
||||
</div>
|
||||
<AnimateHeight
|
||||
animateOpacity={false}
|
||||
animationStateClasses={
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
}
|
||||
|
||||
&.showingMenu {
|
||||
background-color: #EEE;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.treeMenuEllipsis {
|
||||
@@ -78,3 +78,12 @@
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingIconContainer {
|
||||
width: 100%;
|
||||
|
||||
.loadingIcon {
|
||||
height: 6px;
|
||||
margin-left: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import CassandraAddCollectionPane from "./Panes/CassandraAddCollectionPane";
|
||||
import Database from "./Tree/Database";
|
||||
import DeleteCollectionConfirmationPane from "./Panes/DeleteCollectionConfirmationPane";
|
||||
import DeleteDatabaseConfirmationPane from "./Panes/DeleteDatabaseConfirmationPane";
|
||||
import { readOffers, refreshCachedResources } from "../Common/DocumentClientUtilityBase";
|
||||
import { refreshCachedResources } from "../Common/DocumentClientUtilityBase";
|
||||
import { readCollection } from "../Common/dataAccess/readCollection";
|
||||
import { readDatabases } from "../Common/dataAccess/readDatabases";
|
||||
import EditTableEntityPane from "./Panes/Tables/EditTableEntityPane";
|
||||
@@ -133,6 +133,7 @@ export default class Explorer {
|
||||
public isPreferredApiGraph: ko.Computed<boolean>;
|
||||
public isPreferredApiTable: ko.Computed<boolean>;
|
||||
public isFixedCollectionWithSharedThroughputSupported: ko.Computed<boolean>;
|
||||
public isEnableMongoCapabilityPresent: ko.Computed<boolean>;
|
||||
public isServerlessEnabled: ko.Computed<boolean>;
|
||||
public isEmulator: boolean;
|
||||
public isAccountReady: ko.Observable<boolean>;
|
||||
@@ -212,6 +213,7 @@ export default class Explorer {
|
||||
public isGalleryPublishEnabled: ko.Computed<boolean>;
|
||||
public isCodeOfConductEnabled: ko.Computed<boolean>;
|
||||
public isLinkInjectionEnabled: ko.Computed<boolean>;
|
||||
public isSettingsV2Enabled: ko.Computed<boolean>;
|
||||
public isGitHubPaneEnabled: ko.Observable<boolean>;
|
||||
public isPublishNotebookPaneEnabled: ko.Observable<boolean>;
|
||||
public isCopyNotebookPaneEnabled: ko.Observable<boolean>;
|
||||
@@ -421,6 +423,7 @@ export default class Explorer {
|
||||
this.isLinkInjectionEnabled = ko.computed<boolean>(() =>
|
||||
this.isFeatureEnabled(Constants.Features.enableLinkInjection)
|
||||
);
|
||||
this.isSettingsV2Enabled = ko.computed<boolean>(() => this.isFeatureEnabled(Constants.Features.enableSettingsV2));
|
||||
this.isGitHubPaneEnabled = ko.observable<boolean>(false);
|
||||
this.isPublishNotebookPaneEnabled = ko.observable<boolean>(false);
|
||||
this.isCopyNotebookPaneEnabled = ko.observable<boolean>(false);
|
||||
@@ -522,22 +525,7 @@ export default class Explorer {
|
||||
return false;
|
||||
}
|
||||
|
||||
const capabilities = this.databaseAccount().properties && this.databaseAccount().properties.capabilities;
|
||||
|
||||
if (!capabilities) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < capabilities.length; i++) {
|
||||
if (typeof capabilities[i] === "object") {
|
||||
if (capabilities[i].name === Constants.CapabilityNames.EnableMongo) {
|
||||
// version 3.6
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return this.isEnableMongoCapabilityPresent();
|
||||
});
|
||||
|
||||
this.isServerlessEnabled = ko.computed(
|
||||
@@ -569,6 +557,21 @@ export default class Explorer {
|
||||
return false;
|
||||
});
|
||||
|
||||
this.isEnableMongoCapabilityPresent = ko.computed(() => {
|
||||
const capabilities = this.databaseAccount && this.databaseAccount()?.properties?.capabilities;
|
||||
if (!capabilities) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < capabilities.length; i++) {
|
||||
if (typeof capabilities[i] === "object" && capabilities[i].name === Constants.CapabilityNames.EnableMongo) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
this.isHostedDataExplorerEnabled = ko.computed<boolean>(
|
||||
() =>
|
||||
this.getPlatformType() === PlatformType.Portal &&
|
||||
@@ -1424,71 +1427,40 @@ export default class Explorer {
|
||||
|
||||
// TODO: Refactor
|
||||
const deferred: Q.Deferred<any> = Q.defer();
|
||||
|
||||
const refreshDatabases = (offers?: DataModels.Offer[]) => {
|
||||
this._setLoadingStatusText("Fetching databases...");
|
||||
readDatabases().then(
|
||||
(databases: DataModels.Database[]) => {
|
||||
this._setLoadingStatusText("Successfully fetched databases.");
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.LoadDatabases,
|
||||
{
|
||||
databaseAccountName: this.databaseAccount().name,
|
||||
defaultExperience: this.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ResourceTree
|
||||
this._setLoadingStatusText("Fetching databases...");
|
||||
readDatabases().then(
|
||||
(databases: DataModels.Database[]) => {
|
||||
this._setLoadingStatusText("Successfully fetched databases.");
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.LoadDatabases,
|
||||
{
|
||||
databaseAccountName: this.databaseAccount().name,
|
||||
defaultExperience: this.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ResourceTree
|
||||
},
|
||||
startKey
|
||||
);
|
||||
const currentlySelectedNode: ViewModels.TreeNode = this.selectedNode();
|
||||
const deltaDatabases = this.getDeltaDatabases(databases);
|
||||
this.addDatabasesToList(deltaDatabases.toAdd);
|
||||
this.deleteDatabasesFromList(deltaDatabases.toDelete);
|
||||
this.selectedNode(currentlySelectedNode);
|
||||
this._setLoadingStatusText("Fetching containers...");
|
||||
this.refreshAndExpandNewDatabases(deltaDatabases.toAdd)
|
||||
.then(
|
||||
() => {
|
||||
this._setLoadingStatusText("Successfully fetched containers.");
|
||||
deferred.resolve();
|
||||
},
|
||||
startKey
|
||||
);
|
||||
const currentlySelectedNode: ViewModels.TreeNode = this.selectedNode();
|
||||
const deltaDatabases = this.getDeltaDatabases(databases, offers);
|
||||
this.addDatabasesToList(deltaDatabases.toAdd);
|
||||
this.deleteDatabasesFromList(deltaDatabases.toDelete);
|
||||
this.selectedNode(currentlySelectedNode);
|
||||
this._setLoadingStatusText("Fetching containers...");
|
||||
this.refreshAndExpandNewDatabases(deltaDatabases.toAdd)
|
||||
.then(
|
||||
() => {
|
||||
this._setLoadingStatusText("Successfully fetched containers.");
|
||||
deferred.resolve();
|
||||
},
|
||||
reason => {
|
||||
this._setLoadingStatusText("Failed to fetch containers.");
|
||||
deferred.reject(reason);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isRefreshingExplorer(false));
|
||||
},
|
||||
error => {
|
||||
this._setLoadingStatusText("Failed to fetch databases.");
|
||||
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);
|
||||
reason => {
|
||||
this._setLoadingStatusText("Failed to fetch containers.");
|
||||
deferred.reject(reason);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isRefreshingExplorer(false));
|
||||
},
|
||||
error => {
|
||||
this._setLoadingStatusText("Failed to fetch offers.");
|
||||
this._setLoadingStatusText("Failed to fetch databases.");
|
||||
this.isRefreshingExplorer(false);
|
||||
deferred.reject(error);
|
||||
TelemetryProcessor.traceFailure(
|
||||
@@ -2103,16 +2075,13 @@ export default class Explorer {
|
||||
defaultExperience: this.defaultExperience && this.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ResourceTree
|
||||
});
|
||||
databasesToLoad.forEach((database: ViewModels.Database) => {
|
||||
loadCollectionPromises.push(
|
||||
database.loadCollections().finally(() => {
|
||||
const isNewDatabase: boolean = _.some(newDatabases, (db: ViewModels.Database) => db.rid === database.rid);
|
||||
if (isNewDatabase) {
|
||||
database.expandDatabase();
|
||||
}
|
||||
this.tabsManager.refreshActiveTab(tab => tab.collection && tab.collection.getDatabase().rid === database.rid);
|
||||
})
|
||||
);
|
||||
databasesToLoad.forEach(async (database: ViewModels.Database) => {
|
||||
await database.loadCollections();
|
||||
const isNewDatabase: boolean = _.some(newDatabases, (db: ViewModels.Database) => db.rid === database.rid);
|
||||
if (isNewDatabase) {
|
||||
database.expandDatabase();
|
||||
}
|
||||
this.tabsManager.refreshActiveTab(tab => tab.collection && tab.collection.getDatabase().rid === database.rid);
|
||||
});
|
||||
|
||||
Q.all(loadCollectionPromises).done(
|
||||
@@ -2257,8 +2226,7 @@ export default class Explorer {
|
||||
}
|
||||
|
||||
private getDeltaDatabases(
|
||||
updatedDatabaseList: DataModels.Database[],
|
||||
updatedOffersList: DataModels.Offer[]
|
||||
updatedDatabaseList: DataModels.Database[]
|
||||
): { toAdd: ViewModels.Database[]; toDelete: ViewModels.Database[] } {
|
||||
const newDatabases: DataModels.Database[] = _.filter(updatedDatabaseList, (database: DataModels.Database) => {
|
||||
const databaseExists = _.some(
|
||||
@@ -2267,10 +2235,9 @@ export default class Explorer {
|
||||
);
|
||||
return !databaseExists;
|
||||
});
|
||||
const databasesToAdd: ViewModels.Database[] = _.map(newDatabases, (newDatabase: DataModels.Database) => {
|
||||
const databaseOffer: DataModels.Offer = this.getOfferForResource(updatedOffersList, newDatabase._self);
|
||||
return new Database(this, newDatabase, databaseOffer);
|
||||
});
|
||||
const databasesToAdd: ViewModels.Database[] = newDatabases.map(
|
||||
(newDatabase: DataModels.Database) => new Database(this, newDatabase)
|
||||
);
|
||||
|
||||
let databasesToDelete: ViewModels.Database[] = [];
|
||||
ko.utils.arrayForEach(this.databases(), (database: ViewModels.Database) => {
|
||||
@@ -2320,10 +2287,6 @@ export default class Explorer {
|
||||
return null;
|
||||
}
|
||||
|
||||
private getOfferForResource(offers: DataModels.Offer[], resourceId: string): DataModels.Offer {
|
||||
return _.find(offers, (offer: DataModels.Offer) => offer.resource === resourceId);
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -3160,4 +3123,15 @@ export default class Explorer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async loadSelectedDatabaseOffer(): Promise<void> {
|
||||
const database = this.findSelectedDatabase();
|
||||
await database?.loadOffer();
|
||||
}
|
||||
|
||||
public async loadDatabaseOffers(): Promise<void> {
|
||||
this.databases()?.forEach(async (database: ViewModels.Database) => {
|
||||
await database.loadOffer();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import { schemeCategory20 } from "d3";
|
||||
import { event as d3Event, selectAll, select } from "d3-selection";
|
||||
import { schemeCategory10 } from "d3-scale-chromatic";
|
||||
import { selectAll, select } from "d3-selection";
|
||||
import { zoom, zoomIdentity } from "d3-zoom";
|
||||
import { scaleOrdinal } from "d3-scale";
|
||||
import { forceSimulation, forceLink, forceCollide, forceManyBody } from "d3-force";
|
||||
import { interpolateNumber, interpolate } from "d3-interpolate";
|
||||
import { map as d3Map } from "d3-collection";
|
||||
import { drag } from "d3-drag";
|
||||
import { drag, D3DragEvent } from "d3-drag";
|
||||
|
||||
import _ from "underscore";
|
||||
import { NeighborType } from "../../../Contracts/ViewModels";
|
||||
@@ -89,7 +89,7 @@ export class D3ForceGraph implements GraphRenderer {
|
||||
private static readonly PAGINATION_LINE2_Y_OFFSET_PX = 14;
|
||||
|
||||
// We limit the number of different colors to 20
|
||||
private static readonly COLOR_SCHEME_20 = scaleOrdinal(schemeCategory20);
|
||||
private static readonly COLOR_SCHEME = scaleOrdinal(schemeCategory10);
|
||||
private static readonly MAX_COLOR_NB = 20;
|
||||
|
||||
// Some state variables
|
||||
@@ -344,13 +344,13 @@ export class D3ForceGraph implements GraphRenderer {
|
||||
} while ((el = el && el.parentNode));
|
||||
}
|
||||
|
||||
private zoomed() {
|
||||
private zoomed(event: any) {
|
||||
this.zoomTransform = {
|
||||
x: d3Event.transform.x,
|
||||
y: d3Event.transform.y,
|
||||
k: d3Event.transform.k
|
||||
x: event.transform.x,
|
||||
y: event.transform.y,
|
||||
k: event.transform.k
|
||||
};
|
||||
this.g.attr("transform", d3Event.transform);
|
||||
this.g.attr("transform", event.transform);
|
||||
}
|
||||
|
||||
private instantiateSimulation() {
|
||||
@@ -719,17 +719,17 @@ export class D3ForceGraph implements GraphRenderer {
|
||||
})
|
||||
.call(
|
||||
drag()
|
||||
.on("start", (d: D3Node) => {
|
||||
return this.dragstarted(d);
|
||||
})
|
||||
.on("drag", (d: D3Node) => {
|
||||
return this.dragged(d);
|
||||
})
|
||||
.on("end", (d: D3Node) => {
|
||||
return this.dragended(d);
|
||||
})
|
||||
.on("start", ((e: D3DragEvent<SVGGElement, D3Node, unknown>, d: D3Node) => {
|
||||
return this.dragstarted(d, e);
|
||||
}) as any)
|
||||
.on("drag", ((e: D3DragEvent<SVGGElement, D3Node, unknown>, d: D3Node) => {
|
||||
return this.dragged(d, e);
|
||||
}) as any)
|
||||
.on("end", ((e: D3DragEvent<SVGGElement, D3Node, unknown>, d: D3Node) => {
|
||||
return this.dragended(d, e);
|
||||
}) as any)
|
||||
)
|
||||
.on("mouseover", (d: D3Node) => {
|
||||
.on("mouseover", (_: MouseEvent, d: D3Node) => {
|
||||
if (this.isHighlightDisabled || this.selectedNode || this.isDragging) {
|
||||
return;
|
||||
}
|
||||
@@ -737,7 +737,7 @@ export class D3ForceGraph implements GraphRenderer {
|
||||
this.highlightNode(this, d);
|
||||
this.simulation.stop();
|
||||
})
|
||||
.on("mouseout", (d: D3Node) => {
|
||||
.on("mouseout", (_: MouseEvent, d: D3Node) => {
|
||||
if (this.isHighlightDisabled || this.selectedNode || this.isDragging) {
|
||||
return;
|
||||
}
|
||||
@@ -765,17 +765,17 @@ export class D3ForceGraph implements GraphRenderer {
|
||||
.attr("aria-label", (d: D3Node) => {
|
||||
return this.retrieveNodeCaption(d);
|
||||
})
|
||||
.on("dblclick", function(d: D3Node) {
|
||||
.on("dblclick", function(_: MouseEvent, d: D3Node) {
|
||||
// this is the <g> element
|
||||
self.onNodeClicked(this.parentNode, d);
|
||||
})
|
||||
.on("click", function(d: D3Node) {
|
||||
.on("click", function(_: MouseEvent, d: D3Node) {
|
||||
// this is the <g> element
|
||||
self.onNodeClicked(this.parentNode, d);
|
||||
})
|
||||
.on("keypress", function(d: D3Node) {
|
||||
if (d3Event.charCode === Constants.KeyCodes.Space || d3Event.charCode === Constants.KeyCodes.Enter) {
|
||||
d3Event.stopPropagation();
|
||||
.on("keypress", function(event: KeyboardEvent, d: D3Node) {
|
||||
if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) {
|
||||
event.stopPropagation();
|
||||
// this is the <g> element
|
||||
self.onNodeClicked(this.parentNode, d);
|
||||
}
|
||||
@@ -850,24 +850,24 @@ export class D3ForceGraph implements GraphRenderer {
|
||||
return `Next page of nodes for ${this.retrieveNodeCaption(d)}`;
|
||||
})
|
||||
.attr("tabindex", 0)
|
||||
.on("click", function(d: D3Node) {
|
||||
.on("click", ((_: MouseEvent, d: D3Node) => {
|
||||
self.loadNeighbors(d, PAGE_ACTION.NEXT_PAGE);
|
||||
})
|
||||
.on("dblclick", function(d: D3Node) {
|
||||
}) as any)
|
||||
.on("dblclick", ((_: MouseEvent, d: D3Node) => {
|
||||
self.loadNeighbors(d, PAGE_ACTION.NEXT_PAGE);
|
||||
})
|
||||
.on("keypress", function(d: D3Node) {
|
||||
if (d3Event.charCode === Constants.KeyCodes.Space || d3Event.charCode === Constants.KeyCodes.Enter) {
|
||||
d3Event.stopPropagation();
|
||||
}) as any)
|
||||
.on("keypress", ((event: KeyboardEvent, d: D3Node) => {
|
||||
if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) {
|
||||
event.stopPropagation();
|
||||
self.loadNeighbors(d, PAGE_ACTION.NEXT_PAGE);
|
||||
}
|
||||
})
|
||||
.on("mouseover", function(d: D3Node) {
|
||||
select(this).classed("active", true);
|
||||
})
|
||||
.on("mouseout", function(d: D3Node) {
|
||||
select(this).classed("active", false);
|
||||
})
|
||||
}) as any)
|
||||
.on("mouseover", ((e: MouseEvent, d: D3Node) => {
|
||||
select(e.target as any).classed("active", true);
|
||||
}) as any)
|
||||
.on("mouseout", ((e: MouseEvent, d: D3Node) => {
|
||||
select(e.target as any).classed("active", false);
|
||||
}) as any)
|
||||
.attr("visibility", (d: D3Node) => (!d._outEAllLoaded || !d._inEAllLoaded ? "visible" : "hidden"));
|
||||
parent
|
||||
.append("use")
|
||||
@@ -879,24 +879,24 @@ export class D3ForceGraph implements GraphRenderer {
|
||||
return `Previous page of nodes for ${this.retrieveNodeCaption(d)}`;
|
||||
})
|
||||
.attr("tabindex", 0)
|
||||
.on("click", function(d: D3Node) {
|
||||
.on("click", ((_: MouseEvent, d: D3Node) => {
|
||||
self.loadNeighbors(d, PAGE_ACTION.PREVIOUS_PAGE);
|
||||
})
|
||||
.on("dblclick", function(d: D3Node) {
|
||||
}) as any)
|
||||
.on("dblclick", ((_: MouseEvent, d: D3Node) => {
|
||||
self.loadNeighbors(d, PAGE_ACTION.PREVIOUS_PAGE);
|
||||
})
|
||||
.on("keypress", function(d: D3Node) {
|
||||
if (d3Event.charCode === Constants.KeyCodes.Space || d3Event.charCode === Constants.KeyCodes.Enter) {
|
||||
d3Event.stopPropagation();
|
||||
}) as any)
|
||||
.on("keypress", ((event: KeyboardEvent, d: D3Node) => {
|
||||
if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) {
|
||||
event.stopPropagation();
|
||||
self.loadNeighbors(d, PAGE_ACTION.PREVIOUS_PAGE);
|
||||
}
|
||||
})
|
||||
.on("mouseover", function(d: D3Node) {
|
||||
select(this).classed("active", true);
|
||||
})
|
||||
.on("mouseout", function(d: D3Node) {
|
||||
select(this).classed("active", false);
|
||||
})
|
||||
}) as any)
|
||||
.on("mouseover", ((e: MouseEvent, d: D3Node) => {
|
||||
select(e.target as any).classed("active", true);
|
||||
}) as any)
|
||||
.on("mouseout", ((e: MouseEvent, d: D3Node) => {
|
||||
select(e.target as any).classed("active", false);
|
||||
}) as any)
|
||||
.attr("visibility", (d: D3Node) =>
|
||||
!d._pagination || d._pagination.currentPage.start !== 0 ? "visible" : "hidden"
|
||||
);
|
||||
@@ -975,24 +975,24 @@ export class D3ForceGraph implements GraphRenderer {
|
||||
return `Load adjacent nodes for ${this.retrieveNodeCaption(d)}`;
|
||||
})
|
||||
.attr("tabindex", 0)
|
||||
.on("click", function(d: D3Node) {
|
||||
.on("click", ((_: MouseEvent, d: D3Node) => {
|
||||
self.loadNeighbors(d, PAGE_ACTION.FIRST_PAGE);
|
||||
})
|
||||
.on("dblclick", function(d: D3Node) {
|
||||
}) as any)
|
||||
.on("dblclick", ((_: MouseEvent, d: D3Node) => {
|
||||
self.loadNeighbors(d, PAGE_ACTION.FIRST_PAGE);
|
||||
})
|
||||
.on("keypress", function(d: D3Node) {
|
||||
if (d3Event.charCode === Constants.KeyCodes.Space || d3Event.charCode === Constants.KeyCodes.Enter) {
|
||||
d3Event.stopPropagation();
|
||||
}) as any)
|
||||
.on("keypress", ((event: KeyboardEvent, d: D3Node) => {
|
||||
if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) {
|
||||
event.stopPropagation();
|
||||
self.loadNeighbors(d, PAGE_ACTION.FIRST_PAGE);
|
||||
}
|
||||
})
|
||||
.on("mouseover", function(d: D3Node) {
|
||||
select(this).classed("active", true);
|
||||
})
|
||||
.on("mouseout", function(d: D3Node) {
|
||||
select(this).classed("active", false);
|
||||
});
|
||||
}) as any)
|
||||
.on("mouseover", ((e: MouseEvent, d: D3Node) => {
|
||||
select(e.target as any).classed("active", true);
|
||||
}) as any)
|
||||
.on("mouseout", ((e: MouseEvent, d: D3Node) => {
|
||||
select(e.target as any).classed("active", false);
|
||||
}) as any);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1053,7 +1053,7 @@ export class D3ForceGraph implements GraphRenderer {
|
||||
if (index < 0 || index >= D3ForceGraph.MAX_COLOR_NB) {
|
||||
index = D3ForceGraph.MAX_COLOR_NB - 1;
|
||||
}
|
||||
return D3ForceGraph.COLOR_SCHEME_20(index.toString());
|
||||
return D3ForceGraph.COLOR_SCHEME(index.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1071,23 +1071,23 @@ export class D3ForceGraph implements GraphRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
private dragstarted(d: D3Node) {
|
||||
private dragstarted(d: D3Node, event: D3DragEvent<SVGGElement, D3Node, unknown>) {
|
||||
this.isDragging = true;
|
||||
if (!d3Event.active) {
|
||||
if (!event.active) {
|
||||
this.simulation.alphaTarget(0.3).restart();
|
||||
}
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
private dragged(d: D3Node) {
|
||||
d.fx = d3Event.x;
|
||||
d.fy = d3Event.y;
|
||||
private dragged(d: D3Node, event: D3DragEvent<SVGGElement, D3Node, unknown>) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
private dragended(d: D3Node) {
|
||||
private dragended(d: D3Node, event: D3DragEvent<SVGGElement, D3Node, unknown>) {
|
||||
this.isDragging = false;
|
||||
if (!d3Event.active) {
|
||||
if (!event.active) {
|
||||
this.simulation.alphaTarget(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -391,31 +391,6 @@ export class CommandBarComponentButtonFactory {
|
||||
return buttons;
|
||||
}
|
||||
|
||||
private static createScaleAndSettingsButton(container: Explorer): CommandButtonComponentProps {
|
||||
let isShared = false;
|
||||
if (container.isDatabaseNodeSelected()) {
|
||||
isShared = container.findSelectedDatabase().isDatabaseShared();
|
||||
} else if (container.isNodeKindSelected("Collection")) {
|
||||
const database: ViewModels.Database = container.findSelectedCollection().getDatabase();
|
||||
isShared = database && database.isDatabaseShared();
|
||||
}
|
||||
|
||||
const label = isShared ? "Settings" : "Scale & Settings";
|
||||
|
||||
return {
|
||||
iconSrc: ScaleIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: () => {
|
||||
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
|
||||
selectedCollection && (<any>selectedCollection).onSettingsClick();
|
||||
},
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: true,
|
||||
disabled: container.isDatabaseNodeOrNoneSelected()
|
||||
};
|
||||
}
|
||||
|
||||
private static createNewNotebookButton(container: Explorer): CommandButtonComponentProps {
|
||||
const label = "New Notebook";
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
makeAppRecord,
|
||||
makeCommsRecord,
|
||||
makeContentsRecord,
|
||||
makeEditorsRecord,
|
||||
makeEntitiesRecord,
|
||||
makeHostsRecord,
|
||||
makeJupyterHostRecord,
|
||||
@@ -34,6 +35,7 @@ import configureStore from "./NotebookComponent/store";
|
||||
import { Notification } from "react-notification-system";
|
||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { configOption, createConfigCollection, defineConfigOption } from "@nteract/mythic-configuration";
|
||||
|
||||
export type KernelSpecsDisplay = { name: string; displayName: string };
|
||||
|
||||
@@ -112,20 +114,15 @@ export class NotebookClientV2 {
|
||||
host: jupyterHostRecord
|
||||
// TODO: tamitta: notificationSystem.addNotification was removed, do we need a substitute?
|
||||
}),
|
||||
comms: makeCommsRecord(),
|
||||
config: Immutable.Map({
|
||||
theme: "light",
|
||||
editorType: params.cellEditorType || "codemirror",
|
||||
autoSaveInterval: params.autoSaveInterval || Constants.Notebook.autoSaveIntervalMs
|
||||
}),
|
||||
core: makeStateRecord({
|
||||
currentKernelspecsRef: kernelspecsRef,
|
||||
entities: makeEntitiesRecord({
|
||||
editors: makeEditorsRecord({}),
|
||||
hosts: makeHostsRecord({
|
||||
byRef: Immutable.Map<string, HostRecord>().set(this.contentHostRef, jupyterHostRecord)
|
||||
}),
|
||||
comms: makeCommsRecord(),
|
||||
contents: makeContentsRecord({
|
||||
// byRef: Immutable.Map<string, ContentRecord>().set(this.contentRef, record)
|
||||
byRef: Immutable.Map<string, ContentRecord>()
|
||||
}),
|
||||
transforms: makeTransformsRecord({
|
||||
@@ -238,6 +235,24 @@ export class NotebookClientV2 {
|
||||
};
|
||||
|
||||
this.store = configureStore(initialState, params.contentProvider, traceErrorFct, [cacheKernelSpecsMiddleware]);
|
||||
|
||||
// Additional configuration
|
||||
this.store.dispatch(configOption("editorType").action(params.cellEditorType ?? "monaco"));
|
||||
this.store.dispatch(
|
||||
configOption("autoSaveInterval").action(params.autoSaveInterval ?? Constants.Notebook.autoSaveIntervalMs)
|
||||
);
|
||||
createConfigCollection({
|
||||
key: "monaco"
|
||||
});
|
||||
defineConfigOption({
|
||||
label: "Show Line numbers",
|
||||
key: "monaco.lineNumbers",
|
||||
values: [
|
||||
{ label: "Yes", value: true },
|
||||
{ label: "No", value: false }
|
||||
],
|
||||
defaultValue: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -196,7 +196,6 @@ export class NotebookComponentBootstrapper {
|
||||
this.getStore().dispatch(
|
||||
actions.createCellBelow({
|
||||
cellType: "code",
|
||||
source: "",
|
||||
contentRef: this.contentRef
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { StringUtils } from "../../../../../Utils/StringUtils";
|
||||
import { actions, AppState, ContentRef, selectors } from "@nteract/core";
|
||||
import { MonacoEditorProps } from "@nteract/monaco-editor";
|
||||
import { IMonacoProps as MonacoEditorProps } from "@nteract/monaco-editor";
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
@@ -21,7 +21,7 @@ interface MappedStateProps {
|
||||
mimetype: string;
|
||||
text: string;
|
||||
contentRef: ContentRef;
|
||||
theme: string; // "light" | "dark";
|
||||
theme?: "light" | "dark";
|
||||
}
|
||||
|
||||
interface MappedDispatchProps {
|
||||
@@ -65,8 +65,10 @@ export class TextFile extends React.PureComponent<TextFileProps, TextFileState>
|
||||
return (
|
||||
<EditorContainer className="nteract-editor" style={{ position: "static" }}>
|
||||
<Editor
|
||||
id={"no-cell-id-for-single-editor"}
|
||||
contentRef={this.props.contentRef}
|
||||
theme={this.props.theme === "dark" ? "vs-dark" : "vs"}
|
||||
mode={this.props.mimetype}
|
||||
language={"plaintext"}
|
||||
editorFocused
|
||||
value={this.props.text}
|
||||
onChange={this.handleChange.bind(this)}
|
||||
@@ -97,8 +99,7 @@ function makeMapStateToTextFileProps(
|
||||
return {
|
||||
contentRef,
|
||||
mimetype: content.mimetype != null ? content.mimetype : "text/plain",
|
||||
text,
|
||||
theme: selectors.currentTheme(state)
|
||||
text
|
||||
};
|
||||
};
|
||||
return mapStateToTextFileProps;
|
||||
|
||||
@@ -153,7 +153,7 @@ const mapDispatchToProps = (dispatch: Dispatch, ownProps: ContentsProps): object
|
||||
},
|
||||
COPY_CELL: () => dispatch(actions.copyCell({ contentRef })),
|
||||
CREATE_CELL_ABOVE: () => dispatch(actions.createCellAbove({ cellType: "code", contentRef })),
|
||||
CREATE_CELL_BELOW: () => dispatch(actions.createCellBelow({ cellType: "code", source: "", contentRef })),
|
||||
CREATE_CELL_BELOW: () => dispatch(actions.createCellBelow({ cellType: "code", contentRef })),
|
||||
CUT_CELL: () => dispatch(actions.cutCell({ contentRef })),
|
||||
DELETE_CELL: () => dispatch(actions.deleteCell({ contentRef })),
|
||||
EXECUTE_ALL_CELLS: () => dispatch(actions.executeAllCells({ contentRef })),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Immutable from "immutable";
|
||||
import { ActionsObservable, StateObservable } from "redux-observable";
|
||||
import { Subject, empty } from "rxjs";
|
||||
import { StateObservable } from "redux-observable";
|
||||
import { Subject, of } from "rxjs";
|
||||
import { toArray } from "rxjs/operators";
|
||||
import { makeNotebookRecord } from "@nteract/commutable";
|
||||
import { actions, state } from "@nteract/core";
|
||||
@@ -124,7 +124,7 @@ describe("launchWebSocketKernelEpic", () => {
|
||||
const kernelSpecName = "kernelspecname";
|
||||
const sessionId = "sessionId";
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
const action$ = of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
@@ -192,7 +192,7 @@ describe("launchWebSocketKernelEpic", () => {
|
||||
const kernelSpecName = "kernelspecname";
|
||||
const sessionId = "sessionId";
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
const action$ = of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
@@ -247,7 +247,7 @@ describe("launchWebSocketKernelEpic", () => {
|
||||
const kernelSpecName = "kernelspecname";
|
||||
const sessionId = "sessionId";
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
const action$ = of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
@@ -298,7 +298,7 @@ describe("launchWebSocketKernelEpic", () => {
|
||||
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
|
||||
|
||||
const cwd = "/";
|
||||
const action$ = ActionsObservable.of(
|
||||
const action$ = of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
@@ -398,7 +398,7 @@ describe("launchWebSocketKernelEpic", () => {
|
||||
it("launches supported kernel in kernelspecs", async () => {
|
||||
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
const action$ = of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
@@ -421,7 +421,7 @@ describe("launchWebSocketKernelEpic", () => {
|
||||
it("launches undefined kernel uses default kernel from kernelspecs", async () => {
|
||||
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
const action$ = of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
@@ -445,7 +445,7 @@ describe("launchWebSocketKernelEpic", () => {
|
||||
it("launches unsupported kernel uses default kernel from kernelspecs", async () => {
|
||||
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
const action$ = of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
@@ -469,7 +469,7 @@ describe("launchWebSocketKernelEpic", () => {
|
||||
it("launches unsupported kernel uses kernelspecs with similar name", async () => {
|
||||
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
const action$ = of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
@@ -499,7 +499,7 @@ describe("autoStartKernelEpic", () => {
|
||||
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(
|
||||
const action$ = of(
|
||||
actions.fetchContentFulfilled({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
@@ -527,7 +527,7 @@ describe("autoStartKernelEpic", () => {
|
||||
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(
|
||||
const action$ = of(
|
||||
actions.fetchContentFulfilled({
|
||||
contentRef,
|
||||
kernelRef: undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EMPTY, merge, of, timer, concat, Subject, Subscriber, Observable, Observer } from "rxjs";
|
||||
import { webSocket } from "rxjs/webSocket";
|
||||
import { ActionsObservable, StateObservable } from "redux-observable";
|
||||
import { StateObservable } from "redux-observable";
|
||||
import { ofType } from "redux-observable";
|
||||
import {
|
||||
mergeMap,
|
||||
@@ -65,7 +65,7 @@ const logToTelemetry = (state: CdbAppState, title: string, error?: string) => {
|
||||
* @param state$
|
||||
*/
|
||||
const addInitialCodeCellEpic = (
|
||||
action$: ActionsObservable<actions.FetchContentFulfilled>,
|
||||
action$: Observable<actions.FetchContentFulfilled>,
|
||||
state$: StateObservable<AppState>
|
||||
): Observable<{} | actions.CreateCellBelow> => {
|
||||
return action$.pipe(
|
||||
@@ -104,7 +104,7 @@ const addInitialCodeCellEpic = (
|
||||
* @param state$
|
||||
*/
|
||||
export const autoStartKernelEpic = (
|
||||
action$: ActionsObservable<actions.FetchContentFulfilled>,
|
||||
action$: Observable<actions.FetchContentFulfilled>,
|
||||
state$: StateObservable<AppState>
|
||||
): Observable<{} | actions.CreateCellBelow> => {
|
||||
return action$.pipe(
|
||||
@@ -157,7 +157,7 @@ const formWebSocketURL = (serverConfig: NotebookServiceConfig, kernelId: string,
|
||||
* Override from kernel-lifecycle to improve code mirror language intellisense
|
||||
* @param action$
|
||||
*/
|
||||
export const acquireKernelInfoEpic = (action$: ActionsObservable<actions.NewKernelAction>) => {
|
||||
export const acquireKernelInfoEpic = (action$: Observable<actions.NewKernelAction>) => {
|
||||
return action$.pipe(
|
||||
ofType(actions.LAUNCH_KERNEL_SUCCESSFUL),
|
||||
switchMap((action: actions.NewKernelAction) => {
|
||||
@@ -310,7 +310,7 @@ const connect = (serverConfig: NotebookServiceConfig, kernelID: string, sessionI
|
||||
* @param state$
|
||||
*/
|
||||
export const launchWebSocketKernelEpic = (
|
||||
action$: ActionsObservable<actions.LaunchKernelByNameAction>,
|
||||
action$: Observable<actions.LaunchKernelByNameAction>,
|
||||
state$: StateObservable<CdbAppState>
|
||||
) => {
|
||||
return action$.pipe(
|
||||
@@ -422,7 +422,7 @@ export const launchWebSocketKernelEpic = (
|
||||
* TODO: Remove this epic once the /restart endpoint is implemented.
|
||||
*/
|
||||
export const restartWebSocketKernelEpic = (
|
||||
action$: ActionsObservable<actions.RestartKernel | actions.NewKernelAction>,
|
||||
action$: Observable<actions.RestartKernel | actions.NewKernelAction>,
|
||||
state$: StateObservable<AppState>
|
||||
) =>
|
||||
action$.pipe(
|
||||
@@ -532,7 +532,7 @@ export const restartWebSocketKernelEpic = (
|
||||
* @param state$
|
||||
*/
|
||||
const changeWebSocketKernelEpic = (
|
||||
action$: ActionsObservable<actions.ChangeKernelByName>,
|
||||
action$: Observable<actions.ChangeKernelByName>,
|
||||
state$: StateObservable<AppState>
|
||||
) => {
|
||||
return action$.pipe(
|
||||
@@ -614,7 +614,7 @@ const changeWebSocketKernelEpic = (
|
||||
* @param state$
|
||||
*/
|
||||
const focusInitialCodeCellEpic = (
|
||||
action$: ActionsObservable<actions.CreateCellAppend>,
|
||||
action$: Observable<actions.CreateCellAppend>,
|
||||
state$: StateObservable<AppState>
|
||||
): Observable<{} | actions.FocusCell> => {
|
||||
return action$.pipe(
|
||||
@@ -652,10 +652,7 @@ const focusInitialCodeCellEpic = (
|
||||
* @param action$
|
||||
* @param state$
|
||||
*/
|
||||
const notificationsToUserEpic = (
|
||||
action$: ActionsObservable<any>,
|
||||
state$: StateObservable<CdbAppState>
|
||||
): Observable<{}> => {
|
||||
const notificationsToUserEpic = (action$: Observable<any>, state$: StateObservable<CdbAppState>): Observable<{}> => {
|
||||
return action$.pipe(
|
||||
ofType(
|
||||
actions.RESTART_KERNEL_SUCCESSFUL,
|
||||
@@ -705,7 +702,7 @@ const notificationsToUserEpic = (
|
||||
* @param state$
|
||||
*/
|
||||
const handleKernelConnectionLostEpic = (
|
||||
action$: ActionsObservable<actions.UpdateDisplayFailed>,
|
||||
action$: Observable<actions.UpdateDisplayFailed>,
|
||||
state$: StateObservable<CdbAppState>
|
||||
): Observable<CdbActions.UpdateKernelRestartDelayAction | actions.RestartKernel | {}> => {
|
||||
return action$.pipe(
|
||||
@@ -766,7 +763,7 @@ const handleKernelConnectionLostEpic = (
|
||||
* @param state$
|
||||
*/
|
||||
export const cleanKernelOnConnectionLostEpic = (
|
||||
action$: ActionsObservable<actions.UpdateDisplayFailed>,
|
||||
action$: Observable<actions.UpdateDisplayFailed>,
|
||||
state$: StateObservable<AppState>
|
||||
): Observable<actions.KillKernelSuccessful> => {
|
||||
return action$.pipe(
|
||||
@@ -789,7 +786,7 @@ export const cleanKernelOnConnectionLostEpic = (
|
||||
* @param state$
|
||||
*/
|
||||
const executeFocusedCellAndFocusNextEpic = (
|
||||
action$: ActionsObservable<CdbActions.ExecuteFocusedCellAndFocusNextAction>,
|
||||
action$: Observable<CdbActions.ExecuteFocusedCellAndFocusNextAction>,
|
||||
state$: StateObservable<AppState>
|
||||
): Observable<{} | actions.FocusNextCellEditor> => {
|
||||
return action$.pipe(
|
||||
@@ -829,7 +826,7 @@ function getUserPuid(): string {
|
||||
* @param state$
|
||||
*/
|
||||
const closeUnsupportedMimetypesEpic = (
|
||||
action$: ActionsObservable<actions.FetchContentFulfilled>,
|
||||
action$: Observable<actions.FetchContentFulfilled>,
|
||||
state$: StateObservable<AppState>
|
||||
): Observable<{}> => {
|
||||
return action$.pipe(
|
||||
@@ -858,7 +855,7 @@ const closeUnsupportedMimetypesEpic = (
|
||||
* @param state$
|
||||
*/
|
||||
const closeContentFailedToFetchEpic = (
|
||||
action$: ActionsObservable<actions.FetchContentFailed>,
|
||||
action$: Observable<actions.FetchContentFailed>,
|
||||
state$: StateObservable<AppState>
|
||||
): Observable<{}> => {
|
||||
return action$.pipe(
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
import { AppState, epics as coreEpics, reducers, IContentProvider } from "@nteract/core";
|
||||
import {
|
||||
applyMiddleware,
|
||||
combineReducers,
|
||||
compose,
|
||||
createStore,
|
||||
Store,
|
||||
AnyAction,
|
||||
Middleware,
|
||||
Dispatch,
|
||||
MiddlewareAPI
|
||||
} from "redux";
|
||||
import { combineEpics, createEpicMiddleware, Epic, ActionsObservable } from "redux-observable";
|
||||
import { compose, Store, AnyAction, Middleware, Dispatch, MiddlewareAPI } from "redux";
|
||||
import { createEpicMiddleware, Epic } from "redux-observable";
|
||||
import { allEpics } from "./epics";
|
||||
import { coreReducer, cdbReducer } from "./reducers";
|
||||
import { catchError } from "rxjs/operators";
|
||||
import { Observable } from "rxjs";
|
||||
import { configuration } from "@nteract/mythic-configuration";
|
||||
import { makeConfigureStore } from "@nteract/myths";
|
||||
import { CdbAppState } from "./types";
|
||||
|
||||
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
|
||||
export default function configureStore(
|
||||
initialState: Partial<AppState>,
|
||||
initialState: Partial<CdbAppState>,
|
||||
contentProvider: IContentProvider,
|
||||
onTraceFailure: (title: string, message: string) => void,
|
||||
customMiddlewares?: Middleware<{}, any, Dispatch<AnyAction>>[]
|
||||
): Store<AppState, AnyAction> {
|
||||
const rootReducer = combineReducers({
|
||||
app: reducers.app,
|
||||
comms: reducers.comms,
|
||||
config: reducers.config,
|
||||
core: coreReducer,
|
||||
cdb: cdbReducer
|
||||
});
|
||||
|
||||
): Store<CdbAppState, AnyAction> {
|
||||
/**
|
||||
* Catches errors in reducers
|
||||
*/
|
||||
@@ -46,7 +32,7 @@ export default function configureStore(
|
||||
};
|
||||
|
||||
const protect = (epic: Epic) => {
|
||||
return (action$: ActionsObservable<any>, state$: any, dependencies: any) =>
|
||||
return (action$: Observable<any>, state$: any, dependencies: any) =>
|
||||
epic(action$, state$, dependencies).pipe(
|
||||
catchError((error, caught) => {
|
||||
traceFailure("Epic failure", error);
|
||||
@@ -64,9 +50,8 @@ export default function configureStore(
|
||||
}
|
||||
};
|
||||
|
||||
const combineAndProtectEpics = (epics: Epic[]): Epic => {
|
||||
const protectedEpics = epics.map(epic => protect(epic));
|
||||
return combineEpics<Epic>(...protectedEpics);
|
||||
const protectEpics = (epics: Epic[]): Epic[] => {
|
||||
return epics.map(epic => protect(epic));
|
||||
};
|
||||
|
||||
// This list needs to be consistent and in sync with core.allEpics until we figure
|
||||
@@ -93,20 +78,23 @@ export default function configureStore(
|
||||
coreEpics.publishToBookstoreAfterSave,
|
||||
coreEpics.sendInputReplyEpic
|
||||
];
|
||||
const rootEpic = combineAndProtectEpics([...filteredCoreEpics, ...allEpics]);
|
||||
const epicMiddleware = createEpicMiddleware({ dependencies: { contentProvider } });
|
||||
let middlewares: Middleware[] = [epicMiddleware];
|
||||
// TODO: tamitta: errorMiddleware was removed, do we need a substitute?
|
||||
|
||||
if (customMiddlewares) {
|
||||
middlewares = middlewares.concat(customMiddlewares);
|
||||
}
|
||||
middlewares.push(catchErrorMiddleware);
|
||||
const mythConfigureStore = makeConfigureStore<CdbAppState>()({
|
||||
packages: [configuration],
|
||||
reducers: {
|
||||
app: reducers.app,
|
||||
core: coreReducer as any,
|
||||
cdb: cdbReducer
|
||||
},
|
||||
epics: protectEpics([...filteredCoreEpics, ...allEpics]),
|
||||
epicDependencies: { contentProvider },
|
||||
epicMiddleware: [catchErrorMiddleware],
|
||||
enhancer: composeEnhancers
|
||||
});
|
||||
|
||||
const store = createStore(rootReducer, initialState, composeEnhancers(applyMiddleware(...middlewares)));
|
||||
|
||||
epicMiddleware.run(rootEpic);
|
||||
const store = mythConfigureStore(initialState as any);
|
||||
|
||||
// TODO Fix typing issue here: createStore() output type doesn't quite match AppState
|
||||
return store as Store<AppState, AnyAction>;
|
||||
// return store as Store<AppState, AnyAction>;
|
||||
return store as any;
|
||||
}
|
||||
|
||||
@@ -113,11 +113,14 @@ export default class NotebookManager {
|
||||
this.params.resourceTree.initializeGitHubRepos(pinnedRepos);
|
||||
this.params.resourceTree.triggerRender();
|
||||
});
|
||||
this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope);
|
||||
this.refreshPinnedRepos();
|
||||
}
|
||||
|
||||
public refreshPinnedRepos(): void {
|
||||
this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope);
|
||||
const token = this.gitHubOAuthService.getTokenObservable()();
|
||||
if (token) {
|
||||
this.junoClient.getPinnedRepos(token.scope);
|
||||
}
|
||||
}
|
||||
|
||||
public async openPublishNotebookPane(
|
||||
|
||||
@@ -3,31 +3,21 @@ import "./base.css";
|
||||
import "./default.css";
|
||||
|
||||
import { CodeCell, RawCell, Cells, MarkdownCell } from "@nteract/stateful-components";
|
||||
import Prompt, { PassedPromptProps } from "@nteract/stateful-components/lib/inputs/prompt";
|
||||
import { AzureTheme } from "./AzureTheme";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
import { actions, ContentRef } from "@nteract/core";
|
||||
import loadTransform from "../NotebookComponent/loadTransform";
|
||||
import CodeMirrorEditor from "@nteract/stateful-components/lib/inputs/connected-editors/codemirror";
|
||||
import MonacoEditor from "@nteract/stateful-components/lib/inputs/connected-editors/monacoEditor";
|
||||
import { PassedEditorProps } from "@nteract/stateful-components/lib/inputs/editor";
|
||||
import "./NotebookReadOnlyRenderer.less";
|
||||
|
||||
export interface NotebookRendererProps {
|
||||
contentRef: any;
|
||||
hideInputs?: boolean;
|
||||
}
|
||||
|
||||
interface PassedEditorProps {
|
||||
id: string;
|
||||
contentRef: ContentRef;
|
||||
editorFocused: boolean;
|
||||
value: string;
|
||||
channels: any;
|
||||
kernelStatus: string;
|
||||
theme: string;
|
||||
onChange: (text: string) => void;
|
||||
onFocusChange: (focused: boolean) => void;
|
||||
className: string;
|
||||
hidePrompts?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +28,29 @@ class NotebookReadOnlyRenderer extends React.Component<NotebookRendererProps> {
|
||||
loadTransform(this.props as any);
|
||||
}
|
||||
|
||||
private renderPrompt(id: string, contentRef: string): JSX.Element {
|
||||
if (this.props.hidePrompts) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Prompt id={id} contentRef={contentRef}>
|
||||
{(props: PassedPromptProps) => {
|
||||
if (props.status === "busy") {
|
||||
return <React.Fragment>{"[*]"}</React.Fragment>;
|
||||
}
|
||||
if (props.status === "queued") {
|
||||
return <React.Fragment>{"[…]"}</React.Fragment>;
|
||||
}
|
||||
if (typeof props.executionCount === "number") {
|
||||
return <React.Fragment>{`[${props.executionCount}]`}</React.Fragment>;
|
||||
}
|
||||
return <React.Fragment>{"[ ]"}</React.Fragment>;
|
||||
}}
|
||||
</Prompt>
|
||||
);
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
return (
|
||||
<div className="NotebookReadOnlyRender">
|
||||
@@ -46,9 +59,10 @@ class NotebookReadOnlyRenderer extends React.Component<NotebookRendererProps> {
|
||||
code: ({ id, contentRef }: { id: any; contentRef: ContentRef }) => (
|
||||
<CodeCell id={id} contentRef={contentRef}>
|
||||
{{
|
||||
prompt: (props: { id: string; contentRef: string }) => this.renderPrompt(props.id, props.contentRef),
|
||||
editor: {
|
||||
codemirror: (props: PassedEditorProps) =>
|
||||
this.props.hideInputs ? <></> : <CodeMirrorEditor {...props} readOnly={"nocursor"} />
|
||||
monaco: (props: PassedEditorProps) =>
|
||||
this.props.hideInputs ? <></> : <MonacoEditor readOnly={true} {...props} editorType={"monaco"} />
|
||||
}
|
||||
}}
|
||||
</CodeCell>
|
||||
@@ -64,8 +78,8 @@ class NotebookReadOnlyRenderer extends React.Component<NotebookRendererProps> {
|
||||
<RawCell id={id} contentRef={contentRef} cell_type="raw">
|
||||
{{
|
||||
editor: {
|
||||
codemirror: (props: PassedEditorProps) =>
|
||||
this.props.hideInputs ? <></> : <CodeMirrorEditor {...props} readOnly={"nocursor"} />
|
||||
monaco: (props: PassedEditorProps) =>
|
||||
this.props.hideInputs ? <></> : <MonacoEditor {...props} readOnly={true} editorType={"monaco"} />
|
||||
}
|
||||
}}
|
||||
</RawCell>
|
||||
|
||||
@@ -3,7 +3,8 @@ import "./base.css";
|
||||
import "./default.css";
|
||||
|
||||
import { RawCell, Cells, CodeCell, MarkdownCell } from "@nteract/stateful-components";
|
||||
import CodeMirrorEditor from "@nteract/stateful-components/lib/inputs/connected-editors/codemirror";
|
||||
import MonacoEditor from "@nteract/stateful-components/lib/inputs/connected-editors/monacoEditor";
|
||||
import { PassedEditorProps } from "@nteract/stateful-components/lib/inputs/editor";
|
||||
|
||||
import Prompt from "./Prompt";
|
||||
import { promptContent } from "./PromptContent";
|
||||
@@ -42,19 +43,6 @@ interface NotebookRendererDispatchProps {
|
||||
|
||||
type NotebookRendererProps = NotebookRendererBaseProps & NotebookRendererDispatchProps;
|
||||
|
||||
interface PassedEditorProps {
|
||||
id: string;
|
||||
contentRef: ContentRef;
|
||||
editorFocused: boolean;
|
||||
value: string;
|
||||
channels: any;
|
||||
kernelStatus: string;
|
||||
theme: string;
|
||||
onChange: (text: string) => void;
|
||||
onFocusChange: (focused: boolean) => void;
|
||||
className: string;
|
||||
}
|
||||
|
||||
const decorate = (id: string, contentRef: ContentRef, cell_type: CellType, children: React.ReactNode) => {
|
||||
const Cell = () => (
|
||||
<DraggableCell id={id} contentRef={contentRef}>
|
||||
@@ -115,9 +103,7 @@ class BaseNotebookRenderer extends React.Component<NotebookRendererProps> {
|
||||
<CodeCell id={id} contentRef={contentRef} cell_type="code">
|
||||
{{
|
||||
editor: {
|
||||
codemirror: (props: PassedEditorProps) => (
|
||||
<CodeMirrorEditor {...props} lineNumbers={true} />
|
||||
)
|
||||
monaco: (props: PassedEditorProps) => <MonacoEditor {...props} editorType={"monaco"} />
|
||||
},
|
||||
prompt: ({ id, contentRef }: { id: CellId; contentRef: ContentRef }) => (
|
||||
<Prompt id={id} contentRef={contentRef} isHovered={false}>
|
||||
@@ -135,6 +121,9 @@ class BaseNotebookRenderer extends React.Component<NotebookRendererProps> {
|
||||
"markdown",
|
||||
<MarkdownCell id={id} contentRef={contentRef} cell_type="markdown">
|
||||
{{
|
||||
editor: {
|
||||
monaco: (props: PassedEditorProps) => <MonacoEditor {...props} editorType={"monaco"} />
|
||||
},
|
||||
toolbar: () => <CellToolbar id={id} contentRef={contentRef} />
|
||||
}}
|
||||
</MarkdownCell>
|
||||
@@ -147,6 +136,9 @@ class BaseNotebookRenderer extends React.Component<NotebookRendererProps> {
|
||||
"raw",
|
||||
<RawCell id={id} contentRef={contentRef} cell_type="raw">
|
||||
{{
|
||||
editor: {
|
||||
monaco: (props: PassedEditorProps) => <MonacoEditor {...props} editorType={"monaco"} />
|
||||
},
|
||||
toolbar: () => <CellToolbar id={id} contentRef={contentRef} />
|
||||
}}
|
||||
</RawCell>
|
||||
|
||||
@@ -150,9 +150,9 @@ const mapDispatchToProps = (
|
||||
): DispatchProps => ({
|
||||
executeCell: () => dispatch(actions.executeCell({ id, contentRef })),
|
||||
insertCodeCellAbove: () => dispatch(actions.createCellAbove({ id, contentRef, cellType: "code" })),
|
||||
insertCodeCellBelow: () => dispatch(actions.createCellBelow({ id, contentRef, cellType: "code", source: "" })),
|
||||
insertCodeCellBelow: () => dispatch(actions.createCellBelow({ id, contentRef, cellType: "code" })),
|
||||
insertTextCellAbove: () => dispatch(actions.createCellAbove({ id, contentRef, cellType: "markdown" })),
|
||||
insertTextCellBelow: () => dispatch(actions.createCellBelow({ id, contentRef, cellType: "markdown", source: "" })),
|
||||
insertTextCellBelow: () => dispatch(actions.createCellBelow({ id, contentRef, cellType: "markdown" })),
|
||||
moveCell: (destinationId: CellId, above: boolean) =>
|
||||
dispatch(actions.moveCell({ id, contentRef, destinationId, above })),
|
||||
clearOutputs: () => dispatch(actions.clearOutputs({ id, contentRef })),
|
||||
|
||||
@@ -418,7 +418,6 @@
|
||||
</div>
|
||||
<!-- large parition key - end -->
|
||||
|
||||
<!-- Provision collection throughput - start -->
|
||||
<!-- ko if: canConfigureThroughput -->
|
||||
<!-- Provision collection throughput checkbox - start -->
|
||||
<div class="pkPadding" data-bind="visible: databaseHasSharedOffer() && !databaseCreateNew()">
|
||||
@@ -511,19 +510,36 @@
|
||||
<!-- /ko -->
|
||||
<!-- Provision collection throughput - end -->
|
||||
|
||||
<!-- Custom indexes for mongo checkbox - start -->
|
||||
<div class="pkPadding" data-bind="visible: container.isEnableMongoCapabilityPresent()">
|
||||
<p>
|
||||
<span class="addCollectionLabel">Indexing</span>
|
||||
</p>
|
||||
<input type="checkbox" id="mongoWildcardIndex" title="mongoWildcardIndex"
|
||||
data-bind="checked: shouldCreateMongoWildcardIndex" />
|
||||
<span>Create a Wildcard Index on all fields</span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext mongoWildcardIndexTooltipWidth">
|
||||
By default, only the field _id is indexed. Creating a wildcard index on all fields will quickly optimize query performance and is recommended during development.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Custom indexes for mongo checkbox - end -->
|
||||
|
||||
<!-- Enable analytical storage - start -->
|
||||
<div class="enableAnalyticalStorage pkPadding" aria-label="Enable Analytical Store"
|
||||
data-bind="visible: isSynapseLinkSupported">
|
||||
<div>
|
||||
<span class="mandatoryStar">*</span>
|
||||
<span class="addCollectionLabel">Analytical store</span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0" data-bind="event: { focus: function(data, event) { transferFocus('tooltip1', 'link1') } }">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext infoTooltipWidth">
|
||||
<span id="tooltip1" class="tooltiptext infoTooltipWidth" data-bind="event: { mouseout: onMouseOut }">
|
||||
Enable analytical store capability to perform near real-time analytics on your operational
|
||||
data, without impacting the performance of transactional workloads.
|
||||
Learn more <a class="errorLink" href="https://aka.ms/analytical-store-overview"
|
||||
target="_blank">here</a>
|
||||
Learn more <a id="link1" class="errorLink" href="https://aka.ms/analytical-store-overview"
|
||||
target="_blank" data-bind="event: { focusout: onFocusOut, keydown: onKeyDown.bind($data, 'largePartitionKey') }">here</a>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -99,6 +99,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
|
||||
public ruToolTipText: ko.Computed<string>;
|
||||
public canConfigureThroughput: ko.PureComputed<boolean>;
|
||||
public showUpsellMessage: ko.PureComputed<boolean>;
|
||||
public shouldCreateMongoWildcardIndex: ko.Observable<boolean>;
|
||||
|
||||
private _databaseOffers: HashMap<DataModels.Offer>;
|
||||
private _isSynapseLinkEnabled: ko.Computed<boolean>;
|
||||
@@ -608,7 +609,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.container.isPreferredApiMongoDB() && this.container.hasStorageAnalyticsAfecFeature()) {
|
||||
if (this.container.isPreferredApiMongoDB()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -660,13 +661,15 @@ export default class AddCollectionPane extends ContextualPaneBase {
|
||||
changedSelectedValueTo: value ? ActionModifiers.IndexAll : ActionModifiers.NoIndex
|
||||
});
|
||||
});
|
||||
|
||||
this.shouldCreateMongoWildcardIndex = ko.observable(false);
|
||||
}
|
||||
|
||||
public getSharedThroughputDefault(): boolean {
|
||||
const subscriptionType: ViewModels.SubscriptionType =
|
||||
this.container.subscriptionType && this.container.subscriptionType();
|
||||
|
||||
if (subscriptionType === ViewModels.SubscriptionType.EA) {
|
||||
if (subscriptionType === ViewModels.SubscriptionType.EA || this.container.isServerlessEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -681,7 +684,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
|
||||
return true;
|
||||
};
|
||||
|
||||
public open(databaseId?: string) {
|
||||
public async open(databaseId?: string) {
|
||||
super.open();
|
||||
// TODO: Figure out if a database level partition split is about to happen once shared throughput read is available
|
||||
this.formWarnings("");
|
||||
@@ -715,18 +718,40 @@ export default class AddCollectionPane extends ContextualPaneBase {
|
||||
dataExplorerArea: Constants.Areas.ContextualPane
|
||||
};
|
||||
|
||||
await this.container.loadDatabaseOffers();
|
||||
this._onDatabasesChange(this.container.databases());
|
||||
this._setFocus();
|
||||
|
||||
TelemetryProcessor.trace(Action.CreateCollection, ActionModifiers.Open, addCollectionPaneOpenMessage);
|
||||
}
|
||||
|
||||
private transferFocus(elementIdToKeepVisible: string, elementIdToFocus: string): void {
|
||||
document.getElementById(elementIdToKeepVisible).style.visibility = "visible";
|
||||
document.getElementById(elementIdToFocus).focus();
|
||||
}
|
||||
|
||||
private onFocusOut(_: any, event: any): void {
|
||||
event.target.parentElement.style.visibility = "";
|
||||
}
|
||||
|
||||
private onMouseOut(_: any, event: any): void {
|
||||
event.target.style.visibility = "";
|
||||
}
|
||||
|
||||
private onKeyDown(previousActiveElementId: string, _: any, event: KeyboardEvent): boolean {
|
||||
if (event.shiftKey && event.keyCode == Constants.KeyCodes.Tab) {
|
||||
document.getElementById(previousActiveElementId).focus();
|
||||
return false;
|
||||
} else {
|
||||
// Execute default action
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private _onDatabasesChange(newDatabaseIds: ViewModels.Database[]) {
|
||||
const cachedDatabaseIdsList = _.map(newDatabaseIds, (database: ViewModels.Database) => {
|
||||
if (database && database.offer && database.offer()) {
|
||||
this._databaseOffers.set(database.id(), database.offer());
|
||||
} else if (database && database.isDatabaseShared && database.isDatabaseShared()) {
|
||||
database.readSettings();
|
||||
}
|
||||
|
||||
return database.id();
|
||||
@@ -810,9 +835,10 @@ export default class AddCollectionPane extends ContextualPaneBase {
|
||||
let collectionId: string = this.collectionId().trim();
|
||||
|
||||
let indexingPolicy: DataModels.IndexingPolicy;
|
||||
let createMongoWildcardIndex: boolean;
|
||||
// todo - remove mongo indexing policy ticket # 616274
|
||||
if (this.container.isPreferredApiMongoDB()) {
|
||||
indexingPolicy = SharedConstants.IndexingPolicies.Mongo;
|
||||
createMongoWildcardIndex = this.shouldCreateMongoWildcardIndex();
|
||||
} else if (this.showIndexingOptionsForSharedThroughput()) {
|
||||
if (this.useIndexingForSharedThroughput()) {
|
||||
indexingPolicy = SharedConstants.IndexingPolicies.AllPropertiesIndexed;
|
||||
@@ -842,7 +868,8 @@ export default class AddCollectionPane extends ContextualPaneBase {
|
||||
autoPilotMaxThroughput,
|
||||
indexingPolicy,
|
||||
partitionKey,
|
||||
uniqueKeyPolicy
|
||||
uniqueKeyPolicy,
|
||||
createMongoWildcardIndex
|
||||
};
|
||||
|
||||
createCollection(createCollectionParams).then(
|
||||
|
||||
@@ -337,7 +337,7 @@ export default class AddDatabasePane extends ContextualPaneBase {
|
||||
const subscriptionType: ViewModels.SubscriptionType =
|
||||
this.container.subscriptionType && this.container.subscriptionType();
|
||||
|
||||
if (subscriptionType === ViewModels.SubscriptionType.EA) {
|
||||
if (subscriptionType === ViewModels.SubscriptionType.EA || this.container.isServerlessEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -268,8 +268,6 @@ export default class CassandraAddCollectionPane extends ContextualPaneBase {
|
||||
const cachedKeyspaceIdsList = _.map(newKeyspaceIds, (keyspace: ViewModels.Database) => {
|
||||
if (keyspace && keyspace.offer && !!keyspace.offer()) {
|
||||
this.keyspaceOffers.set(keyspace.id(), keyspace.offer());
|
||||
} else if (keyspace && keyspace.isDatabaseShared && keyspace.isDatabaseShared()) {
|
||||
keyspace.readSettings();
|
||||
}
|
||||
return keyspace.id();
|
||||
});
|
||||
|
||||
@@ -134,11 +134,9 @@ describe("Delete Collection Confirmation Pane", () => {
|
||||
expect(telemetryProcessorSpy.called).toBe(true);
|
||||
let deleteFeedback = new DeleteFeedback(SubscriptionId, AccountName, DataModels.ApiKind.SQL, Feedback);
|
||||
expect(
|
||||
telemetryProcessorSpy.calledWith(
|
||||
Action.DeleteCollection,
|
||||
ActionModifiers.Mark,
|
||||
JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback))
|
||||
)
|
||||
telemetryProcessorSpy.calledWith(Action.DeleteCollection, ActionModifiers.Mark, {
|
||||
message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback))
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,11 +88,9 @@ export default class DeleteCollectionConfirmationPane extends ContextualPaneBase
|
||||
this.containerDeleteFeedback()
|
||||
);
|
||||
|
||||
TelemetryProcessor.trace(
|
||||
Action.DeleteCollection,
|
||||
ActionModifiers.Mark,
|
||||
JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback))
|
||||
);
|
||||
TelemetryProcessor.trace(Action.DeleteCollection, ActionModifiers.Mark, {
|
||||
message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback))
|
||||
});
|
||||
|
||||
this.containerDeleteFeedback("");
|
||||
}
|
||||
|
||||
@@ -120,11 +120,9 @@ describe("Delete Database Confirmation Pane", () => {
|
||||
|
||||
return pane.submit().then(() => {
|
||||
let deleteFeedback = new DeleteFeedback(SubscriptionId, AccountName, DataModels.ApiKind.SQL, Feedback);
|
||||
expect(TelemetryProcessor.trace).toHaveBeenCalledWith(
|
||||
Action.DeleteDatabase,
|
||||
ActionModifiers.Mark,
|
||||
JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback))
|
||||
);
|
||||
expect(TelemetryProcessor.trace).toHaveBeenCalledWith(Action.DeleteDatabase, ActionModifiers.Mark, {
|
||||
message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback))
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,11 +97,9 @@ export default class DeleteDatabaseConfirmationPane extends ContextualPaneBase {
|
||||
this.databaseDeleteFeedback()
|
||||
);
|
||||
|
||||
TelemetryProcessor.trace(
|
||||
Action.DeleteDatabase,
|
||||
ActionModifiers.Mark,
|
||||
JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback))
|
||||
);
|
||||
TelemetryProcessor.trace(Action.DeleteDatabase, ActionModifiers.Mark, {
|
||||
message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback))
|
||||
});
|
||||
|
||||
this.databaseDeleteFeedback("");
|
||||
}
|
||||
@@ -132,7 +130,8 @@ export default class DeleteDatabaseConfirmationPane extends ContextualPaneBase {
|
||||
super.resetData();
|
||||
}
|
||||
|
||||
public open() {
|
||||
public async open() {
|
||||
await this.container.loadSelectedDatabaseOffer();
|
||||
this.recordDeleteFeedback(this.shouldRecordFeedback());
|
||||
super.open();
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export default class AddTableEntityPane extends TableEntityPane {
|
||||
);
|
||||
this.updateIsActionEnabled();
|
||||
super.open();
|
||||
this.focusValueElement();
|
||||
});
|
||||
} else {
|
||||
this.displayedAttributes(
|
||||
@@ -79,8 +80,12 @@ export default class AddTableEntityPane extends TableEntityPane {
|
||||
);
|
||||
this.updateIsActionEnabled();
|
||||
super.open();
|
||||
this.focusValueElement();
|
||||
}
|
||||
const focusElement = document.getElementById("closeAddEntityPane");
|
||||
}
|
||||
|
||||
private focusValueElement() {
|
||||
const focusElement = document.getElementById("addTableEntityValue");
|
||||
focusElement && focusElement.focus();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { AuthType } from "../../AuthType";
|
||||
import { ConsoleDataType } from "../../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as Entities from "./Entities";
|
||||
import EnvironmentUtility from "../../Common/EnvironmentUtility";
|
||||
import * as HeadersUtility from "../../Common/HeadersUtility";
|
||||
import * as Logger from "../../Common/Logger";
|
||||
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
|
||||
@@ -308,7 +307,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
authType === AuthType.EncryptedToken
|
||||
? Constants.CassandraBackend.guestQueryApi
|
||||
: Constants.CassandraBackend.queryApi;
|
||||
$.ajax(`${EnvironmentUtility.getCassandraBackendEndpoint(collection.container)}${apiEndpoint}`, {
|
||||
$.ajax(`${collection.container.extensionEndpoint()}/${apiEndpoint}`, {
|
||||
type: "POST",
|
||||
data: {
|
||||
accountName: collection && collection.container.databaseAccount && collection.container.databaseAccount().name,
|
||||
@@ -559,7 +558,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
authType === AuthType.EncryptedToken
|
||||
? Constants.CassandraBackend.guestKeysApi
|
||||
: Constants.CassandraBackend.keysApi;
|
||||
let endpoint = `${EnvironmentUtility.getCassandraBackendEndpoint(collection.container)}${apiEndpoint}`;
|
||||
let endpoint = `${collection.container.extensionEndpoint()}/${apiEndpoint}`;
|
||||
const deferred = Q.defer<CassandraTableKeys>();
|
||||
$.ajax(endpoint, {
|
||||
type: "POST",
|
||||
@@ -614,7 +613,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
authType === AuthType.EncryptedToken
|
||||
? Constants.CassandraBackend.guestSchemaApi
|
||||
: Constants.CassandraBackend.schemaApi;
|
||||
let endpoint = `${EnvironmentUtility.getCassandraBackendEndpoint(collection.container)}${apiEndpoint}`;
|
||||
let endpoint = `${collection.container.extensionEndpoint()}/${apiEndpoint}`;
|
||||
const deferred = Q.defer<CassandraTableKey[]>();
|
||||
$.ajax(endpoint, {
|
||||
type: "POST",
|
||||
@@ -668,7 +667,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
authType === AuthType.EncryptedToken
|
||||
? Constants.CassandraBackend.guestCreateOrDeleteApi
|
||||
: Constants.CassandraBackend.createOrDeleteApi;
|
||||
$.ajax(`${EnvironmentUtility.getCassandraBackendEndpoint(explorer)}${apiEndpoint}`, {
|
||||
$.ajax(`${explorer.extensionEndpoint()}/${apiEndpoint}`, {
|
||||
type: "POST",
|
||||
data: {
|
||||
accountName: explorer.databaseAccount() && explorer.databaseAccount().name,
|
||||
|
||||
@@ -598,7 +598,6 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels.
|
||||
() => {
|
||||
this.container.isRefreshingExplorer(false);
|
||||
this._setBaseline();
|
||||
this.database.readSettings();
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.UpdateSettings,
|
||||
{
|
||||
@@ -643,8 +642,9 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels.
|
||||
};
|
||||
|
||||
public onActivate(): Q.Promise<any> {
|
||||
return super.onActivate().then(() => {
|
||||
return super.onActivate().then(async () => {
|
||||
this.database.selectedSubnodeKind(ViewModels.CollectionTabKind.DatabaseSettings);
|
||||
await this.database.loadOffer();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as Constants from "../../Common/Constants";
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import AuthHeadersUtil from "../../Platform/Hosted/Authorization";
|
||||
import EnvironmentUtility from "../../Common/EnvironmentUtility";
|
||||
import { isInvalidParentFrameOrigin } from "../../Utils/MessageValidation";
|
||||
import Q from "q";
|
||||
import TabsBase from "./TabsBase";
|
||||
@@ -109,11 +108,7 @@ export default class MongoShellTab extends TabsBase {
|
||||
) + Constants.MongoDBAccounts.defaultPort.toString();
|
||||
const databaseId = this.collection.databaseId;
|
||||
const collectionId = this.collection.id();
|
||||
const apiEndpoint = EnvironmentUtility.getMongoBackendEndpoint(
|
||||
this._container.serverId(),
|
||||
userContext.databaseAccount.location,
|
||||
this._container.extensionEndpoint()
|
||||
).replace("/api/mongo/explorer", "");
|
||||
const apiEndpoint = this._container.extensionEndpoint();
|
||||
const encryptedAuthToken: string = userContext.accessToken;
|
||||
|
||||
shellIframe.contentWindow.postMessage(
|
||||
@@ -142,7 +137,7 @@ export default class MongoShellTab extends TabsBase {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataToLog: string = event.data.data.logData;
|
||||
const dataToLog = { message: event.data.data.logData };
|
||||
const logType: string = event.data.data.logType;
|
||||
const shellTraceId: string = event.data.data.traceId || "none";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import Collection from "../Tree/Collection";
|
||||
import Database from "../Tree/Database";
|
||||
import Explorer from "../Explorer";
|
||||
import SettingsTab from "../Tabs/SettingsTab";
|
||||
import SettingsTab from "./SettingsTab";
|
||||
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
|
||||
import { IndexingPolicies } from "../../Shared/Constants";
|
||||
|
||||
@@ -346,7 +346,6 @@ describe("Settings tab", () => {
|
||||
|
||||
const offer: DataModels.Offer = null;
|
||||
const defaultTtl = 200;
|
||||
const database = new Database(explorer, baseDatabase, null);
|
||||
const conflictResolutionPolicy = {
|
||||
mode: DataModels.ConflictResolutionMode.LastWriterWins,
|
||||
conflictResolutionPath: "/_ts"
|
||||
@@ -507,7 +506,6 @@ describe("Settings tab", () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
const database = new Database(explorer, baseDatabase, null);
|
||||
const container: DataModels.Collection = {
|
||||
_rid: "_rid",
|
||||
_self: "",
|
||||
|
||||
@@ -182,7 +182,6 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
|
||||
public partitionKeyVisible: ko.PureComputed<boolean>;
|
||||
public partitionKeyValue: ko.Observable<string>;
|
||||
public isLargePartitionKeyEnabled: ko.Computed<boolean>;
|
||||
public pendingNotification: ko.Observable<DataModels.Notification>;
|
||||
public requestUnitsUsageCost: ko.Computed<string>;
|
||||
public rupmOnId: string;
|
||||
public rupmOffId: string;
|
||||
@@ -517,6 +516,10 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.container.isServerlessEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const numPartitions = this.collection.quotaInfo().numPartitions;
|
||||
return !!this.collection.partitionKeyProperty || numPartitions > 1;
|
||||
});
|
||||
@@ -526,7 +529,7 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
|
||||
);
|
||||
|
||||
this.minRUs = ko.computed<number>(() => {
|
||||
if (this.isTryCosmosDBSubscription()) {
|
||||
if (this.isTryCosmosDBSubscription() || this.container.isServerlessEnabled()) {
|
||||
return SharedConstants.CollectionCreation.DefaultCollectionRUs400;
|
||||
}
|
||||
|
||||
@@ -573,7 +576,7 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
|
||||
|
||||
this.maxRUs = ko.computed<number>(() => {
|
||||
const isTryCosmosDBSubscription = this.isTryCosmosDBSubscription();
|
||||
if (isTryCosmosDBSubscription) {
|
||||
if (isTryCosmosDBSubscription || this.container.isServerlessEnabled()) {
|
||||
return Constants.TryCosmosExperience.maxRU;
|
||||
}
|
||||
|
||||
@@ -749,7 +752,7 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
|
||||
if (
|
||||
this.rupm() === Constants.RUPMStates.on &&
|
||||
this.throughput() >
|
||||
SharedConstants.CollectionCreation.MaxRUPMPerPartition * this.collection.quotaInfo().numPartitions
|
||||
SharedConstants.CollectionCreation.MaxRUPMPerPartition * this.collection.quotaInfo()?.numPartitions
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -859,7 +862,6 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
|
||||
this.ttlOnDefaultFocused = ko.observable<boolean>(false);
|
||||
this.ttlOnFocused = ko.observable<boolean>(false);
|
||||
this.indexingPolicyElementFocused = ko.observable<boolean>(false);
|
||||
this.pendingNotification = ko.observable<DataModels.Notification>(undefined);
|
||||
|
||||
this._offerReplacePending = ko.pureComputed<boolean>(() => {
|
||||
const offer = this.collection && this.collection.offer && this.collection.offer();
|
||||
@@ -1181,7 +1183,6 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
|
||||
|
||||
this.container.isRefreshingExplorer(false);
|
||||
this._setBaseline();
|
||||
this.collection.readSettings();
|
||||
this._wasAutopilotOriginallySet(this.isAutoPilotSelected());
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.UpdateSettings,
|
||||
@@ -1270,8 +1271,10 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
|
||||
}
|
||||
|
||||
public onActivate(): Q.Promise<any> {
|
||||
return super.onActivate().then(() => {
|
||||
return super.onActivate().then(async () => {
|
||||
this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Settings);
|
||||
const database: ViewModels.Database = this.collection.getDatabase();
|
||||
await database.loadOffer();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
1
src/Explorer/Tabs/SettingsTabV2.html
Normal file
1
src/Explorer/Tabs/SettingsTabV2.html
Normal file
@@ -0,0 +1 @@
|
||||
<div style="height: 100%" data-bind="react:settingsComponentAdapter"></div>
|
||||
27
src/Explorer/Tabs/SettingsTabV2.tsx
Normal file
27
src/Explorer/Tabs/SettingsTabV2.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import TabsBase from "./TabsBase";
|
||||
import { SettingsComponentAdapter } from "../Controls/Settings/SettingsComponentAdapter";
|
||||
import { SettingsComponentProps } from "../Controls/Settings/SettingsComponent";
|
||||
import Explorer from "../Explorer";
|
||||
|
||||
export default class SettingsTabV2 extends TabsBase {
|
||||
public settingsComponentAdapter: SettingsComponentAdapter;
|
||||
|
||||
constructor(options: ViewModels.TabOptions) {
|
||||
super(options);
|
||||
const props: SettingsComponentProps = {
|
||||
settingsTab: this
|
||||
};
|
||||
this.settingsComponentAdapter = new SettingsComponentAdapter(props);
|
||||
}
|
||||
|
||||
public onActivate(): Q.Promise<unknown> {
|
||||
return super.onActivate().then(() => {
|
||||
this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.SettingsV2);
|
||||
});
|
||||
}
|
||||
|
||||
public getSettingsTabContainer(): Explorer {
|
||||
return this.getContainer();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import MongoShellTabTemplate from "./MongoShellTab.html";
|
||||
import QueryTabTemplate from "./QueryTab.html";
|
||||
import QueryTablesTabTemplate from "./QueryTablesTab.html";
|
||||
import SettingsTabTemplate from "./SettingsTab.html";
|
||||
import SettingsTabV2Template from "./SettingsTabV2.html";
|
||||
import DatabaseSettingsTabTemplate from "./DatabaseSettingsTab.html";
|
||||
import StoredProcedureTabTemplate from "./StoredProcedureTab.html";
|
||||
import TriggerTabTemplate from "./TriggerTab.html";
|
||||
@@ -141,6 +142,15 @@ export class SettingsTab {
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsTabV2 {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: SettingsTabV2Template
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseSettingsTab {
|
||||
constructor() {
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { RouteHandler } from "../../RouteHandlers/RouteHandler";
|
||||
import { WaitsForTemplateViewModel } from "../WaitsForTemplateViewModel";
|
||||
@@ -29,6 +30,7 @@ export default class TabsBase extends WaitsForTemplateViewModel {
|
||||
public hashLocation: ko.Observable<string>;
|
||||
public isExecutionError: ko.Observable<boolean>;
|
||||
public isExecuting: ko.Observable<boolean>;
|
||||
public pendingNotification?: ko.Observable<DataModels.Notification>;
|
||||
|
||||
protected _theme: string;
|
||||
public onLoadStartKey: number;
|
||||
@@ -56,6 +58,7 @@ export default class TabsBase extends WaitsForTemplateViewModel {
|
||||
this.errorDetailsTabIndex = ko.computed<number>(() => (this.isActive() ? 0 : null));
|
||||
this.isExecutionError = ko.observable<boolean>(false);
|
||||
this.isExecuting = ko.observable<boolean>(false);
|
||||
this.pendingNotification = ko.observable<DataModels.Notification>(undefined);
|
||||
this.onLoadStartKey = options.onLoadStartKey;
|
||||
this.hashLocation = ko.observable<string>(options.hashLocation || "");
|
||||
this.hashLocation.subscribe((newLocation: string) => this.updateGlobalHash(newLocation));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user