Compare commits

...

26 Commits

Author SHA1 Message Date
Jade Welton
9d2034bfdf Increase number of test shards. 2025-04-16 14:34:07 -07:00
Laurent Nguyen
6dbc412fa6 Implement Sample data import for Fabric Home (#2101)
* Implement dialog to import sample data

* Fix format

* Cosmetic fixes

* fix: update help link to point to the new documentation URL

---------

Co-authored-by: Sevo Kukol <sevoku@microsoft.com>
2025-04-16 14:27:22 -07:00
sunghyunkang1111
3470f56535 Pk missing fix (#2107)
* fix partition key missing not being able to load the document

* Implement E2E tests for documents with different partitionkeys

* Implement E2E tests for documents with different partitionkeys

* Implement E2E tests for documents with different partitionkeys

* Updated snapshot

* Updated tests for MongoRU and add create/delete tests

* Fixing system partition key showing up in Data Explorer
2025-04-16 13:12:53 -05:00
Vsevolod Kukol
00ec678569 fix: handle optional activeTab in tab activation logic (#2106) 2025-04-16 10:34:15 -07:00
Laurent Nguyen
27c9ea7ab6 Add tracing events for tracking query execution and upload documents (#2100)
* Add tracing for Execute (submit) query and Upload documents

* Trace query closer to iterator operation

* Add warnings to values used in Fabric

* Fix format

* Don't trace execute queries for filtering calls

* Remove tracing call for documents tab filtering

* Fix failing unit test.

---------

Co-authored-by: Jade Welton <jawelton@microsoft.com>
Co-authored-by: Sevo Kukol <sevoku@microsoft.com>
2025-04-16 10:31:43 -07:00
asier-isayas
d5fe2d9e9f Fix Unit and E2E tests (#2112)
* fix tests

* remove Materialized Views from createResourceTokenTreeNodes

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-04-16 10:05:36 -04:00
Laurent Nguyen
94b1e729d1 Enable original azure resource tree style for Fabric native and turn on Settings tab (#2103)
* Enable original azure resource tree for Fabric native and turn on Settings page

* Fix unit tests
2025-04-15 08:49:16 -07:00
Laurent Nguyen
afdbefe36c Implement refreshResourceTree message and hide refresh button above resource tree for Fabric native (#2102) 2025-04-15 08:48:44 -07:00
asier-isayas
2cff0fc3ff Global Secondary Index (#2071)
* add Materialized Views feature flag

* fetch MV properties from RP API and capture them in our data models

* AddMaterializedViewPanel

* undefined check

* subpartition keys

* Partition Key, Throughput, Unique Keys

* All views associated with a container (#2063) and Materialized View Target Container (#2065)

Identified Source container and Target container
Created tabs in Scale and Settings respectively
Changed the Icon of target container

* Add MV Panel

* format

* format

* styling

* add tests

* tests

* test files (#2074)

Co-authored-by: nishthaAhujaa

* fix type error

* fix tests

* merge conflict

* Panel Integration (#2075)

* integrated panel

* edited header text

---------

Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
Co-authored-by: Asier Isayas <aisayas@microsoft.com>

* updated tests (#2077)

Co-authored-by: nishthaAhujaa

* fix tests

* update treeNodeUtil test snap

* update settings component test snap

* fixed source container in global "New Materialized View"

* source container check (#2079)

Co-authored-by: nishthaAhujaa

* renamed Materialized Views to Global Secondary Index

* more renaming

* fix import

* fix typo

* disable materialized views for Fabric

* updated input validation

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
Co-authored-by: Nishtha Ahuja <45535788+nishthaAhujaa@users.noreply.github.com>
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-04-11 10:39:32 -04:00
sunghyunkang1111
a3bfc89318 Revert "Pk missing fix (#2094)" (#2099)
This reverts commit af0a890516.
2025-04-09 13:04:44 -05:00
Ajay Parulekar
0666e11d89 Renaming Materialized views builder blade text to Global secondary indexes for NoSql API (#1991)
* GSI changes

* GSI changes

* GSI changes

* updating GlobalSecondaryIndexesBuilder.json

* Changes

* Update cost text keys based on user context API type

* Refactor Materialized Views Builder code for improved readability and consistency in API type checks

* Update links in Materialized Views Builder for consistency and accuracy

* Update Global Secondary Indexes links and descriptions for clarity and accuracy based on API type

* Update portal notification message keys based on user context API type for Materialized Views Builder

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Fix capitalization and wording inconsistencies in Materialized Views Builder localization strings

* Fix capitalization and wording inconsistencies in localization strings for Materialized Views Builder

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

---------

Co-authored-by: Justine Cocchi <justine@cocchi.org>
2025-04-09 13:32:35 -04:00
bogercraig
9bb1d0bace Manual Region Selection (#2037)
* Add standin region selection to settings menu.

* Retrieve read and write regions from user context and populate dropdown menu.  Update local storage value.
Need to now connect with updating read region of primary cosmos client.

* Change to only selecting region for cosmos client.  Not setting up separate read and write clients.

* Add read and write endpoint logging to cosmos client.

* Pass changing endpoint from settings menu to client.  Encountered token issues using new endpoint in client.

* Rough implementation of region selection of endpoint for cosmos client.  Still need to:
1 - Use separate context var to track selected region.  Directly updating database account context throws off token generation by acquireMSALTokenForAccount
2 - Remove href overrides in acquireMSALTokenForAccount.

* Update region selection to include global endpoint and generate a unique list of read and write endpoints.
Need to continue with clearing out selected endpoint when global is selected again.
Write operations stall when read region is selected even though 403 returned when region rejects operation.
Need to limit feature availablility to nosql, table, gremlin (maybe).

* Update cosmos client to fix bug.
Clients continuously generate after changing RBAC setting.

* Swapping back to default endpoint value.

* Rebase on client refresh bug fix.

* Enable region selection for NoSql, Table, Gremlin

* Add logic to reset regional endpoint when global is selected.

* Fix state changing when selecting region or resetting to global.

* Rough implementation of configuring regional endpoint when DE is loaded in portal or hosted with AAD/Entra auth.

* Ininitial attempt at adding error handling, but still having issues with errors caught at proxy plugin.

* Added rough error handling in local requestPlugin used in local environments.  Passes new error to calling code.
Might need to add specific error handling for request plugin to the handleError class.

* Change how request plugin returns error so existing error handling utility can process and present error.

* Only enable region selection for nosql accounts.

* Limit region selection to portal and hosted AAD auth.  SQL accounts only.  Could possibly enable on table and gremlin later.

* Update error handling to account for generic error code.

* Refactor error code extraction.

* Update test snapshots and remove unneeded logging.

* Change error handling to use only the message rather than casting to any.

* Clean up debug logging in cosmos client.

* Remove unused storage keys.

* Use endpoint instead of region name to track selected region.  Prevents having to do endpoint lookups.

* Add initial button state update depending on region selection.
Need to update with the API and react to user context changes.

* Disable CRUD buttons when read region selected.

* Default to write enabled in react.

* Disable query saving when read region is selected.

* Patch clientWidth error on conflicts tab.

* Resolve merge conflicts from rebase.

* Make sure proxy endpoints return in all cases.

* Remove excess client logging and match main for ConflictsTab.

* Cleaning up logging and fixing endpoint discovery bug.

* Fix formatting.

* Reformatting if statements with preferred formatting.

* Migrate region selection to local persistence.  Fixes account swapping bug.
TODO: Inspect better way to reset interface elements when deleteAllStates is called.  Need to react to regional endpoint being reset.

* Relocate resetting interface context to helper function.

* Remove legacy state storage for regional endpoint selection.

* Laurent suggestion updates.
2025-04-07 09:29:11 -07:00
sunghyunkang1111
af0a890516 Pk missing fix (#2094)
* fix partition key missing not being able to load the document

* Implement E2E tests for documents with different partitionkeys

* Implement E2E tests for documents with different partitionkeys

* Implement E2E tests for documents with different partitionkeys

* Updated snapshot
2025-04-07 10:45:29 -05:00
bogercraig
e3c3a8b1b7 Hide Keys and Connection Strings in Connect Tab (#2095)
* Hide connection strings and keys by default.  Move URI above pivot since common across tabs.  Matches frontend.  Need to add scrolling of keys when window is small.  Possibly reduce URI width.

* Add vertical scrolling when window size reduces.

* Adding missing semicolon at end of connection strings.
2025-04-03 17:19:28 -07:00
sunghyunkang1111
0f6c979268 Update cleanupDBs.js (#2093) 2025-04-03 11:10:40 -05:00
asier-isayas
32576f50d3 Self Serve text render fix (#2088)
* debug

* added comment

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-03-27 14:17:06 -04:00
sunghyunkang1111
10f5a5fbfe Revert "fix partition key missing not being able to load the document (#2085)" (#2090)
This reverts commit 257256f915.
2025-03-27 12:47:14 -05:00
JustinKol
8eb53674dc Add refresh button to Mongo DB RU and adjust ellipsis so refresh button on single column container doesn't hide it (#2089)
* Moved ellipsis to the left for single column containers

* Added refresh to MongoDB RU

* prettier run
2025-03-27 13:44:22 -04:00
sunghyunkang1111
257256f915 fix partition key missing not being able to load the document (#2085) 2025-03-26 11:26:47 -05:00
jawelton74
41f5401016 Fix input validation patterns for resource ids (#2086)
* Fix input element pattern matching and add validation reporting for
cases where the element is not within a form element.

* Update test snapshots.

* Remove old code and fix trigger error message.

* Move id validation to a util class.

* Add unit tests, fix standalone function, rename constants.
2025-03-26 07:10:47 -07:00
Laurent Nguyen
a4c9a47d4e Add comments for expired token used in test (#2084) 2025-03-26 09:00:55 +01:00
JustinKol
c43132d5c0 Adding container item refresh button back to upper right corner of page (#2083)
* Moved button to upper right

* Reverted background color

* Updated test snapshot

* Added hidding refresh button on overflow

* Ran prettier and updated snapshot
2025-03-25 08:16:39 -04:00
tarazou9
6ce81099ef Handle catalog empty (#2082)
Handle UI errors caused by Catalog API calls returning no offering id.
2025-03-21 16:15:48 -04:00
Nishtha Ahuja
777e411f4f edited screenshot for vcore quickstart shell (#2080)
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-03-20 21:55:03 +05:30
Laurent Nguyen
63d4b4f4ef fix tab wrapping with a lil' css tweak (#2013) (#2076)
Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>
2025-03-17 11:51:59 +01:00
asier-isayas
eaf9a14e7d Cancel Phoenix container allocation on ctrl+c & ctrl+z (#2055)
* Cancel Phoenix container allocation on ctrl+c

* revert package-lock

* fix build issues

* add ctrl+z

* Close terminal when Ctrl key is pressed

* format

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-03-13 14:56:11 -04:00
100 changed files with 40650 additions and 2184 deletions

View File

@@ -164,8 +164,8 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
shardTotal: [8] shardTotal: [10]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: "Az CLI login" - name: "Az CLI login"

View File

@@ -1914,13 +1914,20 @@ input::-webkit-calendar-picker-indicator::after {
} }
.nav-tabs-margin { .nav-tabs-margin {
height: 32px;
background-color: #f2f2f2; background-color: #f2f2f2;
.nav-tabs { .nav-tabs {
display: flex; display: flex;
flex-wrap: wrap;
align-items: flex-end; align-items: flex-end;
height: 100%; height: 100%;
margin-bottom: -0.5px;
li {
// Override the bootstrap defaults here to align with our layout constants.
margin-bottom: 0px;
height: 32px;
}
} }
} }

51
package-lock.json generated
View File

@@ -86,7 +86,7 @@
"mkdirp": "1.0.4", "mkdirp": "1.0.4",
"monaco-editor": "0.44.0", "monaco-editor": "0.44.0",
"ms": "2.1.3", "ms": "2.1.3",
"p-retry": "4.6.2", "p-retry": "6.2.1",
"patch-package": "8.0.0", "patch-package": "8.0.0",
"plotly.js-cartesian-dist-min": "1.52.3", "plotly.js-cartesian-dist-min": "1.52.3",
"post-robot": "10.0.42", "post-robot": "10.0.42",
@@ -12662,7 +12662,9 @@
} }
}, },
"node_modules/@types/retry": { "node_modules/@types/retry": {
"version": "0.12.0", "version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
"integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/sanitize-html": { "node_modules/@types/sanitize-html": {
@@ -21799,6 +21801,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-network-error": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz",
"integrity": "sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-number": { "node_modules/is-number": {
"version": "3.0.0", "version": "3.0.0",
"license": "MIT", "license": "MIT",
@@ -30243,14 +30257,20 @@
} }
}, },
"node_modules/p-retry": { "node_modules/p-retry": {
"version": "4.6.2", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz",
"integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/retry": "0.12.0", "@types/retry": "0.12.2",
"is-network-error": "^1.0.0",
"retry": "^0.13.1" "retry": "^0.13.1"
}, },
"engines": { "engines": {
"node": ">=8" "node": ">=16.17"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": { "node_modules/p-try": {
@@ -35997,6 +36017,13 @@
} }
} }
}, },
"node_modules/webpack-dev-server/node_modules/@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
"dev": true,
"license": "MIT"
},
"node_modules/webpack-dev-server/node_modules/ajv": { "node_modules/webpack-dev-server/node_modules/ajv": {
"version": "8.12.0", "version": "8.12.0",
"dev": true, "dev": true,
@@ -36044,6 +36071,20 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/webpack-dev-server/node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/webpack-dev-server/node_modules/rimraf": { "node_modules/webpack-dev-server/node_modules/rimraf": {
"version": "3.0.2", "version": "3.0.2",
"dev": true, "dev": true,

View File

@@ -81,7 +81,7 @@
"mkdirp": "1.0.4", "mkdirp": "1.0.4",
"monaco-editor": "0.44.0", "monaco-editor": "0.44.0",
"ms": "2.1.3", "ms": "2.1.3",
"p-retry": "4.6.2", "p-retry": "6.2.1",
"patch-package": "8.0.0", "patch-package": "8.0.0",
"plotly.js-cartesian-dist-min": "1.52.3", "plotly.js-cartesian-dist-min": "1.52.3",
"post-robot": "10.0.42", "post-robot": "10.0.42",

View File

@@ -1,5 +1,4 @@
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from "@playwright/test";
/** /**
* See https://playwright.dev/docs/test-configuration. * See https://playwright.dev/docs/test-configuration.
*/ */
@@ -29,7 +28,12 @@ export default defineConfig({
projects: [ projects: [
{ {
name: "chromium", name: "chromium",
use: { ...devices["Desktop Chrome"] }, use: {
...devices["Desktop Chrome"],
launchOptions: {
args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"],
},
},
}, },
{ {
name: "firefox", name: "firefox",

37913
preview/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -530,6 +530,9 @@ export class ariaLabelForLearnMoreLink {
public static readonly AzureSynapseLink = "Learn more about Azure Synapse Link."; public static readonly AzureSynapseLink = "Learn more about Azure Synapse Link.";
} }
export class GlobalSecondaryIndexLabels {
public static readonly NewGlobalSecondaryIndex: string = "New Global Secondary Index";
}
export class FeedbackLabels { export class FeedbackLabels {
public static readonly provideFeedback: string = "Provide feedback"; public static readonly provideFeedback: string = "Provide feedback";
} }

View File

@@ -125,7 +125,11 @@ export const endpoint = () => {
const location = _global.parent ? _global.parent.location : _global.location; const location = _global.parent ? _global.parent.location : _global.location;
return configContext.EMULATOR_ENDPOINT || location.origin; return configContext.EMULATOR_ENDPOINT || location.origin;
} }
return userContext.endpoint || userContext?.databaseAccount?.properties?.documentEndpoint; return (
userContext.selectedRegionalEndpoint ||
userContext.endpoint ||
userContext?.databaseAccount?.properties?.documentEndpoint
);
}; };
export async function getTokenFromAuthService( export async function getTokenFromAuthService(
@@ -203,6 +207,7 @@ export function client(): Cosmos.CosmosClient {
userAgentSuffix: "Azure Portal", userAgentSuffix: "Azure Portal",
defaultHeaders: _defaultHeaders, defaultHeaders: _defaultHeaders,
connectionPolicy: { connectionPolicy: {
enableEndpointDiscovery: !userContext.selectedRegionalEndpoint,
retryOptions: { retryOptions: {
maxRetryAttemptCount: LocalStorageUtility.getEntryNumber(StorageKey.RetryAttempts), maxRetryAttemptCount: LocalStorageUtility.getEntryNumber(StorageKey.RetryAttempts),
fixedRetryIntervalInMilliseconds: LocalStorageUtility.getEntryNumber(StorageKey.RetryInterval), fixedRetryIntervalInMilliseconds: LocalStorageUtility.getEntryNumber(StorageKey.RetryInterval),

View File

@@ -1,5 +1,6 @@
import { TagNames, WorkloadType } from "Common/Constants"; import { TagNames, WorkloadType } from "Common/Constants";
import { Tags } from "Contracts/DataModels"; import { Tags } from "Contracts/DataModels";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { userContext } from "../UserContext"; import { userContext } from "../UserContext";
function isVirtualNetworkFilterEnabled() { function isVirtualNetworkFilterEnabled() {
@@ -26,3 +27,9 @@ export function getWorkloadType(): WorkloadType {
} }
return workloadType; return workloadType;
} }
export function isGlobalSecondaryIndexEnabled(): boolean {
return (
!isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews
);
}

View File

@@ -1,5 +1,8 @@
import { QueryOperationOptions } from "@azure/cosmos"; import { QueryOperationOptions } from "@azure/cosmos";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as Constants from "../Common/Constants";
import { QueryResults } from "../Contracts/ViewModels"; import { QueryResults } from "../Contracts/ViewModels";
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
interface QueryResponse { interface QueryResponse {
// [Todo] remove any // [Todo] remove any
@@ -21,7 +24,9 @@ export function nextPage(
firstItemIndex: number, firstItemIndex: number,
queryOperationOptions?: QueryOperationOptions, queryOperationOptions?: QueryOperationOptions,
): Promise<QueryResults> { ): Promise<QueryResults> {
TelemetryProcessor.traceStart(Action.ExecuteQuery);
return documentsIterator.fetchNext(queryOperationOptions).then((response) => { return documentsIterator.fetchNext(queryOperationOptions).then((response) => {
TelemetryProcessor.traceSuccess(Action.ExecuteQuery, { dataExplorerArea: Constants.Areas.Tab });
const documents = response.resources; const documents = response.resources;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const headers = (response as any).headers || {}; // TODO this is a private key. Remove any const headers = (response as any).headers || {}; // TODO this is a private key. Remove any

View File

@@ -0,0 +1,74 @@
import { constructRpOptions } from "Common/dataAccess/createCollection";
import { handleError } from "Common/ErrorHandlingUtils";
import { Collection, CreateMaterializedViewsParams as CreateGlobalSecondaryIndexParams } from "Contracts/DataModels";
import { userContext } from "UserContext";
import { createUpdateSqlContainer } from "Utils/arm/generatedClients/cosmos/sqlResources";
import {
CreateUpdateOptions,
SqlContainerResource,
SqlDatabaseCreateUpdateParameters,
} from "Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "Utils/NotificationConsoleUtils";
export const createGlobalSecondaryIndex = async (params: CreateGlobalSecondaryIndexParams): Promise<Collection> => {
const clearMessage = logConsoleProgress(
`Creating a new global secondary index ${params.materializedViewId} for database ${params.databaseId}`,
);
const options: CreateUpdateOptions = constructRpOptions(params);
const resource: SqlContainerResource = {
id: params.materializedViewId,
};
if (params.materializedViewDefinition) {
resource.materializedViewDefinition = params.materializedViewDefinition;
}
if (params.analyticalStorageTtl) {
resource.analyticalStorageTtl = params.analyticalStorageTtl;
}
if (params.indexingPolicy) {
resource.indexingPolicy = params.indexingPolicy;
}
if (params.partitionKey) {
resource.partitionKey = params.partitionKey;
}
if (params.uniqueKeyPolicy) {
resource.uniqueKeyPolicy = params.uniqueKeyPolicy;
}
if (params.vectorEmbeddingPolicy) {
resource.vectorEmbeddingPolicy = params.vectorEmbeddingPolicy;
}
if (params.fullTextPolicy) {
resource.fullTextPolicy = params.fullTextPolicy;
}
const rpPayload: SqlDatabaseCreateUpdateParameters = {
properties: {
resource,
options,
},
};
try {
const createResponse = await createUpdateSqlContainer(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
params.databaseId,
params.materializedViewId,
rpPayload,
);
logConsoleInfo(`Successfully created global secondary index ${params.materializedViewId}`);
return createResponse && (createResponse.properties.resource as Collection);
} catch (error) {
handleError(
error,
"CreateGlobalSecondaryIndex",
`Error while creating global secondary index ${params.materializedViewId}`,
);
throw error;
} finally {
clearMessage();
}
};

View File

@@ -1,3 +1,4 @@
import { isFabric } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType"; import { AuthType } from "../../AuthType";
import { Offer, ReadCollectionOfferParams } from "../../Contracts/DataModels"; import { Offer, ReadCollectionOfferParams } from "../../Contracts/DataModels";
import { userContext } from "../../UserContext"; import { userContext } from "../../UserContext";
@@ -13,6 +14,11 @@ import { readOfferWithSDK } from "./readOfferWithSDK";
export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise<Offer> => { export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise<Offer> => {
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`); const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
if (isFabric()) {
// Not exposing offers in Fabric
return undefined;
}
try { try {
if ( if (
userContext.authType === AuthType.AAD && userContext.authType === AuthType.AAD &&

View File

@@ -126,5 +126,12 @@ async function readCollectionsWithARM(databaseId: string): Promise<DataModels.Co
throw new Error(`Unsupported default experience type: ${apiType}`); throw new Error(`Unsupported default experience type: ${apiType}`);
} }
return rpResponse?.value?.map((collection) => collection.properties?.resource as DataModels.Collection); // TO DO: Remove when we get RP API Spec with materializedViews
/* eslint-disable @typescript-eslint/no-explicit-any */
return rpResponse?.value?.map((collection: any) => {
const collectionDataModel: DataModels.Collection = collection.properties?.resource as DataModels.Collection;
collectionDataModel.materializedViews = collection.properties?.resource?.materializedViews;
collectionDataModel.materializedViewDefinition = collection.properties?.resource?.materializedViewDefinition;
return collectionDataModel;
});
} }

View File

@@ -32,6 +32,7 @@ export interface DatabaseAccountExtendedProperties {
writeLocations?: DatabaseAccountResponseLocation[]; writeLocations?: DatabaseAccountResponseLocation[];
enableFreeTier?: boolean; enableFreeTier?: boolean;
enableAnalyticalStorage?: boolean; enableAnalyticalStorage?: boolean;
enableMaterializedViews?: boolean;
isVirtualNetworkFilterEnabled?: boolean; isVirtualNetworkFilterEnabled?: boolean;
ipRules?: IpRule[]; ipRules?: IpRule[];
privateEndpointConnections?: unknown[]; privateEndpointConnections?: unknown[];
@@ -164,6 +165,8 @@ export interface Collection extends Resource {
schema?: ISchema; schema?: ISchema;
requestSchema?: () => void; requestSchema?: () => void;
computedProperties?: ComputedProperties; computedProperties?: ComputedProperties;
materializedViews?: MaterializedView[];
materializedViewDefinition?: MaterializedViewDefinition;
} }
export interface CollectionsWithPagination { export interface CollectionsWithPagination {
@@ -223,6 +226,17 @@ export interface ComputedProperty {
export type ComputedProperties = ComputedProperty[]; export type ComputedProperties = ComputedProperty[];
export interface MaterializedView {
id: string;
_rid: string;
}
export interface MaterializedViewDefinition {
definition: string;
sourceCollectionId: string;
sourceCollectionRid?: string;
}
export interface PartitionKey { export interface PartitionKey {
paths: string[]; paths: string[];
kind: "Hash" | "Range" | "MultiHash"; kind: "Hash" | "Range" | "MultiHash";
@@ -345,9 +359,7 @@ export interface CreateDatabaseParams {
offerThroughput?: number; offerThroughput?: number;
} }
export interface CreateCollectionParams { export interface CreateCollectionParamsBase {
createNewDatabase: boolean;
collectionId: string;
databaseId: string; databaseId: string;
databaseLevelThroughput: boolean; databaseLevelThroughput: boolean;
offerThroughput?: number; offerThroughput?: number;
@@ -361,6 +373,16 @@ export interface CreateCollectionParams {
fullTextPolicy?: FullTextPolicy; fullTextPolicy?: FullTextPolicy;
} }
export interface CreateCollectionParams extends CreateCollectionParamsBase {
createNewDatabase: boolean;
collectionId: string;
}
export interface CreateMaterializedViewsParams extends CreateCollectionParamsBase {
materializedViewId: string;
materializedViewDefinition: MaterializedViewDefinition;
}
export interface VectorEmbeddingPolicy { export interface VectorEmbeddingPolicy {
vectorEmbeddings: VectorEmbedding[]; vectorEmbeddings: VectorEmbedding[];
} }

View File

@@ -81,6 +81,13 @@ export type FabricMessageV3 =
error: string | undefined; error: string | undefined;
data: { accessToken: string }; data: { accessToken: string };
}; };
}
| {
type: "refreshResourceTree";
message: {
id: string;
error: string | undefined;
};
}; };
export enum CosmosDbArtifactType { export enum CosmosDbArtifactType {

View File

@@ -1,4 +1,5 @@
import { import {
JSONObject,
QueryMetrics, QueryMetrics,
Resource, Resource,
StoredProcedureDefinition, StoredProcedureDefinition,
@@ -143,6 +144,8 @@ export interface Collection extends CollectionBase {
geospatialConfig: ko.Observable<DataModels.GeospatialConfig>; geospatialConfig: ko.Observable<DataModels.GeospatialConfig>;
documentIds: ko.ObservableArray<DocumentId>; documentIds: ko.ObservableArray<DocumentId>;
computedProperties: ko.Observable<DataModels.ComputedProperties>; computedProperties: ko.Observable<DataModels.ComputedProperties>;
materializedViews: ko.Observable<DataModels.MaterializedView[]>;
materializedViewDefinition: ko.Observable<DataModels.MaterializedViewDefinition>;
cassandraKeys: CassandraTableKeys; cassandraKeys: CassandraTableKeys;
cassandraSchema: CassandraTableKey[]; cassandraSchema: CassandraTableKey[];
@@ -204,6 +207,12 @@ export interface Collection extends CollectionBase {
onDragOver(source: Collection, event: { originalEvent: DragEvent }): void; onDragOver(source: Collection, event: { originalEvent: DragEvent }): void;
onDrop(source: Collection, event: { originalEvent: DragEvent }): void; onDrop(source: Collection, event: { originalEvent: DragEvent }): void;
uploadFiles(fileList: FileList): Promise<{ data: UploadDetailsRecord[] }>; uploadFiles(fileList: FileList): Promise<{ data: UploadDetailsRecord[] }>;
bulkInsertDocuments(documents: JSONObject[]): Promise<{
numSucceeded: number;
numFailed: number;
numThrottled: number;
errors: string[];
}>;
} }
/** /**

View File

@@ -1,5 +1,11 @@
import { GlobalSecondaryIndexLabels } from "Common/Constants";
import { isGlobalSecondaryIndexEnabled } from "Common/DatabaseAccountUtility";
import { configContext, Platform } from "ConfigContext"; import { configContext, Platform } from "ConfigContext";
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import {
AddGlobalSecondaryIndexPanel,
AddGlobalSecondaryIndexPanelProps,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil"; import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
import { Action } from "Shared/Telemetry/TelemetryConstants"; import { Action } from "Shared/Telemetry/TelemetryConstants";
@@ -164,6 +170,24 @@ export const createCollectionContextMenuButton = (
}); });
} }
if (isGlobalSecondaryIndexEnabled() && !selectedCollection.materializedViewDefinition()) {
items.push({
label: GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
onClick: () => {
const addMaterializedViewPanelProps: AddGlobalSecondaryIndexPanelProps = {
explorer: container,
sourceContainer: selectedCollection,
};
useSidePanel
.getState()
.openSidePanel(
GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
<AddGlobalSecondaryIndexPanel {...addMaterializedViewPanelProps} />,
);
},
});
}
return items; return items;
}; };

View File

@@ -214,8 +214,10 @@ export const Dialog: FC = () => {
{contentHtml} {contentHtml}
{progressIndicatorProps && <ProgressIndicator {...progressIndicatorProps} />} {progressIndicatorProps && <ProgressIndicator {...progressIndicatorProps} />}
<DialogFooter> <DialogFooter>
<PrimaryButton {...primaryButtonProps} /> <PrimaryButton {...primaryButtonProps} data-test={`DialogButton:${primaryButtonText}`} />
{secondaryButtonProps && <DefaultButton {...secondaryButtonProps} />} {secondaryButtonProps && (
<DefaultButton {...secondaryButtonProps} data-test={`DialogButton:${secondaryButtonText}`} />
)}
</DialogFooter> </DialogFooter>
</FluentDialog> </FluentDialog>
) : ( ) : (

View File

@@ -12,6 +12,7 @@ import {
ThroughputBucketsComponentProps, ThroughputBucketsComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent"; } from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { isFullTextSearchEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils"; import { isFullTextSearchEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isRunningOnPublicCloud } from "Utils/CloudUtils"; import { isRunningOnPublicCloud } from "Utils/CloudUtils";
import * as React from "react"; import * as React from "react";
@@ -44,6 +45,10 @@ import {
ConflictResolutionComponent, ConflictResolutionComponent,
ConflictResolutionComponentProps, ConflictResolutionComponentProps,
} from "./SettingsSubComponents/ConflictResolutionComponent"; } from "./SettingsSubComponents/ConflictResolutionComponent";
import {
GlobalSecondaryIndexComponent,
GlobalSecondaryIndexComponentProps,
} from "./SettingsSubComponents/GlobalSecondaryIndexComponent";
import { IndexingPolicyComponent, IndexingPolicyComponentProps } from "./SettingsSubComponents/IndexingPolicyComponent"; import { IndexingPolicyComponent, IndexingPolicyComponentProps } from "./SettingsSubComponents/IndexingPolicyComponent";
import { import {
MongoIndexingPolicyComponent, MongoIndexingPolicyComponent,
@@ -162,6 +167,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private shouldShowComputedPropertiesEditor: boolean; private shouldShowComputedPropertiesEditor: boolean;
private shouldShowIndexingPolicyEditor: boolean; private shouldShowIndexingPolicyEditor: boolean;
private shouldShowPartitionKeyEditor: boolean; private shouldShowPartitionKeyEditor: boolean;
private isGlobalSecondaryIndex: boolean;
private isVectorSearchEnabled: boolean; private isVectorSearchEnabled: boolean;
private isFullTextSearchEnabled: boolean; private isFullTextSearchEnabled: boolean;
private totalThroughputUsed: number; private totalThroughputUsed: number;
@@ -179,6 +185,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.shouldShowComputedPropertiesEditor = userContext.apiType === "SQL"; this.shouldShowComputedPropertiesEditor = userContext.apiType === "SQL";
this.shouldShowIndexingPolicyEditor = userContext.apiType !== "Cassandra" && userContext.apiType !== "Mongo"; this.shouldShowIndexingPolicyEditor = userContext.apiType !== "Cassandra" && userContext.apiType !== "Mongo";
this.shouldShowPartitionKeyEditor = userContext.apiType === "SQL" && isRunningOnPublicCloud(); this.shouldShowPartitionKeyEditor = userContext.apiType === "SQL" && isRunningOnPublicCloud();
this.isGlobalSecondaryIndex =
!!this.collection?.materializedViewDefinition() || !!this.collection?.materializedViews();
this.isVectorSearchEnabled = isVectorSearchEnabled() && !hasDatabaseSharedThroughput(this.collection); this.isVectorSearchEnabled = isVectorSearchEnabled() && !hasDatabaseSharedThroughput(this.collection);
this.isFullTextSearchEnabled = isFullTextSearchEnabled() && !hasDatabaseSharedThroughput(this.collection); this.isFullTextSearchEnabled = isFullTextSearchEnabled() && !hasDatabaseSharedThroughput(this.collection);
@@ -1270,6 +1278,12 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
database: useDatabases.getState().findDatabaseWithId(this.collection.databaseId), database: useDatabases.getState().findDatabaseWithId(this.collection.databaseId),
collection: this.collection, collection: this.collection,
explorer: this.props.settingsTab.getContainer(), explorer: this.props.settingsTab.getContainer(),
isReadOnly: isFabricNative(),
};
const globalSecondaryIndexComponentProps: GlobalSecondaryIndexComponentProps = {
collection: this.collection,
explorer: this.props.settingsTab.getContainer(),
}; };
const tabs: SettingsV2TabInfo[] = []; const tabs: SettingsV2TabInfo[] = [];
@@ -1335,6 +1349,13 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
}); });
} }
if (this.isGlobalSecondaryIndex) {
tabs.push({
tab: SettingsV2TabTypes.GlobalSecondaryIndexTab,
content: <GlobalSecondaryIndexComponent {...globalSecondaryIndexComponentProps} />,
});
}
const pivotProps: IPivotProps = { const pivotProps: IPivotProps = {
onLinkClick: this.onPivotChange, onLinkClick: this.onPivotChange,
selectedKey: SettingsV2TabTypes[this.state.selectedTab], selectedKey: SettingsV2TabTypes[this.state.selectedTab],

View File

@@ -0,0 +1,46 @@
import { shallow } from "enzyme";
import React from "react";
import { collection, container } from "../TestUtils";
import { GlobalSecondaryIndexComponent } from "./GlobalSecondaryIndexComponent";
import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent";
import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent";
describe("GlobalSecondaryIndexComponent", () => {
let testCollection: typeof collection;
let testExplorer: typeof container;
beforeEach(() => {
testCollection = { ...collection };
});
it("renders only the source component when materializedViewDefinition is missing", () => {
testCollection.materializedViews([
{ id: "view1", _rid: "rid1" },
{ id: "view2", _rid: "rid2" },
]);
testCollection.materializedViewDefinition(null);
const wrapper = shallow(<GlobalSecondaryIndexComponent collection={testCollection} explorer={testExplorer} />);
expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(true);
expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(false);
});
it("renders only the target component when materializedViews is missing", () => {
testCollection.materializedViews(null);
testCollection.materializedViewDefinition({
definition: "SELECT * FROM c WHERE c.id = 1",
sourceCollectionId: "source1",
sourceCollectionRid: "rid123",
});
const wrapper = shallow(<GlobalSecondaryIndexComponent collection={testCollection} explorer={testExplorer} />);
expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(false);
expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(true);
});
it("renders neither component when both are missing", () => {
testCollection.materializedViews(null);
testCollection.materializedViewDefinition(null);
const wrapper = shallow(<GlobalSecondaryIndexComponent collection={testCollection} explorer={testExplorer} />);
expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(false);
expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(false);
});
});

View File

@@ -0,0 +1,41 @@
import { FontIcon, Link, Stack, Text } from "@fluentui/react";
import Explorer from "Explorer/Explorer";
import React from "react";
import * as ViewModels from "../../../../Contracts/ViewModels";
import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent";
import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent";
export interface GlobalSecondaryIndexComponentProps {
collection: ViewModels.Collection;
explorer: Explorer;
}
export const GlobalSecondaryIndexComponent: React.FC<GlobalSecondaryIndexComponentProps> = ({
collection,
explorer,
}) => {
const isTargetContainer = !!collection?.materializedViewDefinition();
const isSourceContainer = !!collection?.materializedViews();
return (
<Stack tokens={{ childrenGap: 8 }} styles={{ root: { maxWidth: 600 } }}>
<Stack horizontal verticalAlign="center" wrap tokens={{ childrenGap: 8 }}>
{isSourceContainer && (
<Text styles={{ root: { fontWeight: 600 } }}>This container has the following indexes defined for it.</Text>
)}
<Text>
<Link
target="_blank"
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
>
Learn more
<FontIcon iconName="NavigateExternalInline" style={{ marginLeft: "4px" }} />
</Link>{" "}
about how to define global secondary indexes and how to use them.
</Text>
</Stack>
{isSourceContainer && <GlobalSecondaryIndexSourceComponent collection={collection} explorer={explorer} />}
{isTargetContainer && <GlobalSecondaryIndexTargetComponent collection={collection} />}
</Stack>
);
};

View File

@@ -0,0 +1,42 @@
import { PrimaryButton } from "@fluentui/react";
import { shallow } from "enzyme";
import React from "react";
import { collection, container } from "../TestUtils";
import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent";
describe("GlobalSecondaryIndexSourceComponent", () => {
let testCollection: typeof collection;
let testExplorer: typeof container;
beforeEach(() => {
testCollection = { ...collection };
});
it("renders without crashing", () => {
const wrapper = shallow(
<GlobalSecondaryIndexSourceComponent collection={testCollection} explorer={testExplorer} />,
);
expect(wrapper.exists()).toBe(true);
});
it("renders the PrimaryButton", () => {
const wrapper = shallow(
<GlobalSecondaryIndexSourceComponent collection={testCollection} explorer={testExplorer} />,
);
expect(wrapper.find(PrimaryButton).exists()).toBe(true);
});
it("updates when new global secondary indexes are provided", () => {
const wrapper = shallow(
<GlobalSecondaryIndexSourceComponent collection={testCollection} explorer={testExplorer} />,
);
// Simulating an update by modifying the observable directly
testCollection.materializedViews([{ id: "view3", _rid: "rid3" }]);
wrapper.setProps({ collection: testCollection });
wrapper.update();
expect(wrapper.find(PrimaryButton).exists()).toBe(true);
});
});

View File

@@ -0,0 +1,114 @@
import { PrimaryButton } from "@fluentui/react";
import { GlobalSecondaryIndexLabels } from "Common/Constants";
import { MaterializedView } from "Contracts/DataModels";
import Explorer from "Explorer/Explorer";
import { loadMonaco } from "Explorer/LazyMonaco";
import { AddGlobalSecondaryIndexPanel } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { useDatabases } from "Explorer/useDatabases";
import { useSidePanel } from "hooks/useSidePanel";
import * as monaco from "monaco-editor";
import React, { useEffect, useRef } from "react";
import * as ViewModels from "../../../../Contracts/ViewModels";
export interface GlobalSecondaryIndexSourceComponentProps {
collection: ViewModels.Collection;
explorer: Explorer;
}
export const GlobalSecondaryIndexSourceComponent: React.FC<GlobalSecondaryIndexSourceComponentProps> = ({
collection,
explorer,
}) => {
const editorContainerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>(null);
const globalSecondaryIndexes: MaterializedView[] = collection?.materializedViews() ?? [];
// Helper function to fetch the definition and partition key of targetContainer by traversing through all collections and matching id from MaterializedView[] with collection id.
const getViewDetails = (viewId: string): { definition: string; partitionKey: string[] } => {
let definition = "";
let partitionKey: string[] = [];
useDatabases.getState().databases.find((database) => {
const collection = database.collections().find((collection) => collection.id() === viewId);
if (collection) {
const globalSecondaryIndexDefinition = collection.materializedViewDefinition();
globalSecondaryIndexDefinition && (definition = globalSecondaryIndexDefinition.definition);
collection.partitionKey?.paths && (partitionKey = collection.partitionKey.paths);
}
});
return { definition, partitionKey };
};
//JSON value for the editor using the fetched id and definitions.
const jsonValue = JSON.stringify(
globalSecondaryIndexes.map((view) => {
const { definition, partitionKey } = getViewDetails(view.id);
return {
name: view.id,
partitionKey: partitionKey.join(", "),
definition,
};
}),
null,
2,
);
// Initialize Monaco editor with the computed JSON value.
useEffect(() => {
let disposed = false;
const initMonaco = async () => {
const monacoInstance = await loadMonaco();
if (disposed || !editorContainerRef.current) {
return;
}
editorRef.current = monacoInstance.editor.create(editorContainerRef.current, {
value: jsonValue,
language: "json",
ariaLabel: "Global Secondary Index JSON",
readOnly: true,
});
};
initMonaco();
return () => {
disposed = true;
editorRef.current?.dispose();
};
}, [jsonValue]);
// Update the editor when the jsonValue changes.
useEffect(() => {
if (editorRef.current) {
editorRef.current.setValue(jsonValue);
}
}, [jsonValue]);
return (
<div>
<div
ref={editorContainerRef}
style={{
height: 250,
border: "1px solid #ccc",
borderRadius: 4,
overflow: "hidden",
}}
/>
<PrimaryButton
text="Add index"
styles={{ root: { width: "fit-content", marginTop: 12 } }}
onClick={() =>
useSidePanel
.getState()
.openSidePanel(
GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
<AddGlobalSecondaryIndexPanel explorer={explorer} sourceContainer={collection} />,
)
}
/>
</div>
);
};

View File

@@ -0,0 +1,32 @@
import { Text } from "@fluentui/react";
import { Collection } from "Contracts/ViewModels";
import { shallow } from "enzyme";
import React from "react";
import { collection } from "../TestUtils";
import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent";
describe("GlobalSecondaryIndexTargetComponent", () => {
let testCollection: Collection;
beforeEach(() => {
testCollection = {
...collection,
materializedViewDefinition: collection.materializedViewDefinition,
};
});
it("renders without crashing", () => {
const wrapper = shallow(<GlobalSecondaryIndexTargetComponent collection={testCollection} />);
expect(wrapper.exists()).toBe(true);
});
it("displays the source container ID", () => {
const wrapper = shallow(<GlobalSecondaryIndexTargetComponent collection={testCollection} />);
expect(wrapper.find(Text).at(2).dive().text()).toBe("source1");
});
it("displays the global secondary index definition", () => {
const wrapper = shallow(<GlobalSecondaryIndexTargetComponent collection={testCollection} />);
expect(wrapper.find(Text).at(4).dive().text()).toBe("SELECT * FROM c WHERE c.id = 1");
});
});

View File

@@ -0,0 +1,45 @@
import { Stack, Text } from "@fluentui/react";
import * as React from "react";
import * as ViewModels from "../../../../Contracts/ViewModels";
export interface GlobalSecondaryIndexTargetComponentProps {
collection: ViewModels.Collection;
}
export const GlobalSecondaryIndexTargetComponent: React.FC<GlobalSecondaryIndexTargetComponentProps> = ({
collection,
}) => {
const globalSecondaryIndexDefinition = collection?.materializedViewDefinition();
const textHeadingStyle = {
root: { fontWeight: "600", fontSize: 16 },
};
const valueBoxStyle = {
root: {
backgroundColor: "#f3f3f3",
padding: "5px 10px",
borderRadius: "4px",
},
};
return (
<Stack tokens={{ childrenGap: 15 }} styles={{ root: { maxWidth: 600 } }}>
<Text styles={textHeadingStyle}>Global Secondary Index Settings</Text>
<Stack tokens={{ childrenGap: 5 }}>
<Text styles={{ root: { fontWeight: "600" } }}>Source container</Text>
<Stack styles={valueBoxStyle}>
<Text>{globalSecondaryIndexDefinition?.sourceCollectionId}</Text>
</Stack>
</Stack>
<Stack tokens={{ childrenGap: 5 }}>
<Text styles={{ root: { fontWeight: "600" } }}>Global secondary index definition</Text>
<Stack styles={valueBoxStyle}>
<Text>{globalSecondaryIndexDefinition?.definition}</Text>
</Stack>
</Stack>
</Stack>
);
};

View File

@@ -29,16 +29,26 @@ export interface PartitionKeyComponentProps {
database: ViewModels.Database; database: ViewModels.Database;
collection: ViewModels.Collection; collection: ViewModels.Collection;
explorer: Explorer; explorer: Explorer;
isReadOnly?: boolean; // true: cannot change partition key
} }
export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({ database, collection, explorer }) => { export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({
database,
collection,
explorer,
isReadOnly,
}) => {
const { dataTransferJobs } = useDataTransferJobs(); const { dataTransferJobs } = useDataTransferJobs();
const [portalDataTransferJob, setPortalDataTransferJob] = React.useState<DataTransferJobGetResults>(null); const [portalDataTransferJob, setPortalDataTransferJob] = React.useState<DataTransferJobGetResults>(null);
React.useEffect(() => { React.useEffect(() => {
if (isReadOnly) {
return;
}
const loadDataTransferJobs = refreshDataTransferOperations; const loadDataTransferJobs = refreshDataTransferOperations;
loadDataTransferJobs(); loadDataTransferJobs();
}, []); }, [isReadOnly]);
React.useEffect(() => { React.useEffect(() => {
const currentJob = findPortalDataTransferJob(); const currentJob = findPortalDataTransferJob();
@@ -163,56 +173,61 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({ da
</Stack> </Stack>
</Stack> </Stack>
</Stack> </Stack>
<MessageBar messageBarType={MessageBarType.warning}>
To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the {!isReadOnly && (
source container for the entire duration of the partition key change process. <>
<Link <MessageBar messageBarType={MessageBarType.warning}>
href="https://learn.microsoft.com/azure/cosmos-db/container-copy#how-does-container-copy-work" To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to
target="_blank" the source container for the entire duration of the partition key change process.
underline <Link
> href="https://learn.microsoft.com/azure/cosmos-db/container-copy#how-does-container-copy-work"
Learn more target="_blank"
</Link> underline
</MessageBar> >
<Text> Learn more
To change the partition key, a new destination container must be created or an existing destination container </Link>
selected. Data will then be copied to the destination container. </MessageBar>
</Text> <Text>
{configContext.platform !== Platform.Emulator && ( To change the partition key, a new destination container must be created or an existing destination
<PrimaryButton container selected. Data will then be copied to the destination container.
styles={{ root: { width: "fit-content" } }} </Text>
text="Change" {configContext.platform !== Platform.Emulator && (
onClick={startPartitionkeyChangeWorkflow} <PrimaryButton
disabled={isCurrentJobInProgress(portalDataTransferJob)} styles={{ root: { width: "fit-content" } }}
/> text="Change"
)} onClick={startPartitionkeyChangeWorkflow}
{portalDataTransferJob && ( disabled={isCurrentJobInProgress(portalDataTransferJob)}
<Stack> />
<Text styles={textHeadingStyle}>{partitionKeyName} change job</Text> )}
<Stack {portalDataTransferJob && (
horizontal <Stack>
tokens={{ childrenGap: 20 }} <Text styles={textHeadingStyle}>{partitionKeyName} change job</Text>
styles={{ <Stack
root: { horizontal
alignItems: "center", tokens={{ childrenGap: 20 }}
}, styles={{
}} root: {
> alignItems: "center",
<ProgressIndicator },
label={portalDataTransferJob?.properties?.jobName} }}
description={getProgressDescription()} >
percentComplete={getPercentageComplete()} <ProgressIndicator
styles={{ label={portalDataTransferJob?.properties?.jobName}
root: { description={getProgressDescription()}
width: "85%", percentComplete={getPercentageComplete()}
}, styles={{
}} root: {
></ProgressIndicator> width: "85%",
{isCurrentJobInProgress(portalDataTransferJob) && ( },
<DefaultButton text="Cancel" onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)} /> }}
)} ></ProgressIndicator>
</Stack> {isCurrentJobInProgress(portalDataTransferJob) && (
</Stack> <DefaultButton text="Cancel" onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)} />
)}
</Stack>
</Stack>
)}
</>
)} )}
</Stack> </Stack>
); );

View File

@@ -57,6 +57,7 @@ export enum SettingsV2TabTypes {
ComputedPropertiesTab, ComputedPropertiesTab,
ContainerVectorPolicyTab, ContainerVectorPolicyTab,
ThroughputBucketsTab, ThroughputBucketsTab,
GlobalSecondaryIndexTab,
} }
export enum ContainerPolicyTabTypes { export enum ContainerPolicyTabTypes {
@@ -171,6 +172,8 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => {
return "Container Policies"; return "Container Policies";
case SettingsV2TabTypes.ThroughputBucketsTab: case SettingsV2TabTypes.ThroughputBucketsTab:
return "Throughput Buckets"; return "Throughput Buckets";
case SettingsV2TabTypes.GlobalSecondaryIndexTab:
return "Global Secondary Index (Preview)";
default: default:
throw new Error(`Unknown tab ${tab}`); throw new Error(`Unknown tab ${tab}`);
} }

View File

@@ -48,6 +48,15 @@ export const collection = {
]), ]),
vectorEmbeddingPolicy: ko.observable<DataModels.VectorEmbeddingPolicy>({} as DataModels.VectorEmbeddingPolicy), vectorEmbeddingPolicy: ko.observable<DataModels.VectorEmbeddingPolicy>({} as DataModels.VectorEmbeddingPolicy),
fullTextPolicy: ko.observable<DataModels.FullTextPolicy>({} as DataModels.FullTextPolicy), fullTextPolicy: ko.observable<DataModels.FullTextPolicy>({} as DataModels.FullTextPolicy),
materializedViews: ko.observable<DataModels.MaterializedView[]>([
{ id: "view1", _rid: "rid1" },
{ id: "view2", _rid: "rid2" },
]),
materializedViewDefinition: ko.observable<DataModels.MaterializedViewDefinition>({
definition: "SELECT * FROM c WHERE c.id = 1",
sourceCollectionId: "source1",
sourceCollectionRid: "rid123",
}),
readSettings: () => { readSettings: () => {
return; return;
}, },

View File

@@ -60,6 +60,8 @@ exports[`SettingsComponent renders 1`] = `
"getDatabase": [Function], "getDatabase": [Function],
"id": [Function], "id": [Function],
"indexingPolicy": [Function], "indexingPolicy": [Function],
"materializedViewDefinition": [Function],
"materializedViews": [Function],
"offer": [Function], "offer": [Function],
"partitionKey": { "partitionKey": {
"kind": "hash", "kind": "hash",
@@ -139,6 +141,8 @@ exports[`SettingsComponent renders 1`] = `
"getDatabase": [Function], "getDatabase": [Function],
"id": [Function], "id": [Function],
"indexingPolicy": [Function], "indexingPolicy": [Function],
"materializedViewDefinition": [Function],
"materializedViews": [Function],
"offer": [Function], "offer": [Function],
"partitionKey": { "partitionKey": {
"kind": "hash", "kind": "hash",
@@ -258,6 +262,138 @@ exports[`SettingsComponent renders 1`] = `
"getDatabase": [Function], "getDatabase": [Function],
"id": [Function], "id": [Function],
"indexingPolicy": [Function], "indexingPolicy": [Function],
"materializedViewDefinition": [Function],
"materializedViews": [Function],
"offer": [Function],
"partitionKey": {
"kind": "hash",
"paths": [],
"version": 2,
},
"partitionKeyProperties": [
"partitionKey",
],
"readSettings": [Function],
"uniqueKeyPolicy": {},
"usageSizeInKB": [Function],
"vectorEmbeddingPolicy": [Function],
}
}
explorer={
Explorer {
"_isInitializingNotebooks": false,
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"copyNotebook": [Function],
"parameters": [Function],
},
}
}
isReadOnly={false}
/>
</PivotItem>
<PivotItem
headerText="Computed Properties"
itemKey="ComputedPropertiesTab"
key="ComputedPropertiesTab"
style={
{
"marginTop": 20,
}
}
>
<ComputedPropertiesComponent
computedPropertiesContent={
[
{
"name": "queryName",
"query": "query",
},
]
}
computedPropertiesContentBaseline={
[
{
"name": "queryName",
"query": "query",
},
]
}
logComputedPropertiesSuccessMessage={[Function]}
onComputedPropertiesContentChange={[Function]}
onComputedPropertiesDirtyChange={[Function]}
resetShouldDiscardComputedProperties={[Function]}
shouldDiscardComputedProperties={false}
/>
</PivotItem>
<PivotItem
headerText="Global Secondary Index (Preview)"
itemKey="GlobalSecondaryIndexTab"
key="GlobalSecondaryIndexTab"
style={
{
"marginTop": 20,
}
}
>
<GlobalSecondaryIndexComponent
collection={
{
"analyticalStorageTtl": [Function],
"changeFeedPolicy": [Function],
"computedProperties": [Function],
"conflictResolutionPolicy": [Function],
"container": Explorer {
"_isInitializingNotebooks": false,
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"copyNotebook": [Function],
"parameters": [Function],
},
},
"databaseId": "test",
"defaultTtl": [Function],
"fullTextPolicy": [Function],
"geospatialConfig": [Function],
"getDatabase": [Function],
"id": [Function],
"indexingPolicy": [Function],
"materializedViewDefinition": [Function],
"materializedViews": [Function],
"offer": [Function], "offer": [Function],
"partitionKey": { "partitionKey": {
"kind": "hash", "kind": "hash",
@@ -302,40 +438,6 @@ exports[`SettingsComponent renders 1`] = `
} }
/> />
</PivotItem> </PivotItem>
<PivotItem
headerText="Computed Properties"
itemKey="ComputedPropertiesTab"
key="ComputedPropertiesTab"
style={
{
"marginTop": 20,
}
}
>
<ComputedPropertiesComponent
computedPropertiesContent={
[
{
"name": "queryName",
"query": "query",
},
]
}
computedPropertiesContentBaseline={
[
{
"name": "queryName",
"query": "query",
},
]
}
logComputedPropertiesSuccessMessage={[Function]}
onComputedPropertiesContentChange={[Function]}
onComputedPropertiesDirtyChange={[Function]}
resetShouldDiscardComputedProperties={[Function]}
shouldDiscardComputedProperties={false}
/>
</PivotItem>
</StyledPivot> </StyledPivot>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@ import Explorer from "../Explorer";
import { useDatabases } from "../useDatabases"; import { useDatabases } from "../useDatabases";
import { ContainerSampleGenerator } from "./ContainerSampleGenerator"; import { ContainerSampleGenerator } from "./ContainerSampleGenerator";
// TODO: this does not seem to be used. Remove?
export class DataSamplesUtil { export class DataSamplesUtil {
private static readonly DialogTitle = "Create Sample Container"; private static readonly DialogTitle = "Create Sample Container";
constructor(private container: Explorer) {} constructor(private container: Explorer) {}

View File

@@ -55,7 +55,7 @@ import type NotebookManager from "./Notebook/NotebookManager";
import { NotebookPaneContent } from "./Notebook/NotebookManager"; import { NotebookPaneContent } from "./Notebook/NotebookManager";
import { NotebookUtil } from "./Notebook/NotebookUtil"; import { NotebookUtil } from "./Notebook/NotebookUtil";
import { useNotebook } from "./Notebook/useNotebook"; import { useNotebook } from "./Notebook/useNotebook";
import { AddCollectionPanel } from "./Panes/AddCollectionPanel"; import { AddCollectionPanel } from "./Panes/AddCollectionPanel/AddCollectionPanel";
import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane"; import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane";
import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane"; import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane";
import { StringInputPane } from "./Panes/StringInputPane/StringInputPane"; import { StringInputPane } from "./Panes/StringInputPane/StringInputPane";

View File

@@ -2,7 +2,7 @@
* Notebook container related stuff * Notebook container related stuff
*/ */
import { useDialog } from "Explorer/Controls/Dialog"; import { useDialog } from "Explorer/Controls/Dialog";
import promiseRetry, { AbortError } from "p-retry"; import promiseRetry, { AbortError, Options } from "p-retry";
import { PhoenixClient } from "Phoenix/PhoenixClient"; import { PhoenixClient } from "Phoenix/PhoenixClient";
import * as Constants from "../../Common/Constants"; import * as Constants from "../../Common/Constants";
import { ConnectionStatusType, HttpHeaders, HttpStatusCodes, Notebook, PoolIdType } from "../../Common/Constants"; import { ConnectionStatusType, HttpHeaders, HttpStatusCodes, Notebook, PoolIdType } from "../../Common/Constants";
@@ -19,7 +19,7 @@ export class NotebookContainerClient {
private clearReconnectionAttemptMessage? = () => {}; private clearReconnectionAttemptMessage? = () => {};
private isResettingWorkspace: boolean; private isResettingWorkspace: boolean;
private phoenixClient: PhoenixClient; private phoenixClient: PhoenixClient;
private retryOptions: promiseRetry.Options; private retryOptions: Options;
private scheduleTimerId: NodeJS.Timeout; private scheduleTimerId: NodeJS.Timeout;
constructor(private onConnectionLost: () => void) { constructor(private onConnectionLost: () => void) {

View File

@@ -1,6 +1,6 @@
import { shallow } from "enzyme"; import { shallow } from "enzyme";
import React from "react"; import React from "react";
import Explorer from "../Explorer"; import Explorer from "../../Explorer";
import { AddCollectionPanel } from "./AddCollectionPanel"; import { AddCollectionPanel } from "./AddCollectionPanel";
const props = { const props = {

View File

@@ -21,11 +21,25 @@ import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import { configContext, Platform } from "ConfigContext"; import { configContext, Platform } from "ConfigContext";
import * as DataModels from "Contracts/DataModels"; import * as DataModels from "Contracts/DataModels";
import { import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
FullTextPoliciesComponent,
getFullTextLanguageOptions,
} from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent"; import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent";
import {
AllPropertiesIndexed,
AnalyticalStorageContent,
ContainerVectorPolicyTooltipContent,
FullTextPolicyDefault,
getPartitionKey,
getPartitionKeyName,
getPartitionKeyPlaceHolder,
getPartitionKeyTooltipText,
isFreeTierAccount,
isSynapseLinkEnabled,
parseUniqueKeys,
scrollToSection,
SharedDatabaseDefault,
shouldShowAnalyticalStoreOptions,
UniqueKeysHeader,
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import { useSidePanel } from "hooks/useSidePanel"; import { useSidePanel } from "hooks/useSidePanel";
import { useTeachingBubble } from "hooks/useTeachingBubble"; import { useTeachingBubble } from "hooks/useTeachingBubble";
import { isFabricNative } from "Platform/Fabric/FabricUtil"; import { isFabricNative } from "Platform/Fabric/FabricUtil";
@@ -42,15 +56,15 @@ import {
isVectorSearchEnabled, isVectorSearchEnabled,
} from "Utils/CapabilityUtils"; } from "Utils/CapabilityUtils";
import { getUpsellMessage } from "Utils/PricingUtils"; import { getUpsellMessage } from "Utils/PricingUtils";
import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent"; import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput"; import { CollapsibleSectionComponent } from "../../Controls/CollapsiblePanel/CollapsibleSectionComponent";
import "../Controls/ThroughputInput/ThroughputInput.less"; import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput";
import { ContainerSampleGenerator } from "../DataSamples/ContainerSampleGenerator"; import { ContainerSampleGenerator } from "../../DataSamples/ContainerSampleGenerator";
import Explorer from "../Explorer"; import Explorer from "../../Explorer";
import { useDatabases } from "../useDatabases"; import { useDatabases } from "../../useDatabases";
import { PanelFooterComponent } from "./PanelFooterComponent"; import { PanelFooterComponent } from "../PanelFooterComponent";
import { PanelInfoErrorComponent } from "./PanelInfoErrorComponent"; import { PanelInfoErrorComponent } from "../PanelInfoErrorComponent";
import { PanelLoadingScreen } from "./PanelLoadingScreen"; import { PanelLoadingScreen } from "../PanelLoadingScreen";
export interface AddCollectionPanelProps { export interface AddCollectionPanelProps {
explorer: Explorer; explorer: Explorer;
@@ -58,40 +72,6 @@ export interface AddCollectionPanelProps {
isQuickstart?: boolean; isQuickstart?: boolean;
} }
const SharedDatabaseDefault: DataModels.IndexingPolicy = {
indexingMode: "consistent",
automatic: true,
includedPaths: [],
excludedPaths: [
{
path: "/*",
},
],
};
export const AllPropertiesIndexed: DataModels.IndexingPolicy = {
indexingMode: "consistent",
automatic: true,
includedPaths: [
{
path: "/*",
indexes: [
{
kind: "Range",
dataType: "Number",
precision: -1,
},
{
kind: "Range",
dataType: "String",
precision: -1,
},
],
},
],
excludedPaths: [],
};
export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = { export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = {
vectorEmbeddings: [], vectorEmbeddings: [],
}; };
@@ -144,7 +124,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
collectionId: props.isQuickstart ? `Sample${getCollectionName()}` : "", collectionId: props.isQuickstart ? `Sample${getCollectionName()}` : "",
enableIndexing: true, enableIndexing: true,
isSharded: userContext.apiType !== "Tables", isSharded: userContext.apiType !== "Tables",
partitionKey: this.getPartitionKey(), partitionKey: getPartitionKey(props.isQuickstart),
subPartitionKeys: [], subPartitionKeys: [],
enableDedicatedThroughput: false, enableDedicatedThroughput: false,
createMongoWildCardIndex: createMongoWildCardIndex:
@@ -160,7 +140,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
vectorEmbeddingPolicy: [], vectorEmbeddingPolicy: [],
vectorIndexingPolicy: [], vectorIndexingPolicy: [],
vectorPolicyValidated: true, vectorPolicyValidated: true,
fullTextPolicy: { defaultLanguage: getFullTextLanguageOptions()[0].key as never, fullTextPaths: [] }, fullTextPolicy: FullTextPolicyDefault,
fullTextIndexes: [], fullTextIndexes: [],
fullTextPolicyValidated: true, fullTextPolicyValidated: true,
}; };
@@ -174,7 +154,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
componentDidUpdate(_prevProps: AddCollectionPanelProps, prevState: AddCollectionPanelState): void { componentDidUpdate(_prevProps: AddCollectionPanelProps, prevState: AddCollectionPanelState): void {
if (this.state.errorMessage && this.state.errorMessage !== prevState.errorMessage) { if (this.state.errorMessage && this.state.errorMessage !== prevState.errorMessage) {
this.scrollToSection("panelContainer"); scrollToSection("panelContainer");
} }
} }
@@ -191,7 +171,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
/> />
)} )}
{!this.state.errorMessage && this.isFreeTierAccount() && ( {!this.state.errorMessage && isFreeTierAccount() && (
<PanelInfoErrorComponent <PanelInfoErrorComponent
message={getUpsellMessage(userContext.portalEnv, true, isFirstResourceCreated, true)} message={getUpsellMessage(userContext.portalEnv, true, isFirstResourceCreated, true)}
messageType="info" messageType="info"
@@ -351,8 +331,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
required required
type="text" type="text"
autoComplete="off" autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]" pattern={ValidCosmosDbIdInputPattern.source}
title="May not end with space nor contain characters '\' '/' '#' '?'" title={ValidCosmosDbIdDescription}
placeholder="Type a new database id" placeholder="Type a new database id"
size={40} size={40}
className="panelTextField" className="panelTextField"
@@ -399,10 +379,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{!isServerlessAccount() && this.state.isSharedThroughputChecked && ( {!isServerlessAccount() && this.state.isSharedThroughputChecked && (
<ThroughputInput <ThroughputInput
showFreeTierExceedThroughputTooltip={this.isFreeTierAccount() && !isFirstResourceCreated} showFreeTierExceedThroughputTooltip={isFreeTierAccount() && !isFirstResourceCreated}
isDatabase={true} isDatabase={true}
isSharded={this.state.isSharded} isSharded={this.state.isSharded}
isFreeTier={this.isFreeTierAccount()} isFreeTier={isFreeTierAccount()}
isQuickstart={this.props.isQuickstart} isQuickstart={this.props.isQuickstart}
setThroughputValue={(throughput: number) => (this.newDatabaseThroughput = throughput)} setThroughputValue={(throughput: number) => (this.newDatabaseThroughput = throughput)}
setIsAutoscale={(isAutoscale: boolean) => (this.isNewDatabaseAutoscale = isAutoscale)} setIsAutoscale={(isAutoscale: boolean) => (this.isNewDatabaseAutoscale = isAutoscale)}
@@ -459,8 +439,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
aria-required aria-required
required required
autoComplete="off" autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]" pattern={ValidCosmosDbIdInputPattern.source}
title="May not end with space nor contain characters '\' '/' '#' '?'" title={ValidCosmosDbIdDescription}
placeholder={`e.g., ${getCollectionName()}1`} placeholder={`e.g., ${getCollectionName()}1`}
size={40} size={40}
className="panelTextField" className="panelTextField"
@@ -579,17 +559,14 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<Stack horizontal> <Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span> <span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small"> <Text className="panelTextBold" variant="small">
{this.getPartitionKeyName()} {getPartitionKeyName()}
</Text> </Text>
<TooltipHost <TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={getPartitionKeyTooltipText()}>
directionalHint={DirectionalHint.bottomLeftEdge}
content={this.getPartitionKeyTooltipText()}
>
<Icon <Icon
iconName="Info" iconName="Info"
className="panelInfoIcon" className="panelInfoIcon"
tabIndex={0} tabIndex={0}
ariaLabel={this.getPartitionKeyTooltipText()} ariaLabel={getPartitionKeyTooltipText()}
/> />
</TooltipHost> </TooltipHost>
</Stack> </Stack>
@@ -603,8 +580,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
required required
size={40} size={40}
className="panelTextField" className="panelTextField"
placeholder={this.getPartitionKeyPlaceHolder()} placeholder={getPartitionKeyPlaceHolder()}
aria-label={this.getPartitionKeyName()} aria-label={getPartitionKeyName()}
pattern={userContext.apiType === "Gremlin" ? "^/[^/]*" : ".*"} pattern={userContext.apiType === "Gremlin" ? "^/[^/]*" : ".*"}
title={userContext.apiType === "Gremlin" ? "May not use composite partition key" : ""} title={userContext.apiType === "Gremlin" ? "May not use composite partition key" : ""}
value={this.state.partitionKey} value={this.state.partitionKey}
@@ -642,8 +619,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
tabIndex={index > 0 ? 1 : 0} tabIndex={index > 0 ? 1 : 0}
className="panelTextField" className="panelTextField"
autoComplete="off" autoComplete="off"
placeholder={this.getPartitionKeyPlaceHolder(index)} placeholder={getPartitionKeyPlaceHolder(index)}
aria-label={this.getPartitionKeyName()} aria-label={getPartitionKeyName()}
pattern={".*"} pattern={".*"}
title={""} title={""}
value={subPartitionKey} value={subPartitionKey}
@@ -734,10 +711,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{this.shouldShowCollectionThroughputInput() && ( {this.shouldShowCollectionThroughputInput() && (
<ThroughputInput <ThroughputInput
showFreeTierExceedThroughputTooltip={this.isFreeTierAccount() && !isFirstResourceCreated} showFreeTierExceedThroughputTooltip={isFreeTierAccount() && !isFirstResourceCreated}
isDatabase={false} isDatabase={false}
isSharded={this.state.isSharded} isSharded={this.state.isSharded}
isFreeTier={this.isFreeTierAccount()} isFreeTier={isFreeTierAccount()}
isQuickstart={this.props.isQuickstart} isQuickstart={this.props.isQuickstart}
setThroughputValue={(throughput: number) => (this.collectionThroughput = throughput)} setThroughputValue={(throughput: number) => (this.collectionThroughput = throughput)}
setIsAutoscale={(isAutoscale: boolean) => (this.isCollectionAutoscale = isAutoscale)} setIsAutoscale={(isAutoscale: boolean) => (this.isCollectionAutoscale = isAutoscale)}
@@ -752,27 +729,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{!isFabricNative() && userContext.apiType === "SQL" && ( {!isFabricNative() && userContext.apiType === "SQL" && (
<Stack> <Stack>
<Stack horizontal> {UniqueKeysHeader()}
<Text className="panelTextBold" variant="small">
Unique keys
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={
"Unique keys provide developers with the ability to add a layer of data integrity to their database. By creating a unique key policy when a container is created, you ensure the uniqueness of one or more values per partition key."
}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={
"Unique keys provide developers with the ability to add a layer of data integrity to their database. By creating a unique key policy when a container is created, you ensure the uniqueness of one or more values per partition key."
}
/>
</TooltipHost>
</Stack>
{this.state.uniqueKeys.map((uniqueKey: string, i: number): JSX.Element => { {this.state.uniqueKeys.map((uniqueKey: string, i: number): JSX.Element => {
return ( return (
<Stack style={{ marginBottom: 8 }} key={`uniqueKey${i}`} horizontal> <Stack style={{ marginBottom: 8 }} key={`uniqueKey${i}`} horizontal>
@@ -820,10 +777,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</Stack> </Stack>
)} )}
{this.shouldShowAnalyticalStoreOptions() && ( {shouldShowAnalyticalStoreOptions() && (
<Stack className="panelGroupSpacing"> <Stack className="panelGroupSpacing">
<Text className="panelTextBold" variant="small"> <Text className="panelTextBold" variant="small">
{this.getAnalyticalStorageContent()} {AnalyticalStorageContent()}
</Text> </Text>
<Stack horizontal verticalAlign="center"> <Stack horizontal verticalAlign="center">
@@ -831,7 +788,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<input <input
className="panelRadioBtn" className="panelRadioBtn"
checked={this.state.enableAnalyticalStore} checked={this.state.enableAnalyticalStore}
disabled={!this.isSynapseLinkEnabled()} disabled={!isSynapseLinkEnabled()}
aria-label="Enable analytical store" aria-label="Enable analytical store"
aria-checked={this.state.enableAnalyticalStore} aria-checked={this.state.enableAnalyticalStore}
name="analyticalStore" name="analyticalStore"
@@ -846,7 +803,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<input <input
className="panelRadioBtn" className="panelRadioBtn"
checked={!this.state.enableAnalyticalStore} checked={!this.state.enableAnalyticalStore}
disabled={!this.isSynapseLinkEnabled()} disabled={!isSynapseLinkEnabled()}
aria-label="Disable analytical store" aria-label="Disable analytical store"
aria-checked={!this.state.enableAnalyticalStore} aria-checked={!this.state.enableAnalyticalStore}
name="analyticalStore" name="analyticalStore"
@@ -860,7 +817,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</div> </div>
</Stack> </Stack>
{!this.isSynapseLinkEnabled() && ( {!isSynapseLinkEnabled() && (
<Stack className="panelGroupSpacing"> <Stack className="panelGroupSpacing">
<Text variant="small"> <Text variant="small">
Azure Synapse Link is required for creating an analytical store{" "} Azure Synapse Link is required for creating an analytical store{" "}
@@ -890,9 +847,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
title="Container Vector Policy" title="Container Vector Policy"
isExpandedByDefault={false} isExpandedByDefault={false}
onExpand={() => { onExpand={() => {
this.scrollToSection("collapsibleVectorPolicySectionContent"); scrollToSection("collapsibleVectorPolicySectionContent");
}} }}
tooltipContent={this.getContainerVectorPolicyTooltipContent()} tooltipContent={ContainerVectorPolicyTooltipContent()}
> >
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}> <Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
<Stack styles={{ root: { paddingLeft: 40 } }}> <Stack styles={{ root: { paddingLeft: 40 } }}>
@@ -918,7 +875,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
title="Container Full Text Search Policy" title="Container Full Text Search Policy"
isExpandedByDefault={false} isExpandedByDefault={false}
onExpand={() => { onExpand={() => {
this.scrollToSection("collapsibleFullTextPolicySectionContent"); scrollToSection("collapsibleFullTextPolicySectionContent");
}} }}
//TODO: uncomment when learn more text becomes available //TODO: uncomment when learn more text becomes available
// tooltipContent={this.getContainerFullTextPolicyTooltipContent()} // tooltipContent={this.getContainerFullTextPolicyTooltipContent()}
@@ -946,7 +903,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
isExpandedByDefault={false} isExpandedByDefault={false}
onExpand={() => { onExpand={() => {
TelemetryProcessor.traceOpen(Action.ExpandAddCollectionPaneAdvancedSection); TelemetryProcessor.traceOpen(Action.ExpandAddCollectionPaneAdvancedSection);
this.scrollToSection("collapsibleAdvancedSectionContent"); scrollToSection("collapsibleAdvancedSectionContent");
}} }}
> >
<Stack className="panelGroupSpacing" id="collapsibleAdvancedSectionContent"> <Stack className="panelGroupSpacing" id="collapsibleAdvancedSectionContent">
@@ -1056,31 +1013,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
})); }));
} }
private getPartitionKeyName(isLowerCase?: boolean): string {
const partitionKeyName = userContext.apiType === "Mongo" ? "Shard key" : "Partition key";
return isLowerCase ? partitionKeyName.toLocaleLowerCase() : partitionKeyName;
}
private getPartitionKeyPlaceHolder(index?: number): string {
switch (userContext.apiType) {
case "Mongo":
return "e.g., categoryId";
case "Gremlin":
return "e.g., /address";
case "SQL":
return `${
index === undefined
? "Required - first partition key e.g., /TenantId"
: index === 0
? "second partition key e.g., /UserId"
: "third partition key e.g., /SessionId"
}`;
default:
return "e.g., /address/zipCode";
}
}
private onCreateNewDatabaseRadioBtnChange(event: React.ChangeEvent<HTMLInputElement>): void { private onCreateNewDatabaseRadioBtnChange(event: React.ChangeEvent<HTMLInputElement>): void {
if (event.target.checked && !this.state.createNewDatabase) { if (event.target.checked && !this.state.createNewDatabase) {
this.setState({ this.setState({
@@ -1168,48 +1100,12 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return !!selectedDatabase?.offer(); return !!selectedDatabase?.offer();
} }
private isFreeTierAccount(): boolean {
return userContext.databaseAccount?.properties?.enableFreeTier;
}
private getFreeTierIndexingText(): string { private getFreeTierIndexingText(): string {
return this.state.enableIndexing return this.state.enableIndexing
? "All properties in your documents will be indexed by default for flexible and efficient queries." ? "All properties in your documents will be indexed by default for flexible and efficient queries."
: "Indexing will be turned off. Recommended if you don't need to run queries or only have key value operations."; : "Indexing will be turned off. Recommended if you don't need to run queries or only have key value operations.";
} }
private getPartitionKeyTooltipText(): string {
if (userContext.apiType === "Mongo") {
return "The shard key (field) is used to split your data across many replica sets (shards) to achieve unlimited scalability. Its critical to choose a field that will evenly distribute your data.";
}
let tooltipText = `The ${this.getPartitionKeyName(
true,
)} is used to automatically distribute data across partitions for scalability. Choose a property in your JSON document that has a wide range of values and evenly distributes request volume.`;
if (userContext.apiType === "SQL") {
tooltipText += " For small read-heavy workloads or write-heavy workloads of any size, id is often a good choice.";
}
return tooltipText;
}
private getPartitionKey(): string {
if (userContext.apiType !== "SQL" && userContext.apiType !== "Mongo") {
return "";
}
if (userContext.features.partitionKeyDefault) {
return userContext.apiType === "SQL" ? "/id" : "_id";
}
if (userContext.features.partitionKeyDefault2) {
return userContext.apiType === "SQL" ? "/pk" : "pk";
}
if (this.props.isQuickstart) {
return userContext.apiType === "SQL" ? "/categoryId" : "categoryId";
}
return "";
}
private getPartitionKeySubtext(): string { private getPartitionKeySubtext(): string {
if ( if (
userContext.features.partitionKeyDefault && userContext.features.partitionKeyDefault &&
@@ -1221,34 +1117,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return ""; return "";
} }
private getAnalyticalStorageContent(): JSX.Element {
return (
<Text variant="small">
Enable analytical store capability to perform near real-time analytics on your operational data, without
impacting the performance of transactional workloads.{" "}
<Link
aria-label={Constants.ariaLabelForLearnMoreLink.AnalyticalStore}
target="_blank"
href="https://aka.ms/analytical-store-overview"
>
Learn more
</Link>
</Text>
);
}
private getContainerVectorPolicyTooltipContent(): JSX.Element {
return (
<Text variant="small">
Describe any properties in your data that contain vectors, so that they can be made available for similarity
queries.{" "}
<Link target="_blank" href="https://aka.ms/CosmosDBVectorSetup">
Learn more
</Link>
</Text>
);
}
//TODO: uncomment when learn more text becomes available //TODO: uncomment when learn more text becomes available
// private getContainerFullTextPolicyTooltipContent(): JSX.Element { // private getContainerFullTextPolicyTooltipContent(): JSX.Element {
// return ( // return (
@@ -1279,7 +1147,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
} }
private shouldShowIndexingOptionsForFreeTierAccount(): boolean { private shouldShowIndexingOptionsForFreeTierAccount(): boolean {
if (!this.isFreeTierAccount()) { if (!isFreeTierAccount()) {
return false; return false;
} }
@@ -1288,39 +1156,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
: this.isSelectedDatabaseSharedThroughput(); : this.isSelectedDatabaseSharedThroughput();
} }
private shouldShowAnalyticalStoreOptions(): boolean {
if (isFabricNative() || configContext.platform === Platform.Emulator) {
return false;
}
switch (userContext.apiType) {
case "SQL":
case "Mongo":
return true;
default:
return false;
}
}
private isSynapseLinkEnabled(): boolean {
if (!userContext.databaseAccount) {
return false;
}
const { properties } = userContext.databaseAccount;
if (!properties) {
return false;
}
if (properties.enableAnalyticalStorage) {
return true;
}
return properties.capabilities?.some(
(capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics,
);
}
private shouldShowVectorSearchParameters() { private shouldShowVectorSearchParameters() {
return isVectorSearchEnabled() && (isServerlessAccount() || this.shouldShowCollectionThroughputInput()); return isVectorSearchEnabled() && (isServerlessAccount() || this.shouldShowCollectionThroughputInput());
} }
@@ -1401,11 +1236,11 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
} }
private getAnalyticalStorageTtl(): number { private getAnalyticalStorageTtl(): number {
if (!this.isSynapseLinkEnabled()) { if (!isSynapseLinkEnabled()) {
return undefined; return undefined;
} }
if (!this.shouldShowAnalyticalStoreOptions()) { if (!shouldShowAnalyticalStoreOptions()) {
return undefined; return undefined;
} }
@@ -1419,10 +1254,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return Constants.AnalyticalStorageTtl.Disabled; return Constants.AnalyticalStorageTtl.Disabled;
} }
private scrollToSection(id: string): void {
document.getElementById(id)?.scrollIntoView();
}
private getSampleDBName(): string { private getSampleDBName(): string {
const existingSampleDBs = useDatabases const existingSampleDBs = useDatabases
.getState() .getState()
@@ -1457,7 +1288,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
partitionKeyString = "/'$pk'"; partitionKeyString = "/'$pk'";
} }
const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = this.parseUniqueKeys(); const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = parseUniqueKeys(this.state.uniqueKeys);
const partitionKeyVersion = this.state.useHashV1 ? undefined : 2; const partitionKeyVersion = this.state.useHashV1 ? undefined : 2;
const partitionKey: DataModels.PartitionKey = partitionKeyString const partitionKey: DataModels.PartitionKey = partitionKeyString
? { ? {

View File

@@ -0,0 +1,217 @@
import { DirectionalHint, Icon, Link, Stack, Text, TooltipHost } from "@fluentui/react";
import * as Constants from "Common/Constants";
import { configContext, Platform } from "ConfigContext";
import * as DataModels from "Contracts/DataModels";
import { getFullTextLanguageOptions } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import React from "react";
import { userContext } from "UserContext";
export function getPartitionKeyTooltipText(): string {
if (userContext.apiType === "Mongo") {
return "The shard key (field) is used to split your data across many replica sets (shards) to achieve unlimited scalability. Its critical to choose a field that will evenly distribute your data.";
}
let tooltipText = `The ${getPartitionKeyName(
true,
)} is used to automatically distribute data across partitions for scalability. Choose a property in your JSON document that has a wide range of values and evenly distributes request volume.`;
if (userContext.apiType === "SQL") {
tooltipText += " For small read-heavy workloads or write-heavy workloads of any size, id is often a good choice.";
}
return tooltipText;
}
export function getPartitionKeyName(isLowerCase?: boolean): string {
const partitionKeyName = userContext.apiType === "Mongo" ? "Shard key" : "Partition key";
return isLowerCase ? partitionKeyName.toLocaleLowerCase() : partitionKeyName;
}
export function getPartitionKeyPlaceHolder(index?: number): string {
switch (userContext.apiType) {
case "Mongo":
return "e.g., categoryId";
case "Gremlin":
return "e.g., /address";
case "SQL":
return `${
index === undefined
? "Required - first partition key e.g., /TenantId"
: index === 0
? "second partition key e.g., /UserId"
: "third partition key e.g., /SessionId"
}`;
default:
return "e.g., /address/zipCode";
}
}
export function getPartitionKey(isQuickstart?: boolean): string {
if (userContext.apiType !== "SQL" && userContext.apiType !== "Mongo") {
return "";
}
if (userContext.features.partitionKeyDefault) {
return userContext.apiType === "SQL" ? "/id" : "_id";
}
if (userContext.features.partitionKeyDefault2) {
return userContext.apiType === "SQL" ? "/pk" : "pk";
}
if (isQuickstart) {
return userContext.apiType === "SQL" ? "/categoryId" : "categoryId";
}
return "";
}
export function isFreeTierAccount(): boolean {
return userContext.databaseAccount?.properties?.enableFreeTier;
}
export function UniqueKeysHeader(): JSX.Element {
const tooltipContent =
"Unique keys provide developers with the ability to add a layer of data integrity to their database. By creating a unique key policy when a container is created, you ensure the uniqueness of one or more values per partition key.";
return (
<Stack horizontal>
<Text className="panelTextBold" variant="small">
Unique keys
</Text>
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={tooltipContent}>
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} ariaLabel={tooltipContent} />
</TooltipHost>
</Stack>
);
}
export function shouldShowAnalyticalStoreOptions(): boolean {
if (isFabricNative() || configContext.platform === Platform.Emulator) {
return false;
}
switch (userContext.apiType) {
case "SQL":
case "Mongo":
return true;
default:
return false;
}
}
export function AnalyticalStorageContent(): JSX.Element {
return (
<Text variant="small">
Enable analytical store capability to perform near real-time analytics on your operational data, without impacting
the performance of transactional workloads.{" "}
<Link
aria-label={Constants.ariaLabelForLearnMoreLink.AnalyticalStore}
target="_blank"
href="https://aka.ms/analytical-store-overview"
>
Learn more
</Link>
</Text>
);
}
export function isSynapseLinkEnabled(): boolean {
if (!userContext.databaseAccount) {
return false;
}
const { properties } = userContext.databaseAccount;
if (!properties) {
return false;
}
if (properties.enableAnalyticalStorage) {
return true;
}
return properties.capabilities?.some(
(capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics,
);
}
export function scrollToSection(id: string): void {
document.getElementById(id)?.scrollIntoView();
}
export function ContainerVectorPolicyTooltipContent(): JSX.Element {
return (
<Text variant="small">
Describe any properties in your data that contain vectors, so that they can be made available for similarity
queries.{" "}
<Link target="_blank" href="https://aka.ms/CosmosDBVectorSetup">
Learn more
</Link>
</Text>
);
}
export function parseUniqueKeys(uniqueKeys: string[]): DataModels.UniqueKeyPolicy {
if (uniqueKeys?.length === 0) {
return undefined;
}
const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = { uniqueKeys: [] };
uniqueKeys.forEach((uniqueKey: string) => {
if (uniqueKey) {
const validPaths: string[] = uniqueKey.split(",")?.filter((path) => path?.length > 0);
const trimmedPaths: string[] = validPaths?.map((path) => path.trim());
if (trimmedPaths?.length > 0) {
if (userContext.apiType === "Mongo") {
trimmedPaths.map((path) => {
const transformedPath = path.split(".").join("/");
if (transformedPath[0] !== "/") {
return "/" + transformedPath;
}
return transformedPath;
});
}
uniqueKeyPolicy.uniqueKeys.push({ paths: trimmedPaths });
}
}
});
return uniqueKeyPolicy;
}
export const SharedDatabaseDefault: DataModels.IndexingPolicy = {
indexingMode: "consistent",
automatic: true,
includedPaths: [],
excludedPaths: [
{
path: "/*",
},
],
};
export const FullTextPolicyDefault: DataModels.FullTextPolicy = {
defaultLanguage: getFullTextLanguageOptions()[0].key as never,
fullTextPaths: [],
};
export const AllPropertiesIndexed: DataModels.IndexingPolicy = {
indexingMode: "consistent",
automatic: true,
includedPaths: [
{
path: "/*",
indexes: [
{
kind: "Range",
dataType: "Number",
precision: -1,
},
{
kind: "Range",
dataType: "String",
precision: -1,
},
],
},
],
excludedPaths: [],
};

View File

@@ -93,7 +93,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
id="newDatabaseId" id="newDatabaseId"
name="newDatabaseId" name="newDatabaseId"
onChange={[Function]} onChange={[Function]}
pattern="[^/?#\\\\]*[^/?# \\\\]" pattern="[^\\/?#\\\\]*[^\\/?# \\\\]"
placeholder="Type a new database id" placeholder="Type a new database id"
required={true} required={true}
size={40} size={40}
@@ -178,7 +178,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
id="collectionId" id="collectionId"
name="collectionId" name="collectionId"
onChange={[Function]} onChange={[Function]}
pattern="[^/?#\\\\]*[^/?# \\\\]" pattern="[^\\/?#\\\\]*[^\\/?# \\\\]"
placeholder="e.g., Container1" placeholder="e.g., Container1"
required={true} required={true}
size={40} size={40}

View File

@@ -1,5 +1,6 @@
import { Checkbox, Stack, Text, TextField } from "@fluentui/react"; import { Checkbox, Stack, Text, TextField } from "@fluentui/react";
import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility"; import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
import React, { FunctionComponent, useEffect, useState } from "react"; import React, { FunctionComponent, useEffect, useState } from "react";
import * as Constants from "../../../Common/Constants"; import * as Constants from "../../../Common/Constants";
import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils";
@@ -204,8 +205,8 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
type="text" type="text"
aria-required="true" aria-required="true"
autoComplete="off" autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]" pattern={ValidCosmosDbIdInputPattern.source}
title="May not end with space nor contain characters '\' '/' '#' '?'" title={ValidCosmosDbIdDescription}
size={40} size={40}
aria-label={databaseIdLabel} aria-label={databaseIdLabel}
placeholder={databaseIdPlaceHolder} placeholder={databaseIdPlaceHolder}

View File

@@ -39,7 +39,7 @@ exports[`AddDatabasePane Pane should render Default properly 1`] = `
data-lpignore={true} data-lpignore={true}
id="database-id" id="database-id"
onChange={[Function]} onChange={[Function]}
pattern="[^/?#\\\\]*[^/?# \\\\]" pattern="[^\\/?#\\\\]*[^\\/?# \\\\]"
placeholder="Type a new database id" placeholder="Type a new database id"
size={40} size={40}
styles={ styles={

View File

@@ -0,0 +1,28 @@
import { shallow, ShallowWrapper } from "enzyme";
import Explorer from "Explorer/Explorer";
import {
AddGlobalSecondaryIndexPanel,
AddGlobalSecondaryIndexPanelProps,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import React, { Component } from "react";
const props: AddGlobalSecondaryIndexPanelProps = {
explorer: new Explorer(),
};
describe("AddGlobalSecondaryIndexPanel", () => {
it("render default panel", () => {
const wrapper: ShallowWrapper<AddGlobalSecondaryIndexPanelProps, object, Component> = shallow(
<AddGlobalSecondaryIndexPanel {...props} />,
);
expect(wrapper).toMatchSnapshot();
});
it("should render form", () => {
const wrapper: ShallowWrapper<AddGlobalSecondaryIndexPanelProps, object, Component> = shallow(
<AddGlobalSecondaryIndexPanel {...props} />,
);
const form = wrapper.find("form").first();
expect(form).toBeDefined();
});
});

View File

@@ -0,0 +1,431 @@
import {
DirectionalHint,
Dropdown,
DropdownMenuItemType,
Icon,
IDropdownOption,
Link,
Separator,
Stack,
Text,
TooltipHost,
} from "@fluentui/react";
import * as Constants from "Common/Constants";
import { createGlobalSecondaryIndex } from "Common/dataAccess/createMaterializedView";
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import * as DataModels from "Contracts/DataModels";
import { FullTextIndex, FullTextPolicy, VectorEmbedding, VectorIndex } from "Contracts/DataModels";
import { Collection, Database } from "Contracts/ViewModels";
import Explorer from "Explorer/Explorer";
import {
AllPropertiesIndexed,
FullTextPolicyDefault,
getPartitionKey,
isSynapseLinkEnabled,
parseUniqueKeys,
scrollToSection,
shouldShowAnalyticalStoreOptions,
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import {
chooseSourceContainerStyle,
chooseSourceContainerStyles,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles";
import { AdvancedComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent";
import { AnalyticalStoreComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent";
import { FullTextSearchComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent";
import { PartitionKeyComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent";
import { ThroughputComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent";
import { UniqueKeysComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/UniqueKeysComponent";
import { VectorSearchComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent";
import { PanelFooterComponent } from "Explorer/Panes/PanelFooterComponent";
import { PanelInfoErrorComponent } from "Explorer/Panes/PanelInfoErrorComponent";
import { PanelLoadingScreen } from "Explorer/Panes/PanelLoadingScreen";
import { useDatabases } from "Explorer/useDatabases";
import { useSidePanel } from "hooks/useSidePanel";
import React, { useEffect, useState } from "react";
import { CollectionCreation } from "Shared/Constants";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
import { userContext } from "UserContext";
import { isFullTextSearchEnabled, isServerlessAccount, isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
export interface AddGlobalSecondaryIndexPanelProps {
explorer: Explorer;
sourceContainer?: Collection;
}
export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanelProps): JSX.Element => {
const { explorer, sourceContainer } = props;
const [sourceContainerOptions, setSourceContainerOptions] = useState<IDropdownOption[]>();
const [selectedSourceContainer, setSelectedSourceContainer] = useState<Collection>(sourceContainer);
const [globalSecondaryIndexId, setGlobalSecondaryIndexId] = useState<string>();
const [definition, setDefinition] = useState<string>();
const [partitionKey, setPartitionKey] = useState<string>(getPartitionKey());
const [subPartitionKeys, setSubPartitionKeys] = useState<string[]>([]);
const [useHashV1, setUseHashV1] = useState<boolean>();
const [enableDedicatedThroughput, setEnabledDedicatedThroughput] = useState<boolean>();
const [isThroughputCapExceeded, setIsThroughputCapExceeded] = useState<boolean>();
const [uniqueKeys, setUniqueKeys] = useState<string[]>([]);
const [enableAnalyticalStore, setEnableAnalyticalStore] = useState<boolean>();
const [vectorEmbeddingPolicy, setVectorEmbeddingPolicy] = useState<VectorEmbedding[]>();
const [vectorIndexingPolicy, setVectorIndexingPolicy] = useState<VectorIndex[]>();
const [vectorPolicyValidated, setVectorPolicyValidated] = useState<boolean>();
const [fullTextPolicy, setFullTextPolicy] = useState<FullTextPolicy>(FullTextPolicyDefault);
const [fullTextIndexes, setFullTextIndexes] = useState<FullTextIndex[]>();
const [fullTextPolicyValidated, setFullTextPolicyValidated] = useState<boolean>();
const [errorMessage, setErrorMessage] = useState<string>();
const [showErrorDetails, setShowErrorDetails] = useState<boolean>();
const [isExecuting, setIsExecuting] = useState<boolean>();
useEffect(() => {
const sourceContainerOptions: IDropdownOption[] = [];
useDatabases.getState().databases.forEach((database: Database) => {
sourceContainerOptions.push({
key: database.rid,
text: database.id(),
itemType: DropdownMenuItemType.Header,
});
database.collections().forEach((collection: Collection) => {
const isGlobalSecondaryIndex: boolean = !!collection.materializedViewDefinition();
sourceContainerOptions.push({
key: collection.rid,
text: collection.id(),
disabled: isGlobalSecondaryIndex,
...(isGlobalSecondaryIndex && {
title: "This is a global secondary index.",
}),
data: collection,
});
});
});
setSourceContainerOptions(sourceContainerOptions);
}, []);
useEffect(() => {
scrollToSection("panelContainer");
}, [errorMessage]);
let globalSecondaryIndexThroughput: number;
let isGlobalSecondaryIndexAutoscale: boolean;
let isCostAcknowledged: boolean;
const globalSecondaryIndexThroughputOnChange = (globalSecondaryIndexThroughputValue: number): void => {
globalSecondaryIndexThroughput = globalSecondaryIndexThroughputValue;
};
const isGlobalSecondaryIndexAutoscaleOnChange = (isGlobalSecondaryIndexAutoscaleValue: boolean): void => {
isGlobalSecondaryIndexAutoscale = isGlobalSecondaryIndexAutoscaleValue;
};
const isCostAknowledgedOnChange = (isCostAcknowledgedValue: boolean): void => {
isCostAcknowledged = isCostAcknowledgedValue;
};
const isSelectedSourceContainerSharedThroughput = (): boolean => {
if (!selectedSourceContainer) {
return false;
}
return !!selectedSourceContainer.getDatabase().offer();
};
const showCollectionThroughputInput = (): boolean => {
if (isServerlessAccount()) {
return false;
}
if (enableDedicatedThroughput) {
return true;
}
return !!selectedSourceContainer && !isSelectedSourceContainerSharedThroughput();
};
const showVectorSearchParameters = (): boolean => {
return isVectorSearchEnabled() && (isServerlessAccount() || showCollectionThroughputInput());
};
const showFullTextSearchParameters = (): boolean => {
return isFullTextSearchEnabled() && (isServerlessAccount() || showCollectionThroughputInput());
};
const getAnalyticalStorageTtl = (): number => {
if (!isSynapseLinkEnabled()) {
return undefined;
}
if (!shouldShowAnalyticalStoreOptions()) {
return undefined;
}
if (enableAnalyticalStore) {
// TODO: always default to 90 days once the backend hotfix is deployed
return userContext.features.ttl90Days
? Constants.AnalyticalStorageTtl.Days90
: Constants.AnalyticalStorageTtl.Infinite;
}
return Constants.AnalyticalStorageTtl.Disabled;
};
const validateInputs = (): boolean => {
if (!selectedSourceContainer) {
setErrorMessage("Please select a source container");
return false;
}
if (globalSecondaryIndexThroughput > CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) {
const errorMessage = isGlobalSecondaryIndexAutoscale
? "Please acknowledge the estimated monthly spend."
: "Please acknowledge the estimated daily spend.";
setErrorMessage(errorMessage);
return false;
}
if (globalSecondaryIndexThroughput > CollectionCreation.MaxRUPerPartition) {
setErrorMessage("Unsharded collections support up to 10,000 RUs");
return false;
}
if (showVectorSearchParameters()) {
if (!vectorPolicyValidated) {
setErrorMessage("Please fix errors in container vector policy");
return false;
}
if (!fullTextPolicyValidated) {
setErrorMessage("Please fix errors in container full text search policy");
return false;
}
}
return true;
};
const submit = async (event?: React.FormEvent<HTMLFormElement>): Promise<void> => {
event?.preventDefault();
if (!validateInputs()) {
return;
}
const globalSecondaryIdTrimmed: string = globalSecondaryIndexId.trim();
const globalSecondaryIndexDefinition: DataModels.MaterializedViewDefinition = {
sourceCollectionId: selectedSourceContainer.id(),
definition: definition,
};
const partitionKeyTrimmed: string = partitionKey.trim();
const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = parseUniqueKeys(uniqueKeys);
const partitionKeyVersion = useHashV1 ? undefined : 2;
const partitionKeyPaths: DataModels.PartitionKey = partitionKeyTrimmed
? {
paths: [
partitionKeyTrimmed,
...(userContext.apiType === "SQL" && subPartitionKeys.length > 0 ? subPartitionKeys : []),
],
kind: userContext.apiType === "SQL" && subPartitionKeys.length > 0 ? "MultiHash" : "Hash",
version: partitionKeyVersion,
}
: undefined;
const indexingPolicy: DataModels.IndexingPolicy = AllPropertiesIndexed;
let vectorEmbeddingPolicyFinal: DataModels.VectorEmbeddingPolicy;
if (showVectorSearchParameters()) {
indexingPolicy.vectorIndexes = vectorIndexingPolicy;
vectorEmbeddingPolicyFinal = {
vectorEmbeddings: vectorEmbeddingPolicy,
};
}
if (showFullTextSearchParameters()) {
indexingPolicy.fullTextIndexes = fullTextIndexes;
}
const telemetryData: TelemetryProcessor.TelemetryData = {
database: {
id: selectedSourceContainer.databaseId,
shared: isSelectedSourceContainerSharedThroughput(),
},
collection: {
id: globalSecondaryIdTrimmed,
throughput: globalSecondaryIndexThroughput,
isAutoscale: isGlobalSecondaryIndexAutoscale,
partitionKeyPaths,
uniqueKeyPolicy,
collectionWithDedicatedThroughput: enableDedicatedThroughput,
},
subscriptionQuotaId: userContext.quotaId,
dataExplorerArea: Constants.Areas.ContextualPane,
};
const startKey: number = TelemetryProcessor.traceStart(Action.CreateCollection, telemetryData);
const databaseLevelThroughput: boolean = isSelectedSourceContainerSharedThroughput() && !enableDedicatedThroughput;
let offerThroughput: number;
let autoPilotMaxThroughput: number;
if (!databaseLevelThroughput) {
if (isGlobalSecondaryIndexAutoscale) {
autoPilotMaxThroughput = globalSecondaryIndexThroughput;
} else {
offerThroughput = globalSecondaryIndexThroughput;
}
}
const createGlobalSecondaryIndexParams: DataModels.CreateMaterializedViewsParams = {
materializedViewId: globalSecondaryIdTrimmed,
materializedViewDefinition: globalSecondaryIndexDefinition,
databaseId: selectedSourceContainer.databaseId,
databaseLevelThroughput: databaseLevelThroughput,
offerThroughput: offerThroughput,
autoPilotMaxThroughput: autoPilotMaxThroughput,
analyticalStorageTtl: getAnalyticalStorageTtl(),
indexingPolicy: indexingPolicy,
partitionKey: partitionKeyPaths,
uniqueKeyPolicy: uniqueKeyPolicy,
vectorEmbeddingPolicy: vectorEmbeddingPolicyFinal,
fullTextPolicy: fullTextPolicy,
};
setIsExecuting(true);
try {
await createGlobalSecondaryIndex(createGlobalSecondaryIndexParams);
await explorer.refreshAllDatabases();
TelemetryProcessor.traceSuccess(Action.CreateGlobalSecondaryIndex, telemetryData, startKey);
useSidePanel.getState().closeSidePanel();
} catch (error) {
const errorMessage: string = getErrorMessage(error);
setErrorMessage(errorMessage);
setShowErrorDetails(true);
const failureTelemetryData = { ...telemetryData, error: errorMessage, errorStack: getErrorStack(error) };
TelemetryProcessor.traceFailure(Action.CreateGlobalSecondaryIndex, failureTelemetryData, startKey);
} finally {
setIsExecuting(false);
}
};
return (
<form className="panelFormWrapper" id="panelGlobalSecondaryIndex" onSubmit={submit}>
{errorMessage && (
<PanelInfoErrorComponent message={errorMessage} messageType="error" showErrorDetails={showErrorDetails} />
)}
<div className="panelMainContent">
<Stack>
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Source container id
</Text>
</Stack>
<Dropdown
placeholder="Choose source container"
options={sourceContainerOptions}
defaultSelectedKey={selectedSourceContainer?.rid}
styles={chooseSourceContainerStyles()}
style={chooseSourceContainerStyle()}
onChange={(_, options: IDropdownOption) => setSelectedSourceContainer(options.data as Collection)}
/>
<Separator className="panelSeparator" />
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Global secondary index container id
</Text>
</Stack>
<input
id="globalSecondaryIndexId"
type="text"
aria-required
required
autoComplete="off"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder={`e.g., indexbyEmailId`}
size={40}
className="panelTextField"
value={globalSecondaryIndexId}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setGlobalSecondaryIndexId(event.target.value)}
/>
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Global secondary index definition
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={
<Link
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
target="blank"
>
Learn more about defining global secondary indexes.
</Link>
}
>
<Icon role="button" iconName="Info" className="panelInfoIcon" tabIndex={0} />
</TooltipHost>
</Stack>
<input
id="globalSecondaryIndexDefinition"
type="text"
aria-required
required
autoComplete="off"
placeholder={"SELECT c.email, c.accountId FROM c"}
size={40}
className="panelTextField"
value={definition || ""}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDefinition(event.target.value)}
/>
<PartitionKeyComponent
{...{ partitionKey, setPartitionKey, subPartitionKeys, setSubPartitionKeys, useHashV1 }}
/>
<ThroughputComponent
{...{
enableDedicatedThroughput,
setEnabledDedicatedThroughput,
isSelectedSourceContainerSharedThroughput,
showCollectionThroughputInput,
globalSecondaryIndexThroughputOnChange,
isGlobalSecondaryIndexAutoscaleOnChange,
setIsThroughputCapExceeded,
isCostAknowledgedOnChange,
}}
/>
<UniqueKeysComponent {...{ uniqueKeys, setUniqueKeys }} />
{shouldShowAnalyticalStoreOptions() && (
<AnalyticalStoreComponent {...{ explorer, enableAnalyticalStore, setEnableAnalyticalStore }} />
)}
{showVectorSearchParameters() && (
<VectorSearchComponent
{...{
vectorEmbeddingPolicy,
setVectorEmbeddingPolicy,
vectorIndexingPolicy,
setVectorIndexingPolicy,
vectorPolicyValidated,
setVectorPolicyValidated,
}}
/>
)}
{showFullTextSearchParameters() && (
<FullTextSearchComponent
{...{ fullTextPolicy, setFullTextPolicy, setFullTextIndexes, setFullTextPolicyValidated }}
/>
)}
<AdvancedComponent {...{ useHashV1, setUseHashV1, setSubPartitionKeys }} />
</Stack>
</div>
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={isThroughputCapExceeded} />
{isExecuting && <PanelLoadingScreen />}
</form>
);
};

View File

@@ -0,0 +1,15 @@
import { IDropdownStyleProps, IDropdownStyles, IStyleFunctionOrObject } from "@fluentui/react";
import { CSSProperties } from "react";
export function chooseSourceContainerStyles(): IStyleFunctionOrObject<IDropdownStyleProps, IDropdownStyles> {
return {
title: { height: 27, lineHeight: 27 },
dropdownItem: { fontSize: 12 },
dropdownItemDisabled: { fontSize: 12 },
dropdownItemSelected: { fontSize: 12 },
};
}
export function chooseSourceContainerStyle(): CSSProperties {
return { width: 300, fontSize: 12 };
}

View File

@@ -0,0 +1,54 @@
import { Checkbox, Icon, Link, Stack, Text } from "@fluentui/react";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import { scrollToSection } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
export interface AdvancedComponentProps {
useHashV1: boolean;
setUseHashV1: React.Dispatch<React.SetStateAction<boolean>>;
setSubPartitionKeys: React.Dispatch<React.SetStateAction<string[]>>;
}
export const AdvancedComponent = (props: AdvancedComponentProps): JSX.Element => {
const { useHashV1, setUseHashV1, setSubPartitionKeys } = props;
const useHashV1CheckboxOnChange = (isChecked: boolean): void => {
setUseHashV1(isChecked);
setSubPartitionKeys([]);
};
return (
<CollapsibleSectionComponent
title="Advanced"
isExpandedByDefault={false}
onExpand={() => {
TelemetryProcessor.traceOpen(Action.ExpandAddGlobalSecondaryIndexPaneAdvancedSection);
scrollToSection("collapsibleAdvancedSectionContent");
}}
>
<Stack className="panelGroupSpacing" id="collapsibleAdvancedSectionContent">
<Checkbox
label="My application uses an older Cosmos .NET or Java SDK version (.NET V1 or Java V2)"
checked={useHashV1}
styles={{
text: { fontSize: 12 },
checkbox: { width: 12, height: 12 },
label: { padding: 0, alignItems: "center", wordWrap: "break-word", whiteSpace: "break-spaces" },
}}
onChange={(ev: React.FormEvent<HTMLElement>, isChecked: boolean) => {
useHashV1CheckboxOnChange(isChecked);
}}
/>
<Text variant="small">
<Icon iconName="InfoSolid" className="removeIcon" /> To ensure compatibility with older SDKs, the created
container will use a legacy partitioning scheme that supports partition key values of size only up to 101
bytes. If this is enabled, you will not be able to use hierarchical partition keys.{" "}
<Link href="https://aka.ms/cosmos-large-pk" target="_blank">
Learn more
</Link>
</Text>
</Stack>
</CollapsibleSectionComponent>
);
};

View File

@@ -0,0 +1,99 @@
import { DefaultButton, Link, Stack, Text } from "@fluentui/react";
import * as Constants from "Common/Constants";
import Explorer from "Explorer/Explorer";
import {
AnalyticalStorageContent,
isSynapseLinkEnabled,
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
import { getCollectionName } from "Utils/APITypeUtils";
export interface AnalyticalStoreComponentProps {
explorer: Explorer;
enableAnalyticalStore: boolean;
setEnableAnalyticalStore: React.Dispatch<React.SetStateAction<boolean>>;
}
export const AnalyticalStoreComponent = (props: AnalyticalStoreComponentProps): JSX.Element => {
const { explorer, enableAnalyticalStore, setEnableAnalyticalStore } = props;
const onEnableAnalyticalStoreRadioButtonChange = (checked: boolean): void => {
if (checked && !enableAnalyticalStore) {
setEnableAnalyticalStore(true);
}
};
const onDisableAnalyticalStoreRadioButtonnChange = (checked: boolean): void => {
if (checked && enableAnalyticalStore) {
setEnableAnalyticalStore(false);
}
};
return (
<Stack className="panelGroupSpacing">
<Text className="panelTextBold" variant="small">
{AnalyticalStorageContent()}
</Text>
<Stack horizontal verticalAlign="center">
<div role="radiogroup">
<input
className="panelRadioBtn"
checked={enableAnalyticalStore}
disabled={!isSynapseLinkEnabled()}
aria-label="Enable analytical store"
aria-checked={enableAnalyticalStore}
name="analyticalStore"
type="radio"
role="radio"
id="enableAnalyticalStoreBtn"
tabIndex={0}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onEnableAnalyticalStoreRadioButtonChange(event.target.checked);
}}
/>
<span className="panelRadioBtnLabel">On</span>
<input
className="panelRadioBtn"
checked={!enableAnalyticalStore}
disabled={!isSynapseLinkEnabled()}
aria-label="Disable analytical store"
aria-checked={!enableAnalyticalStore}
name="analyticalStore"
type="radio"
role="radio"
id="disableAnalyticalStoreBtn"
tabIndex={0}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onDisableAnalyticalStoreRadioButtonnChange(event.target.checked);
}}
/>
<span className="panelRadioBtnLabel">Off</span>
</div>
</Stack>
{!isSynapseLinkEnabled() && (
<Stack className="panelGroupSpacing">
<Text variant="small">
Azure Synapse Link is required for creating an analytical store {getCollectionName().toLocaleLowerCase()}.
Enable Synapse Link for this Cosmos DB account.{" "}
<Link
href="https://aka.ms/cosmosdb-synapselink"
target="_blank"
aria-label={Constants.ariaLabelForLearnMoreLink.AzureSynapseLink}
className="capacitycalculator-link"
>
Learn more
</Link>
</Text>
<DefaultButton
text="Enable"
onClick={() => explorer.openEnableSynapseLinkDialog()}
style={{ height: 27, width: 80 }}
styles={{ label: { fontSize: 12 } }}
/>
</Stack>
)}
</Stack>
);
};

View File

@@ -0,0 +1,45 @@
import { Stack } from "@fluentui/react";
import { FullTextIndex, FullTextPolicy } from "Contracts/DataModels";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
import { scrollToSection } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
export interface FullTextSearchComponentProps {
fullTextPolicy: FullTextPolicy;
setFullTextPolicy: React.Dispatch<React.SetStateAction<FullTextPolicy>>;
setFullTextIndexes: React.Dispatch<React.SetStateAction<FullTextIndex[]>>;
setFullTextPolicyValidated: React.Dispatch<React.SetStateAction<boolean>>;
}
export const FullTextSearchComponent = (props: FullTextSearchComponentProps): JSX.Element => {
const { fullTextPolicy, setFullTextPolicy, setFullTextIndexes, setFullTextPolicyValidated } = props;
return (
<Stack>
<CollapsibleSectionComponent
title="Container Full Text Search Policy"
isExpandedByDefault={false}
onExpand={() => {
scrollToSection("collapsibleFullTextPolicySectionContent");
}}
>
<Stack id="collapsibleFullTextPolicySectionContent" styles={{ root: { position: "relative" } }}>
<Stack styles={{ root: { paddingLeft: 40 } }}>
<FullTextPoliciesComponent
fullTextPolicy={fullTextPolicy}
onFullTextPathChange={(
fullTextPolicy: FullTextPolicy,
fullTextIndexes: FullTextIndex[],
fullTextPolicyValidated: boolean,
) => {
setFullTextPolicy(fullTextPolicy);
setFullTextIndexes(fullTextIndexes);
setFullTextPolicyValidated(fullTextPolicyValidated);
}}
/>
</Stack>
</Stack>
</CollapsibleSectionComponent>
</Stack>
);
};

View File

@@ -0,0 +1,132 @@
import { DefaultButton, DirectionalHint, Icon, IconButton, Link, Stack, Text, TooltipHost } from "@fluentui/react";
import * as Constants from "Common/Constants";
import {
getPartitionKeyName,
getPartitionKeyPlaceHolder,
getPartitionKeyTooltipText,
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
export interface PartitionKeyComponentProps {
partitionKey?: string;
setPartitionKey: React.Dispatch<React.SetStateAction<string>>;
subPartitionKeys: string[];
setSubPartitionKeys: React.Dispatch<React.SetStateAction<string[]>>;
useHashV1: boolean;
}
export const PartitionKeyComponent = (props: PartitionKeyComponentProps): JSX.Element => {
const { partitionKey, setPartitionKey, subPartitionKeys, setSubPartitionKeys, useHashV1 } = props;
const partitionKeyValueOnChange = (value: string): void => {
if (!partitionKey && !value.startsWith("/")) {
setPartitionKey("/" + value);
} else {
setPartitionKey(value);
}
};
const subPartitionKeysValueOnChange = (value: string, index: number): void => {
const updatedSubPartitionKeys: string[] = [...subPartitionKeys];
if (!updatedSubPartitionKeys[index] && !value.startsWith("/")) {
updatedSubPartitionKeys[index] = "/" + value.trim();
} else {
updatedSubPartitionKeys[index] = value.trim();
}
setSubPartitionKeys(updatedSubPartitionKeys);
};
return (
<Stack>
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Partition key
</Text>
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={getPartitionKeyTooltipText()}>
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} />
</TooltipHost>
</Stack>
<input
type="text"
id="addGlobalSecondaryIndex-partitionKeyValue"
aria-required
required
size={40}
className="panelTextField"
placeholder={getPartitionKeyPlaceHolder()}
aria-label={getPartitionKeyName()}
pattern=".*"
value={partitionKey}
style={{ marginBottom: 8 }}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
partitionKeyValueOnChange(event.target.value);
}}
/>
{subPartitionKeys.map((subPartitionKey: string, subPartitionKeyIndex: number) => {
return (
<Stack style={{ marginBottom: 8 }} key={`uniqueKey${subPartitionKeyIndex}`} horizontal>
<div
style={{
width: "20px",
border: "solid",
borderWidth: "0px 0px 1px 1px",
marginRight: "5px",
}}
></div>
<input
type="text"
id="addGlobalSecondaryIndex-partitionKeyValue"
key={`addGlobalSecondaryIndex-partitionKeyValue_${subPartitionKeyIndex}`}
aria-required
required
size={40}
tabIndex={subPartitionKeyIndex > 0 ? 1 : 0}
className="panelTextField"
autoComplete="off"
placeholder={getPartitionKeyPlaceHolder(subPartitionKeyIndex)}
aria-label={getPartitionKeyName()}
pattern={".*"}
title={""}
value={subPartitionKey}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
subPartitionKeysValueOnChange(event.target.value, subPartitionKeyIndex);
}}
/>
<IconButton
iconProps={{ iconName: "Delete" }}
style={{ height: 27 }}
onClick={() => {
const updatedSubPartitionKeys = subPartitionKeys.filter(
(_, subPartitionKeyIndexToRemove) => subPartitionKeyIndex !== subPartitionKeyIndexToRemove,
);
setSubPartitionKeys(updatedSubPartitionKeys);
}}
/>
</Stack>
);
})}
<Stack className="panelGroupSpacing">
<DefaultButton
styles={{ root: { padding: 0, width: 200, height: 30 }, label: { fontSize: 12 } }}
hidden={useHashV1}
disabled={subPartitionKeys.length >= Constants.BackendDefaults.maxNumMultiHashPartition}
onClick={() => setSubPartitionKeys([...subPartitionKeys, ""])}
>
Add hierarchical partition key
</DefaultButton>
{subPartitionKeys.length > 0 && (
<Text variant="small">
<Icon iconName="InfoSolid" className="removeIcon" tabIndex={0} /> This feature allows you to partition your
data with up to three levels of keys for better data distribution. Requires .NET V3, Java V4 SDK, or preview
JavaScript V3 SDK.{" "}
<Link href="https://aka.ms/cosmos-hierarchical-partitioning" target="_blank">
Learn more
</Link>
</Text>
)}
</Stack>
</Stack>
);
};

View File

@@ -0,0 +1,71 @@
import { Checkbox, Stack } from "@fluentui/react";
import { ThroughputInput } from "Explorer/Controls/ThroughputInput/ThroughputInput";
import { isFreeTierAccount } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import { useDatabases } from "Explorer/useDatabases";
import React from "react";
import { getCollectionName } from "Utils/APITypeUtils";
import { isServerlessAccount } from "Utils/CapabilityUtils";
export interface ThroughputComponentProps {
enableDedicatedThroughput: boolean;
setEnabledDedicatedThroughput: React.Dispatch<React.SetStateAction<boolean>>;
isSelectedSourceContainerSharedThroughput: () => boolean;
showCollectionThroughputInput: () => boolean;
globalSecondaryIndexThroughputOnChange: (globalSecondaryIndexThroughputValue: number) => void;
isGlobalSecondaryIndexAutoscaleOnChange: (isGlobalSecondaryIndexAutoscaleValue: boolean) => void;
setIsThroughputCapExceeded: React.Dispatch<React.SetStateAction<boolean>>;
isCostAknowledgedOnChange: (isCostAknowledgedValue: boolean) => void;
}
export const ThroughputComponent = (props: ThroughputComponentProps): JSX.Element => {
const {
enableDedicatedThroughput,
setEnabledDedicatedThroughput,
isSelectedSourceContainerSharedThroughput,
showCollectionThroughputInput,
globalSecondaryIndexThroughputOnChange,
isGlobalSecondaryIndexAutoscaleOnChange,
setIsThroughputCapExceeded,
isCostAknowledgedOnChange,
} = props;
return (
<Stack>
{!isServerlessAccount() && isSelectedSourceContainerSharedThroughput() && (
<Stack horizontal verticalAlign="center">
<Checkbox
label={`Provision dedicated throughput for this ${getCollectionName().toLocaleLowerCase()}`}
checked={enableDedicatedThroughput}
styles={{
text: { fontSize: 12 },
checkbox: { width: 12, height: 12 },
label: { padding: 0, alignItems: "center" },
}}
onChange={(_, isChecked: boolean) => setEnabledDedicatedThroughput(isChecked)}
/>
</Stack>
)}
{showCollectionThroughputInput() && (
<ThroughputInput
showFreeTierExceedThroughputTooltip={isFreeTierAccount() && !useDatabases.getState().isFirstResourceCreated()}
isDatabase={false}
isSharded={false}
isFreeTier={isFreeTierAccount()}
isQuickstart={false}
setThroughputValue={(throughput: number) => {
globalSecondaryIndexThroughputOnChange(throughput);
}}
setIsAutoscale={(isAutoscale: boolean) => {
isGlobalSecondaryIndexAutoscaleOnChange(isAutoscale);
}}
setIsThroughputCapExceeded={(isThroughputCapExceeded: boolean) => {
setIsThroughputCapExceeded(isThroughputCapExceeded);
}}
onCostAcknowledgeChange={(isAcknowledged: boolean) => {
isCostAknowledgedOnChange(isAcknowledged);
}}
/>
)}
</Stack>
);
};

View File

@@ -0,0 +1,78 @@
import { ActionButton, IconButton, Stack } from "@fluentui/react";
import { UniqueKeysHeader } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
import { userContext } from "UserContext";
export interface UniqueKeysComponentProps {
uniqueKeys: string[];
setUniqueKeys: React.Dispatch<React.SetStateAction<string[]>>;
}
export const UniqueKeysComponent = (props: UniqueKeysComponentProps): JSX.Element => {
const { uniqueKeys, setUniqueKeys } = props;
const updateUniqueKeysOnChange = (value: string, uniqueKeyToReplaceIndex: number): void => {
const updatedUniqueKeys = uniqueKeys.map((uniqueKey: string, uniqueKeyIndex: number) => {
if (uniqueKeyToReplaceIndex === uniqueKeyIndex) {
return value;
}
return uniqueKey;
});
setUniqueKeys(updatedUniqueKeys);
};
const deleteUniqueKeyOnClick = (uniqueKeyToDeleteIndex: number): void => {
const updatedUniqueKeys = uniqueKeys.filter((_, uniqueKeyIndex) => uniqueKeyToDeleteIndex !== uniqueKeyIndex);
setUniqueKeys(updatedUniqueKeys);
};
const addUniqueKeyOnClick = (): void => {
setUniqueKeys([...uniqueKeys, ""]);
};
return (
<Stack>
{UniqueKeysHeader()}
{uniqueKeys.map((uniqueKey: string, uniqueKeyIndex: number): JSX.Element => {
return (
<Stack style={{ marginBottom: 8 }} key={`uniqueKey-${uniqueKeyIndex}`} horizontal>
<input
type="text"
autoComplete="off"
placeholder={
userContext.apiType === "Mongo"
? "Comma separated paths e.g. firstName,address.zipCode"
: "Comma separated paths e.g. /firstName,/address/zipCode"
}
className="panelTextField"
autoFocus
value={uniqueKey}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
updateUniqueKeysOnChange(event.target.value, uniqueKeyIndex);
}}
/>
<IconButton
iconProps={{ iconName: "Delete" }}
style={{ height: 27 }}
onClick={() => {
deleteUniqueKeyOnClick(uniqueKeyIndex);
}}
/>
</Stack>
);
})}
<ActionButton
iconProps={{ iconName: "Add" }}
styles={{ root: { padding: 0 }, label: { fontSize: 12 } }}
onClick={() => {
addUniqueKeyOnClick();
}}
>
Add unique key
</ActionButton>
</Stack>
);
};

View File

@@ -0,0 +1,58 @@
import { Stack } from "@fluentui/react";
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent";
import {
ContainerVectorPolicyTooltipContent,
scrollToSection,
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import React from "react";
export interface VectorSearchComponentProps {
vectorEmbeddingPolicy: VectorEmbedding[];
setVectorEmbeddingPolicy: React.Dispatch<React.SetStateAction<VectorEmbedding[]>>;
vectorIndexingPolicy: VectorIndex[];
setVectorIndexingPolicy: React.Dispatch<React.SetStateAction<VectorIndex[]>>;
setVectorPolicyValidated: React.Dispatch<React.SetStateAction<boolean>>;
}
export const VectorSearchComponent = (props: VectorSearchComponentProps): JSX.Element => {
const {
vectorEmbeddingPolicy,
setVectorEmbeddingPolicy,
vectorIndexingPolicy,
setVectorIndexingPolicy,
setVectorPolicyValidated,
} = props;
return (
<Stack>
<CollapsibleSectionComponent
title="Container Vector Policy"
isExpandedByDefault={false}
onExpand={() => {
scrollToSection("collapsibleVectorPolicySectionContent");
}}
tooltipContent={ContainerVectorPolicyTooltipContent()}
>
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
<Stack styles={{ root: { paddingLeft: 40 } }}>
<VectorEmbeddingPoliciesComponent
vectorEmbeddings={vectorEmbeddingPolicy}
vectorIndexes={vectorIndexingPolicy}
onVectorEmbeddingChange={(
vectorEmbeddingPolicy: VectorEmbedding[],
vectorIndexingPolicy: VectorIndex[],
vectorPolicyValidated: boolean,
) => {
setVectorEmbeddingPolicy(vectorEmbeddingPolicy);
setVectorIndexingPolicy(vectorIndexingPolicy);
setVectorPolicyValidated(vectorPolicyValidated);
}}
/>
</Stack>
</Stack>
</CollapsibleSectionComponent>
</Stack>
);
};

View File

@@ -0,0 +1,190 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddGlobalSecondaryIndexPanel render default panel 1`] = `
<form
className="panelFormWrapper"
id="panelGlobalSecondaryIndex"
onSubmit={[Function]}
>
<div
className="panelMainContent"
>
<Stack>
<Stack
horizontal={true}
>
<span
className="mandatoryStar"
>
* 
</span>
<Text
className="panelTextBold"
variant="small"
>
Source container id
</Text>
</Stack>
<Dropdown
onChange={[Function]}
placeholder="Choose source container"
style={
{
"fontSize": 12,
"width": 300,
}
}
styles={
{
"dropdownItem": {
"fontSize": 12,
},
"dropdownItemDisabled": {
"fontSize": 12,
},
"dropdownItemSelected": {
"fontSize": 12,
},
"title": {
"height": 27,
"lineHeight": 27,
},
}
}
/>
<Separator
className="panelSeparator"
/>
<Stack
horizontal={true}
>
<span
className="mandatoryStar"
>
* 
</span>
<Text
className="panelTextBold"
variant="small"
>
Global secondary index container id
</Text>
</Stack>
<input
aria-required={true}
autoComplete="off"
className="panelTextField"
id="globalSecondaryIndexId"
onChange={[Function]}
pattern="[^\\/?#\\\\]*[^\\/?# \\\\]"
placeholder="e.g., indexbyEmailId"
required={true}
size={40}
title="May not end with space nor contain characters '\\' '/' '#' '?'"
type="text"
/>
<Stack
horizontal={true}
>
<span
className="mandatoryStar"
>
* 
</span>
<Text
className="panelTextBold"
variant="small"
>
Global secondary index definition
</Text>
<StyledTooltipHostBase
content={
<StyledLinkBase
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
target="blank"
>
Learn more about defining global secondary indexes.
</StyledLinkBase>
}
directionalHint={4}
>
<Icon
className="panelInfoIcon"
iconName="Info"
role="button"
tabIndex={0}
/>
</StyledTooltipHostBase>
</Stack>
<input
aria-required={true}
autoComplete="off"
className="panelTextField"
id="globalSecondaryIndexDefinition"
onChange={[Function]}
placeholder="SELECT c.email, c.accountId FROM c"
required={true}
size={40}
type="text"
value=""
/>
<PartitionKeyComponent
partitionKey=""
setPartitionKey={[Function]}
setSubPartitionKeys={[Function]}
subPartitionKeys={[]}
/>
<ThroughputComponent
globalSecondaryIndexThroughputOnChange={[Function]}
isCostAknowledgedOnChange={[Function]}
isGlobalSecondaryIndexAutoscaleOnChange={[Function]}
isSelectedSourceContainerSharedThroughput={[Function]}
setEnabledDedicatedThroughput={[Function]}
setIsThroughputCapExceeded={[Function]}
showCollectionThroughputInput={[Function]}
/>
<UniqueKeysComponent
setUniqueKeys={[Function]}
uniqueKeys={[]}
/>
<AnalyticalStoreComponent
explorer={
Explorer {
"_isInitializingNotebooks": false,
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"copyNotebook": [Function],
"parameters": [Function],
},
}
}
setEnableAnalyticalStore={[Function]}
/>
<AdvancedComponent
setSubPartitionKeys={[Function]}
setUseHashV1={[Function]}
/>
</Stack>
</div>
<PanelFooterComponent
buttonLabel="OK"
/>
</form>
`;

View File

@@ -7,6 +7,7 @@ import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import { isServerlessAccount } from "Utils/CapabilityUtils"; import { isServerlessAccount } from "Utils/CapabilityUtils";
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
import { useSidePanel } from "hooks/useSidePanel"; import { useSidePanel } from "hooks/useSidePanel";
import React, { FunctionComponent, useState } from "react"; import React, { FunctionComponent, useState } from "react";
import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput"; import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput";
@@ -202,8 +203,8 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
required={true} required={true}
autoComplete="off" autoComplete="off"
styles={getTextFieldStyles()} styles={getTextFieldStyles()}
pattern="[^/?#\\-]*[^/?#- \\]" pattern={ValidCosmosDbIdInputPattern.source}
title="May not end with space nor contain characters '\' '/' '#' '?' '-'" title={ValidCosmosDbIdDescription}
placeholder="Type a new keyspace id" placeholder="Type a new keyspace id"
size={40} size={40}
value={newKeyspaceId} value={newKeyspaceId}
@@ -292,8 +293,8 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
required={true} required={true}
ariaLabel="addCollection-table Id Create table" ariaLabel="addCollection-table Id Create table"
autoComplete="off" autoComplete="off"
pattern="[^/?#\\-]*[^/?#- \\]" pattern={ValidCosmosDbIdInputPattern.source}
title="May not end with space nor contain characters '\' '/' '#' '?' '-'" title={ValidCosmosDbIdDescription}
placeholder="Enter table Id" placeholder="Enter table Id"
size={20} size={20}
value={tableId} value={tableId}

View File

@@ -28,6 +28,7 @@ import { RightPaneForm } from "Explorer/Panes/RightPaneForm/RightPaneForm";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import { getCollectionName } from "Utils/APITypeUtils"; import { getCollectionName } from "Utils/APITypeUtils";
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
import { useSidePanel } from "hooks/useSidePanel"; import { useSidePanel } from "hooks/useSidePanel";
import * as React from "react"; import * as React from "react";
@@ -235,8 +236,8 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
aria-required aria-required
required required
autoComplete="off" autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]" pattern={ValidCosmosDbIdInputPattern.source}
title="May not end with space nor contain characters '\' '/' '#' '?'" title={ValidCosmosDbIdDescription}
placeholder={`e.g., ${getCollectionName()}1`} placeholder={`e.g., ${getCollectionName()}1`}
size={40} size={40}
className="panelTextField" className="panelTextField"

View File

@@ -6,7 +6,9 @@ import {
Checkbox, Checkbox,
ChoiceGroup, ChoiceGroup,
DefaultButton, DefaultButton,
Dropdown,
IChoiceGroupOption, IChoiceGroupOption,
IDropdownOption,
ISpinButtonStyles, ISpinButtonStyles,
IToggleStyles, IToggleStyles,
Position, Position,
@@ -21,7 +23,15 @@ import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
import { Platform, configContext } from "ConfigContext"; import { Platform, configContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog"; import { useDialog } from "Explorer/Controls/Dialog";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import { deleteAllStates } from "Shared/AppStatePersistenceUtility"; import { isFabric } from "Platform/Fabric/FabricUtil";
import {
AppStateComponentNames,
deleteAllStates,
deleteState,
hasState,
loadState,
saveState,
} from "Shared/AppStatePersistenceUtility";
import { import {
DefaultRUThreshold, DefaultRUThreshold,
LocalStorageUtility, LocalStorageUtility,
@@ -37,6 +47,7 @@ import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils"; import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
import { useClientWriteEnabled } from "hooks/useClientWriteEnabled";
import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useQueryCopilot } from "hooks/useQueryCopilot";
import { useSidePanel } from "hooks/useSidePanel"; import { useSidePanel } from "hooks/useSidePanel";
import React, { FunctionComponent, useState } from "react"; import React, { FunctionComponent, useState } from "react";
@@ -143,6 +154,17 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
? LocalStorageUtility.getEntryString(StorageKey.IsGraphAutoVizDisabled) ? LocalStorageUtility.getEntryString(StorageKey.IsGraphAutoVizDisabled)
: "false", : "false",
); );
const [selectedRegionalEndpoint, setSelectedRegionalEndpoint] = useState<string>(
hasState({
componentName: AppStateComponentNames.SelectedRegionalEndpoint,
globalAccountName: userContext.databaseAccount?.name,
})
? (loadState({
componentName: AppStateComponentNames.SelectedRegionalEndpoint,
globalAccountName: userContext.databaseAccount?.name,
}) as string)
: undefined,
);
const [retryAttempts, setRetryAttempts] = useState<number>( const [retryAttempts, setRetryAttempts] = useState<number>(
LocalStorageUtility.hasItem(StorageKey.RetryAttempts) LocalStorageUtility.hasItem(StorageKey.RetryAttempts)
? LocalStorageUtility.getEntryNumber(StorageKey.RetryAttempts) ? LocalStorageUtility.getEntryNumber(StorageKey.RetryAttempts)
@@ -189,6 +211,44 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
configContext.platform !== Platform.Fabric && configContext.platform !== Platform.Fabric &&
!isEmulator; !isEmulator;
const shouldShowPriorityLevelOption = PriorityBasedExecutionUtils.isFeatureEnabled() && !isEmulator; const shouldShowPriorityLevelOption = PriorityBasedExecutionUtils.isFeatureEnabled() && !isEmulator;
const uniqueAccountRegions = new Set<string>();
const regionOptions: IDropdownOption[] = [];
regionOptions.push({
key: userContext?.databaseAccount?.properties?.documentEndpoint,
text: `Global (Default)`,
data: {
isGlobal: true,
writeEnabled: true,
},
});
userContext?.databaseAccount?.properties?.writeLocations?.forEach((loc) => {
if (!uniqueAccountRegions.has(loc.locationName)) {
uniqueAccountRegions.add(loc.locationName);
regionOptions.push({
key: loc.documentEndpoint,
text: `${loc.locationName} (Read/Write)`,
data: {
isGlobal: false,
writeEnabled: true,
},
});
}
});
userContext?.databaseAccount?.properties?.readLocations?.forEach((loc) => {
if (!uniqueAccountRegions.has(loc.locationName)) {
uniqueAccountRegions.add(loc.locationName);
regionOptions.push({
key: loc.documentEndpoint,
text: `${loc.locationName} (Read)`,
data: {
isGlobal: false,
writeEnabled: false,
},
});
}
});
const shouldShowCopilotSampleDBOption = const shouldShowCopilotSampleDBOption =
userContext.apiType === "SQL" && userContext.apiType === "SQL" &&
useQueryCopilot.getState().copilotEnabled && useQueryCopilot.getState().copilotEnabled &&
@@ -274,6 +334,46 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
} }
} }
const storedRegionalEndpoint = loadState({
componentName: AppStateComponentNames.SelectedRegionalEndpoint,
globalAccountName: userContext.databaseAccount?.name,
}) as string;
const selectedRegionIsGlobal =
selectedRegionalEndpoint === userContext?.databaseAccount?.properties?.documentEndpoint;
if (selectedRegionIsGlobal && storedRegionalEndpoint) {
deleteState({
componentName: AppStateComponentNames.SelectedRegionalEndpoint,
globalAccountName: userContext.databaseAccount?.name,
});
updateUserContext({
selectedRegionalEndpoint: undefined,
writeEnabledInSelectedRegion: true,
refreshCosmosClient: true,
});
useClientWriteEnabled.setState({ clientWriteEnabled: true });
} else if (
selectedRegionalEndpoint &&
!selectedRegionIsGlobal &&
selectedRegionalEndpoint !== storedRegionalEndpoint
) {
saveState(
{
componentName: AppStateComponentNames.SelectedRegionalEndpoint,
globalAccountName: userContext.databaseAccount?.name,
},
selectedRegionalEndpoint,
);
const validWriteEndpoint = userContext.databaseAccount?.properties?.writeLocations?.find(
(loc) => loc.documentEndpoint === selectedRegionalEndpoint,
);
updateUserContext({
selectedRegionalEndpoint: selectedRegionalEndpoint,
writeEnabledInSelectedRegion: !!validWriteEndpoint,
refreshCosmosClient: true,
});
useClientWriteEnabled.setState({ clientWriteEnabled: !!validWriteEndpoint });
}
LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled); LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled);
LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled); LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled);
LocalStorageUtility.setEntryNumber(StorageKey.RetryAttempts, retryAttempts); LocalStorageUtility.setEntryNumber(StorageKey.RetryAttempts, retryAttempts);
@@ -423,6 +523,10 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
setDefaultQueryResultsView(option.key as SplitterDirection); setDefaultQueryResultsView(option.key as SplitterDirection);
}; };
const handleOnSelectedRegionOptionChange = (ev: React.FormEvent<HTMLInputElement>, option: IDropdownOption): void => {
setSelectedRegionalEndpoint(option.key as string);
};
const handleOnQueryRetryAttemptsSpinButtonChange = (ev: React.MouseEvent<HTMLElement>, newValue?: string): void => { const handleOnQueryRetryAttemptsSpinButtonChange = (ev: React.MouseEvent<HTMLElement>, newValue?: string): void => {
const retryAttempts = Number(newValue); const retryAttempts = Number(newValue);
if (!isNaN(retryAttempts)) { if (!isNaN(retryAttempts)) {
@@ -583,9 +687,39 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
)} )}
{userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && !isFabric() && (
<AccordionItem value="3">
<AccordionHeader>
<div className={styles.header}>Region Selection</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
Changes region the Cosmos Client uses to access account.
</div>
<div>
<span className={styles.subHeader}>Select Region</span>
<InfoTooltip className={styles.headerIcon}>
Changes the account endpoint used to perform client operations.
</InfoTooltip>
</div>
<Dropdown
placeholder={
selectedRegionalEndpoint
? regionOptions.find((option) => option.key === selectedRegionalEndpoint)?.text
: regionOptions[0]?.text
}
onChange={handleOnSelectedRegionOptionChange}
options={regionOptions}
styles={{ root: { marginBottom: "10px" } }}
/>
</div>
</AccordionPanel>
</AccordionItem>
)}
{userContext.apiType === "SQL" && !isEmulator && ( {userContext.apiType === "SQL" && !isEmulator && (
<> <>
<AccordionItem value="3"> <AccordionItem value="4">
<AccordionHeader> <AccordionHeader>
<div className={styles.header}>Query Timeout</div> <div className={styles.header}>Query Timeout</div>
</AccordionHeader> </AccordionHeader>
@@ -626,7 +760,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem value="4"> <AccordionItem value="5">
<AccordionHeader> <AccordionHeader>
<div className={styles.header}>RU Limit</div> <div className={styles.header}>RU Limit</div>
</AccordionHeader> </AccordionHeader>
@@ -660,7 +794,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem value="5"> <AccordionItem value="6">
<AccordionHeader> <AccordionHeader>
<div className={styles.header}>Default Query Results View</div> <div className={styles.header}>Default Query Results View</div>
</AccordionHeader> </AccordionHeader>
@@ -681,8 +815,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem> </AccordionItem>
</> </>
)} )}
{showRetrySettings && ( {showRetrySettings && (
<AccordionItem value="6"> <AccordionItem value="7">
<AccordionHeader> <AccordionHeader>
<div className={styles.header}>Retry Settings</div> <div className={styles.header}>Retry Settings</div>
</AccordionHeader> </AccordionHeader>
@@ -755,7 +890,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem> </AccordionItem>
)} )}
{!isEmulator && ( {!isEmulator && (
<AccordionItem value="7"> <AccordionItem value="8">
<AccordionHeader> <AccordionHeader>
<div className={styles.header}>Enable container pagination</div> <div className={styles.header}>Enable container pagination</div>
</AccordionHeader> </AccordionHeader>
@@ -779,7 +914,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem> </AccordionItem>
)} )}
{shouldShowCrossPartitionOption && ( {shouldShowCrossPartitionOption && (
<AccordionItem value="8"> <AccordionItem value="9">
<AccordionHeader> <AccordionHeader>
<div className={styles.header}>Enable cross-partition query</div> <div className={styles.header}>Enable cross-partition query</div>
</AccordionHeader> </AccordionHeader>
@@ -804,7 +939,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem> </AccordionItem>
)} )}
{shouldShowParallelismOption && ( {shouldShowParallelismOption && (
<AccordionItem value="9"> <AccordionItem value="10">
<AccordionHeader> <AccordionHeader>
<div className={styles.header}>Max degree of parallelism</div> <div className={styles.header}>Max degree of parallelism</div>
</AccordionHeader> </AccordionHeader>
@@ -837,7 +972,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem> </AccordionItem>
)} )}
{shouldShowPriorityLevelOption && ( {shouldShowPriorityLevelOption && (
<AccordionItem value="10"> <AccordionItem value="11">
<AccordionHeader> <AccordionHeader>
<div className={styles.header}>Priority Level</div> <div className={styles.header}>Priority Level</div>
</AccordionHeader> </AccordionHeader>
@@ -860,7 +995,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem> </AccordionItem>
)} )}
{shouldShowGraphAutoVizOption && ( {shouldShowGraphAutoVizOption && (
<AccordionItem value="11"> <AccordionItem value="12">
<AccordionHeader> <AccordionHeader>
<div className={styles.header}>Display Gremlin query results as:&nbsp;</div> <div className={styles.header}>Display Gremlin query results as:&nbsp;</div>
</AccordionHeader> </AccordionHeader>
@@ -881,7 +1016,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem> </AccordionItem>
)} )}
{shouldShowCopilotSampleDBOption && ( {shouldShowCopilotSampleDBOption && (
<AccordionItem value="12"> <AccordionItem value="13">
<AccordionHeader> <AccordionHeader>
<div className={styles.header}>Enable sample database</div> <div className={styles.header}>Enable sample database</div>
</AccordionHeader> </AccordionHeader>
@@ -916,7 +1051,15 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
"Clear History", "Clear History",
undefined, undefined,
"Are you sure you want to proceed?", "Are you sure you want to proceed?",
() => deleteAllStates(), () => {
deleteAllStates();
updateUserContext({
selectedRegionalEndpoint: undefined,
writeEnabledInSelectedRegion: true,
refreshCosmosClient: true,
});
useClientWriteEnabled.setState({ clientWriteEnabled: true });
},
"Cancel", "Cancel",
undefined, undefined,
<> <>
@@ -927,6 +1070,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
<li>Reset your customized tab layout, including the splitter positions</li> <li>Reset your customized tab layout, including the splitter positions</li>
<li>Erase your table column preferences, including any custom columns</li> <li>Erase your table column preferences, including any custom columns</li>
<li>Clear your filter history</li> <li>Clear your filter history</li>
<li>Reset region selection to global</li>
</ul> </ul>
</>, </>,
); );

View File

@@ -107,7 +107,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem <AccordionItem
value="3" value="4"
> >
<AccordionHeader> <AccordionHeader>
<div <div
@@ -148,7 +148,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem <AccordionItem
value="4" value="5"
> >
<AccordionHeader> <AccordionHeader>
<div <div
@@ -219,7 +219,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem <AccordionItem
value="5" value="6"
> >
<AccordionHeader> <AccordionHeader>
<div <div
@@ -281,7 +281,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem <AccordionItem
value="6" value="7"
> >
<AccordionHeader> <AccordionHeader>
<div <div
@@ -423,7 +423,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem <AccordionItem
value="7" value="8"
> >
<AccordionHeader> <AccordionHeader>
<div <div
@@ -459,7 +459,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem <AccordionItem
value="8" value="9"
> >
<AccordionHeader> <AccordionHeader>
<div <div
@@ -495,7 +495,7 @@ exports[`Settings Pane should render Default properly 1`] = `
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem <AccordionItem
value="9" value="10"
> >
<AccordionHeader> <AccordionHeader>
<div <div
@@ -575,7 +575,7 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
className="customAccordion ___1uf6361_0000000 fz7g6wx" className="customAccordion ___1uf6361_0000000 fz7g6wx"
> >
<AccordionItem <AccordionItem
value="6" value="7"
> >
<AccordionHeader> <AccordionHeader>
<div <div
@@ -717,7 +717,7 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem <AccordionItem
value="7" value="8"
> >
<AccordionHeader> <AccordionHeader>
<div <div
@@ -753,7 +753,7 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
<AccordionItem <AccordionItem
value="11" value="12"
> >
<AccordionHeader> <AccordionHeader>
<div <div

View File

@@ -18,7 +18,7 @@ import { createCollection } from "Common/dataAccess/createCollection";
import * as DataModels from "Contracts/DataModels"; import * as DataModels from "Contracts/DataModels";
import { ContainerSampleGenerator } from "Explorer/DataSamples/ContainerSampleGenerator"; import { ContainerSampleGenerator } from "Explorer/DataSamples/ContainerSampleGenerator";
import Explorer from "Explorer/Explorer"; import Explorer from "Explorer/Explorer";
import { AllPropertiesIndexed } from "Explorer/Panes/AddCollectionPanel"; import { AllPropertiesIndexed } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
import { PromptCard } from "Explorer/QueryCopilot/PromptCard"; import { PromptCard } from "Explorer/QueryCopilot/PromptCard";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import { useCarousel } from "hooks/useCarousel"; import { useCarousel } from "hooks/useCarousel";

View File

@@ -13,9 +13,15 @@ import {
SplitButton, SplitButton,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { Add16Regular, ArrowSync12Regular, ChevronLeft12Regular, ChevronRight12Regular } from "@fluentui/react-icons"; import { Add16Regular, ArrowSync12Regular, ChevronLeft12Regular, ChevronRight12Regular } from "@fluentui/react-icons";
import { GlobalSecondaryIndexLabels } from "Common/Constants";
import { isGlobalSecondaryIndexEnabled } from "Common/DatabaseAccountUtility";
import { configContext, Platform } from "ConfigContext"; import { configContext, Platform } from "ConfigContext";
import Explorer from "Explorer/Explorer"; import Explorer from "Explorer/Explorer";
import { AddDatabasePanel } from "Explorer/Panes/AddDatabasePanel/AddDatabasePanel"; import { AddDatabasePanel } from "Explorer/Panes/AddDatabasePanel/AddDatabasePanel";
import {
AddGlobalSecondaryIndexPanel,
AddGlobalSecondaryIndexPanelProps,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { Tabs } from "Explorer/Tabs/Tabs"; import { Tabs } from "Explorer/Tabs/Tabs";
import { CosmosFluentProvider, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil"; import { CosmosFluentProvider, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
import { ResourceTree } from "Explorer/Tree/ResourceTree"; import { ResourceTree } from "Explorer/Tree/ResourceTree";
@@ -162,6 +168,25 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
}); });
} }
if (isGlobalSecondaryIndexEnabled()) {
const addMaterializedViewPanelProps: AddGlobalSecondaryIndexPanelProps = {
explorer,
};
actions.push({
id: "new_materialized_view",
label: GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
icon: <Add16Regular />,
onClick: () =>
useSidePanel
.getState()
.openSidePanel(
GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
<AddGlobalSecondaryIndexPanel {...addMaterializedViewPanelProps} />,
),
});
}
return actions; return actions;
}, [explorer]); }, [explorer]);
@@ -315,16 +340,18 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
<> <>
<div className={styles.floatingControlsContainer}> <div className={styles.floatingControlsContainer}>
<div className={styles.floatingControls}> <div className={styles.floatingControls}>
<button {!isFabricNative() && (
type="button" <button
data-test="Sidebar/RefreshButton" type="button"
className={styles.floatingControlButton} data-test="Sidebar/RefreshButton"
disabled={loading} className={styles.floatingControlButton}
title="Refresh" disabled={loading}
onClick={onRefreshClick} title="Refresh"
> onClick={onRefreshClick}
<ArrowSync12Regular /> >
</button> <ArrowSync12Regular />
</button>
)}
<button <button
type="button" type="button"
className={styles.floatingControlButton} className={styles.floatingControlButton}

View File

@@ -3,6 +3,8 @@
*/ */
import { Link, makeStyles, tokens } from "@fluentui/react-components"; import { Link, makeStyles, tokens } from "@fluentui/react-components";
import { DocumentAddRegular, LinkMultipleRegular } from "@fluentui/react-icons"; import { DocumentAddRegular, LinkMultipleRegular } from "@fluentui/react-icons";
import { SampleDataImportDialog } from "Explorer/SplashScreen/SampleDataImportDialog";
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
import { isFabricNative } from "Platform/Fabric/FabricUtil"; import { isFabricNative } from "Platform/Fabric/FabricUtil";
import * as React from "react"; import * as React from "react";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
@@ -108,12 +110,10 @@ const FabricHomeScreenButton: React.FC<FabricHomeScreenButtonProps & { className
onClick, onClick,
}) => { }) => {
const styles = useStyles(); const styles = useStyles();
// TODO Make this a11y copmliant: aria-label for icon
return ( return (
<div role="button" className={`${styles.buttonContainer} ${className}`} onClick={onClick}> <div role="button" className={`${styles.buttonContainer} ${className}`} onClick={onClick}>
<div className={styles.buttonUpperPart}>{icon}</div> <div className={styles.buttonUpperPart}>{icon}</div>
<div className={styles.buttonLowerPart}> <div aria-label={title} className={styles.buttonLowerPart}>
<div>{title}</div> <div>{title}</div>
<div>{description}</div> <div>{description}</div>
</div> </div>
@@ -123,6 +123,8 @@ const FabricHomeScreenButton: React.FC<FabricHomeScreenButtonProps & { className
export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScreenProps) => { export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScreenProps) => {
const styles = useStyles(); const styles = useStyles();
const [openSampleDataImportDialog, setOpenSampleDataImportDialog] = React.useState(false);
const getSplashScreenButtons = (): JSX.Element => { const getSplashScreenButtons = (): JSX.Element => {
const buttons: FabricHomeScreenButtonProps[] = [ const buttons: FabricHomeScreenButtonProps[] = [
{ {
@@ -138,11 +140,13 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
title: "Sample data", title: "Sample data",
description: "Automatically load sample data in your database", description: "Automatically load sample data in your database",
icon: <img src={CosmosDbBlackIcon} />, icon: <img src={CosmosDbBlackIcon} />,
onClick: () => setOpenSampleDataImportDialog(true),
}, },
{ {
title: "App development", title: "App development",
description: "Start here to use an SDK to build your apps", description: "Start here to use an SDK to build your apps",
icon: <LinkMultipleRegular />, icon: <LinkMultipleRegular />,
onClick: () => window.open("https://aka.ms/cosmosdbfabricsdk", "_blank"),
}, },
]; ];
@@ -157,17 +161,25 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
const title = "Build your database"; const title = "Build your database";
return ( return (
<div className={styles.homeContainer}> <>
<div className={styles.title} role="heading" aria-label={title}> <CosmosFluentProvider className={styles.homeContainer}>
{title} <SampleDataImportDialog
</div> open={openSampleDataImportDialog}
{getSplashScreenButtons()} setOpen={setOpenSampleDataImportDialog}
<div className={styles.footer}> explorer={props.explorer}
Need help?{" "} databaseName={userContext.fabricContext?.databaseName}
<Link href="https://cosmos.azure.com/docs" target="_blank"> />
Learn more <img src={LinkIcon} alt="Learn more" /> <div className={styles.title} role="heading" aria-label={title}>
</Link> {title}
</div> </div>
</div> {getSplashScreenButtons()}
<div className={styles.footer}>
Need help?{" "}
<Link href="https://aka.ms/cosmosdbfabricdocs" target="_blank">
Learn more <img src={LinkIcon} alt="Learn more" />
</Link>
</div>
</CosmosFluentProvider>
</>
); );
}; };

View File

@@ -0,0 +1,158 @@
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
makeStyles,
Spinner,
tokens,
} from "@fluentui/react-components";
import Explorer from "Explorer/Explorer";
import { checkContainerExists, createContainer, importData } from "Explorer/SplashScreen/SampleUtil";
import React, { useEffect, useState } from "react";
import * as ViewModels from "../../Contracts/ViewModels";
const SAMPLE_DATA_CONTAINER_NAME = "SampleData";
const useStyles = makeStyles({
dialogContent: {
alignItems: "center",
marginBottom: tokens.spacingVerticalL,
},
});
/**
* This dialog:
* - creates a container
* - imports data into the container
* @param props
* @returns
*/
export const SampleDataImportDialog: React.FC<{
open: boolean;
setOpen: (open: boolean) => void;
explorer: Explorer;
databaseName: string;
}> = (props) => {
const [status, setStatus] = useState<"idle" | "creating" | "importing" | "completed" | "error">("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const containerName = SAMPLE_DATA_CONTAINER_NAME;
const [collection, setCollection] = useState<ViewModels.Collection>(undefined);
const styles = useStyles();
useEffect(() => {
// Reset state when dialog opens
if (props.open) {
setStatus("idle");
setErrorMessage(undefined);
}
}, [props.open]);
const handleStartImport = async (): Promise<void> => {
setStatus("creating");
const databaseName = props.databaseName;
if (checkContainerExists(databaseName, containerName)) {
const msg = `The container "${containerName}" in database "${databaseName}" already exists. Please delete it and retry.`;
setStatus("error");
setErrorMessage(msg);
return;
}
let collection;
try {
collection = await createContainer(databaseName, containerName, props.explorer);
} catch (error) {
setStatus("error");
setErrorMessage(`Failed to create container: ${error instanceof Error ? error.message : String(error)}`);
return;
}
try {
setStatus("importing");
await importData(collection);
setCollection(collection);
setStatus("completed");
} catch (error) {
setStatus("error");
setErrorMessage(`Failed to import data: ${error instanceof Error ? error.message : String(error)}`);
}
};
const handleActionOnClick = () => {
switch (status) {
case "idle":
handleStartImport();
break;
case "error":
props.setOpen(false);
break;
case "creating":
case "importing":
props.setOpen(false);
break;
case "completed":
props.setOpen(false);
collection.openTab();
break;
}
};
const renderContent = () => {
switch (status) {
case "idle":
return `Create a container "${containerName}" and import sample data into it. This may take a few minutes.`;
case "creating":
return <Spinner size="small" labelPosition="above" label={`Creating container "${containerName}"...`} />;
case "importing":
return <Spinner size="small" labelPosition="above" label={`Importing data into "${containerName}"...`} />;
case "completed":
return `Successfully created "${containerName}" with sample data.`;
case "error":
return (
<div style={{ color: "red" }}>
<div>Error: {errorMessage}</div>
</div>
);
}
};
const getButtonLabel = () => {
switch (status) {
case "idle":
return "Start";
case "creating":
case "importing":
return "Close";
case "completed":
return "Close";
case "error":
return "Close";
}
};
return (
<Dialog open={props.open} onOpenChange={(event, data) => props.setOpen(data.open)}>
<DialogSurface>
<DialogBody>
<DialogTitle>Sample Data</DialogTitle>
<DialogContent>
<div className={styles.dialogContent}>{renderContent()}</div>
</DialogContent>
<DialogActions>
<Button
appearance="primary"
onClick={handleActionOnClick}
disabled={status === "creating" || status === "importing"}
>
{getButtonLabel()}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
};

View File

@@ -0,0 +1,56 @@
import { createCollection } from "Common/dataAccess/createCollection";
import Explorer from "Explorer/Explorer";
import { useDatabases } from "Explorer/useDatabases";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
/**
* Public for unit tests
* @param databaseName
* @param containerName
* @param containerDatabases
*/
const hasContainer = (
databaseName: string,
containerName: string,
containerDatabases: ViewModels.Database[],
): boolean => {
const filteredDatabases = containerDatabases.filter((database) => database.id() === databaseName);
return (
filteredDatabases.length > 0 &&
filteredDatabases[0].collections().filter((collection) => collection.id() === containerName).length > 0
);
};
export const checkContainerExists = (databaseName: string, containerName: string) =>
hasContainer(databaseName, containerName, useDatabases.getState().databases);
export const createContainer = async (
databaseName: string,
containerName: string,
explorer: Explorer,
): Promise<ViewModels.Collection> => {
const createRequest: DataModels.CreateCollectionParams = {
createNewDatabase: false,
collectionId: containerName,
databaseId: databaseName,
databaseLevelThroughput: false,
};
await createCollection(createRequest);
await explorer.refreshAllDatabases();
const database = useDatabases.getState().findDatabaseWithId(databaseName);
if (!database) {
return undefined;
}
await database.loadCollections();
const newCollection = database.findCollectionWithId(containerName);
return newCollection;
};
export const importData = async (collection: ViewModels.Collection): Promise<void> => {
// TODO: keep same chunk as ContainerSampleGenerator
const dataFileContent = await import(
/* webpackChunkName: "queryCopilotSampleData" */ "../../../sampleData/queryCopilotSampleData.json"
);
await collection.bulkInsertDocuments(dataFileContent.data);
};

View File

@@ -16,10 +16,20 @@ export const ConnectTab: React.FC = (): JSX.Element => {
const [primaryReadonlyMasterKey, setPrimaryReadonlyMasterKey] = useState<string>(""); const [primaryReadonlyMasterKey, setPrimaryReadonlyMasterKey] = useState<string>("");
const [secondaryReadonlyMasterKey, setSecondaryReadonlyMasterKey] = useState<string>(""); const [secondaryReadonlyMasterKey, setSecondaryReadonlyMasterKey] = useState<string>("");
const uri: string = userContext.databaseAccount.properties?.documentEndpoint; const uri: string = userContext.databaseAccount.properties?.documentEndpoint;
const primaryConnectionStr = `AccountEndpoint=${uri};AccountKey=${primaryMasterKey}`; const primaryConnectionStr = `AccountEndpoint=${uri};AccountKey=${primaryMasterKey};`;
const secondaryConnectionStr = `AccountEndpoint=${uri};AccountKey=${secondaryMasterKey}`; const secondaryConnectionStr = `AccountEndpoint=${uri};AccountKey=${secondaryMasterKey};`;
const primaryReadonlyConnectionStr = `AccountEndpoint=${uri};AccountKey=${primaryReadonlyMasterKey}`; const primaryReadonlyConnectionStr = `AccountEndpoint=${uri};AccountKey=${primaryReadonlyMasterKey};`;
const secondaryReadonlyConnectionStr = `AccountEndpoint=${uri};AccountKey=${secondaryReadonlyMasterKey}`; const secondaryReadonlyConnectionStr = `AccountEndpoint=${uri};AccountKey=${secondaryReadonlyMasterKey};`;
const maskedValue: string =
"*********************************************************************************************************************************";
const [showPrimaryMasterKey, setShowPrimaryMasterKey] = useState<boolean>(false);
const [showSecondaryMasterKey, setShowSecondaryMasterKey] = useState<boolean>(false);
const [showPrimaryReadonlyMasterKey, setShowPrimaryReadonlyMasterKey] = useState<boolean>(false);
const [showSecondaryReadonlyMasterKey, setShowSecondaryReadonlyMasterKey] = useState<boolean>(false);
const [showPrimaryConnectionStr, setShowPrimaryConnectionStr] = useState<boolean>(false);
const [showSecondaryConnectionStr, setShowSecondaryConnectionStr] = useState<boolean>(false);
const [showPrimaryReadonlyConnectionStr, setShowPrimaryReadonlyConnectionStr] = useState<boolean>(false);
const [showSecondaryReadonlyConnectionStr, setShowSecondaryReadonlyConnectionStr] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
fetchKeys(); fetchKeys();
@@ -62,55 +72,97 @@ export const ConnectTab: React.FC = (): JSX.Element => {
root: { width: "100%" }, root: { width: "100%" },
field: { backgroundColor: "rgb(230, 230, 230)" }, field: { backgroundColor: "rgb(230, 230, 230)" },
fieldGroup: { borderColor: "rgb(138, 136, 134)" }, fieldGroup: { borderColor: "rgb(138, 136, 134)" },
suffix: {
backgroundColor: "rgb(230, 230, 230)",
margin: 0,
padding: 0,
},
}; };
const renderCopyButton = (selector: string) => (
<IconButton
iconProps={{ iconName: "Copy" }}
onClick={() => onCopyBtnClicked(selector)}
styles={{
root: {
height: "100%",
backgroundColor: "rgb(230, 230, 230)",
border: "none",
},
rootHovered: {
backgroundColor: "rgb(220, 220, 220)",
},
rootPressed: {
backgroundColor: "rgb(210, 210, 210)",
},
}}
/>
);
return ( return (
<div style={{ width: "100%", padding: 16 }}> <div style={{ width: "100%", padding: 16 }}>
<Stack horizontal verticalAlign="end" style={{ marginBottom: 16, margin: 10 }}>
<TextField
label="URI"
id="uriTextfield"
readOnly
value={uri}
styles={textfieldStyles}
onRenderSuffix={() => renderCopyButton("#uriTextfield")}
/>
<div style={{ width: 32 }}></div>
</Stack>
<Pivot> <Pivot>
{userContext.hasWriteAccess && ( {userContext.hasWriteAccess && (
<PivotItem headerText="Read-write Keys"> <PivotItem headerText="Read-write Keys">
<Stack style={{ margin: 10 }}> <Stack style={{ margin: 10, overflow: "auto", maxHeight: "calc(100vh - 300px)" }}>
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
<TextField label="URI" id="uriTextfield" readOnly value={uri} styles={textfieldStyles} />
<IconButton iconProps={{ iconName: "Copy" }} onClick={() => onCopyBtnClicked("#uriTextfield")} />
</Stack>
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}> <Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
<TextField <TextField
label="PRIMARY KEY" label="PRIMARY KEY"
id="primaryKeyTextfield" id="primaryKeyTextfield"
readOnly readOnly
value={primaryMasterKey} value={showPrimaryMasterKey ? primaryMasterKey : maskedValue}
styles={textfieldStyles} styles={textfieldStyles}
{...(showPrimaryMasterKey && {
onRenderSuffix: () => renderCopyButton("#primaryKeyTextfield"),
})}
/>
<IconButton
iconProps={{ iconName: showPrimaryMasterKey ? "Hide3" : "View" }}
onClick={() => setShowPrimaryMasterKey(!showPrimaryMasterKey)}
/> />
<IconButton iconProps={{ iconName: "Copy" }} onClick={() => onCopyBtnClicked("#primaryKeyTextfield")} />
</Stack> </Stack>
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}> <Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
<TextField <TextField
label="SECONDARY KEY" label="SECONDARY KEY"
id="secondaryKeyTextfield" id="secondaryKeyTextfield"
readOnly readOnly
value={secondaryMasterKey} value={showSecondaryMasterKey ? secondaryMasterKey : maskedValue}
styles={textfieldStyles} styles={textfieldStyles}
{...(showSecondaryMasterKey && {
onRenderSuffix: () => renderCopyButton("#secondaryKeyTextfield"),
})}
/> />
<IconButton <IconButton
iconProps={{ iconName: "Copy" }} iconProps={{ iconName: showSecondaryMasterKey ? "Hide3" : "View" }}
onClick={() => onCopyBtnClicked("#secondaryKeyTextfield")} onClick={() => setShowSecondaryMasterKey(!showSecondaryMasterKey)}
/> />
</Stack> </Stack>
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}> <Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
<TextField <TextField
label="PRIMARY CONNECTION STRING" label="PRIMARY CONNECTION STRING"
id="primaryConStrTextfield" id="primaryConStrTextfield"
readOnly readOnly
value={primaryConnectionStr} value={showPrimaryConnectionStr ? primaryConnectionStr : maskedValue}
styles={textfieldStyles} styles={textfieldStyles}
{...(showPrimaryConnectionStr && {
onRenderSuffix: () => renderCopyButton("#primaryConStrTextfield"),
})}
/> />
<IconButton <IconButton
iconProps={{ iconName: "Copy" }} iconProps={{ iconName: showPrimaryConnectionStr ? "Hide3" : "View" }}
onClick={() => onCopyBtnClicked("#primaryConStrTextfield")} onClick={() => setShowPrimaryConnectionStr(!showPrimaryConnectionStr)}
/> />
</Stack> </Stack>
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}> <Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
@@ -118,34 +170,36 @@ export const ConnectTab: React.FC = (): JSX.Element => {
label="SECONDARY CONNECTION STRING" label="SECONDARY CONNECTION STRING"
id="secondaryConStrTextfield" id="secondaryConStrTextfield"
readOnly readOnly
value={secondaryConnectionStr} value={showSecondaryConnectionStr ? secondaryConnectionStr : maskedValue}
styles={textfieldStyles} styles={textfieldStyles}
{...(showSecondaryConnectionStr && {
onRenderSuffix: () => renderCopyButton("#secondaryConStrTextfield"),
})}
/> />
<IconButton <IconButton
iconProps={{ iconName: "Copy" }} iconProps={{ iconName: showSecondaryConnectionStr ? "Hide3" : "View" }}
onClick={() => onCopyBtnClicked("#secondaryConStrTextfield")} onClick={() => setShowSecondaryConnectionStr(!showSecondaryConnectionStr)}
/> />
</Stack> </Stack>
</Stack> </Stack>
</PivotItem> </PivotItem>
)} )}
<PivotItem headerText="Read-only Keys"> <PivotItem headerText="Read-only Keys">
<Stack style={{ margin: 10 }}> <Stack style={{ margin: 10, overflow: "auto", maxHeight: "calc(100vh - 300px)" }}>
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
<TextField label="URI" id="uriReadOnlyTextfield" readOnly value={uri} styles={textfieldStyles} />
<IconButton iconProps={{ iconName: "Copy" }} onClick={() => onCopyBtnClicked("#uriReadOnlyTextfield")} />
</Stack>
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}> <Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
<TextField <TextField
label="PRIMARY READ-ONLY KEY" label="PRIMARY READ-ONLY KEY"
id="primaryReadonlyKeyTextfield" id="primaryReadonlyKeyTextfield"
readOnly readOnly
value={primaryReadonlyMasterKey} value={showPrimaryReadonlyMasterKey ? primaryReadonlyMasterKey : maskedValue}
styles={textfieldStyles} styles={textfieldStyles}
{...(showPrimaryReadonlyMasterKey && {
onRenderSuffix: () => renderCopyButton("#primaryReadonlyKeyTextfield"),
})}
/> />
<IconButton <IconButton
iconProps={{ iconName: "Copy" }} iconProps={{ iconName: showPrimaryReadonlyMasterKey ? "Hide3" : "View" }}
onClick={() => onCopyBtnClicked("#primaryReadonlyKeyTextfield")} onClick={() => setShowPrimaryReadonlyMasterKey(!showPrimaryReadonlyMasterKey)}
/> />
</Stack> </Stack>
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}> <Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
@@ -153,12 +207,15 @@ export const ConnectTab: React.FC = (): JSX.Element => {
label="SECONDARY READ-ONLY KEY" label="SECONDARY READ-ONLY KEY"
id="secondaryReadonlyKeyTextfield" id="secondaryReadonlyKeyTextfield"
readOnly readOnly
value={secondaryReadonlyMasterKey} value={showSecondaryReadonlyMasterKey ? secondaryReadonlyMasterKey : maskedValue}
styles={textfieldStyles} styles={textfieldStyles}
{...(showSecondaryReadonlyMasterKey && {
onRenderSuffix: () => renderCopyButton("#secondaryReadonlyKeyTextfield"),
})}
/> />
<IconButton <IconButton
iconProps={{ iconName: "Copy" }} iconProps={{ iconName: showSecondaryReadonlyMasterKey ? "Hide3" : "View" }}
onClick={() => onCopyBtnClicked("#secondaryReadonlyKeyTextfield")} onClick={() => setShowSecondaryReadonlyMasterKey(!showSecondaryReadonlyMasterKey)}
/> />
</Stack> </Stack>
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}> <Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
@@ -166,25 +223,31 @@ export const ConnectTab: React.FC = (): JSX.Element => {
label="PRIMARY READ-ONLY CONNECTION STRING" label="PRIMARY READ-ONLY CONNECTION STRING"
id="primaryReadonlyConStrTextfield" id="primaryReadonlyConStrTextfield"
readOnly readOnly
value={primaryReadonlyConnectionStr} value={showPrimaryReadonlyConnectionStr ? primaryReadonlyConnectionStr : maskedValue}
styles={textfieldStyles} styles={textfieldStyles}
{...(showPrimaryReadonlyConnectionStr && {
onRenderSuffix: () => renderCopyButton("#primaryReadonlyConStrTextfield"),
})}
/> />
<IconButton <IconButton
iconProps={{ iconName: "Copy" }} iconProps={{ iconName: showPrimaryReadonlyConnectionStr ? "Hide3" : "View" }}
onClick={() => onCopyBtnClicked("#primaryReadonlyConStrTextfield")} onClick={() => setShowPrimaryReadonlyConnectionStr(!showPrimaryReadonlyConnectionStr)}
/> />
</Stack> </Stack>
<Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}> <Stack horizontal verticalAlign="end" style={{ marginBottom: 8 }}>
<TextField <TextField
label="SECONDARY READ-ONLY CONNECTION STRING" label="SECONDARY READ-ONLY CONNECTION STRING"
id="secondaryReadonlyConStrTextfield" id="secondaryReadonlyConStrTextfield"
value={secondaryReadonlyConnectionStr} value={showSecondaryReadonlyConnectionStr ? secondaryReadonlyConnectionStr : maskedValue}
readOnly readOnly
styles={textfieldStyles} styles={textfieldStyles}
{...(showSecondaryReadonlyConnectionStr && {
onRenderSuffix: () => renderCopyButton("#secondaryReadonlyConStrTextfield"),
})}
/> />
<IconButton <IconButton
iconProps={{ iconName: "Copy" }} iconProps={{ iconName: showSecondaryReadonlyConnectionStr ? "Hide3" : "View" }}
onClick={() => onCopyBtnClicked("#secondaryReadonlyConStrTextfield")} onClick={() => setShowSecondaryReadonlyConnectionStr(!showSecondaryReadonlyConnectionStr)}
/> />
</Stack> </Stack>
</Stack> </Stack>

View File

@@ -49,12 +49,14 @@ import { Action } from "Shared/Telemetry/TelemetryConstants";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
import { Allotment } from "allotment"; import { Allotment } from "allotment";
import { useClientWriteEnabled } from "hooks/useClientWriteEnabled";
import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { format } from "react-string-format"; import { format } from "react-string-format";
import DeleteDocumentIcon from "../../../../images/DeleteDocument.svg"; import DeleteDocumentIcon from "../../../../images/DeleteDocument.svg";
import NewDocumentIcon from "../../../../images/NewDocument.svg"; import NewDocumentIcon from "../../../../images/NewDocument.svg";
import UploadIcon from "../../../../images/Upload_16x16.svg"; import UploadIcon from "../../../../images/Upload_16x16.svg";
import DiscardIcon from "../../../../images/discard.svg"; import DiscardIcon from "../../../../images/discard.svg";
import RefreshIcon from "../../../../images/refresh-cosmos.svg";
import SaveIcon from "../../../../images/save-cosmos.svg"; import SaveIcon from "../../../../images/save-cosmos.svg";
import * as Constants from "../../../Common/Constants"; import * as Constants from "../../../Common/Constants";
import * as HeadersUtility from "../../../Common/HeadersUtility"; import * as HeadersUtility from "../../../Common/HeadersUtility";
@@ -131,6 +133,14 @@ export const useDocumentsTabStyles = makeStyles({
backgroundColor: "white", backgroundColor: "white",
zIndex: 1, zIndex: 1,
}, },
refreshBtn: {
position: "absolute",
top: "3px",
right: "4px",
float: "right",
zIndex: 1,
backgroundColor: "transparent",
},
deleteProgressContent: { deleteProgressContent: {
paddingTop: tokens.spacingVerticalL, paddingTop: tokens.spacingVerticalL,
}, },
@@ -296,6 +306,7 @@ export type ButtonsDependencies = {
selectedRows: Set<TableRowId>; selectedRows: Set<TableRowId>;
editorState: ViewModels.DocumentExplorerState; editorState: ViewModels.DocumentExplorerState;
isPreferredApiMongoDB: boolean; isPreferredApiMongoDB: boolean;
clientWriteEnabled: boolean;
onNewDocumentClick: UiKeyboardEvent; onNewDocumentClick: UiKeyboardEvent;
onSaveNewDocumentClick: UiKeyboardEvent; onSaveNewDocumentClick: UiKeyboardEvent;
onRevertNewDocumentClick: UiKeyboardEvent; onRevertNewDocumentClick: UiKeyboardEvent;
@@ -319,6 +330,7 @@ const createUploadButton = (container: Explorer): CommandButtonComponentProps =>
hasPopup: true, hasPopup: true,
disabled: disabled:
useSelectedNode.getState().isDatabaseNodeOrNoneSelected() || useSelectedNode.getState().isDatabaseNodeOrNoneSelected() ||
!useClientWriteEnabled.getState().clientWriteEnabled ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(), useSelectedNode.getState().isQueryCopilotCollectionSelected(),
}; };
}; };
@@ -337,6 +349,7 @@ export const getTabsButtons = ({
selectedRows, selectedRows,
editorState, editorState,
isPreferredApiMongoDB, isPreferredApiMongoDB,
clientWriteEnabled,
onNewDocumentClick, onNewDocumentClick,
onSaveNewDocumentClick, onSaveNewDocumentClick,
onRevertNewDocumentClick, onRevertNewDocumentClick,
@@ -362,6 +375,7 @@ export const getTabsButtons = ({
hasPopup: false, hasPopup: false,
disabled: disabled:
!getNewDocumentButtonState(editorState).enabled || !getNewDocumentButtonState(editorState).enabled ||
!clientWriteEnabled ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(), useSelectedNode.getState().isQueryCopilotCollectionSelected(),
id: NEW_DOCUMENT_BUTTON_ID, id: NEW_DOCUMENT_BUTTON_ID,
}); });
@@ -379,6 +393,7 @@ export const getTabsButtons = ({
hasPopup: false, hasPopup: false,
disabled: disabled:
!getSaveNewDocumentButtonState(editorState).enabled || !getSaveNewDocumentButtonState(editorState).enabled ||
!clientWriteEnabled ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(), useSelectedNode.getState().isQueryCopilotCollectionSelected(),
id: SAVE_BUTTON_ID, id: SAVE_BUTTON_ID,
}); });
@@ -413,6 +428,7 @@ export const getTabsButtons = ({
hasPopup: false, hasPopup: false,
disabled: disabled:
!getSaveExistingDocumentButtonState(editorState).enabled || !getSaveExistingDocumentButtonState(editorState).enabled ||
!clientWriteEnabled ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(), useSelectedNode.getState().isQueryCopilotCollectionSelected(),
id: UPDATE_BUTTON_ID, id: UPDATE_BUTTON_ID,
}); });
@@ -445,7 +461,7 @@ export const getTabsButtons = ({
commandButtonLabel: label, commandButtonLabel: label,
ariaLabel: label, ariaLabel: label,
hasPopup: false, hasPopup: false,
disabled: useSelectedNode.getState().isQueryCopilotCollectionSelected(), disabled: useSelectedNode.getState().isQueryCopilotCollectionSelected() || !clientWriteEnabled,
id: DELETE_BUTTON_ID, id: DELETE_BUTTON_ID,
}); });
} }
@@ -619,6 +635,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
); );
// State // State
const clientWriteEnabled = useClientWriteEnabled((state) => state.clientWriteEnabled);
const [tabStateData, setTabStateData] = useState<TabDivider>(() => const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
readDocumentsTabSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, { readDocumentsTabSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, {
leftPaneWidthPercent: 35, leftPaneWidthPercent: 35,
@@ -756,16 +773,14 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
[_collection, _partitionKey], [_collection, _partitionKey],
); );
const partitionKeyPropertyHeaders: string[] = useMemo( const partitionKeyPropertyHeaders: string[] = useMemo(
() => _collection?.partitionKeyPropertyHeaders || partitionKey?.paths, () => (partitionKey?.systemKey ? [] : _collection?.partitionKeyPropertyHeaders || partitionKey?.paths),
[_collection?.partitionKeyPropertyHeaders, partitionKey?.paths], [_collection?.partitionKeyPropertyHeaders, partitionKey?.paths, partitionKey?.systemKey],
);
let partitionKeyProperties = useMemo(
() =>
partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) =>
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""),
),
[partitionKeyPropertyHeaders],
); );
let partitionKeyProperties = useMemo(() => {
return partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) =>
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""),
);
}, [partitionKeyPropertyHeaders]);
const getInitialColumnSelection = () => { const getInitialColumnSelection = () => {
const defaultColumnsIds = ["id"]; const defaultColumnsIds = ["id"];
@@ -856,6 +871,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
selectedRows, selectedRows,
editorState, editorState,
isPreferredApiMongoDB, isPreferredApiMongoDB,
clientWriteEnabled,
onNewDocumentClick, onNewDocumentClick,
onSaveNewDocumentClick, onSaveNewDocumentClick,
onRevertNewDocumentClick, onRevertNewDocumentClick,
@@ -1028,6 +1044,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
); );
const selectedDocumentId = documentIds[clickedRowIndex as number]; const selectedDocumentId = documentIds[clickedRowIndex as number];
const originalPartitionKeyValue = selectedDocumentId.partitionKeyValue;
selectedDocumentId.partitionKeyValue = partitionKeyValueArray; selectedDocumentId.partitionKeyValue = partitionKeyValueArray;
onExecutionErrorChange(false); onExecutionErrorChange(false);
@@ -1063,6 +1080,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setColumnDefinitionsFromDocument(documentContent); setColumnDefinitionsFromDocument(documentContent);
}, },
(error) => { (error) => {
// in case of any kind of failures of accidently changing partition key, restore the original
// so that when user navigates away from current document and comes back,
// it doesnt fail to load due to using the invalid partition keys
selectedDocumentId.partitionKeyValue = originalPartitionKeyValue;
onExecutionErrorChange(true); onExecutionErrorChange(true);
const errorMessage = getErrorMessage(error); const errorMessage = getErrorMessage(error);
useDialog.getState().showOkModalDialog("Update document failed", errorMessage); useDialog.getState().showOkModalDialog("Update document failed", errorMessage);
@@ -1270,6 +1291,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
selectedRows, selectedRows,
editorState, editorState,
isPreferredApiMongoDB, isPreferredApiMongoDB,
clientWriteEnabled,
onNewDocumentClick, onNewDocumentClick,
onSaveNewDocumentClick, onSaveNewDocumentClick,
onRevertNewDocumentClick, onRevertNewDocumentClick,
@@ -1282,6 +1304,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
selectedRows, selectedRows,
editorState, editorState,
isPreferredApiMongoDB, isPreferredApiMongoDB,
clientWriteEnabled,
onNewDocumentClick, onNewDocumentClick,
onSaveNewDocumentClick, onSaveNewDocumentClick,
onRevertNewDocumentClick, onRevertNewDocumentClick,
@@ -1700,7 +1723,8 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
renderObjectForEditor = (value: unknown): string => MongoUtility.tojson(value, null, false); renderObjectForEditor = (value: unknown): string => MongoUtility.tojson(value, null, false);
const _hasShardKeySpecified = (document: unknown): boolean => { const _hasShardKeySpecified = (document: unknown): boolean => {
return Boolean(extractPartitionKeyValues(document, _getPartitionKeyDefinition() as PartitionKeyDefinition)); const partitionKeyDefinition: PartitionKeyDefinition = _getPartitionKeyDefinition() as PartitionKeyDefinition;
return partitionKeyDefinition.systemKey || Boolean(extractPartitionKeyValues(document, partitionKeyDefinition));
}; };
const _getPartitionKeyDefinition = (): DataModels.PartitionKey => { const _getPartitionKeyDefinition = (): DataModels.PartitionKey => {
@@ -1724,7 +1748,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return partitionKey; return partitionKey;
}; };
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => { partitionKeyProperties = partitionKeyProperties.map((partitionKeyProperty, i) => {
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) { if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, ""); partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
} }
@@ -2074,8 +2098,8 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return ( return (
<CosmosFluentProvider className={styles.container}> <CosmosFluentProvider className={styles.container}>
<div className="tab-pane active" role="tabpanel" style={{ display: "flex" }}> <div data-test={"DocumentsTab"} className="tab-pane active" role="tabpanel" style={{ display: "flex" }}>
<div className={styles.filterRow}> <div data-test={"DocumentsTab/Filter"} className={styles.filterRow}>
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>} {!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
<InputDataList <InputDataList
dropdownOptions={getFilterChoices()} dropdownOptions={getFilterChoices()}
@@ -2117,7 +2141,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}} }}
> >
<Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}> <Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}>
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}> <div
data-test={"DocumentsTab/DocumentsPane"}
style={{ height: "100%", width: "100%", overflow: "hidden" }}
ref={tableContainerRef}
>
<div className={styles.tableContainer}> <div className={styles.tableContainer}>
<div <div
style={ style={
@@ -2144,6 +2172,18 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
isColumnSelectionDisabled={isPreferredApiMongoDB} isColumnSelectionDisabled={isPreferredApiMongoDB}
/> />
</div> </div>
{tableContainerSizePx?.width >= calculateOffset(selectedColumnIds.length) + 200 && (
<div
title="Refresh"
className={styles.refreshBtn}
role="button"
onClick={() => refreshDocumentsGrid(false)}
aria-label="Refresh"
tabIndex={0}
>
<img src={RefreshIcon} alt="Refresh" />
</div>
)}
</div> </div>
{tableItems.length > 0 && ( {tableItems.length > 0 && (
<a <a
@@ -2159,7 +2199,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
</div> </div>
</Allotment.Pane> </Allotment.Pane>
<Allotment.Pane minSize={30}> <Allotment.Pane minSize={30}>
<div style={{ height: "100%", width: "100%" }}> <div data-test={"DocumentsTab/ResultsPane"} style={{ height: "100%", width: "100%" }}>
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && ( {isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
<EditorReact <EditorReact
language={"json"} language={"json"}

View File

@@ -233,7 +233,7 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
aria-label="Select column" aria-label="Select column"
size="small" size="small"
icon={<MoreHorizontalRegular />} icon={<MoreHorizontalRegular />}
style={{ position: "absolute", right: 0, backgroundColor: tokens.colorNeutralBackground1 }} style={{ position: "absolute", right: 10, backgroundColor: tokens.colorNeutralBackground1 }}
/> />
</MenuTrigger> </MenuTrigger>
<MenuPopover> <MenuPopover>

View File

@@ -6,6 +6,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
> >
<div <div
className="tab-pane active" className="tab-pane active"
data-test="DocumentsTab"
role="tabpanel" role="tabpanel"
style={ style={
{ {
@@ -15,6 +16,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
> >
<div <div
className="___11ktxfv_0000000 f1o614cb fy9rknc f22iagw fsnqrgy f1f5gg8d fjodcmx f122n59 f1f09k3d fg706s2 frpde29" className="___11ktxfv_0000000 f1o614cb fy9rknc f22iagw fsnqrgy f1f5gg8d fjodcmx f122n59 f1f09k3d fg706s2 frpde29"
data-test="DocumentsTab/Filter"
> >
<span> <span>
SELECT * FROM c SELECT * FROM c
@@ -65,6 +67,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
preferredSize="35%" preferredSize="35%"
> >
<div <div
data-test="DocumentsTab/DocumentsPane"
style={ style={
{ {
"height": "100%", "height": "100%",
@@ -126,6 +129,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
minSize={30} minSize={30}
> >
<div <div
data-test="DocumentsTab/ResultsPane"
style={ style={
{ {
"height": "100%", "height": "100%",

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-console */ /* eslint-disable no-console */
import { FeedOptions, QueryOperationOptions } from "@azure/cosmos"; import { FeedOptions, QueryOperationOptions } from "@azure/cosmos";
import { AuthType } from "AuthType";
import QueryError, { createMonacoErrorLocationResolver, createMonacoMarkersForQueryErrors } from "Common/QueryError"; import QueryError, { createMonacoErrorLocationResolver, createMonacoMarkersForQueryErrors } from "Common/QueryError";
import { SplitterDirection } from "Common/Splitter"; import { SplitterDirection } from "Common/Splitter";
import { Platform, configContext } from "ConfigContext"; import { Platform, configContext } from "ConfigContext";
@@ -21,6 +22,7 @@ import { QueryConstants } from "Shared/Constants";
import { LocalStorageUtility, StorageKey, getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility"; import { LocalStorageUtility, StorageKey, getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
import { Action } from "Shared/Telemetry/TelemetryConstants"; import { Action } from "Shared/Telemetry/TelemetryConstants";
import { Allotment } from "allotment"; import { Allotment } from "allotment";
import { useClientWriteEnabled } from "hooks/useClientWriteEnabled";
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot"; import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
import { TabsState, useTabs } from "hooks/useTabs"; import { TabsState, useTabs } from "hooks/useTabs";
import React, { Fragment, createRef } from "react"; import React, { Fragment, createRef } from "react";
@@ -484,7 +486,9 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
commandButtonLabel: label, commandButtonLabel: label,
ariaLabel: label, ariaLabel: label,
hasPopup: false, hasPopup: false,
disabled: !this.saveQueryButton.enabled, disabled:
!this.saveQueryButton.enabled ||
(!useClientWriteEnabled.getState().clientWriteEnabled && userContext.authType === AuthType.AAD),
}); });
} }
@@ -696,6 +700,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
} }
private unsubscribeCopilotSidebar: () => void; private unsubscribeCopilotSidebar: () => void;
private unsubscribeClientWriteEnabled: () => void;
componentDidMount(): void { componentDidMount(): void {
useTabs.subscribe((state: TabsState) => { useTabs.subscribe((state: TabsState) => {
@@ -712,10 +717,17 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
useCommandBar.getState().setContextButtons(this.getTabsButtons()); useCommandBar.getState().setContextButtons(this.getTabsButtons());
document.addEventListener("keydown", this.handleCopilotKeyDown); document.addEventListener("keydown", this.handleCopilotKeyDown);
this.unsubscribeClientWriteEnabled = useClientWriteEnabled.subscribe(() => {
useCommandBar.getState().setContextButtons(this.getTabsButtons());
});
} }
componentWillUnmount(): void { componentWillUnmount(): void {
document.removeEventListener("keydown", this.handleCopilotKeyDown); document.removeEventListener("keydown", this.handleCopilotKeyDown);
if (this.unsubscribeClientWriteEnabled) {
this.unsubscribeClientWriteEnabled();
}
} }
private getEditorAndQueryResult(): JSX.Element { private getEditorAndQueryResult(): JSX.Element {

View File

@@ -1,5 +1,6 @@
import * as ko from "knockout"; import * as ko from "knockout";
import Q from "q"; import Q from "q";
import { IsValidCosmosDbResourceId } from "Utils/ValidationUtils";
import DiscardIcon from "../../../images/discard.svg"; import DiscardIcon from "../../../images/discard.svg";
import SaveIcon from "../../../images/save-cosmos.svg"; import SaveIcon from "../../../images/save-cosmos.svg";
import * as Constants from "../../Common/Constants"; import * as Constants from "../../Common/Constants";
@@ -57,7 +58,7 @@ export default abstract class ScriptTabBase extends TabsBase implements ViewMode
} }
this.id = editable.observable<string>(); this.id = editable.observable<string>();
this.id.validations([ScriptTabBase._isValidId]); this.id.validations([IsValidCosmosDbResourceId]);
this.editorContent = editable.observable<string>(); this.editorContent = editable.observable<string>();
this.editorContent.validations([ScriptTabBase._isNotEmpty]); this.editorContent.validations([ScriptTabBase._isNotEmpty]);
@@ -262,29 +263,6 @@ export default abstract class ScriptTabBase extends TabsBase implements ViewMode
this.updateNavbarWithTabsButtons(); this.updateNavbarWithTabsButtons();
} }
private static _isValidId(id: string): boolean {
if (!id) {
return false;
}
const invalidStartCharacters = /^[/?#\\]/;
if (invalidStartCharacters.test(id)) {
return false;
}
const invalidMiddleCharacters = /^.+[/?#\\]/;
if (invalidMiddleCharacters.test(id)) {
return false;
}
const invalidEndCharacters = /.*[/?#\\ ]$/;
if (invalidEndCharacters.test(id)) {
return false;
}
return true;
}
private static _isNotEmpty(value: string): boolean { private static _isNotEmpty(value: string): boolean {
return !!value; return !!value;
} }

View File

@@ -1,6 +1,7 @@
import { Resource, StoredProcedureDefinition } from "@azure/cosmos"; import { Resource, StoredProcedureDefinition } from "@azure/cosmos";
import { Pivot, PivotItem } from "@fluentui/react"; import { Pivot, PivotItem } from "@fluentui/react";
import { KeyboardAction } from "KeyboardShortcuts"; import { KeyboardAction } from "KeyboardShortcuts";
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
import React from "react"; import React from "react";
import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg"; import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg";
import DiscardIcon from "../../../../images/discard.svg"; import DiscardIcon from "../../../../images/discard.svg";
@@ -455,11 +456,12 @@ export default class StoredProcedureTabComponent extends React.Component<
} }
public handleIdOnChange(event: React.ChangeEvent<HTMLInputElement>): void { public handleIdOnChange(event: React.ChangeEvent<HTMLInputElement>): void {
const isValidId: boolean = event.currentTarget.reportValidity();
if (this.state.saveButton.visible) { if (this.state.saveButton.visible) {
this.setState({ this.setState({
id: event.target.value, id: event.target.value,
saveButton: { saveButton: {
enabled: true, enabled: isValidId,
visible: this.props.scriptTabBaseInstance.isNew(), visible: this.props.scriptTabBaseInstance.isNew(),
}, },
discardButton: { discardButton: {
@@ -528,8 +530,8 @@ export default class StoredProcedureTabComponent extends React.Component<
className="formTree" className="formTree"
type="text" type="text"
required required
pattern="[^/?#\\]*[^/?# \\]" pattern={ValidCosmosDbIdInputPattern.source}
title="May not end with space nor contain characters '\' '/' '#' '?'" title={ValidCosmosDbIdDescription}
aria-label="Stored procedure id" aria-label="Stored procedure id"
placeholder="Enter the new stored procedure id" placeholder="Enter the new stored procedure id"
size={40} size={40}

View File

@@ -5,6 +5,7 @@ import { checkFirewallRules } from "Explorer/Tabs/Shared/CheckFirewallRules";
import * as ko from "knockout"; import * as ko from "knockout";
import * as React from "react"; import * as React from "react";
import FirewallRuleScreenshot from "../../../images/firewallRule.png"; import FirewallRuleScreenshot from "../../../images/firewallRule.png";
import VcoreFirewallRuleScreenshot from "../../../images/vcoreMongoFirewallRule.png";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import * as DataModels from "../../Contracts/DataModels"; import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels"; import * as ViewModels from "../../Contracts/ViewModels";
@@ -42,7 +43,11 @@ class NotebookTerminalComponentAdapter implements ReactAdapter {
return ( return (
<QuickstartFirewallNotification <QuickstartFirewallNotification
messageType={MessageTypes.OpenPostgresNetworkingBlade} messageType={MessageTypes.OpenPostgresNetworkingBlade}
screenshot={FirewallRuleScreenshot} screenshot={
this.kind === ViewModels.TerminalKind.Mongo || this.kind === ViewModels.TerminalKind.VCoreMongo
? VcoreFirewallRuleScreenshot
: FirewallRuleScreenshot
}
shellName={this.getShellNameForDisplay(this.kind)} shellName={this.getShellNameForDisplay(this.kind)}
/> />
); );

View File

@@ -1,6 +1,7 @@
import { TriggerDefinition } from "@azure/cosmos"; import { TriggerDefinition } from "@azure/cosmos";
import { Dropdown, IDropdownOption, Label, TextField } from "@fluentui/react"; import { Dropdown, IDropdownOption, Label, TextField } from "@fluentui/react";
import { KeyboardAction } from "KeyboardShortcuts"; import { KeyboardAction } from "KeyboardShortcuts";
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
import React, { Component } from "react"; import React, { Component } from "react";
import DiscardIcon from "../../../images/discard.svg"; import DiscardIcon from "../../../images/discard.svg";
import SaveIcon from "../../../images/save-cosmos.svg"; import SaveIcon from "../../../images/save-cosmos.svg";
@@ -192,29 +193,6 @@ export class TriggerTabContent extends Component<TriggerTab, ITriggerTabContentS
}); });
} }
private isValidId(id: string): boolean {
if (!id) {
return false;
}
const invalidStartCharacters = /^[/?#\\]/;
if (invalidStartCharacters.test(id)) {
return false;
}
const invalidMiddleCharacters = /^.+[/?#\\]/;
if (invalidMiddleCharacters.test(id)) {
return false;
}
const invalidEndCharacters = /.*[/?#\\ ]$/;
if (invalidEndCharacters.test(id)) {
return false;
}
return true;
}
private isNotEmpty(value: string): boolean { private isNotEmpty(value: string): boolean {
return !!value; return !!value;
} }
@@ -286,7 +264,13 @@ export class TriggerTabContent extends Component<TriggerTab, ITriggerTabContentS
_event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, _event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
newValue?: string, newValue?: string,
): void => { ): void => {
this.saveButton.enabled = this.isValidId(newValue) && this.isNotEmpty(newValue); const inputElement = _event.currentTarget as HTMLInputElement;
let isValidId: boolean = true;
if (inputElement) {
isValidId = inputElement.reportValidity();
}
this.saveButton.enabled = this.isNotEmpty(newValue) && isValidId;
this.setState({ triggerId: newValue }); this.setState({ triggerId: newValue });
}; };
@@ -313,7 +297,8 @@ export class TriggerTabContent extends Component<TriggerTab, ITriggerTabContentS
autoFocus autoFocus
required required
type="text" type="text"
pattern="[^/?#\\]*[^/?# \\]" pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder="Enter the new trigger id" placeholder="Enter the new trigger id"
size={40} size={40}
value={triggerId} value={triggerId}

View File

@@ -1,6 +1,7 @@
import { UserDefinedFunctionDefinition } from "@azure/cosmos"; import { UserDefinedFunctionDefinition } from "@azure/cosmos";
import { Label, TextField } from "@fluentui/react"; import { Label, TextField } from "@fluentui/react";
import { KeyboardAction } from "KeyboardShortcuts"; import { KeyboardAction } from "KeyboardShortcuts";
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
import React, { Component } from "react"; import React, { Component } from "react";
import DiscardIcon from "../../../images/discard.svg"; import DiscardIcon from "../../../images/discard.svg";
import SaveIcon from "../../../images/save-cosmos.svg"; import SaveIcon from "../../../images/save-cosmos.svg";
@@ -64,7 +65,13 @@ export default class UserDefinedFunctionTabContent extends Component<
_event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, _event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
newValue?: string, newValue?: string,
): void => { ): void => {
this.saveButton.enabled = this.isValidId(newValue) && this.isNotEmpty(newValue); const inputElement = _event.currentTarget as HTMLInputElement;
let isValidId: boolean = true;
if (inputElement) {
isValidId = inputElement.reportValidity();
}
this.saveButton.enabled = this.isNotEmpty(newValue) && isValidId;
this.setState({ udfId: newValue }); this.setState({ udfId: newValue });
}; };
@@ -238,29 +245,6 @@ export default class UserDefinedFunctionTabContent extends Component<
}); });
} }
private isValidId(id: string): boolean {
if (!id) {
return false;
}
const invalidStartCharacters = /^[/?#\\]/;
if (invalidStartCharacters.test(id)) {
return false;
}
const invalidMiddleCharacters = /^.+[/?#\\]/;
if (invalidMiddleCharacters.test(id)) {
return false;
}
const invalidEndCharacters = /.*[/?#\\ ]$/;
if (invalidEndCharacters.test(id)) {
return false;
}
return true;
}
private isNotEmpty(value: string): boolean { private isNotEmpty(value: string): boolean {
return !!value; return !!value;
} }
@@ -284,7 +268,8 @@ export default class UserDefinedFunctionTabContent extends Component<
required required
readOnly={!isUdfIdEditable} readOnly={!isUdfIdEditable}
type="text" type="text"
pattern="[^/?#\\]*[^/?# \\]" pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder="Enter the new user defined function id" placeholder="Enter the new user defined function id"
size={40} size={40}
value={udfId} value={udfId}

View File

@@ -1,4 +1,10 @@
import { Resource, StoredProcedureDefinition, TriggerDefinition, UserDefinedFunctionDefinition } from "@azure/cosmos"; import {
JSONObject,
Resource,
StoredProcedureDefinition,
TriggerDefinition,
UserDefinedFunctionDefinition,
} from "@azure/cosmos";
import { useNotebook } from "Explorer/Notebook/useNotebook"; import { useNotebook } from "Explorer/Notebook/useNotebook";
import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import { isFabricMirrored } from "Platform/Fabric/FabricUtil"; import { isFabricMirrored } from "Platform/Fabric/FabricUtil";
@@ -58,6 +64,8 @@ export default class Collection implements ViewModels.Collection {
public uniqueKeyPolicy: DataModels.UniqueKeyPolicy; public uniqueKeyPolicy: DataModels.UniqueKeyPolicy;
public usageSizeInKB: ko.Observable<number>; public usageSizeInKB: ko.Observable<number>;
public computedProperties: ko.Observable<DataModels.ComputedProperties>; public computedProperties: ko.Observable<DataModels.ComputedProperties>;
public materializedViews: ko.Observable<DataModels.MaterializedView[]>;
public materializedViewDefinition: ko.Observable<DataModels.MaterializedViewDefinition>;
public offer: ko.Observable<DataModels.Offer>; public offer: ko.Observable<DataModels.Offer>;
public conflictResolutionPolicy: ko.Observable<DataModels.ConflictResolutionPolicy>; public conflictResolutionPolicy: ko.Observable<DataModels.ConflictResolutionPolicy>;
@@ -124,6 +132,8 @@ export default class Collection implements ViewModels.Collection {
this.requestSchema = data.requestSchema; this.requestSchema = data.requestSchema;
this.geospatialConfig = ko.observable(data.geospatialConfig); this.geospatialConfig = ko.observable(data.geospatialConfig);
this.computedProperties = ko.observable(data.computedProperties); this.computedProperties = ko.observable(data.computedProperties);
this.materializedViews = ko.observable(data.materializedViews);
this.materializedViewDefinition = ko.observable(data.materializedViewDefinition);
this.partitionKeyPropertyHeaders = this.partitionKey?.paths; this.partitionKeyPropertyHeaders = this.partitionKey?.paths;
this.partitionKeyProperties = this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => { this.partitionKeyProperties = this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => {
@@ -1040,9 +1050,22 @@ export default class Collection implements ViewModels.Collection {
} }
public async uploadFiles(files: FileList): Promise<{ data: UploadDetailsRecord[] }> { public async uploadFiles(files: FileList): Promise<{ data: UploadDetailsRecord[] }> {
const data = await Promise.all(Array.from(files).map((file) => this.uploadFile(file))); try {
TelemetryProcessor.trace(Action.UploadDocuments, ActionModifiers.Start, {
return { data }; nbFiles: files.length,
});
const data = await Promise.all(Array.from(files).map((file) => this.uploadFile(file)));
TelemetryProcessor.trace(Action.UploadDocuments, ActionModifiers.Success, {
nbFiles: files.length,
});
return { data };
} catch (error) {
TelemetryProcessor.trace(Action.UploadDocuments, ActionModifiers.Failed, {
error: getErrorMessage(error),
errorStack: getErrorStack(error),
});
throw error;
}
} }
private uploadFile(file: File): Promise<UploadDetailsRecord> { private uploadFile(file: File): Promise<UploadDetailsRecord> {
@@ -1069,6 +1092,56 @@ export default class Collection implements ViewModels.Collection {
}); });
} }
public async bulkInsertDocuments(documents: JSONObject[]): Promise<{
numSucceeded: number;
numFailed: number;
numThrottled: number;
errors: string[];
}> {
const stats = {
numSucceeded: 0,
numFailed: 0,
numThrottled: 0,
errors: [] as string[],
};
const chunkSize = 100; // 100 is the max # of bulk operations the SDK currently accepts
const chunkedContent = Array.from({ length: Math.ceil(documents.length / chunkSize) }, (_, index) =>
documents.slice(index * chunkSize, index * chunkSize + chunkSize),
);
for (const chunk of chunkedContent) {
let retryAttempts = 0;
let chunkComplete = false;
let documentsToAttempt = chunk;
while (retryAttempts < 10 && !chunkComplete) {
const responses = await bulkCreateDocument(this, documentsToAttempt);
const attemptedDocuments = [...documentsToAttempt];
documentsToAttempt = [];
responses.forEach((response, index) => {
if (response.statusCode === 201) {
stats.numSucceeded++;
} else if (response.statusCode === 429) {
documentsToAttempt.push(attemptedDocuments[index]);
} else {
stats.numFailed++;
stats.errors.push(JSON.stringify(response.resourceBody));
}
});
if (documentsToAttempt.length === 0) {
chunkComplete = true;
break;
}
logConsoleInfo(
`${documentsToAttempt.length} document creations were throttled. Waiting ${retryAttempts} seconds and retrying throttled documents`,
);
retryAttempts++;
await sleep(retryAttempts);
}
}
return stats;
}
private async _createDocumentsFromFile(fileName: string, documentContent: string): Promise<UploadDetailsRecord> { private async _createDocumentsFromFile(fileName: string, documentContent: string): Promise<UploadDetailsRecord> {
const record: UploadDetailsRecord = { const record: UploadDetailsRecord = {
fileName: fileName, fileName: fileName,
@@ -1081,38 +1154,11 @@ export default class Collection implements ViewModels.Collection {
try { try {
const parsedContent = JSON.parse(documentContent); const parsedContent = JSON.parse(documentContent);
if (Array.isArray(parsedContent)) { if (Array.isArray(parsedContent)) {
const chunkSize = 100; // 100 is the max # of bulk operations the SDK currently accepts const { numSucceeded, numFailed, numThrottled, errors } = await this.bulkInsertDocuments(parsedContent);
const chunkedContent = Array.from({ length: Math.ceil(parsedContent.length / chunkSize) }, (_, index) => record.numSucceeded = numSucceeded;
parsedContent.slice(index * chunkSize, index * chunkSize + chunkSize), record.numFailed = numFailed;
); record.numThrottled = numThrottled;
for (const chunk of chunkedContent) { record.errors = errors;
let retryAttempts = 0;
let chunkComplete = false;
let documentsToAttempt = chunk;
while (retryAttempts < 10 && !chunkComplete) {
const responses = await bulkCreateDocument(this, documentsToAttempt);
const attemptedDocuments = [...documentsToAttempt];
documentsToAttempt = [];
responses.forEach((response, index) => {
if (response.statusCode === 201) {
record.numSucceeded++;
} else if (response.statusCode === 429) {
documentsToAttempt.push(attemptedDocuments[index]);
} else {
record.numFailed++;
}
});
if (documentsToAttempt.length === 0) {
chunkComplete = true;
break;
}
logConsoleInfo(
`${documentsToAttempt.length} document creations were throttled. Waiting ${retryAttempts} seconds and retrying throttled documents`,
);
retryAttempts++;
await sleep(retryAttempts);
}
}
} else { } else {
await createDocument(this, parsedContent); await createDocument(this, parsedContent);
record.numSucceeded++; record.numSucceeded++;

View File

@@ -19,7 +19,7 @@ import { logConsoleError } from "../../Utils/NotificationConsoleUtils";
import { useSidePanel } from "../../hooks/useSidePanel"; import { useSidePanel } from "../../hooks/useSidePanel";
import { useTabs } from "../../hooks/useTabs"; import { useTabs } from "../../hooks/useTabs";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { AddCollectionPanel } from "../Panes/AddCollectionPanel"; import { AddCollectionPanel } from "../Panes/AddCollectionPanel/AddCollectionPanel";
import { DatabaseSettingsTabV2 } from "../Tabs/SettingsTabV2"; import { DatabaseSettingsTabV2 } from "../Tabs/SettingsTabV2";
import { useDatabases } from "../useDatabases"; import { useDatabases } from "../useDatabases";
import { useSelectedNode } from "../useSelectedNode"; import { useSelectedNode } from "../useSelectedNode";

View File

@@ -30,7 +30,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -72,7 +72,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -145,7 +145,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -264,7 +264,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -369,7 +369,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -442,7 +442,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -546,7 +546,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -696,7 +696,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -740,12 +740,38 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
] ]
`; `;
exports[`createDatabaseTreeNodes generates the correct tree structure for the SQL API, on Fabric non read-only 1`] = ` exports[`createDatabaseTreeNodes generates the correct tree structure for the SQL API, on Fabric non read-only (native) 1`] = `
[ [
{ {
"children": [ "children": [
{ {
"children": undefined, "children": [
{
"contextMenu": [
{
"iconSrc": {},
"label": "New SQL Query",
"onClick": [Function],
},
{
"iconSrc": {},
"label": "Delete Container",
"onClick": [Function],
"styleClass": "deleteCollectionMenuItem",
},
],
"id": "",
"isSelected": [Function],
"label": "Items",
"onClick": [Function],
},
{
"id": "",
"isSelected": [Function],
"label": "Settings",
"onClick": [Function],
},
],
"className": "collectionNode", "className": "collectionNode",
"contextMenu": [ "contextMenu": [
{ {
@@ -760,7 +786,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -772,7 +798,38 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"onExpanded": [Function], "onExpanded": [Function],
}, },
{ {
"children": undefined, "children": [
{
"contextMenu": [
{
"iconSrc": {},
"label": "New SQL Query",
"onClick": [Function],
},
{
"iconSrc": {},
"label": "Delete Container",
"onClick": [Function],
"styleClass": "deleteCollectionMenuItem",
},
],
"id": "",
"isSelected": [Function],
"label": "Items",
"onClick": [Function],
},
{
"id": "",
"isSelected": [Function],
"label": "Settings",
"onClick": [Function],
},
{
"isSelected": [Function],
"label": "Conflicts",
"onClick": [Function],
},
],
"className": "collectionNode", "className": "collectionNode",
"contextMenu": [ "contextMenu": [
{ {
@@ -787,7 +844,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -806,12 +863,6 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"label": "New Container", "label": "New Container",
"onClick": [Function], "onClick": [Function],
}, },
{
"iconSrc": {},
"label": "Delete Database",
"onClick": [Function],
"styleClass": "deleteDatabaseMenuItem",
},
], ],
"iconSrc": <DatabaseRegular "iconSrc": <DatabaseRegular
fontSize={16} fontSize={16}
@@ -826,7 +877,33 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
{ {
"children": [ "children": [
{ {
"children": undefined, "children": [
{
"contextMenu": [
{
"iconSrc": {},
"label": "New SQL Query",
"onClick": [Function],
},
{
"iconSrc": {},
"label": "Delete Container",
"onClick": [Function],
"styleClass": "deleteCollectionMenuItem",
},
],
"id": "sampleItems",
"isSelected": [Function],
"label": "Items",
"onClick": [Function],
},
{
"id": "sampleSettings",
"isSelected": [Function],
"label": "Settings",
"onClick": [Function],
},
],
"className": "collectionNode", "className": "collectionNode",
"contextMenu": [ "contextMenu": [
{ {
@@ -841,7 +918,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -860,12 +937,6 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"label": "New Container", "label": "New Container",
"onClick": [Function], "onClick": [Function],
}, },
{
"iconSrc": {},
"label": "Delete Database",
"onClick": [Function],
"styleClass": "deleteDatabaseMenuItem",
},
], ],
"iconSrc": <DatabaseRegular "iconSrc": <DatabaseRegular
fontSize={16} fontSize={16}
@@ -880,7 +951,88 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
{ {
"children": [ "children": [
{ {
"children": undefined, "children": [
{
"contextMenu": [
{
"iconSrc": {},
"label": "New SQL Query",
"onClick": [Function],
},
{
"iconSrc": {},
"label": "Delete Container",
"onClick": [Function],
"styleClass": "deleteCollectionMenuItem",
},
],
"id": "",
"isSelected": [Function],
"label": "Items",
"onClick": [Function],
},
{
"id": "",
"isSelected": [Function],
"label": "Settings",
"onClick": [Function],
},
{
"children": [
{
"children": [
{
"children": [
{
"label": "string",
},
{
"label": "HasNulls: false",
},
],
"label": "street",
},
{
"children": [
{
"label": "string",
},
{
"label": "HasNulls: true",
},
],
"label": "line2",
},
{
"children": [
{
"label": "number",
},
{
"label": "HasNulls: false",
},
],
"label": "zip",
},
],
"label": "address",
},
{
"children": [
{
"label": "string",
},
{
"label": "HasNulls: false",
},
],
"label": "orderId",
},
],
"label": "Schema",
"onClick": [Function],
},
],
"className": "collectionNode", "className": "collectionNode",
"contextMenu": [ "contextMenu": [
{ {
@@ -895,7 +1047,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -919,12 +1071,6 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"label": "New Container", "label": "New Container",
"onClick": [Function], "onClick": [Function],
}, },
{
"iconSrc": {},
"label": "Delete Database",
"onClick": [Function],
"styleClass": "deleteDatabaseMenuItem",
},
], ],
"iconSrc": <DatabaseRegular "iconSrc": <DatabaseRegular
fontSize={16} fontSize={16}
@@ -939,7 +1085,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
] ]
`; `;
exports[`createDatabaseTreeNodes generates the correct tree structure for the SQL API, on Fabric read-only 1`] = ` exports[`createDatabaseTreeNodes generates the correct tree structure for the SQL API, on Fabric read-only (mirrored) 1`] = `
[ [
{ {
"children": [ "children": [
@@ -953,7 +1099,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"onClick": [Function], "onClick": [Function],
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -974,7 +1120,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"onClick": [Function], "onClick": [Function],
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -1010,7 +1156,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"onClick": [Function], "onClick": [Function],
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -1046,7 +1192,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"onClick": [Function], "onClick": [Function],
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -1208,7 +1354,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -1311,7 +1457,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -1445,7 +1591,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -1625,7 +1771,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -1799,7 +1945,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -1897,7 +2043,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -2031,7 +2177,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,
@@ -2211,7 +2357,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteCollectionMenuItem", "styleClass": "deleteCollectionMenuItem",
}, },
], ],
"iconSrc": <DocumentMultipleRegular "iconSrc": <EyeRegular
fontSize={16} fontSize={16}
/>, />,
"isExpanded": true, "isExpanded": true,

View File

@@ -82,6 +82,7 @@ jest.mock("Explorer/Tree/Trigger", () => {
jest.mock("Common/DatabaseAccountUtility", () => { jest.mock("Common/DatabaseAccountUtility", () => {
return { return {
isPublicInternetAccessAllowed: () => true, isPublicInternetAccessAllowed: () => true,
isGlobalSecondaryIndexEnabled: () => false,
}; };
}); });
@@ -134,6 +135,15 @@ const baseCollection = {
kind: "hash", kind: "hash",
version: 2, version: 2,
}, },
materializedViews: ko.observable<DataModels.MaterializedView[]>([
{ id: "view1", _rid: "rid1" },
{ id: "view2", _rid: "rid2" },
]),
materializedViewDefinition: ko.observable<DataModels.MaterializedViewDefinition>({
definition: "SELECT * FROM c WHERE c.id = 1",
sourceCollectionId: "source1",
sourceCollectionRid: "rid123",
}),
storedProcedures: ko.observableArray([]), storedProcedures: ko.observableArray([]),
userDefinedFunctions: ko.observableArray([]), userDefinedFunctions: ko.observableArray([]),
triggers: ko.observableArray([]), triggers: ko.observableArray([]),
@@ -363,18 +373,28 @@ describe("createDatabaseTreeNodes", () => {
it.each<[string, Platform, boolean, Partial<DataModels.DatabaseAccountExtendedProperties>, Partial<UserContext>]>([ it.each<[string, Platform, boolean, Partial<DataModels.DatabaseAccountExtendedProperties>, Partial<UserContext>]>([
[ [
"the SQL API, on Fabric read-only", "the SQL API, on Fabric read-only (mirrored)",
Platform.Fabric, Platform.Fabric,
false, false,
{ capabilities: [], enableMultipleWriteLocations: true }, { capabilities: [], enableMultipleWriteLocations: true },
{ fabricContext: { isReadOnly: true } as FabricContext<CosmosDbArtifactType> }, {
fabricContext: {
isReadOnly: true,
artifactType: CosmosDbArtifactType.MIRRORED_KEY,
} as FabricContext<CosmosDbArtifactType>,
},
], ],
[ [
"the SQL API, on Fabric non read-only", "the SQL API, on Fabric non read-only (native)",
Platform.Fabric, Platform.Fabric,
false, false,
{ capabilities: [], enableMultipleWriteLocations: true }, { capabilities: [], enableMultipleWriteLocations: true },
{ fabricContext: { isReadOnly: false } as FabricContext<CosmosDbArtifactType> }, {
fabricContext: {
isReadOnly: false,
artifactType: CosmosDbArtifactType.NATIVE,
} as FabricContext<CosmosDbArtifactType>,
},
], ],
[ [
"the SQL API, on Portal", "the SQL API, on Portal",

View File

@@ -1,4 +1,4 @@
import { DatabaseRegular, DocumentMultipleRegular, SettingsRegular } from "@fluentui/react-icons"; import { DatabaseRegular, DocumentMultipleRegular, EyeRegular, SettingsRegular } from "@fluentui/react-icons";
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity"; import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity";
import TabsBase from "Explorer/Tabs/TabsBase"; import TabsBase from "Explorer/Tabs/TabsBase";
@@ -6,7 +6,7 @@ import StoredProcedure from "Explorer/Tree/StoredProcedure";
import Trigger from "Explorer/Tree/Trigger"; import Trigger from "Explorer/Tree/Trigger";
import UserDefinedFunction from "Explorer/Tree/UserDefinedFunction"; import UserDefinedFunction from "Explorer/Tree/UserDefinedFunction";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import { isFabricMirrored } from "Platform/Fabric/FabricUtil"; import { isFabric, isFabricMirrored, isFabricNative } from "Platform/Fabric/FabricUtil";
import { getItemName } from "Utils/APITypeUtils"; import { getItemName } from "Utils/APITypeUtils";
import { isServerlessAccount } from "Utils/CapabilityUtils"; import { isServerlessAccount } from "Utils/CapabilityUtils";
import { useTabs } from "hooks/useTabs"; import { useTabs } from "hooks/useTabs";
@@ -23,12 +23,13 @@ import { useNotebook } from "../Notebook/useNotebook";
import { useSelectedNode } from "../useSelectedNode"; import { useSelectedNode } from "../useSelectedNode";
export const shouldShowScriptNodes = (): boolean => { export const shouldShowScriptNodes = (): boolean => {
return !isFabricMirrored() && (userContext.apiType === "SQL" || userContext.apiType === "Gremlin"); return !isFabric() && (userContext.apiType === "SQL" || userContext.apiType === "Gremlin");
}; };
const TreeDatabaseIcon = <DatabaseRegular fontSize={16} />; const TreeDatabaseIcon = <DatabaseRegular fontSize={16} />;
const TreeSettingsIcon = <SettingsRegular fontSize={16} />; const TreeSettingsIcon = <SettingsRegular fontSize={16} />;
const TreeCollectionIcon = <DocumentMultipleRegular fontSize={16} />; const TreeCollectionIcon = <DocumentMultipleRegular fontSize={16} />;
const GlobalSecondaryIndexCollectionIcon = <EyeRegular fontSize={16} />; //check icon
export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: ViewModels.CollectionBase): TreeNode[] => { export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: ViewModels.CollectionBase): TreeNode[] => {
const updatedSampleTree: TreeNode = { const updatedSampleTree: TreeNode = {
@@ -219,7 +220,7 @@ export const buildCollectionNode = (
): TreeNode => { ): TreeNode => {
let children: TreeNode[]; let children: TreeNode[];
// Flat Tree for Fabric // Flat Tree for Fabric
if (configContext.platform !== Platform.Fabric) { if (!isFabricMirrored()) {
children = buildCollectionNodeChildren(database, collection, isNotebookEnabled, container, refreshActiveTab); children = buildCollectionNodeChildren(database, collection, isNotebookEnabled, container, refreshActiveTab);
} }
@@ -228,7 +229,7 @@ export const buildCollectionNode = (
children: children, children: children,
className: "collectionNode", className: "collectionNode",
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection), contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection),
iconSrc: TreeCollectionIcon, iconSrc: collection.materializedViewDefinition() ? GlobalSecondaryIndexCollectionIcon : TreeCollectionIcon,
onClick: () => { onClick: () => {
useSelectedNode.getState().setSelectedNode(collection); useSelectedNode.getState().setSelectedNode(collection);
collection.openTab(); collection.openTab();
@@ -317,7 +318,7 @@ const buildCollectionNodeChildren = (
children.push({ children.push({
id, id,
label: database.isDatabaseShared() || isServerlessAccount() ? "Settings" : "Scale & Settings", label: database.isDatabaseShared() || isServerlessAccount() || isFabricNative() ? "Settings" : "Scale & Settings",
onClick: collection.onSettingsClick.bind(collection), onClick: collection.onSettingsClick.bind(collection),
isSelected: () => isSelected: () =>
useSelectedNode useSelectedNode

View File

@@ -1,11 +1,11 @@
{ {
"MaterializedViewsBuilderDescription": "Provision a Materializedviews builder cluster for your Azure Cosmos DB account. Materializedviews builder is compute in your account that performs read operations on source collection for any updates and applies them on materialized views as per the materializedview definition.", "MaterializedViewsBuilderDescription": "Provision a materialized views builder for your Azure Cosmos DB account. Materialized views builder is compute in your account that performs read operations on source collection for any updates and applies them on materialized views as per the materialized view definition.",
"MaterializedViewsBuilder": "Materializedviews Builder", "MaterializedViewsBuilder": "Materialized views builder",
"Provisioned": "Provisioned", "Provisioned": "Provisioned",
"Deprovisioned": "Deprovisioned", "Deprovisioned": "Deprovisioned",
"LearnAboutMaterializedViews": "Learn more about materializedviews.", "LearnAboutMaterializedViews": "Learn more about materialized views.",
"DeprovisioningDetailsText": "Learn more about materializedviews.", "DeprovisioningDetailsText": "Learn more about materialized views.",
"MaterializedviewsBuilderPricing": "Learn more about materializedviews pricing.", "MaterializedviewsBuilderPricing": "Learn more about materialized views pricing.",
"SKUs": "SKUs", "SKUs": "SKUs",
"SKUsPlaceHolder": "Select SKUs", "SKUsPlaceHolder": "Select SKUs",
"NumberOfInstances": "Number of instances", "NumberOfInstances": "Number of instances",
@@ -14,35 +14,58 @@
"CosmosD8s": "Cosmos.D8s (General Purpose Cosmos Compute with 8 vCPUs, 32 GB Memory)", "CosmosD8s": "Cosmos.D8s (General Purpose Cosmos Compute with 8 vCPUs, 32 GB Memory)",
"CosmosD16s": "Cosmos.D16s (General Purpose Cosmos Compute with 16 vCPUs, 64 GB Memory)", "CosmosD16s": "Cosmos.D16s (General Purpose Cosmos Compute with 16 vCPUs, 64 GB Memory)",
"CosmosD32s": "Cosmos.D32s (General Purpose Cosmos Compute with 32 vCPUs, 128 GB Memory)", "CosmosD32s": "Cosmos.D32s (General Purpose Cosmos Compute with 32 vCPUs, 128 GB Memory)",
"CreateMessage": "MaterializedViewsBuilder resource is being created.", "CreateMessage": "Materialized views builder resource is being created.",
"CreateInitializeTitle": "Provisioning resource", "CreateInitializeTitle": "Provisioning resource",
"CreateInitializeMessage": "Materializedviews Builder resource will be provisioned.", "CreateInitializeMessage": "Materialized views builder resource will be provisioned.",
"CreateSuccessTitle": "Resource provisioned", "CreateSuccessTitle": "Resource provisioned",
"CreateSuccesseMessage": "Materializedviews Builder resource provisioned.", "CreateSuccesseMessage": "Materialized views builder resource provisioned.",
"CreateFailureTitle": "Failed to provision resource", "CreateFailureTitle": "Failed to provision resource",
"CreateFailureMessage": "Materializedviews Builder resource provisioning failed.", "CreateFailureMessage": "Materialized views builder resource provisioning failed.",
"UpdateMessage": "MaterializedViewsBuilder resource is being updated.", "UpdateMessage": "Materialized views builder resource is being updated.",
"UpdateInitializeTitle": "Updating resource", "UpdateInitializeTitle": "Updating resource",
"UpdateInitializeMessage": "Materializedviews Builder resource will be updated.", "UpdateInitializeMessage": "Materialized views builder resource will be updated.",
"UpdateSuccessTitle": "Resource updated", "UpdateSuccessTitle": "Resource updated",
"UpdateSuccesseMessage": "Materializedviews Builder resource updated.", "UpdateSuccesseMessage": "Materialized views builder resource updated.",
"UpdateFailureTitle": "Failed to update resource", "UpdateFailureTitle": "Failed to update resource",
"UpdateFailureMessage": "Materializedviews Builder resource updation failed.", "UpdateFailureMessage": "Materialized views builder resource update failed.",
"DeleteMessage": "MaterializedViewsBuilder resource is being deleted.", "DeleteMessage": "Materialized views builder resource is being deleted.",
"DeleteInitializeTitle": "Deleting resource", "DeleteInitializeTitle": "Deleting resource",
"DeleteInitializeMessage": "Materializedviews Builder resource will be deleted.", "DeleteInitializeMessage": "Materialized views builder resource will be deleted.",
"DeleteSuccessTitle": "Resource deleted", "DeleteSuccessTitle": "Resource deleted",
"DeleteSuccesseMessage": "Materializedviews Builder resource deleted.", "DeleteSuccesseMessage": "Materialized views builder resource deleted.",
"DeleteFailureTitle": "Failed to delete resource", "DeleteFailureTitle": "Failed to delete resource",
"DeleteFailureMessage": "Materializedviews Builder resource deletion failed.", "DeleteFailureMessage": "Materialized views builder resource deletion failed.",
"ApproximateCost": "Approximate Cost Per Hour", "ApproximateCost": "Approximate Cost Per Hour",
"CostText": "Hourly cost of the Materializedviews Builder resource depends on the SKU selection, number of instances per region, and number of regions.", "CostText": "Hourly cost of the materialized views builder resource depends on the SKU selection, number of instances per region, and number of regions.",
"MetricsString": "Metrics", "MetricsString": "Metrics",
"MetricsText": "Monitor the CPU and memory usage for the Materializedviews Builder instances in ", "MetricsText": "Monitor the CPU and memory usage for the materialized views builder instances in ",
"MetricsBlade": "the metrics blade.", "MetricsBlade": "the metrics blade.",
"MonitorUsage": "Monitor Usage", "MonitorUsage": "Monitor Usage",
"ResizingDecisionText": "To understand if the Materializedviews Builder is the right size, ", "ResizingDecisionText": "To understand if the materialized views builder is the right size, ",
"ResizingDecisionLink": "learn more about Materializedviews Builder sizing.", "ResizingDecisionLink": "learn more about materialized views builder sizing.",
"WarningBannerOnUpdate": "Adding or modifying Materializedviews Builder instances may affect your bill.", "WarningBannerOnUpdate": "Adding or modifying materialized views builder instances may affect your bill.",
"WarningBannerOnDelete": "After deprovisioning the Materializedviews Builder, your materializedviews will not be updated with new source changes anymore. Materializedviews builder is compute in your account that performs read operations on source collection for any updates and applies them on materialized views as per the materializedview definition." "WarningBannerOnDelete": "After deprovisioning the materialized views builder, your materialized views will not be updated with new source changes anymore. Materialized views builder is compute in your account that performs read operations on source containers for any updates and applies them on materialized views as per the materialized view definition.",
"GlobalsecondaryindexesBuilderDescription": "Provision a global secondary indexes builder for your Azure Cosmos DB account. The global secondary indexes builder is compute in your account that performs read operations on source collections for any updates and populates the global secondary indexes as per their definition.",
"GlobalsecondaryindexesBuilder": "Global secondary indexes builder",
"LearnAboutGlobalSecondaryIndexes": "Learn more about global secondary indexes.",
"GlobalsecondaryindexesDeprovisioningDetailsText": "Learn more about global secondary indexes.",
"GlobalsecondaryindexesBuilderPricing": "Learn more about global secondary indexes pricing.",
"GlobalsecondaryindexesCreateMessage": "Global secondary indexes builder resource is being created.",
"GlobalsecondaryindexesCreateInitializeMessage": "Global secondary indexes builder resource will be provisioned.",
"GlobalsecondaryindexesCreateSuccesseMessage": "Global secondary indexes builder resource provisioned.",
"GlobalsecondaryindexesCreateFailureMessage": "Global secondary indexes builder resource provisioning failed.",
"GlobalsecondaryindexesUpdateMessage": "Global secondary indexes builder resource is being updated.",
"GlobalsecondaryindexesUpdateInitializeMessage": "Global secondary indexes builder resource will be updated.",
"GlobalsecondaryindexesUpdateSuccesseMessage": "Global secondary indexes builder resource updated.",
"GlobalsecondaryindexesUpdateFailureMessage": "Global secondary indexes builder resource update failed.",
"GlobalsecondaryindexesDeleteMessage": "Global secondary indexes builder resource is being deleted.",
"GlobalsecondaryindexesDeleteInitializeMessage": "Global secondary indexes builder resource will be deleted.",
"GlobalsecondaryindexesDeleteSuccesseMessage": "Global secondary indexes builder resource deleted.",
"GlobalsecondaryindexesDeleteFailureMessage": "Global secondary indexes builder resource deletion failed.",
"GlobalsecondaryindexesCostText": "Hourly cost of the global secondary indexes builder resource depends on the SKU selection, number of instances per region, and number of regions.",
"GlobalsecondaryindexesMetricsText": "Monitor the CPU and memory usage for the global secondary indexes builder instances in ",
"GlobalsecondaryindexesResizingDecisionText": "To understand if the global secondary indexes builder is the right size, ",
"GlobalsecondaryindexesesizingDecisionLink": "learn more about global secondary indexes builder sizing.",
"GlobalsecondaryindexesWarningBannerOnUpdate": "Adding or modifying global secondary indexes builder instances may affect your bill.",
"GlobalsecondaryindexesWarningBannerOnDelete": "After deprovisioning the global secondary indexes builder, your global secondary indexes will no longer be updated with new source changes. Global secondary indexes builder is compute in your account that performs read operations on source containers for any updates and applies them on global secondary indexes as per their definition."
} }

View File

@@ -4,7 +4,7 @@ import { Action } from "Shared/Telemetry/TelemetryConstants";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import { allowedJunoOrigins, validateEndpoint } from "Utils/EndpointUtils"; import { allowedJunoOrigins, validateEndpoint } from "Utils/EndpointUtils";
import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useQueryCopilot } from "hooks/useQueryCopilot";
import promiseRetry, { AbortError } from "p-retry"; import promiseRetry, { AbortError, Options } from "p-retry";
import { import {
Areas, Areas,
ConnectionStatusType, ConnectionStatusType,
@@ -35,21 +35,26 @@ import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
export class PhoenixClient { export class PhoenixClient {
private armResourceId: string; private armResourceId: string;
private containerHealthHandler: NodeJS.Timeout; private containerHealthHandler: NodeJS.Timeout;
private retryOptions: promiseRetry.Options = { private retryOptions: Options = {
retries: Notebook.retryAttempts, retries: Notebook.retryAttempts,
maxTimeout: Notebook.retryAttemptDelayMs, maxTimeout: Notebook.retryAttemptDelayMs,
minTimeout: Notebook.retryAttemptDelayMs, minTimeout: Notebook.retryAttemptDelayMs,
}; };
private abortController: AbortController;
private abortSignal: AbortSignal;
constructor(armResourceId: string) { constructor(armResourceId: string) {
this.armResourceId = armResourceId; this.armResourceId = armResourceId;
} }
public async allocateContainer(provisionData: IProvisionData): Promise<IResponse<IPhoenixServiceInfo>> { public async allocateContainer(provisionData: IProvisionData): Promise<IResponse<IPhoenixServiceInfo>> {
this.initializeCancelEventListener();
return promiseRetry(() => this.executeContainerAssignmentOperation(provisionData, "allocate"), { return promiseRetry(() => this.executeContainerAssignmentOperation(provisionData, "allocate"), {
retries: 4, retries: 4,
maxTimeout: 20000, maxTimeout: 20000,
minTimeout: 20000, minTimeout: 20000,
signal: this.abortSignal,
}); });
} }
@@ -270,6 +275,17 @@ export class PhoenixClient {
}; };
} }
private initializeCancelEventListener(): void {
this.abortController = new AbortController();
this.abortSignal = this.abortController.signal;
document.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.ctrlKey && (event.key === "c" || event.key === "z")) {
this.abortController.abort(new AbortError("Request canceled"));
}
});
}
public ConvertToForbiddenErrorString(jsonData: IPhoenixError): string { public ConvertToForbiddenErrorString(jsonData: IPhoenixError): string {
const errInfo = jsonData; const errInfo = jsonData;
switch (errInfo?.type) { switch (errInfo?.type) {

View File

@@ -6,9 +6,9 @@ import { RefreshResult } from "../SelfServeTypes";
import MaterializedViewsBuilder from "./MaterializedViewsBuilder"; import MaterializedViewsBuilder from "./MaterializedViewsBuilder";
import { import {
FetchPricesResponse, FetchPricesResponse,
MaterializedViewsBuilderServiceResource,
PriceMapAndCurrencyCode, PriceMapAndCurrencyCode,
RegionsResponse, RegionsResponse,
MaterializedViewsBuilderServiceResource,
UpdateMaterializedViewsBuilderRequestParameters, UpdateMaterializedViewsBuilderRequestParameters,
} from "./MaterializedViewsBuilderTypes"; } from "./MaterializedViewsBuilderTypes";
@@ -123,11 +123,23 @@ export const refreshMaterializedViewsBuilderProvisioning = async (): Promise<Ref
if (response.properties.status === ResourceStatus.Running.toString()) { if (response.properties.status === ResourceStatus.Running.toString()) {
return { isUpdateInProgress: false, updateInProgressMessageTKey: undefined }; return { isUpdateInProgress: false, updateInProgressMessageTKey: undefined };
} else if (response.properties.status === ResourceStatus.Creating.toString()) { } else if (response.properties.status === ResourceStatus.Creating.toString()) {
return { isUpdateInProgress: true, updateInProgressMessageTKey: "CreateMessage" }; return {
isUpdateInProgress: true,
updateInProgressMessageTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesCreateMessage" : "CreateMessage",
};
} else if (response.properties.status === ResourceStatus.Deleting.toString()) { } else if (response.properties.status === ResourceStatus.Deleting.toString()) {
return { isUpdateInProgress: true, updateInProgressMessageTKey: "DeleteMessage" }; return {
isUpdateInProgress: true,
updateInProgressMessageTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesDeleteMessage" : "DeleteMessage",
};
} else { } else {
return { isUpdateInProgress: true, updateInProgressMessageTKey: "UpdateMessage" }; return {
isUpdateInProgress: true,
updateInProgressMessageTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesUpdateMessage" : "UpdateMessage",
};
} }
} catch { } catch {
//TODO differentiate between different failures //TODO differentiate between different failures

View File

@@ -29,17 +29,20 @@ import {
updateMaterializedViewsBuilderResource, updateMaterializedViewsBuilderResource,
} from "./MaterializedViewsBuilder.rp"; } from "./MaterializedViewsBuilder.rp";
import { userContext } from "../../UserContext";
const costPerHourDefaultValue: Description = { const costPerHourDefaultValue: Description = {
textTKey: "CostText", textTKey: userContext.apiType === "SQL" ? "GlobalsecondaryindexesCostText" : "CostText",
type: DescriptionType.Text, type: DescriptionType.Text,
link: { link: {
href: "https://aka.ms/cosmos-db-materializedviewsbuilder-pricing", href: "https://aka.ms/cosmos-db-materializedviewsbuilder-pricing",
textTKey: "MaterializedviewsBuilderPricing", textTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesBuilderPricing" : "MaterializedviewsBuilderPricing",
}, },
}; };
const metricsStringValue: Description = { const metricsStringValue: Description = {
textTKey: "MetricsText", textTKey: userContext.apiType === "SQL" ? "GlobalsecondaryindexesMetricsText" : "MetricsText",
type: DescriptionType.Text, type: DescriptionType.Text,
link: { link: {
href: generateBladeLink(BladeType.Metrics), href: generateBladeLink(BladeType.Metrics),
@@ -76,7 +79,8 @@ const onNumberOfInstancesChange = (
textTKey: "WarningBannerOnUpdate", textTKey: "WarningBannerOnUpdate",
link: { link: {
href: "https://aka.ms/cosmos-db-materializedviewsbuilder-pricing", href: "https://aka.ms/cosmos-db-materializedviewsbuilder-pricing",
textTKey: "MaterializedviewsBuilderPricing", textTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesBuilderPricing" : "MaterializedviewsBuilderPricing",
}, },
} as Description, } as Description,
hidden: false, hidden: false,
@@ -116,7 +120,8 @@ const onEnableMaterializedViewsBuilderChange = (
textTKey: "WarningBannerOnUpdate", textTKey: "WarningBannerOnUpdate",
link: { link: {
href: "https://aka.ms/cosmos-db-materializedviewsbuilder-pricing", href: "https://aka.ms/cosmos-db-materializedviewsbuilder-pricing",
textTKey: "MaterializedviewsBuilderPricing", textTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesBuilderPricing" : "MaterializedviewsBuilderPricing",
}, },
} as Description, } as Description,
hidden: false, hidden: false,
@@ -129,10 +134,17 @@ const onEnableMaterializedViewsBuilderChange = (
} else { } else {
currentValues.set("warningBanner", { currentValues.set("warningBanner", {
value: { value: {
textTKey: "WarningBannerOnDelete", textTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesWarningBannerOnDelete" : "WarningBannerOnDelete",
link: { link: {
href: "https://aka.ms/cosmos-db-materializedviews", href:
textTKey: "DeprovisioningDetailsText", userContext.apiType === "SQL"
? "https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views"
: "https://learn.microsoft.com/en-us/azure/cosmos-db/cassandra/materialized-views",
textTKey:
userContext.apiType === "SQL"
? "GlobalsecondaryindexesDeprovisioningDetailsText"
: "DeprovisioningDetailsText",
}, },
} as Description, } as Description,
hidden: false, hidden: false,
@@ -182,18 +194,19 @@ const getInstancesMax = async (): Promise<number> => {
}; };
const NumberOfInstancesDropdownInfo: Info = { const NumberOfInstancesDropdownInfo: Info = {
messageTKey: "ResizingDecisionText", messageTKey: userContext.apiType === "SQL" ? "GlobalsecondaryindexesResizingDecisionText" : "ResizingDecisionText",
link: { link: {
href: "https://aka.ms/cosmos-db-materializedviewsbuilder-size", href: "https://aka.ms/cosmos-db-materializedviewsbuilder-size",
textTKey: "ResizingDecisionLink", textTKey: userContext.apiType === "SQL" ? "GlobalsecondaryindexesesizingDecisionLink" : "ResizingDecisionLink",
}, },
}; };
const ApproximateCostDropDownInfo: Info = { const ApproximateCostDropDownInfo: Info = {
messageTKey: "CostText", messageTKey: userContext.apiType === "SQL" ? "GlobalsecondaryindexesCostText" : "CostText",
link: { link: {
href: "https://aka.ms/cosmos-db-materializedviewsbuilder-pricing", href: "https://aka.ms/cosmos-db-materializedviewsbuilder-pricing",
textTKey: "MaterializedviewsBuilderPricing", textTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesBuilderPricing" : "MaterializedviewsBuilderPricing",
}, },
}; };
@@ -268,15 +281,20 @@ export default class MaterializedViewsBuilder extends SelfServeBaseClass {
portalNotification: { portalNotification: {
initialize: { initialize: {
titleTKey: "DeleteInitializeTitle", titleTKey: "DeleteInitializeTitle",
messageTKey: "DeleteInitializeMessage", messageTKey:
userContext.apiType === "SQL"
? "GlobalsecondaryindexesDeleteInitializeMessage"
: "DeleteInitializeMessage",
}, },
success: { success: {
titleTKey: "DeleteSuccessTitle", titleTKey: "DeleteSuccessTitle",
messageTKey: "DeleteSuccesseMessage", messageTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesDeleteSuccesseMessage" : "DeleteSuccesseMessage",
}, },
failure: { failure: {
titleTKey: "DeleteFailureTitle", titleTKey: "DeleteFailureTitle",
messageTKey: "DeleteFailureMessage", messageTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesDeleteFailureMessage" : "DeleteFailureMessage",
}, },
}, },
}; };
@@ -289,15 +307,20 @@ export default class MaterializedViewsBuilder extends SelfServeBaseClass {
portalNotification: { portalNotification: {
initialize: { initialize: {
titleTKey: "UpdateInitializeTitle", titleTKey: "UpdateInitializeTitle",
messageTKey: "UpdateInitializeMessage", messageTKey:
userContext.apiType === "SQL"
? "GlobalsecondaryindexesUpdateInitializeMessage"
: "UpdateInitializeMessage",
}, },
success: { success: {
titleTKey: "UpdateSuccessTitle", titleTKey: "UpdateSuccessTitle",
messageTKey: "UpdateSuccesseMessage", messageTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesUpdateSuccesseMessage" : "UpdateSuccesseMessage",
}, },
failure: { failure: {
titleTKey: "UpdateFailureTitle", titleTKey: "UpdateFailureTitle",
messageTKey: "UpdateFailureMessage", messageTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesUpdateFailureMessage" : "UpdateFailureMessage",
}, },
}, },
}; };
@@ -311,15 +334,20 @@ export default class MaterializedViewsBuilder extends SelfServeBaseClass {
portalNotification: { portalNotification: {
initialize: { initialize: {
titleTKey: "CreateInitializeTitle", titleTKey: "CreateInitializeTitle",
messageTKey: "CreateInitializeMessage", messageTKey:
userContext.apiType === "SQL"
? "GlobalsecondaryindexesCreateInitializeMessage"
: "CreateInitializeMessage",
}, },
success: { success: {
titleTKey: "CreateSuccessTitle", titleTKey: "CreateSuccessTitle",
messageTKey: "CreateSuccesseMessage", messageTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesCreateSuccesseMessage" : "CreateSuccesseMessage",
}, },
failure: { failure: {
titleTKey: "CreateFailureTitle", titleTKey: "CreateFailureTitle",
messageTKey: "CreateFailureMessage", messageTKey:
userContext.apiType === "SQL" ? "GlobalsecondaryindexesCreateFailureMessage" : "CreateFailureMessage",
}, },
}, },
}; };
@@ -366,11 +394,17 @@ export default class MaterializedViewsBuilder extends SelfServeBaseClass {
@Values({ @Values({
description: { description: {
textTKey: "MaterializedViewsBuilderDescription", textTKey:
userContext.apiType === "SQL"
? "GlobalsecondaryindexesBuilderDescription"
: "MaterializedViewsBuilderDescription",
type: DescriptionType.Text, type: DescriptionType.Text,
link: { link: {
href: "https://aka.ms/cosmos-db-materializedviews", href:
textTKey: "LearnAboutMaterializedViews", userContext.apiType === "SQL"
? "https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views"
: "https://learn.microsoft.com/en-us/azure/cosmos-db/cassandra/materialized-views",
textTKey: userContext.apiType === "SQL" ? "LearnAboutGlobalSecondaryIndexes" : "LearnAboutMaterializedViews",
}, },
}, },
}) })
@@ -378,7 +412,7 @@ export default class MaterializedViewsBuilder extends SelfServeBaseClass {
@OnChange(onEnableMaterializedViewsBuilderChange) @OnChange(onEnableMaterializedViewsBuilderChange)
@Values({ @Values({
labelTKey: "MaterializedViewsBuilder", labelTKey: userContext.apiType === "SQL" ? "GlobalSecondaryIndexesBuilder" : "MaterializedViewsBuilder",
trueLabelTKey: "Provisioned", trueLabelTKey: "Provisioned",
falseLabelTKey: "Deprovisioned", falseLabelTKey: "Deprovisioned",
}) })

View File

@@ -11,13 +11,24 @@ import { updateUserContext } from "../UserContext";
import { isInvalidParentFrameOrigin } from "../Utils/MessageValidation"; import { isInvalidParentFrameOrigin } from "../Utils/MessageValidation";
import "./SelfServe.less"; import "./SelfServe.less";
import { SelfServeComponent } from "./SelfServeComponent"; import { SelfServeComponent } from "./SelfServeComponent";
import { SelfServeDescriptor } from "./SelfServeTypes"; import { SelfServeBaseClass, SelfServeDescriptor } from "./SelfServeTypes";
import { SelfServeType } from "./SelfServeUtils"; import { SelfServeType } from "./SelfServeUtils";
initializeIcons(); initializeIcons();
const loadTranslationFile = async (className: string): Promise<void> => { const loadTranslationFile = async (
className: string | SelfServeBaseClass,
selfServeType?: SelfServeType,
): Promise<void> => {
const language = i18n.languages[0]; const language = i18n.languages[0];
const fileName = `${className}.json`; let namespace: string; // className is used as a key to retrieve the localized strings
let fileName: string;
if (className instanceof SelfServeBaseClass) {
fileName = `${selfServeType}.json`;
namespace = className.constructor.name;
} else {
fileName = `${className}.json`;
namespace = className;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let translations: any; let translations: any;
@@ -28,12 +39,16 @@ const loadTranslationFile = async (className: string): Promise<void> => {
} catch (e) { } catch (e) {
translations = await import(/* webpackChunkName: "Localization-en-[request]" */ `../Localization/en/${fileName}`); translations = await import(/* webpackChunkName: "Localization-en-[request]" */ `../Localization/en/${fileName}`);
} }
i18n.addResourceBundle(language, className, translations.default, true);
i18n.addResourceBundle(language, namespace, translations.default, true);
}; };
const loadTranslations = async (className: string): Promise<void> => { const loadTranslations = async (
className: string | SelfServeBaseClass,
selfServeType: SelfServeType,
): Promise<void> => {
await loadTranslationFile("Common"); await loadTranslationFile("Common");
await loadTranslationFile(className); await loadTranslationFile(className, selfServeType);
}; };
const getDescriptor = async (selfServeType: SelfServeType): Promise<SelfServeDescriptor> => { const getDescriptor = async (selfServeType: SelfServeType): Promise<SelfServeDescriptor> => {
@@ -41,13 +56,13 @@ const getDescriptor = async (selfServeType: SelfServeType): Promise<SelfServeDes
case SelfServeType.example: { case SelfServeType.example: {
const SelfServeExample = await import(/* webpackChunkName: "SelfServeExample" */ "./Example/SelfServeExample"); const SelfServeExample = await import(/* webpackChunkName: "SelfServeExample" */ "./Example/SelfServeExample");
const selfServeExample = new SelfServeExample.default(); const selfServeExample = new SelfServeExample.default();
await loadTranslations(selfServeType); await loadTranslations(selfServeExample, selfServeType);
return selfServeExample.toSelfServeDescriptor(); return selfServeExample.toSelfServeDescriptor();
} }
case SelfServeType.sqlx: { case SelfServeType.sqlx: {
const SqlX = await import(/* webpackChunkName: "SqlX" */ "./SqlX/SqlX"); const SqlX = await import(/* webpackChunkName: "SqlX" */ "./SqlX/SqlX");
const sqlX = new SqlX.default(); const sqlX = new SqlX.default();
await loadTranslations(selfServeType); await loadTranslations(sqlX, selfServeType);
return sqlX.toSelfServeDescriptor(); return sqlX.toSelfServeDescriptor();
} }
case SelfServeType.graphapicompute: { case SelfServeType.graphapicompute: {
@@ -55,7 +70,7 @@ const getDescriptor = async (selfServeType: SelfServeType): Promise<SelfServeDes
/* webpackChunkName: "GraphAPICompute" */ "./GraphAPICompute/GraphAPICompute" /* webpackChunkName: "GraphAPICompute" */ "./GraphAPICompute/GraphAPICompute"
); );
const graphAPICompute = new GraphAPICompute.default(); const graphAPICompute = new GraphAPICompute.default();
await loadTranslations(selfServeType); await loadTranslations(graphAPICompute, selfServeType);
return graphAPICompute.toSelfServeDescriptor(); return graphAPICompute.toSelfServeDescriptor();
} }
case SelfServeType.materializedviewsbuilder: { case SelfServeType.materializedviewsbuilder: {
@@ -63,7 +78,7 @@ const getDescriptor = async (selfServeType: SelfServeType): Promise<SelfServeDes
/* webpackChunkName: "MaterializedViewsBuilder" */ "./MaterializedViewsBuilder/MaterializedViewsBuilder" /* webpackChunkName: "MaterializedViewsBuilder" */ "./MaterializedViewsBuilder/MaterializedViewsBuilder"
); );
const materializedViewsBuilder = new MaterializedViewsBuilder.default(); const materializedViewsBuilder = new MaterializedViewsBuilder.default();
await loadTranslations(selfServeType); await loadTranslations(materializedViewsBuilder, selfServeType);
return materializedViewsBuilder.toSelfServeDescriptor(); return materializedViewsBuilder.toSelfServeDescriptor();
} }
default: default:

View File

@@ -10,7 +10,7 @@ import {
Text, Text,
} from "@fluentui/react"; } from "@fluentui/react";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import promiseRetry, { AbortError } from "p-retry"; import promiseRetry, { AbortError, Options } from "p-retry";
import React from "react"; import React from "react";
import { WithTranslation } from "react-i18next"; import { WithTranslation } from "react-i18next";
import * as _ from "underscore"; import * as _ from "underscore";
@@ -80,7 +80,7 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
private static readonly defaultRetryIntervalInMs = 30000; private static readonly defaultRetryIntervalInMs = 30000;
private smartUiGeneratorClassName: string; private smartUiGeneratorClassName: string;
private retryIntervalInMs: number; private retryIntervalInMs: number;
private retryOptions: promiseRetry.Options; private retryOptions: Options;
private translationFunction: TFunction; private translationFunction: TFunction;
componentDidMount(): void { componentDidMount(): void {

View File

@@ -197,6 +197,11 @@ export const getPriceMapAndCurrencyCode = async (map: OfferingIdMap): Promise<Pr
const priceMap = new Map<string, Map<string, number>>(); const priceMap = new Map<string, Map<string, number>>();
let billingCurrency; let billingCurrency;
for (const region of map.keys()) { for (const region of map.keys()) {
// if no offering id is found for that region, skipping calling price API
const subMap = map.get(region);
if (!subMap || subMap.size === 0) {
continue;
}
const regionPriceMap = new Map<string, number>(); const regionPriceMap = new Map<string, number>();
const regionShortName = await getRegionShortName(region); const regionShortName = await getRegionShortName(region);
const requestBody: OfferingIdRequest = { const requestBody: OfferingIdRequest = {
@@ -237,7 +242,7 @@ export const getPriceMapAndCurrencyCode = async (map: OfferingIdMap): Promise<Pr
} catch (err) { } catch (err) {
const failureTelemetry = { err, selfServeClassName: SqlX.name }; const failureTelemetry = { err, selfServeClassName: SqlX.name };
selfServeTraceFailure(failureTelemetry, getPriceMapAndCurrencyCodeTimestamp); selfServeTraceFailure(failureTelemetry, getPriceMapAndCurrencyCodeTimestamp);
return { priceMap: undefined, billingCurrency: undefined }; return { priceMap: new Map<string, Map<string, number>>(), billingCurrency: undefined };
} }
}; };
@@ -286,6 +291,6 @@ export const getOfferingIds = async (regions: Array<RegionItem>): Promise<Offeri
} catch (err) { } catch (err) {
const failureTelemetry = { err, selfServeClassName: SqlX.name }; const failureTelemetry = { err, selfServeClassName: SqlX.name };
selfServeTraceFailure(failureTelemetry, getOfferingIdsCodeTimestamp); selfServeTraceFailure(failureTelemetry, getOfferingIdsCodeTimestamp);
return undefined; return new Map<string, Map<string, string>>();
} }
}; };

View File

@@ -227,11 +227,13 @@ const calculateCost = (skuName: string, instanceCount: number): Description => {
let costPerHour = 0; let costPerHour = 0;
let costBreakdown = ""; let costBreakdown = "";
for (const regionItem of regions) { for (const regionItem of regions) {
const incrementalCost = priceMap.get(regionItem.locationName).get(skuName.replace("Cosmos.", "")); const incrementalCost = priceMap?.get(regionItem.locationName)?.get(skuName.replace("Cosmos.", ""));
if (incrementalCost === undefined) { if (incrementalCost === undefined) {
throw new Error(`${regionItem.locationName} not found in price map.`); throw new Error(`${regionItem.locationName} not found in price map.`);
} else if (incrementalCost === 0) { } else if (incrementalCost === 0) {
throw new Error(`${regionItem.locationName} cost per hour = 0`); throw new Error(`${regionItem.locationName} cost per hour = 0`);
} else if (currencyCode === undefined) {
throw new Error(`Currency code not found in price map.`);
} }
let regionalInstanceCount = instanceCount; let regionalInstanceCount = instanceCount;

View File

@@ -10,6 +10,7 @@ export enum AppStateComponentNames {
MostRecentActivity = "MostRecentActivity", MostRecentActivity = "MostRecentActivity",
QueryCopilot = "QueryCopilot", QueryCopilot = "QueryCopilot",
DataExplorerAction = "DataExplorerAction", DataExplorerAction = "DataExplorerAction",
SelectedRegionalEndpoint = "SelectedRegionalEndpoint",
} }
// Subcomponent for DataExplorerAction // Subcomponent for DataExplorerAction

View File

@@ -1,16 +1,18 @@
// Data Explorer specific actions. No need to keep this in sync with the one in Portal. // Data Explorer specific actions. No need to keep this in sync with the one in Portal.
// Some of the enums names are used in Fabric. Please do not rename them.
export enum Action { export enum Action {
CollapseTreeNode, CollapseTreeNode,
CreateCollection, CreateCollection, // Used in Fabric. Please do not rename.
CreateDocument, CreateGlobalSecondaryIndex,
CreateDocument, // Used in Fabric. Please do not rename.
CreateStoredProcedure, CreateStoredProcedure,
CreateTrigger, CreateTrigger,
CreateUDF, CreateUDF,
DeleteCollection, DeleteCollection, // Used in Fabric. Please do not rename.
DeleteDatabase, DeleteDatabase,
DeleteDocument, DeleteDocument,
ExpandTreeNode, ExpandTreeNode,
ExecuteQuery, ExecuteQuery, // Used in Fabric. Please do not rename.
HasFeature, HasFeature,
GetVNETServices, GetVNETServices,
InitializeAccountLocationFromResourceGroup, InitializeAccountLocationFromResourceGroup,
@@ -119,6 +121,7 @@ export enum Action {
NotebooksGalleryPublishedCount, NotebooksGalleryPublishedCount,
SelfServe, SelfServe,
ExpandAddCollectionPaneAdvancedSection, ExpandAddCollectionPaneAdvancedSection,
ExpandAddGlobalSecondaryIndexPaneAdvancedSection,
SchemaAnalyzerClickAnalyze, SchemaAnalyzerClickAnalyze,
SelfServeComponent, SelfServeComponent,
LaunchQuickstart, LaunchQuickstart,
@@ -142,6 +145,7 @@ export enum Action {
ReadPersistedTabState, ReadPersistedTabState,
SavePersistedTabState, SavePersistedTabState,
DeletePersistedTabState, DeletePersistedTabState,
UploadDocuments, // Used in Fabric. Please do not rename.
} }
export const ActionModifiers = { export const ActionModifiers = {

View File

@@ -17,7 +17,7 @@ export class JupyterLabAppFactory {
if (userContext.apiType === "VCoreMongo" && content?.includes("MongoServerError: Invalid key")) { if (userContext.apiType === "VCoreMongo" && content?.includes("MongoServerError: Invalid key")) {
this.restartShell = true; this.restartShell = true;
} }
return content?.includes("cosmosuser@"); return content?.includes("cosmosshelluser@");
} }
private isMongoShellStarted(content: string | undefined) { private isMongoShellStarted(content: string | undefined) {
@@ -68,7 +68,6 @@ export class JupyterLabAppFactory {
const session = await manager.startNew(); const session = await manager.startNew();
session.messageReceived.connect(async (_, message: IMessage) => { session.messageReceived.connect(async (_, message: IMessage) => {
const content = message.content && message.content[0]?.toString(); const content = message.content && message.content[0]?.toString();
if (this.checkShellStarted && message.type == "stdout") { if (this.checkShellStarted && message.type == "stdout") {
//Close the terminal tab once the shell closed messages are received //Close the terminal tab once the shell closed messages are received
if (!this.isShellStarted) { if (!this.isShellStarted) {
@@ -114,6 +113,13 @@ export class JupyterLabAppFactory {
panel.dispose(); panel.dispose();
}); });
// Close terminal when Ctrl key is pressed
term.node.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.ctrlKey) {
this.onShellExited(false);
}
});
return session; return session;
} }
} }

View File

@@ -111,6 +111,8 @@ export interface UserContext {
readonly isReplica?: boolean; readonly isReplica?: boolean;
collectionCreationDefaults: CollectionCreationDefaults; collectionCreationDefaults: CollectionCreationDefaults;
sampleDataConnectionInfo?: ParsedResourceTokenConnectionString; sampleDataConnectionInfo?: ParsedResourceTokenConnectionString;
readonly selectedRegionalEndpoint?: string;
readonly writeEnabledInSelectedRegion?: boolean;
readonly vcoreMongoConnectionParams?: VCoreMongoConnectionParams; readonly vcoreMongoConnectionParams?: VCoreMongoConnectionParams;
readonly feedbackPolicies?: AdminFeedbackPolicySettings; readonly feedbackPolicies?: AdminFeedbackPolicySettings;
readonly dataPlaneRbacEnabled?: boolean; readonly dataPlaneRbacEnabled?: boolean;

View File

@@ -39,6 +39,7 @@ describe("AuthorizationUtils", () => {
it("should throw an error if token is malformed", () => { it("should throw an error if token is malformed", () => {
expect(() => expect(() =>
AuthorizationUtils.decryptJWTToken( AuthorizationUtils.decryptJWTToken(
// This is an invalid JWT token used for testing
"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyIsImtpZCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyJ9.", "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyIsImtpZCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyJ9.",
), ),
).toThrow(); ).toThrow();
@@ -47,6 +48,7 @@ describe("AuthorizationUtils", () => {
it("should return decrypted token payload", () => { it("should return decrypted token payload", () => {
expect( expect(
AuthorizationUtils.decryptJWTToken( AuthorizationUtils.decryptJWTToken(
// This is an expired JWT token used for testing
"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyIsImtpZCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyJ9.eyJhdWQiOiJodHRwczovL3dvcmtzcGFjZWFydGlmYWN0cy5wcm9qZWN0YXJjYWRpYS5uZXQiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC83MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDcvIiwiaWF0IjoxNTcxOTUwMjIwLCJuYmYiOjE1NzE5NTAyMjAsImV4cCI6MTU3MTk1NDEyMCwiYWNyIjoiMSIsImFpbyI6IkFWUUFxLzhOQUFBQVJ5c1pWWW1qV3lqeG1zU3VpdUdGbUZLSEwxKytFM2JBK0xhck5mMUVYUnZ1MFB6bDlERWFaMVNMdi8vSXlscG5hanFwZG1aSjFaSXNZUEN0UzJrY1lJbWdTVjFvUitsM2VlNWZlT1JZRjZvPSIsImFtciI6WyJyc2EiLCJtZmEiXSwiYXBwaWQiOiIyMDNmMTE0NS04NTZhLTQyMzItODNkNC1hNDM1NjhmYmEyM2QiLCJhcHBpZGFjciI6IjAiLCJmYW1pbHlfbmFtZSI6IlJhbmdhaXNoZW52aSIsImdpdmVuX25hbWUiOiJWaWduZXNoIiwiaGFzZ3JvdXBzIjoidHJ1ZSIsImlwYWRkciI6IjEzMS4xMDcuMTQ3LjE0NiIsIm5hbWUiOiJWaWduZXNoIFJhbmdhaXNoZW52aSIsIm9pZCI6ImJiN2Q0YjliLTZlOGYtNDg4NS05OTI4LTBhOWM5OWQwN2Q1NSIsIm9ucHJlbV9zaWQiOiJTLTEtNS0yMS0yMTI3NTIxMTg0LTE2MDQwMTI5MjAtMTg4NzkyNzUyNy0yNzEyNTYzNiIsInB1aWQiOiIxMDAzMDAwMEEyNjJGNDE4Iiwic2NwIjoid29ya3NwYWNlYXJ0aWZhY3RzLm1hbmFnZW1lbnQiLCJzdWIiOiI0X3hzSVdTdWZncHEtN2ZBV1dxaURYT3U5bGtKbDRpWEtBV0JVeUZ0Mm5vIiwidGlkIjoiNzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3IiwidW5pcXVlX25hbWUiOiJ2aXJhbmdhaUBtaWNyb3NvZnQuY29tIiwidXBuIjoidmlyYW5nYWlAbWljcm9zb2Z0LmNvbSIsInV0aSI6InoxRldzZzlWU2tPR1BTcEdremdWQUEiLCJ2ZXIiOiIxLjAifQ.nd-CZ6jpTQ8_2wkxQzuaoJCyEeR_woFK4MGMpHEVttwTd5WBDbVOUgk6gz36Jm2fdFemrQFJ03n1MXtCJYNnMoJX37SrGD3lAzZlXs5aBQig6ZrexWkiUDaaNcbx5qVy8O5JEQPds8OGMArsfUra0DG7iW0v7rgvhInX0umeC8ugnU5C-xEMPSZ9xYj0Q7m62AQrrCIIc94nUicEpxm_cusfsbT-CJHf2yLdmLYQkSx-ewzyBca0jiIl98sm0xA9btXDcwnWcmTY9scyGZ9mlSMtz4zmVY0NUdwssysKm7Js4aWtbA_ON8tsNEElViuwy_w3havM_3RQaNv26J87eQ", "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyIsImtpZCI6ImFQY3R3X29kdlJPb0VOZzNWb09sSWgydGlFcyJ9.eyJhdWQiOiJodHRwczovL3dvcmtzcGFjZWFydGlmYWN0cy5wcm9qZWN0YXJjYWRpYS5uZXQiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC83MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDcvIiwiaWF0IjoxNTcxOTUwMjIwLCJuYmYiOjE1NzE5NTAyMjAsImV4cCI6MTU3MTk1NDEyMCwiYWNyIjoiMSIsImFpbyI6IkFWUUFxLzhOQUFBQVJ5c1pWWW1qV3lqeG1zU3VpdUdGbUZLSEwxKytFM2JBK0xhck5mMUVYUnZ1MFB6bDlERWFaMVNMdi8vSXlscG5hanFwZG1aSjFaSXNZUEN0UzJrY1lJbWdTVjFvUitsM2VlNWZlT1JZRjZvPSIsImFtciI6WyJyc2EiLCJtZmEiXSwiYXBwaWQiOiIyMDNmMTE0NS04NTZhLTQyMzItODNkNC1hNDM1NjhmYmEyM2QiLCJhcHBpZGFjciI6IjAiLCJmYW1pbHlfbmFtZSI6IlJhbmdhaXNoZW52aSIsImdpdmVuX25hbWUiOiJWaWduZXNoIiwiaGFzZ3JvdXBzIjoidHJ1ZSIsImlwYWRkciI6IjEzMS4xMDcuMTQ3LjE0NiIsIm5hbWUiOiJWaWduZXNoIFJhbmdhaXNoZW52aSIsIm9pZCI6ImJiN2Q0YjliLTZlOGYtNDg4NS05OTI4LTBhOWM5OWQwN2Q1NSIsIm9ucHJlbV9zaWQiOiJTLTEtNS0yMS0yMTI3NTIxMTg0LTE2MDQwMTI5MjAtMTg4NzkyNzUyNy0yNzEyNTYzNiIsInB1aWQiOiIxMDAzMDAwMEEyNjJGNDE4Iiwic2NwIjoid29ya3NwYWNlYXJ0aWZhY3RzLm1hbmFnZW1lbnQiLCJzdWIiOiI0X3hzSVdTdWZncHEtN2ZBV1dxaURYT3U5bGtKbDRpWEtBV0JVeUZ0Mm5vIiwidGlkIjoiNzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3IiwidW5pcXVlX25hbWUiOiJ2aXJhbmdhaUBtaWNyb3NvZnQuY29tIiwidXBuIjoidmlyYW5nYWlAbWljcm9zb2Z0LmNvbSIsInV0aSI6InoxRldzZzlWU2tPR1BTcEdremdWQUEiLCJ2ZXIiOiIxLjAifQ.nd-CZ6jpTQ8_2wkxQzuaoJCyEeR_woFK4MGMpHEVttwTd5WBDbVOUgk6gz36Jm2fdFemrQFJ03n1MXtCJYNnMoJX37SrGD3lAzZlXs5aBQig6ZrexWkiUDaaNcbx5qVy8O5JEQPds8OGMArsfUra0DG7iW0v7rgvhInX0umeC8ugnU5C-xEMPSZ9xYj0Q7m62AQrrCIIc94nUicEpxm_cusfsbT-CJHf2yLdmLYQkSx-ewzyBca0jiIl98sm0xA9btXDcwnWcmTY9scyGZ9mlSMtz4zmVY0NUdwssysKm7Js4aWtbA_ON8tsNEElViuwy_w3havM_3RQaNv26J87eQ",
), ),
).toBeDefined(); ).toBeDefined();

View File

@@ -35,6 +35,13 @@ describe("Query Utils", () => {
version: 2, version: 2,
}; };
}; };
const generatePartitionKeysForPaths = (paths: string[]): DataModels.PartitionKey => {
return {
paths: paths,
kind: "Hash",
version: 2,
};
};
describe("buildDocumentsQueryPartitionProjections()", () => { describe("buildDocumentsQueryPartitionProjections()", () => {
it("should return empty string if partition key is undefined", () => { it("should return empty string if partition key is undefined", () => {
@@ -89,6 +96,18 @@ describe("Query Utils", () => {
expect(query).toContain("c.id"); expect(query).toContain("c.id");
}); });
it("should always include {} for any missing partition keys", () => {
const query = QueryUtils.buildDocumentsQuery(
"",
["a", "b", "c"],
generatePartitionKeysForPaths(["/a", "/b", "/c"]),
[],
);
expect(query).toContain('IIF(IS_DEFINED(c["a"]), c["a"], {})');
expect(query).toContain('IIF(IS_DEFINED(c["b"]), c["b"], {})');
expect(query).toContain('IIF(IS_DEFINED(c["c"]), c["c"], {})');
});
}); });
describe("queryPagesUntilContentPresent()", () => { describe("queryPagesUntilContentPresent()", () => {
@@ -201,18 +220,6 @@ describe("Query Utils", () => {
expect(expectedPartitionKeyValues).toContain(documentContent["Category"]); expect(expectedPartitionKeyValues).toContain(documentContent["Category"]);
}); });
it("should extract no partition key values in the case nested partition key", () => {
const singlePartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.Hash,
paths: ["/Location.type"],
};
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(
documentContent,
singlePartitionKeyDefinition,
);
expect(partitionKeyValues.length).toBe(0);
});
it("should extract all partition key values for hierarchical and nested partition keys", () => { it("should extract all partition key values for hierarchical and nested partition keys", () => {
const mixedPartitionKeyDefinition: PartitionKeyDefinition = { const mixedPartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.MultiHash, kind: PartitionKeyKind.MultiHash,
@@ -225,5 +232,52 @@ describe("Query Utils", () => {
expect(partitionKeyValues.length).toBe(2); expect(partitionKeyValues.length).toBe(2);
expect(partitionKeyValues).toEqual(["United States", "Point"]); expect(partitionKeyValues).toEqual(["United States", "Point"]);
}); });
it("if any partition key is null or empty string, the partitionKeyValues shall match", () => {
const newDocumentContent = {
...documentContent,
...{
Country: null,
Location: {
type: "",
coordinates: [-121.49, 46.206],
},
},
};
const mixedPartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.MultiHash,
paths: ["/Country", "/Location/type"],
};
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(
newDocumentContent,
mixedPartitionKeyDefinition,
);
expect(partitionKeyValues.length).toBe(2);
expect(partitionKeyValues).toEqual([null, ""]);
});
it("if any partition key doesn't exist, it should still set partitionkey value as {}", () => {
const newDocumentContent = {
...documentContent,
...{
Country: null,
Location: {
coordinates: [-121.49, 46.206],
},
},
};
const mixedPartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.MultiHash,
paths: ["/Country", "/Location/type"],
};
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(
newDocumentContent,
mixedPartitionKeyDefinition,
);
expect(partitionKeyValues.length).toBe(2);
expect(partitionKeyValues).toEqual([null, {}]);
});
}); });
}); });

View File

@@ -47,6 +47,7 @@ export function buildDocumentsQueryPartitionProjections(
for (const index in partitionKey.paths) { for (const index in partitionKey.paths) {
// TODO: Handle "/" in partition key definitions // TODO: Handle "/" in partition key definitions
const projectedProperties: string[] = partitionKey.paths[index].split("/").slice(1); const projectedProperties: string[] = partitionKey.paths[index].split("/").slice(1);
const isSystemPartitionKey: boolean = partitionKey.systemKey || false;
let projectedProperty = ""; let projectedProperty = "";
projectedProperties.forEach((property: string) => { projectedProperties.forEach((property: string) => {
@@ -61,8 +62,13 @@ export function buildDocumentsQueryPartitionProjections(
projectedProperty += `[${projection}]`; projectedProperty += `[${projection}]`;
} }
}); });
const fullAccess = `${collectionAlias}${projectedProperty}`;
projections.push(`${collectionAlias}${projectedProperty}`); if (!isSystemPartitionKey) {
const wrappedProjection = `IIF(IS_DEFINED(${fullAccess}), ${fullAccess}, {})`;
projections.push(wrappedProjection);
} else {
projections.push(fullAccess);
}
} }
return projections.join(","); return projections.join(",");
@@ -118,7 +124,7 @@ export const extractPartitionKeyValues = (
documentContent: any, documentContent: any,
partitionKeyDefinition: PartitionKeyDefinition, partitionKeyDefinition: PartitionKeyDefinition,
): PartitionKey[] => { ): PartitionKey[] => {
if (!partitionKeyDefinition.paths || partitionKeyDefinition.paths.length === 0) { if (!partitionKeyDefinition.paths || partitionKeyDefinition.paths.length === 0 || partitionKeyDefinition.systemKey) {
return undefined; return undefined;
} }
@@ -130,6 +136,8 @@ export const extractPartitionKeyValues = (
if (value !== undefined) { if (value !== undefined) {
partitionKeyValues.push(value); partitionKeyValues.push(value);
} else {
partitionKeyValues.push({});
} }
}); });

View File

@@ -0,0 +1,18 @@
import { IsValidCosmosDbResourceId } from "Utils/ValidationUtils";
const testCases = [
["validId", true],
["forward/slash", false],
["back\\slash", false],
["question?mark", false],
["hash#mark", false],
["?invalidstart", false],
["invalidEnd/", false],
["space-at-end ", false],
];
describe("IsValidCosmosDbResourceId", () => {
test.each(testCases)("IsValidCosmosDbResourceId(%p). Expected: %p", (id: string, expected: boolean) => {
expect(IsValidCosmosDbResourceId(id)).toBe(expected);
});
});

View File

@@ -0,0 +1,24 @@
//
// Common methods and constants for validation
//
//
// Validation of id for Cosmos DB resources:
// - Database
// - Container
// - Stored Procedure
// - User Defined Function (UDF)
// - Trigger
//
// Use these with <input> elements
// eslint-disable-next-line no-useless-escape
export const ValidCosmosDbIdInputPattern: RegExp = /[^\/?#\\]*[^\/?# \\]/;
export const ValidCosmosDbIdDescription: string = "May not end with space nor contain characters '\\' '/' '#' '?'";
// For a standalone function regex, we need to wrap the previous reg expression,
// to test against the entire value. This is done implicitly by input elements.
const ValidCosmosDbIdRegex: RegExp = new RegExp(`^(?:${ValidCosmosDbIdInputPattern.source})$`);
export function IsValidCosmosDbResourceId(id: string): boolean {
return id && ValidCosmosDbIdRegex.test(id);
}

View File

@@ -0,0 +1,10 @@
import create, { UseStore } from "zustand";
interface ClientWriteEnabledState {
clientWriteEnabled: boolean;
setClientWriteEnabled: (writeEnabled: boolean) => void;
}
export const useClientWriteEnabled: UseStore<ClientWriteEnabledState> = create((set) => ({
clientWriteEnabled: true,
setClientWriteEnabled: (clientWriteEnabled: boolean) => set({ clientWriteEnabled }),
}));

View File

@@ -17,12 +17,16 @@ import { useSelectedNode } from "Explorer/useSelectedNode";
import { isFabricMirroredKey, scheduleRefreshFabricToken } from "Platform/Fabric/FabricUtil"; import { isFabricMirroredKey, scheduleRefreshFabricToken } from "Platform/Fabric/FabricUtil";
import { import {
AppStateComponentNames, AppStateComponentNames,
deleteState,
hasState,
loadState,
OPEN_TABS_SUBCOMPONENT_NAME, OPEN_TABS_SUBCOMPONENT_NAME,
readSubComponentState, readSubComponentState,
} from "Shared/AppStatePersistenceUtility"; } from "Shared/AppStatePersistenceUtility";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { isDataplaneRbacSupported } from "Utils/APITypeUtils"; import { isDataplaneRbacSupported } from "Utils/APITypeUtils";
import { logConsoleError } from "Utils/NotificationConsoleUtils"; import { logConsoleError } from "Utils/NotificationConsoleUtils";
import { useClientWriteEnabled } from "hooks/useClientWriteEnabled";
import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useQueryCopilot } from "hooks/useQueryCopilot";
import { ReactTabKind, useTabs } from "hooks/useTabs"; import { ReactTabKind, useTabs } from "hooks/useTabs";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -211,6 +215,10 @@ async function configureFabric(): Promise<Explorer> {
} }
break; break;
} }
case "refreshResourceTree": {
explorer.onRefreshResourcesClick();
break;
}
default: default:
console.error(`Unknown Fabric message type: ${JSON.stringify(data)}`); console.error(`Unknown Fabric message type: ${JSON.stringify(data)}`);
break; break;
@@ -345,6 +353,9 @@ async function configureHostedWithAAD(config: AAD): Promise<Explorer> {
`Configuring Data Explorer for ${userContext.apiType} account ${account.name}`, `Configuring Data Explorer for ${userContext.apiType} account ${account.name}`,
"Explorer/configureHostedWithAAD", "Explorer/configureHostedWithAAD",
); );
if (userContext.apiType === "SQL") {
checkAndUpdateSelectedRegionalEndpoint();
}
if (!userContext.features.enableAadDataPlane) { if (!userContext.features.enableAadDataPlane) {
Logger.logInfo(`AAD Feature flag is not enabled for account ${account.name}`, "Explorer/configureHostedWithAAD"); Logger.logInfo(`AAD Feature flag is not enabled for account ${account.name}`, "Explorer/configureHostedWithAAD");
if (isDataplaneRbacSupported(userContext.apiType)) { if (isDataplaneRbacSupported(userContext.apiType)) {
@@ -706,6 +717,10 @@ async function configurePortal(): Promise<Explorer> {
const { databaseAccount: account, subscriptionId, resourceGroup } = userContext; const { databaseAccount: account, subscriptionId, resourceGroup } = userContext;
if (userContext.apiType === "SQL") {
checkAndUpdateSelectedRegionalEndpoint();
}
let dataPlaneRbacEnabled; let dataPlaneRbacEnabled;
if (isDataplaneRbacSupported(userContext.apiType)) { if (isDataplaneRbacSupported(userContext.apiType)) {
if (LocalStorageUtility.hasItem(StorageKey.DataPlaneRbacEnabled)) { if (LocalStorageUtility.hasItem(StorageKey.DataPlaneRbacEnabled)) {
@@ -824,6 +839,41 @@ function updateAADEndpoints(portalEnv: PortalEnv) {
} }
} }
function checkAndUpdateSelectedRegionalEndpoint() {
const accountName = userContext.databaseAccount?.name;
if (hasState({ componentName: AppStateComponentNames.SelectedRegionalEndpoint, globalAccountName: accountName })) {
const storedRegionalEndpoint = loadState({
componentName: AppStateComponentNames.SelectedRegionalEndpoint,
globalAccountName: accountName,
}) as string;
const validEndpoint = userContext.databaseAccount?.properties?.readLocations?.find(
(loc) => loc.documentEndpoint === storedRegionalEndpoint,
);
const validWriteEndpoint = userContext.databaseAccount?.properties?.writeLocations?.find(
(loc) => loc.documentEndpoint === storedRegionalEndpoint,
);
if (validEndpoint) {
updateUserContext({
selectedRegionalEndpoint: storedRegionalEndpoint,
writeEnabledInSelectedRegion: !!validWriteEndpoint,
refreshCosmosClient: true,
});
useClientWriteEnabled.setState({ clientWriteEnabled: !!validWriteEndpoint });
} else {
deleteState({ componentName: AppStateComponentNames.SelectedRegionalEndpoint, globalAccountName: accountName });
updateUserContext({
writeEnabledInSelectedRegion: true,
});
useClientWriteEnabled.setState({ clientWriteEnabled: true });
}
} else {
updateUserContext({
writeEnabledInSelectedRegion: true,
});
useClientWriteEnabled.setState({ clientWriteEnabled: true });
}
}
function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) { function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
if ( if (
configContext.PORTAL_BACKEND_ENDPOINT && configContext.PORTAL_BACKEND_ENDPOINT &&

View File

@@ -115,7 +115,7 @@ export const useTabs: UseStore<TabsState> = create((set, get) => ({
set({ activeTab: undefined, activeReactTab: undefined }); set({ activeTab: undefined, activeReactTab: undefined });
} }
if (tab.tabId === activeTab.tabId && tabIndex !== -1) { if (tab.tabId === activeTab?.tabId && tabIndex !== -1) {
const tabToTheRight = updatedTabs[tabIndex]; const tabToTheRight = updatedTabs[tabIndex];
const lastOpenTab = updatedTabs[updatedTabs.length - 1]; const lastOpenTab = updatedTabs[updatedTabs.length - 1];
const newActiveTab = tabToTheRight ?? lastOpenTab; const newActiveTab = tabToTheRight ?? lastOpenTab;

View File

@@ -1,5 +1,5 @@
import { AzureCliCredential } from "@azure/identity"; import { AzureCliCredential } from "@azure/identity";
import { expect, Frame, Locator, Page } from "@playwright/test"; import { Frame, Locator, Page, expect } from "@playwright/test";
import crypto from "crypto"; import crypto from "crypto";
const RETRY_COUNT = 3; const RETRY_COUNT = 3;
@@ -26,7 +26,7 @@ export function getAzureCLICredentials(): AzureCliCredential {
export async function getAzureCLICredentialsToken(): Promise<string> { export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = getAzureCLICredentials(); const credentials = getAzureCLICredentials();
const token = (await credentials.getToken("https://management.core.windows.net//.default")).token; const token = (await credentials.getToken("https://management.core.windows.net//.default"))?.token || "";
return token; return token;
} }
@@ -35,8 +35,10 @@ export enum TestAccount {
Cassandra = "Cassandra", Cassandra = "Cassandra",
Gremlin = "Gremlin", Gremlin = "Gremlin",
Mongo = "Mongo", Mongo = "Mongo",
MongoReadonly = "MongoReadOnly",
Mongo32 = "Mongo32", Mongo32 = "Mongo32",
SQL = "SQL", SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
} }
export const defaultAccounts: Record<TestAccount, string> = { export const defaultAccounts: Record<TestAccount, string> = {
@@ -44,8 +46,10 @@ export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Cassandra]: "github-e2etests-cassandra", [TestAccount.Cassandra]: "github-e2etests-cassandra",
[TestAccount.Gremlin]: "github-e2etests-gremlin", [TestAccount.Gremlin]: "github-e2etests-gremlin",
[TestAccount.Mongo]: "github-e2etests-mongo", [TestAccount.Mongo]: "github-e2etests-mongo",
[TestAccount.MongoReadonly]: "github-e2etests-mongo-readonly",
[TestAccount.Mongo32]: "github-e2etests-mongo32", [TestAccount.Mongo32]: "github-e2etests-mongo32",
[TestAccount.SQL]: "github-e2etests-sql", [TestAccount.SQL]: "github-e2etests-sql",
[TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly",
}; };
export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "de-e2e-tests"; export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "de-e2e-tests";
@@ -214,6 +218,25 @@ export class QueryTab {
} }
} }
export class DocumentsTab {
documentsFilter: Locator;
documentsListPane: Locator;
documentResultsPane: Locator;
resultsEditor: Editor;
constructor(
public frame: Frame,
public tabId: string,
public tab: Locator,
public locator: Locator,
) {
this.documentsFilter = this.locator.getByTestId("DocumentsTab/Filter");
this.documentsListPane = this.locator.getByTestId("DocumentsTab/DocumentsPane");
this.documentResultsPane = this.locator.getByTestId("DocumentsTab/ResultsPane");
this.resultsEditor = new Editor(this.frame, this.documentResultsPane.getByTestId("EditorReact/Host/Loaded"));
}
}
type PanelOpenOptions = { type PanelOpenOptions = {
closeTimeout?: number; closeTimeout?: number;
}; };
@@ -232,6 +255,12 @@ export class DataExplorer {
return new QueryTab(this.frame, tabId, tab, queryTab); return new QueryTab(this.frame, tabId, tab, queryTab);
} }
documentsTab(tabId: string): DocumentsTab {
const tab = this.tab(tabId);
const documentsTab = tab.getByTestId("DocumentsTab");
return new DocumentsTab(this.frame, tabId, tab, documentsTab);
}
/** Select the primary global command button. /** Select the primary global command button.
* *
* There's only a single "primary" button, but we still require you to pass the label to confirm you're selecting the right button. * There's only a single "primary" button, but we still require you to pass the label to confirm you're selecting the right button.
@@ -245,6 +274,10 @@ export class DataExplorer {
return this.frame.getByTestId(`CommandBar/Button:${label}`).and(this.frame.locator("css=button")); return this.frame.getByTestId(`CommandBar/Button:${label}`).and(this.frame.locator("css=button"));
} }
dialogButton(label: string): Locator {
return this.frame.getByTestId(`DialogButton:${label}`).and(this.frame.locator("css=button"));
}
/** Select the side panel with the specified title */ /** Select the side panel with the specified title */
panel(title: string): Locator { panel(title: string): Locator {
return this.frame.getByTestId(`Panel:${title}`); return this.frame.getByTestId(`Panel:${title}`);
@@ -294,6 +327,26 @@ export class DataExplorer {
return await this.waitForNode(`${databaseId}/${containerId}`); return await this.waitForNode(`${databaseId}/${containerId}`);
} }
async waitForContainerItemsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Items`);
}
async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Documents`);
}
async waitForCommandBarButton(label: string, timeout?: number): Promise<Locator> {
const commandBar = this.commandBarButton(label);
await commandBar.waitFor({ state: "visible", timeout });
return commandBar;
}
async waitForDialogButton(label: string, timeout?: number): Promise<Locator> {
const dialogButton = this.dialogButton(label);
await dialogButton.waitFor({ timeout });
return dialogButton;
}
/** Select the tree node with the specified id */ /** Select the tree node with the specified id */
treeNode(id: string): TreeNode { treeNode(id: string): TreeNode {
return new TreeNode(this.frame.getByTestId(`TreeNode:${id}`), this.frame, id); return new TreeNode(this.frame.getByTestId(`TreeNode:${id}`), this.frame, id);

View File

@@ -0,0 +1,89 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, DocumentsTab, TestAccount } from "../fx";
import { retry, serializeMongoToJson, setPartitionKeys } from "../testData";
import { documentTestCases } from "./testCases";
let explorer: DataExplorer = null!;
let documentsTab: DocumentsTab = null!;
for (const { name, databaseId, containerId, documents } of documentTestCases) {
test.describe(`Test MongoRU Documents with ${name}`, () => {
test.beforeEach("Open documents tab", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.MongoReadonly);
const containerNode = await explorer.waitForContainerNode(databaseId, containerId);
await containerNode.expand();
const containerMenuNode = await explorer.waitForContainerDocumentsNode(databaseId, containerId);
await containerMenuNode.element.click();
documentsTab = explorer.documentsTab("tab0");
await documentsTab.documentsFilter.waitFor();
await documentsTab.documentsListPane.waitFor();
await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
});
for (const document of documents) {
const { documentId: docId, partitionKeys } = document;
test.describe(`Document ID: ${docId}`, () => {
test(`should load and view document ${docId}`, async () => {
const span = documentsTab.documentsListPane.getByText(docId, { exact: true }).nth(0);
await span.waitFor();
await expect(span).toBeVisible();
await span.click();
await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
const resultText = await documentsTab.resultsEditor.text();
const resultData = serializeMongoToJson(resultText!);
expect(resultText).not.toBeNull();
expect(resultData?._id).not.toBeNull();
expect(resultData?._id).toEqual(docId);
});
test(`should be able to create and delete new document from ${docId}`, async () => {
const span = documentsTab.documentsListPane.getByText(docId, { exact: true }).nth(0);
await span.waitFor();
await expect(span).toBeVisible();
await span.click();
let newDocumentId;
await retry(async () => {
const newDocumentButton = await explorer.waitForCommandBarButton("New Document", 5000);
await expect(newDocumentButton).toBeVisible();
await expect(newDocumentButton).toBeEnabled();
await newDocumentButton.click();
await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
newDocumentId = `${Date.now().toString()}-delete`;
const newDocument = {
_id: newDocumentId,
...setPartitionKeys(partitionKeys || []),
};
await documentsTab.resultsEditor.setText(JSON.stringify(newDocument));
const saveButton = await explorer.waitForCommandBarButton("Save", 5000);
await saveButton.click({ timeout: 5000 });
}, 3);
const newSpan = documentsTab.documentsListPane.getByText(newDocumentId, { exact: true }).nth(0);
await newSpan.waitFor();
await newSpan.click();
await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
const deleteButton = await explorer.waitForCommandBarButton("Delete", 5000);
await deleteButton.click();
const deleteDialogButton = await explorer.waitForDialogButton("Delete", 5000);
await deleteDialogButton.click();
const deletedSpan = documentsTab.documentsListPane.getByText(newDocumentId, { exact: true }).nth(0);
await expect(deletedSpan).toHaveCount(0);
});
});
}
});
}

31
test/mongo/testCases.ts Normal file
View File

@@ -0,0 +1,31 @@
import { DocumentTestCase } from "../testData";
export const documentTestCases: DocumentTestCase[] = [
{
name: "Unsharded Collection",
databaseId: "e2etests-mongo-readonly",
containerId: "unsharded",
documents: [
{
documentId: "unsharded",
partitionKeys: [],
},
],
},
{
name: "Sharded Collection",
databaseId: "e2etests-mongo-readonly",
containerId: "sharded",
documents: [
{
documentId: "sharded",
partitionKeys: [
{
key: "/shardKey",
value: "shardKey",
},
],
},
],
},
];

93
test/sql/document.spec.ts Normal file
View File

@@ -0,0 +1,93 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, DocumentsTab, TestAccount } from "../fx";
import { retry, setPartitionKeys } from "../testData";
import { documentTestCases } from "./testCases";
let explorer: DataExplorer = null!;
let documentsTab: DocumentsTab = null!;
for (const { name, databaseId, containerId, documents } of documentTestCases) {
test.describe(`Test SQL Documents with ${name}`, () => {
test.beforeEach("Open documents tab", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQLReadOnly);
const containerNode = await explorer.waitForContainerNode(databaseId, containerId);
await containerNode.expand();
const containerMenuNode = await explorer.waitForContainerItemsNode(databaseId, containerId);
await containerMenuNode.element.click();
documentsTab = explorer.documentsTab("tab0");
await documentsTab.documentsFilter.waitFor();
await documentsTab.documentsListPane.waitFor();
await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
});
for (const document of documents) {
const { documentId: docId, partitionKeys } = document;
test.describe(`Document ID: ${docId}`, () => {
test(`should load and view document ${docId}`, async () => {
const span = documentsTab.documentsListPane.getByText(docId, { exact: true }).nth(0);
await span.waitFor();
await expect(span).toBeVisible();
await span.click();
await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
const resultText = await documentsTab.resultsEditor.text();
const resultData = JSON.parse(resultText!);
expect(resultText).not.toBeNull();
expect(resultData?.id).toEqual(docId);
});
test(`should be able to create and delete new document from ${docId}`, async ({ page }) => {
const span = documentsTab.documentsListPane.getByText(docId, { exact: true }).nth(0);
await span.waitFor();
await expect(span).toBeVisible();
await span.click();
let newDocumentId;
await page.waitForTimeout(5000);
await retry(async () => {
// const discardButton = await explorer.waitForCommandBarButton("Discard", 5000);
// if (await discardButton.isEnabled()) {
// await discardButton.click();
// }
const newDocumentButton = await explorer.waitForCommandBarButton("New Item", 5000);
await expect(newDocumentButton).toBeVisible();
await expect(newDocumentButton).toBeEnabled();
await newDocumentButton.click();
await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
newDocumentId = `${Date.now().toString()}-delete`;
const newDocument = {
id: newDocumentId,
...setPartitionKeys(partitionKeys || []),
};
await documentsTab.resultsEditor.setText(JSON.stringify(newDocument));
const saveButton = await explorer.waitForCommandBarButton("Save", 5000);
await saveButton.click({ timeout: 5000 });
}, 3);
const newSpan = documentsTab.documentsListPane.getByText(newDocumentId, { exact: true }).nth(0);
await newSpan.waitFor();
await newSpan.click();
await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
const deleteButton = await explorer.waitForCommandBarButton("Delete", 5000);
await deleteButton.click();
const deleteDialogButton = await explorer.waitForDialogButton("Delete", 5000);
await deleteDialogButton.click();
const deletedSpan = documentsTab.documentsListPane.getByText(newDocumentId, { exact: true }).nth(0);
await expect(deletedSpan).toHaveCount(0);
});
});
}
});
}

235
test/sql/testCases.ts Normal file
View File

@@ -0,0 +1,235 @@
import { DocumentTestCase } from "../testData";
export const documentTestCases: DocumentTestCase[] = [
{
name: "System Partition Key",
databaseId: "e2etests-sql-readonly",
containerId: "systemPartitionKey",
documents: [{ documentId: "systempartition", partitionKeys: [] }],
},
{
name: "Single Partition Key",
databaseId: "e2etests-sql-readonly",
containerId: "singlePartitionKey",
documents: [
{
documentId: "singlePartitionKey",
partitionKeys: [{ key: "/singlePartitionKey", value: "singlePartitionKey" }],
},
{
documentId: "singlePartitionKey_empty_string",
partitionKeys: [{ key: "/singlePartitionKey", value: "" }],
},
{
documentId: "singlePartitionKey_null",
partitionKeys: [{ key: "/singlePartitionKey", value: null }],
},
{
documentId: "singlePartitionKey_missing",
partitionKeys: [],
},
],
},
{
name: "Single Nested Partition Key",
databaseId: "e2etests-sql-readonly",
containerId: "singleNestedPartitionKey",
documents: [
{
documentId: "singlePartitionKey_nested",
partitionKeys: [{ key: "/singlePartitionKey/nested", value: "nestedValue" }],
},
{
documentId: "singlePartitionKey_nested_empty_string",
partitionKeys: [{ key: "/singlePartitionKey/nested", value: "" }],
},
{
documentId: "singlePartitionKey_nested_null",
partitionKeys: [{ key: "/singlePartitionKey/nested", value: null }],
},
{
documentId: "singlePartitionKey_nested_missing",
partitionKeys: [],
},
],
},
{
name: "2-Level Hierarchical Partition Key",
databaseId: "e2etests-sql-readonly",
containerId: "twoLevelPartitionKey",
documents: [
{
documentId: "twoLevelPartitionKey_value_empty",
partitionKeys: [
{ key: "/twoLevelPartitionKey_1", value: "value" },
{ key: "/twoLevelPartitionKey_2", value: "" },
],
},
{
documentId: "twoLevelPartitionKey_value_null",
partitionKeys: [
{ key: "/twoLevelPartitionKey_1", value: "value" },
{ key: "/twoLevelPartitionKey_2", value: null },
],
},
{
documentId: "twoLevelPartitionKey_value_missing",
partitionKeys: [{ key: "/twoLevelPartitionKey_1", value: "value" }],
},
{
documentId: "twoLevelPartitionKey_empty_null",
partitionKeys: [
{ key: "/twoLevelPartitionKey_1", value: "" },
{ key: "/twoLevelPartitionKey_2", value: null },
],
},
{
documentId: "twoLevelPartitionKey_null_missing",
partitionKeys: [{ key: "/twoLevelPartitionKey_1", value: null }],
},
{
documentId: "twoLevelPartitionKey_missing_value",
partitionKeys: [{ key: "/twoLevelPartitionKey_2", value: "value" }],
},
],
},
{
name: "2-Level Hierarchical Nested Partition Key",
databaseId: "e2etests-sql-readonly",
containerId: "twoLevelNestedPartitionKey",
documents: [
{
documentId: "twoLevelNestedPartitionKey_nested_value_empty",
partitionKeys: [
{ key: "/twoLevelNestedPartitionKey/nested", value: "value" },
{ key: "/twoLevelNestedPartitionKey/nested_value/nested", value: "" },
],
},
{
documentId: "twoLevelNestedPartitionKey_nested_null_missing",
partitionKeys: [{ key: "/twoLevelNestedPartitionKey/nested", value: null }],
},
{
documentId: "twoLevelNestedPartitionKey_nested_missing_value",
partitionKeys: [{ key: "/twoLevelNestedPartitionKey/nested_value/nested", value: "value" }],
},
],
},
{
name: "3-Level Hierarchical Partition Key",
databaseId: "e2etests-sql-readonly",
containerId: "threeLevelPartitionKey",
documents: [
{
documentId: "threeLevelPartitionKey_value_empty_null",
partitionKeys: [
{ key: "/threeLevelPartitionKey_1", value: "value" },
{ key: "/threeLevelPartitionKey_2", value: "" },
{ key: "/threeLevelPartitionKey_3", value: null },
],
},
{
documentId: "threeLevelPartitionKey_value_null_missing",
partitionKeys: [
{ key: "/threeLevelPartitionKey_1", value: "value" },
{ key: "/threeLevelPartitionKey_2", value: null },
],
},
{
documentId: "threeLevelPartitionKey_value_missing_null",
partitionKeys: [
{ key: "/threeLevelPartitionKey_1", value: "value" },
{ key: "/threeLevelPartitionKey_3", value: null },
],
},
{
documentId: "threeLevelPartitionKey_null_empty_value",
partitionKeys: [
{ key: "/threeLevelPartitionKey_1", value: null },
{ key: "/threeLevelPartitionKey_2", value: "" },
{ key: "/threeLevelPartitionKey_3", value: "value" },
],
},
{
documentId: "threeLevelPartitionKey_missing_value_value",
partitionKeys: [
{ key: "/threeLevelPartitionKey_2", value: "value" },
{ key: "/threeLevelPartitionKey_3", value: "value" },
],
},
{
documentId: "threeLevelPartitionKey_empty_value_missing",
partitionKeys: [
{ key: "/threeLevelPartitionKey_1", value: "" },
{ key: "/threeLevelPartitionKey_2", value: "value" },
],
},
],
},
{
name: "3-Level Nested Hierarchical Partition Key",
databaseId: "e2etests-sql-readonly",
containerId: "threeLevelNestedPartitionKey",
documents: [
{
documentId: "threeLevelNestedPartitionKey_nested_empty_value_null",
partitionKeys: [
{ key: "/threeLevelPartitionKey_1/nested/nested_key", value: "" },
{ key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: "value" },
{ key: "/threeLevelPartitionKey_1/nested/nested_2/nested_3/nested_key", value: null },
],
},
{
documentId: "threeLevelNestedPartitionKey_nested_null_value_missing",
partitionKeys: [
{ key: "/threeLevelPartitionKey_1/nested/nested_key", value: null },
{ key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: "value" },
],
},
{
documentId: "threeLevelNestedPartitionKey_nested_missing_value_null",
partitionKeys: [
{ key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: "value" },
{ key: "/threeLevelPartitionKey_1/nested/nested_2/nested_3/nested_key", value: null },
],
},
{
documentId: "threeLevelNestedPartitionKey_nested_null_empty_missing",
partitionKeys: [
{ key: "/threeLevelPartitionKey_1/nested/nested_key", value: null },
{ key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: "" },
],
},
{
documentId: "threeLevelNestedPartitionKey_nested_value_missing_empty",
partitionKeys: [
{ key: "/threeLevelPartitionKey_1/nested/nested_key", value: "value" },
{ key: "/threeLevelPartitionKey_1/nested/nested_2/nested_3/nested_key", value: "" },
],
},
{
documentId: "threeLevelNestedPartitionKey_nested_missing_null_empty",
partitionKeys: [
{ key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: null },
{ key: "/threeLevelPartitionKey_1/nested/nested_2/nested_3/nested_key", value: "" },
],
},
{
documentId: "threeLevelNestedPartitionKey_nested_empty_null_value",
partitionKeys: [
{ key: "/threeLevelPartitionKey_1/nested/nested_key", value: "" },
{ key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: null },
{ key: "/threeLevelPartitionKey_1/nested/nested_2/nested_3/nested_key", value: "value" },
],
},
{
documentId: "threeLevelNestedPartitionKey_nested_value_null_empty",
partitionKeys: [
{ key: "/threeLevelPartitionKey_1/nested/nested_key", value: "value" },
{ key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: null },
{ key: "/threeLevelPartitionKey_1/nested/nested_2/nested_3/nested_key", value: "" },
],
},
],
},
];

View File

@@ -16,6 +16,23 @@ export interface TestItem {
randomData: string; randomData: string;
} }
export interface DocumentTestCase {
name: string;
databaseId: string;
containerId: string;
documents: TestDocument[];
}
export interface TestDocument {
documentId: string;
partitionKeys?: PartitionKey[];
}
export interface PartitionKey {
key: string;
value: string | null;
}
const partitionCount = 4; const partitionCount = 4;
// If we increase this number, we need to split bulk creates into multiple batches. // If we increase this number, we need to split bulk creates into multiple batches.
@@ -93,3 +110,46 @@ export async function createTestSQLContainer(includeTestData?: boolean) {
throw e; throw e;
} }
} }
export const setPartitionKeys = (partitionKeys: PartitionKey[]) => {
const result = {};
partitionKeys.forEach((partitionKey) => {
const { key: keyPath, value: keyValue } = partitionKey;
const cleanPath = keyPath.startsWith("/") ? keyPath.slice(1) : keyPath;
const keys = cleanPath.split("/");
let current = result;
keys.forEach((key, index) => {
if (index === keys.length - 1) {
current[key] = keyValue;
} else {
current[key] = current[key] || {};
current = current[key];
}
});
});
return result;
};
export const serializeMongoToJson = (text: string) => {
const normalized = text.replace(/ObjectId\("([0-9a-fA-F]{24})"\)/g, '"$1"');
return JSON.parse(normalized);
};
export async function retry<T>(fn: () => Promise<T>, retries = 3, delayMs = 1000): Promise<T> {
let lastError: unknown;
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
console.warn(`Retry ${i + 1}/${retries} failed: ${(error as Error).message}`);
if (i < retries - 1) {
await new Promise((res) => setTimeout(res, delayMs));
}
}
}
throw lastError;
}

View File

@@ -20,6 +20,10 @@ async function main() {
const client = new CosmosDBManagementClient(credentials, subscriptionId); const client = new CosmosDBManagementClient(credentials, subscriptionId);
const accounts = await client.databaseAccounts.list(resourceGroupName); const accounts = await client.databaseAccounts.list(resourceGroupName);
for (const account of accounts) { for (const account of accounts) {
if (account.name.endsWith("-readonly")) {
console.log(`SKIPPED: ${account.name}`);
continue;
}
if (account.kind === "MongoDB") { if (account.kind === "MongoDB") {
const mongoDatabases = await client.mongoDBResources.listMongoDBDatabases(resourceGroupName, account.name); const mongoDatabases = await client.mongoDBResources.listMongoDBDatabases(resourceGroupName, account.name);
for (const database of mongoDatabases) { for (const database of mongoDatabases) {