Compare commits

...

54 Commits

Author SHA1 Message Date
Sung-Hyun Kang
3ae92f2398 testing icons 2025-01-14 17:16:02 -06:00
Sung-Hyun Kang
a0853c9167 testing 2025-01-14 14:45:19 -06:00
jawelton74
2fdb3df4ae Update to Nuget setup action v2. (#2024) 2025-01-10 17:32:12 -08:00
bogercraig
7c9802c07d Settings Menu Client Refresh Bug Fix, Limit Client Options in APIs (#2023)
* Reset hasDataPlaneRbacSettingChanged back to false after cosmos client is refreshed with new settings.
Dispose of old client before new one is created.

* Update client refresh variable after settings change.

* Only refresh client when related settings are changed.

* Update comparisons in settings menu.

* Remove unnecessary comments.

* Update refresh variable naming.

* Attempting to sync package.json and package-lock.json in CI.

* Remove npm install from CI after successful CI run.

* Only show retry settings with those APIs using the cosmos client -> NoSQL, Table, Gremlin
2025-01-10 12:42:03 -08:00
jawelton74
e5609bd91e Update Playwright to latest and rename MongoProxy development endpoint constant (#2022)
* Rename MongoProxy development endpoint constant to be consistent with other
endpoints.

* Update Playwright version to latest release due to test setup break.
2025-01-08 07:56:04 -08:00
jawelton74
4b75e86b74 Remove Network Warning banner from Data Explorer. (#2019) 2024-12-12 15:44:28 -08:00
SATYA SB
abf061089d [accessibility-3102896]: [Keyboard Navigation - Azure CosmosDB - Data Explorer]: "Learn more" link is not accessible using keyboard present inside the tooltip of "Analytical store’. (#1989)
Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2024-12-10 10:14:19 +05:30
Laurent Nguyen
ec25586a6e Close all tabs and always load first container when initializing Fabric (#2014)
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2024-12-06 17:14:37 +01:00
tarazou9
c15d1432b2 Fix the filter for getting offering id (#2015)
Fix offering id filter
2024-12-05 13:04:46 -05:00
Laurent Nguyen
73d2686025 Restore open collection tabs: Query, Documents, Settings (#2004)
* Persist query multiple query texts

* Save multiple query tab histories

* Save and restore states for QueryTab and DocumentsTab for SQL and Mongo

* Enable Collection Scale/Settings restore

* Persist documents tab current filter

* Fix DocumentsTab conflict resolve mistake

* Remove unused variable

* Fix e2e test

* Fix e2e localStorage reference

* Try clearing local storage via playwright page

* Clear local storage after opening page

* Move restore flag behind feature flag. Whitelist restorable tabs in for Fabric. Restore e2e tests.

* Fix typo

* Fix: avoid setting undefined for preferredSize for the <Allotment.Pane>

* Add comments

* Move restore tabs after knockout configure step from Explorer constructor (which could be called multiple times)
2024-11-28 11:18:55 +01:00
vchske
80b926214b Vector Embedding and Full Text Search (#2009)
* Replaced monaco editor on Container Vector Policy tab with controls same as on create container ux

* Adds vector embedding policy to container management. Adds FullTextSearch to both add container and container management.

* Fixing unit tests and formatting issues

* More fixes

* Updating full text controls based on feedback

* Minor updates

* Editing test to fix compile issue

* Minor fix

* Adding paths for jest to ignore transform due to recent changes in upstream dependencies

* Adding mock to temporarily get unit tests to pass

* Hiding FTS feature behind the new EnableNoSQLFullTextSearch capability
2024-11-18 12:49:27 -08:00
Laurent Nguyen
070b7a4ca7 Remove unnecessary padding for Fabric (#2005)
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2024-11-13 09:52:51 +01:00
jawelton74
d61ff5dcb5 Update upload/download-artifact actions to v4 due to v3 deprecation. (#2006) 2024-11-11 11:17:48 -08:00
Laurent Nguyen
d42eebaa5a Improve DocumentsTab filter input (#1998)
* Rework Input and dropdown in DocumentsTab

* Improve input: implement Escape and add clear button

* Undo body :focus outline, since fluent UI has a nicer focus style

* Close dropdown if last element is tabbed

* Fix unit tests

* Fix theme and remove autocomplete

* Load theme inside rendering function to fix using correct colors

* Remove commented code

* Add aria-label to clear filter button

* Fix format

* Fix keyboard navigation with tab and arrow up/down. Clear button becomes down button.

---------

Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2024-11-01 16:59:26 +01:00
Ashley Stanton-Nurse
056be2a74d add more edge cases to Query Error parser (#2003) 2024-10-30 08:43:18 -07:00
jawelton74
b93c90e7d1 Update query advisor privacy statement and link (#2000)
* Update query advisor privacy details.

* Update test snapshot.
2024-10-29 10:56:04 -07:00
Laurent Nguyen
82de81f2b6 Fix row selection issue in DocumentsTab when sorting rows (#1997)
* Fix bug clicking on item highlights wrong row. Remove unused prop.

* Fix clicking on table row on sorted rows and multi-select using ctrl

* Update test snaphosts

* Remove unnecessary setTimeout
2024-10-25 12:08:55 +02:00
Ashley Stanton-Nurse
236f075cf6 fix layout issues in fabric (#1996) 2024-10-24 09:59:03 -07:00
bogercraig
d478af3869 Correct spelling of CreateDocumen to CreateDocuments (#1995) 2024-10-22 14:23:07 -07:00
Laurent Nguyen
93c1fdc238 Revert "Persist and restore query text, tab position and splitter direction in QueryTabComponent (#1993)" (#1994)
This reverts commit d562fc0f40.
2024-10-22 19:03:10 +02:00
Laurent Nguyen
d562fc0f40 Persist and restore query text, tab position and splitter direction in QueryTabComponent (#1993)
* Save query text, tab splitter direction and position in QueryTabComponent

* Fix unit tests
2024-10-22 14:31:09 +02:00
bogercraig
808faa9fa5 CP and MP API Overrides from Config.json (#1992)
* Force useMongoProxyEndpoint to always return true if valid endpoint provided.  Enables new Mongo proxy in all environments.

* Checking MP endpoint in config context.

* Enabling cassandra proxy in all environments.  Requires later cleanup.

* Simplifying and removing endpoint validation since run when config context is generated.

* Enabling one MP API at a time globally.

* Revent to existing CP selection logic.

* Creating list of globally enable CP apis.

* Add list of mongo and cassandra APIs to config and only enable if environment outside existing list of environments.

* Remove environment checks.  If API globally enabled, return true.

* Adding config initialization for mongo unit tests.

* Default to empty enable list to minimize possible impact.  Config.json overrides can be used for testing.
2024-10-17 13:41:22 -07:00
Vsevolod Kukol
c1bc11d27d Support multi-tenant switching for Data Plane RBAC (#1988)
* Fix API endpoint for CassandraProxy query API

* activate Mongo Proxy and Cassandra Proxy in Prod

* Add CP Prod endpoint

* Run npm format and tests

* Revert code

* fix bug that blocked local mongo proxy and cassandra proxy development

* Add prod endpoint

* fix pr check tests

* Remove prod

* Remove prod endpoint

* Remove dev endpoint

* Support data plane RBAC

* Support data plane RBAC

* Add additional changes for Portal RBAC functionality

* Remove unnecessary code

* Remove unnecessary code

* Add code to fix VCoreMongo/PG bug

* Address feedback

* Add more logs for RBAC feature

* Add more logs for RBAC features

* Add AAD endpoints for all environments

* Add AAD endpoints

* Run npm format

* Support multi-tenant switching for Data Plane RBAC

* Remove tenantID duplicates

---------

Co-authored-by: Senthamil Sindhu <sindhuba@microsoft.com>
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-10-10 07:36:19 -07:00
sindhuba
ac2e2a6f8e Add tenantId info in Data Explorer while opening from Portal (#1987)
* Fix API endpoint for CassandraProxy query API

* activate Mongo Proxy and Cassandra Proxy in Prod

* Add CP Prod endpoint

* Run npm format and tests

* Revert code

* fix bug that blocked local mongo proxy and cassandra proxy development

* Add prod endpoint

* fix pr check tests

* Remove prod

* Remove prod endpoint

* Remove dev endpoint

* Support data plane RBAC

* Support data plane RBAC

* Add additional changes for Portal RBAC functionality

* Remove unnecessary code

* Remove unnecessary code

* Add code to fix VCoreMongo/PG bug

* Address feedback

* Add more logs for RBAC feature

* Add more logs for RBAC features

* Add AAD endpoints for all environments

* Add AAD endpoints

* Run npm format

* Support multi-tenant switching for Data plane RBAC

* Run npm format

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-10-09 15:41:58 -07:00
Laurent Nguyen
3138580eae Move column selection out of mpac (#1980) 2024-10-09 14:23:31 +02:00
SATYA SB
aa88815c6e [Keyboard Navigation - Azure Cosmos DB - Data Explorer]: Keyboard focus is not retaining back to 'more' button after closing 'Delete container' dialog. (#1978)
* [accessibility-2262594]: [Keyboard Navigation - Azure Cosmos DB - Data Explorer]: Keyboard focus is not retaining back to 'more' button after closing 'Delete container' dialog.

* Optimize closeSidePanel: add timeout cleanup to prevent memory leaks and ensure proper focus behavior

---------

Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2024-10-07 09:09:23 +05:30
Vsevolod Kukol
5a2f78b51e Improve Entra ID token acquisition logic (#1940)
* Add a silent parameter to acquireTokenWithMsal

If true, the function won't retry to sign in using a Popup if silent token acquisition fails.

* Improve Login for Entra ID RBAC button logic

Try to reuse an existing signed-in MSAL account to get the AAD token
and fall back to full sign-in otherwise.

Also move the logic to AuthorizationUtils

* Try to acquire an Entra ID token silently on startup.

When running in Portal MSAL should be able to reuse the
MSAL account from Portal and allow us to silently get
the RBAC token. If it fails we'll show the Login for Entry ID RBAC
button as usual.

* Small code improvements

* Remove the RBAC notice from settings pane
and try to acquire RBAC token silently after enabling RBAC.

* Use msal.ssoSilent with an optional login hint
to avoid more sign-in popups.
msal.loginPopup will be used as a backup option if ssoSilent fails.
Ideally the parent environment (Portal/Fabric) should send
a loginHint with the username of the currently signed in user that
can be passed to the token acquisition flow.

* Improve RBAC error wording, clarifying where to find the Login button.
2024-10-04 08:45:29 +02:00
bogercraig
fbc2e1335b Pull Additional Allowed Cassandra and Mongo Proxy Endpoints from Deployed Config (#1984)
* Updating to take default cassandra proxy endpoints from external config.json.

* Updating allow list for mongo proxy endpoints.
2024-10-02 14:05:21 -07:00
SATYA SB
eb0d7b71b3 [accessibility-3100029]:[Screen Reader - Azure Cosmos DB - Add Table Row]: Descriptive Label is not provided for 'Value' edit fields under 'Add Table Row' pane. (#1970)
Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2024-10-01 09:15:25 +05:30
Asier Isayas
261289b031 Remove legacy backend references in tests and local dev (#1983)
* remove legacy backend references in tests and local dev

* fix unit tests

* fixed bulk delete

* fix tests

* fix cosmosclient

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-09-30 14:34:37 -04:00
Asier Isayas
fae4589427 Bulk Delete API fix (#1977)
* Bulk Delete API fix

* Bulk Delete API fix

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-09-30 11:40:48 -04:00
jawelton74
cbcb7e6240 Switch E2E tests to use new accounts. (#1982) 2024-09-30 07:29:24 -07:00
jawelton74
e0b773d920 Set shared throughput default to false for New Databases (#1981)
* Introduce common function for shared throughput default and set to
false.

* Add new file.

* Adjust E2E tests to not set throughput for database create.
2024-09-27 09:59:41 -07:00
Ashley Stanton-Nurse
9ec2cea95c Ensure the "Ctrl+Alt+["/"Ctrl+Alt+]" shortcuts don't get triggered on "AltGr+8"/"AltGr+9" (#1979)
* Remove the "Ctrl+Alt+[" and "Ctrl+Alt+]" shortcuts, as they conflict on non-US keyboard layouts

* Use "BracketLeft" and "BracketRight" to re-enable shortcut for US keyboards
2024-09-25 09:15:54 -07:00
Ashley Stanton-Nurse
1a4f713a79 Clarifying copy-edit to delete database panel (#1974)
* change 'Database id' to 'Database name' in Delete Database confirm prompt

* put 'name' in a parenthetical instead of replacing 'id'

* update test snapshots
2024-09-23 11:34:49 -07:00
Laurent Nguyen
7128133874 Only show throttling warning when throttling happened. (#1976) 2024-09-23 17:30:42 +02:00
Ashley Stanton-Nurse
053dc9d76b Add config files for Codespaces (#1975) 2024-09-20 08:28:03 -07:00
Laurent Nguyen
23b2e59560 Migrate Most Recent activity local storage to App State persistence (#1967)
* Rewrite MostRecentActivity to leverage AppStatePersistenceUtility.

* Fix format. Update type enum.

* Migrate Item enum to string enum

* Fix unit tests

* Fix build issue
2024-09-20 08:26:58 +02:00
sunghyunkang1111
869d81dfbc fix partitionkey value fetching (#1972)
* fix partitionkey value fetching

* fix partitionkey value fetching

* added unit test

* Added some unit tests

* move the constant
2024-09-19 13:09:09 -05:00
Laurent Nguyen
42a1c6c319 Move table column selection out of feature flag to MPAC. (#1973) 2024-09-19 07:18:03 +02:00
Asier Isayas
9f1cc4cd5c Force Mongo and Proxy users to switch to Mongo and Cassandra Proxy (#1971)
* Force Mongo and Cassandra users to the new Proxies

* npm run format

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-09-18 13:49:09 -04:00
Asier Isayas
78154bd976 Disable Bulk Delete API (#1968)
* disable bulk delete

* disable bulk delete

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-09-13 08:36:53 -04:00
Laurent Nguyen
91649d2f52 Migrate Copilot local persistence (toggle and prompt history) to new local storage infrastructure (#1948)
* Migrate copilot persistence to AppState

* Migrate persistence of toggle and history to new infra

* Save toggle value as boolean

* Fix compile bug

* Fix unit tests
2024-09-13 12:01:14 +02:00
vchske
d7647b2ecf Fixed issue with Tables API when selecting a row with the same row key in different partition keys (#1969) 2024-09-12 16:36:22 -07:00
Ashley Stanton-Nurse
2c7e788358 Replace RU limit banner by clarifying the error when RU limit is exceeded (#1966)
* allow DE to provide clearer error messages for certain conditions

* allow rendeering a "help" link for an error

* use TableCellLayout where possible

* remove RU Threshold banner, now that we have a clearer error

* refmt

* fix QueryError test

* change "RU Threshold" to "RU Limit"
2024-09-12 11:45:10 -07:00
Laurent Nguyen
fdbbbd7378 Better handling throttling error in bulk delete (#1954)
* Implement retry on throttling for nosql

* Clean up code

* Produce specific error for throttling error in mongoProxy bulk delete. Clean up code.

* Fix throttling doc url

* Fix mongo error wording

* Fix unit test

* Unit test cleanup

* Fix format

* Fix unit tests

* Fix format

* Fix unit test

* Fix format

* Improve comments

* Improve error message wording. Fix URL and add specific URL for Mongo and NoSql.

* Fix error messages. Add console errors.

* Clean up selection of various delete fct

* Fix error display
2024-09-11 17:11:41 +02:00
Asier Isayas
82bdeff158 Add new Portal Backend Sample Data API and remove Notifications API references (#1965)
* Fixed Sample Data logic and remove notifications references

* fixed undefined

* fixed unit tests

* fixed format test

* cleanup

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-09-11 08:07:50 -04:00
Laurent Nguyen
825a5d5257 Enable column selection and sorting in DocumentsTab (with persistence) with improvements (#1963)
* Reapply "Enable column selection and sorting in DocumentsTab (with persistence) (#1881)" (#1960)

This reverts commit fe9730206e.

* Fix logic bug: always include defaultQueryFields in query.

* Show resize column option outside of feature flag

* Improve prevention of no selected columns

* Add more unit tests

* Fix styling on table

* Update test snapshots

* Remove "sortable" property on table which makes the header cell focusable (user sorts by selecting menu item, not by clicking on cell)
2024-09-11 13:26:49 +02:00
vchske
d75553a94d Removing trailing ; from resource token which is incompatible with v2 tokens (#1962)
* Removing trailing ; from resource token which is incompatible with v2 tokens

* Adding check in case resourceToken is undefined

* Fixing unit tests
2024-09-09 12:04:16 -07:00
Laurent Nguyen
50c47a82d6 Allow slashes in persistence keys (#1961)
* Allow slashes in persistence keys

* Add unit tests
2024-09-09 19:12:55 +02:00
Laurent Nguyen
2c2f0c8d7b Disable bulkdelete for users point to old mongo proxy (#1964)
* Disable bulk delete if old mongo proxy

* Bug fix

* Fix unit tests

* Fix formatting
2024-09-09 11:13:52 -04:00
SATYA SB
cfc8196c4b [accessibility-3100032]:[Programmatic Access - Azure Cosmos DB - Data Explorer]: Close button does not have discernible text under 'Data Explorer' pane. (#1949) 2024-09-06 11:56:05 +05:30
Asier Isayas
87024f4bf4 use old backend for Mongo and Cassandra accounts depending on their IP addresses (#1959)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-09-05 16:25:53 -04:00
Laurent Nguyen
fe9730206e Revert "Enable column selection and sorting in DocumentsTab (with persistence) (#1881)" (#1960)
This reverts commit 7e95f5d8c8.
2024-09-05 21:44:33 +02:00
146 changed files with 4586 additions and 2506 deletions

16
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm
# Install pre-reqs for gyp, and 'canvas' npm module
RUN apt-get update && \
apt-get install -y \
make \
gcc \
g++ \
python3-minimal \
libcairo2-dev \
libpango1.0-dev \
&& \
rm -rf /var/lib/apt/lists/*
# Install node-gyp to build native modules
RUN npm install -g node-gyp

View File

@@ -0,0 +1,32 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Azure Cosmos DB Explorer",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"build": {
"dockerfile": "Dockerfile"
},
"onCreateCommand": ".devcontainer/oncreate",
"features": {
"ghcr.io/devcontainers/features/azure-cli:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/github-cli:1": {
"installDirectlyFromGitHubRelease": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/sshd:1": {
"version": "latest"
}
}
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

4
.devcontainer/oncreate Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Install packages once, to prime the node_modules directory.
npm ci

View File

@@ -92,7 +92,7 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=4096"
- run: cp -r ./Contracts ./dist/contracts
- run: cp -r ./configs ./dist/configs
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
@@ -113,18 +113,18 @@ jobs:
NUGET_SOURCE: ${{ secrets.NUGET_SOURCE }}
AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }}
steps:
- uses: nuget/setup-nuget@v1
- uses: nuget/setup-nuget@v2
with:
nuget-api-key: ${{ secrets.NUGET_API_KEY }}
- name: Download Dist Folder
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: dist
- run: cp ./configs/prod.json config.json
- run: nuget sources add -Name "ADO" -Source "$NUGET_SOURCE" -UserName "jawelton@microsoft.com" -Password "$AZURE_DEVOPS_PAT"
- run: nuget pack -Version "2.0.0-github-${GITHUB_SHA}"
- run: nuget push -SkipDuplicate -Source "$NUGET_SOURCE" -ApiKey Az *.nupkg
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
name: packages
with:
path: "*.nupkg"
@@ -137,11 +137,11 @@ jobs:
NUGET_SOURCE: ${{ secrets.NUGET_SOURCE }}
AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }}
steps:
- uses: nuget/setup-nuget@v1
- uses: nuget/setup-nuget@v2
with:
nuget-api-key: ${{ secrets.NUGET_API_KEY }}
- name: Download Dist Folder
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: dist
- run: cp ./configs/mpac.json config.json
@@ -149,7 +149,7 @@ jobs:
- run: nuget sources add -Name "ADO" -Source "$NUGET_SOURCE" -UserName "jawelton@microsoft.com" -Password "$AZURE_DEVOPS_PAT"
- run: nuget pack -Version "2.0.0-github-${GITHUB_SHA}"
- run: nuget push -SkipDuplicate -Source "$NUGET_SOURCE" -ApiKey Az *.nupkg
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
name: packages
with:
path: "*.nupkg"
@@ -185,9 +185,9 @@ jobs:
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shardIndex }}
path: blob-report
retention-days: 1
name: blob-report-${{ matrix.shardIndex }}
path: blob-report
retention-days: 1
merge-playwright-reports:
name: "Merge Playwright Reports"
@@ -197,26 +197,26 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- name: Install dependencies
run: npm ci
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Download blob reports from GitHub Actions Artifacts
uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Download blob reports from GitHub Actions Artifacts
uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge into HTML Report
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: Merge into HTML Report
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4
with:
name: html-report--attempt-${{ github.run_attempt }}
path: playwright-report
retention-days: 14
- name: Upload HTML report
uses: actions/upload-artifact@v4
with:
name: html-report--attempt-${{ github.run_attempt }}
path: playwright-report
retention-days: 14

View File

@@ -18,7 +18,7 @@ Run `npm start` to start the development server and automatically rebuild on cha
### Hosted Development (https://cosmos.azure.com)
- Visit: `https://localhost:1234/hostedExplorer.html`
- The default webpack dev server configuration will proxy requests to the production portal backend: `https://main.documentdb.ext.azure.com`. This will allow you to use production connection strings on your local machine.
- The default webpack dev server configuration will proxy requests to the production portal backend: `https://cdb-ms-mpac-pbe.cosmos.azure.com`. This will allow you to use production connection strings on your local machine.
### Emulator Development

View File

@@ -82,7 +82,7 @@
</a>
<ul>
<li>Visit: <code>https://localhost:1234/hostedExplorer.html</code></li>
<li>The default webpack dev server configuration will proxy requests to the production portal backend: <code>https://main.documentdb.ext.azure.com</code>. This will allow you to use production connection strings on your local machine.</li>
<li>The default webpack dev server configuration will proxy requests to the production portal backend: <code>https://cdb-ms-mpac-pbe.cosmos.azure.com</code>. This will allow you to use production connection strings on your local machine.</li>
</ul>
<a href="#emulator-development" id="emulator-development" style="color: inherit; text-decoration: none;">
<h3>Emulator Development</h3>

View File

@@ -174,7 +174,11 @@ module.exports = {
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
transformIgnorePatterns: ["/node_modules/(?!@fluentui/react-icons)", "/externals/"],
transformIgnorePatterns: [
"/node_modules/(?!@fluentui/react-icons|(.*)/dist/browser)/",
"/node_modules/plotly.js-cartesian-dist-min",
"/externals/",
],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,

View File

@@ -3117,3 +3117,7 @@ a:link {
background: white;
height: 100%;
}
.sidebarContainer {
height: 100%;
}

View File

@@ -20,6 +20,10 @@ a:focus {
text-decoration: underline;
}
.splashLoaderContainer {
background-color: #f5f5f5;
}
#divExplorer {
background-color: #f5f5f5;
}
@@ -27,26 +31,24 @@ a:focus {
.resourceTreeAndTabs {
border-radius: 0px;
box-shadow: @FabricBoxBorderShadow;
margin: @FabricBoxMargin;
margin-top: 0px;
margin-bottom: 0px;
background-color: #ffffff;
}
.tabsManagerContainer {
background-color: #ffffff
background-color: #ffffff;
}
.nav-tabs-margin {
padding-top: 5px;
background-color: #ffffff
background-color: #ffffff;
}
.commandBarContainer {
background-color: #ffffff;
border-radius: @FabricBoxBorderRadius @FabricBoxBorderRadius 0px 0px;
box-shadow: @FabricBoxBorderShadow;
margin: @FabricBoxMargin;
margin-top: 0px;
margin-bottom: 0px;
padding-top: 2px;
@@ -65,17 +67,16 @@ a:focus {
}
}
.nav-tabs>li>.tabNavContentContainer>.tab_Content:hover {
.nav-tabs > li > .tabNavContentContainer > .tab_Content:hover {
border-bottom: 2px solid #e0e0e0;
}
.nav-tabs>li.active>.tabNavContentContainer>.tab_Content,
.nav-tabs>li.active>.tabNavContentContainer>.tab_Content:hover {
.nav-tabs > li.active > .tabNavContentContainer > .tab_Content,
.nav-tabs > li.active > .tabNavContentContainer > .tab_Content:hover {
border-bottom: 2px solid @FabricAccentMedium;
}
.nav-tabs>li.active>.tabNavContentContainer>.tab_Content>.contentWrapper>.tabNavText {
.nav-tabs > li.active > .tabNavContentContainer > .tab_Content > .contentWrapper > .tabNavText {
border-bottom: 0px none transparent;
}
@@ -94,10 +95,10 @@ a:focus {
padding-bottom: @SmallSpace;
.contentWrapper {
.statusIconContainer {
margin-left: 0px;
}
.statusIconContainer {
margin-left: 0px;
}
}
.tabIconSection {
.cancelButton {
@@ -119,7 +120,6 @@ a:focus {
}
}
.resourceTree {
padding: 12px;
}
@@ -156,25 +156,21 @@ a:focus {
}
.selected {
&>.treeNodeHeader {
& > .treeNodeHeader {
background-color: @FabricAccentExtra;
}
}
}
}
.dataExplorerErrorConsoleContainer {
border-radius: 0px 0px @FabricBoxBorderRadius @FabricBoxBorderRadius;
border-radius: 0px 0px @FabricBoxBorderRadius @FabricBoxBorderRadius;
box-shadow: @FabricBoxBorderShadow;
margin: @FabricBoxMargin;
margin-top: 0px;
width: auto;
align-self: auto;
}
.filterbtnstyle {
background: #fff;
color: #000;
@@ -200,12 +196,10 @@ a:focus {
border: solid 1px #d1d1d1;
}
.gridRowSelected .tabdocumentsGridElement:hover {
background-color: @FabricAccentLight !important;
}
.refreshcol {
filter: brightness(0) saturate(100%);
}
@@ -216,4 +210,4 @@ a:focus {
.fileImportImg img {
filter: brightness(0) saturate(100%);
}
}

251
package-lock.json generated
View File

@@ -10,7 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.0.1-beta.3",
"@azure/cosmos": "4.2.0-beta.1",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "1.5.2",
"@azure/ms-rest-nodeauth": "3.1.1",
@@ -122,7 +122,7 @@
"@babel/preset-env": "7.24.7",
"@babel/preset-react": "7.24.7",
"@babel/preset-typescript": "7.24.7",
"@playwright/test": "1.44.0",
"@playwright/test": "1.49.1",
"@testing-library/react": "11.2.3",
"@types/applicationinsights-js": "1.0.7",
"@types/codemirror": "0.0.56",
@@ -228,18 +228,20 @@
}
},
"node_modules/@azure/abort-controller": {
"version": "1.1.0",
"license": "MIT",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"dependencies": {
"tslib": "^2.2.0"
"tslib": "^2.6.2"
},
"engines": {
"node": ">=12.0.0"
"node": ">=18.0.0"
}
},
"node_modules/@azure/abort-controller/node_modules/tslib": {
"version": "2.6.2",
"license": "0BSD"
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/@azure/arm-cosmosdb": {
"version": "9.1.0",
@@ -251,15 +253,16 @@
}
},
"node_modules/@azure/core-auth": {
"version": "1.5.0",
"license": "MIT",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.9.0.tgz",
"integrity": "sha512-FPwHpZywuyasDSLMqJ6fhbOK3TqUdviZNF8OqRGA4W5Ewib2lEEZ+pBsYcBa88B2NGO/SEnYPGhyBqNlE8ilSw==",
"dependencies": {
"@azure/abort-controller": "^1.0.0",
"@azure/core-util": "^1.1.0",
"tslib": "^2.2.0"
"@azure/abort-controller": "^2.0.0",
"@azure/core-util": "^1.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=14.0.0"
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-auth/node_modules/tslib": {
@@ -282,36 +285,61 @@
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-client/node_modules/@azure/abort-controller": {
"version": "2.1.2",
"license": "MIT",
"node_modules/@azure/core-client/node_modules/tslib": {
"version": "2.6.2",
"license": "0BSD"
},
"node_modules/@azure/core-rest-pipeline": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.18.0.tgz",
"integrity": "sha512-QSoGUp4Eq/gohEFNJaUOwTN7BCc2nHTjjbm75JT0aD7W65PWM1H/tItz0GsABn22uaKyGxiMhWQLt2r+FGU89Q==",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
"@azure/core-auth": "^1.8.0",
"@azure/core-tracing": "^1.0.1",
"@azure/core-util": "^1.11.0",
"@azure/logger": "^1.0.0",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-client/node_modules/tslib": {
"version": "2.6.2",
"license": "0BSD"
},
"node_modules/@azure/core-rest-pipeline": {
"version": "1.12.2",
"license": "MIT",
"node_modules/@azure/core-rest-pipeline/node_modules/agent-base": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
"integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
"dependencies": {
"@azure/abort-controller": "^1.0.0",
"@azure/core-auth": "^1.4.0",
"@azure/core-tracing": "^1.0.1",
"@azure/core-util": "^1.3.0",
"@azure/logger": "^1.0.0",
"form-data": "^4.0.0",
"http-proxy-agent": "^5.0.0",
"https-proxy-agent": "^5.0.0",
"tslib": "^2.2.0"
"debug": "^4.3.4"
},
"engines": {
"node": ">=16.0.0"
"node": ">= 14"
}
},
"node_modules/@azure/core-rest-pipeline/node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/@azure/core-rest-pipeline/node_modules/https-proxy-agent": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz",
"integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==",
"dependencies": {
"agent-base": "^7.0.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/@azure/core-rest-pipeline/node_modules/tslib": {
@@ -319,13 +347,14 @@
"license": "0BSD"
},
"node_modules/@azure/core-tracing": {
"version": "1.0.1",
"license": "MIT",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.2.0.tgz",
"integrity": "sha512-UKTiEJPkWcESPYJz3X5uKRYyOcJD+4nYph+KpfdPRnQJVrZfk0KJgdnaAWKfhsBBtAf/D58Az4AvCJEmWgIBAg==",
"dependencies": {
"tslib": "^2.2.0"
"tslib": "^2.6.2"
},
"engines": {
"node": ">=12.0.0"
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-tracing/node_modules/tslib": {
@@ -333,14 +362,15 @@
"license": "0BSD"
},
"node_modules/@azure/core-util": {
"version": "1.6.1",
"license": "MIT",
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.11.0.tgz",
"integrity": "sha512-DxOSLua+NdpWoSqULhjDyAZTXFdP/LKkqtYuxxz1SCN289zk3OG8UOpnCQAz/tygyACBtWp/BoO72ptK7msY8g==",
"dependencies": {
"@azure/abort-controller": "^1.0.0",
"tslib": "^2.2.0"
"@azure/abort-controller": "^2.0.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=16.0.0"
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-util/node_modules/tslib": {
@@ -348,22 +378,20 @@
"license": "0BSD"
},
"node_modules/@azure/cosmos": {
"version": "4.0.1-beta.3",
"license": "MIT",
"version": "4.2.0-beta.1",
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.2.0-beta.1.tgz",
"integrity": "sha512-mREONehm1DxjEKXGaNU6Wmpf9Ckb9IrhKFXhDFVs45pxmoEb3y2s/Ub0owuFmqlphpcS1zgtYQn5exn+lwnJuQ==",
"dependencies": {
"@azure/abort-controller": "^1.0.0",
"@azure/core-auth": "^1.3.0",
"@azure/core-rest-pipeline": "^1.2.0",
"@azure/core-tracing": "^1.0.0",
"debug": "^4.1.1",
"@azure/abort-controller": "^2.0.0",
"@azure/core-auth": "^1.7.1",
"@azure/core-rest-pipeline": "^1.15.1",
"@azure/core-tracing": "^1.1.1",
"@azure/core-util": "^1.8.1",
"fast-json-stable-stringify": "^2.1.0",
"jsbi": "^3.1.3",
"node-abort-controller": "^3.0.0",
"jsbi": "^4.3.0",
"priorityqueuejs": "^2.0.0",
"semaphore": "^1.0.5",
"tslib": "^2.2.0",
"universal-user-agent": "^6.0.0",
"uuid": "^8.3.0"
"semaphore": "^1.1.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
@@ -10147,34 +10175,18 @@
}
},
"node_modules/@playwright/test": {
"version": "1.44.0",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.44.0"
"playwright": "1.49.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@playwright/test/node_modules/playwright": {
"version": "1.44.0",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.44.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"fsevents": "2.3.2"
"node": ">=18"
}
},
"node_modules/@polka/url": {
@@ -11708,6 +11720,7 @@
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
@@ -14775,6 +14788,15 @@
"node": ">=8"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"optional": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bintrees": {
"version": "1.0.2",
"license": "MIT"
@@ -19437,6 +19459,12 @@
"version": "2.0.5",
"license": "MIT"
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"optional": true
},
"node_modules/filesize": {
"version": "8.0.7",
"dev": true,
@@ -19895,6 +19923,7 @@
},
"node_modules/form-data": {
"version": "4.0.0",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -20024,6 +20053,19 @@
"version": "1.0.0",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
@@ -21095,6 +21137,7 @@
},
"node_modules/http-proxy-agent": {
"version": "5.0.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@tootallnate/once": "2",
@@ -24129,6 +24172,24 @@
"fsevents": "^1.2.7"
}
},
"node_modules/jest-haste-map/node_modules/fsevents": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"deprecated": "Upgrade to fsevents v2 to mitigate potential security issues",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"dependencies": {
"bindings": "^1.5.0",
"nan": "^2.12.1"
},
"engines": {
"node": ">= 4.0"
}
},
"node_modules/jest-haste-map/node_modules/jest-worker": {
"version": "24.9.0",
"license": "MIT",
@@ -27067,8 +27128,9 @@
}
},
"node_modules/jsbi": {
"version": "3.2.5",
"license": "Apache-2.0"
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
"integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
},
"node_modules/jsbn": {
"version": "0.1.1",
@@ -29737,7 +29799,9 @@
},
"node_modules/node-abort-controller": {
"version": "3.1.1",
"license": "MIT"
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/node-addon-api": {
"version": "4.3.0",
@@ -30764,15 +30828,34 @@
"node": ">=8"
}
},
"node_modules/playwright-core": {
"version": "1.44.0",
"node_modules/playwright": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
"dev": true,
"dependencies": {
"playwright-core": "1.49.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
"node": ">=18"
}
},
"node_modules/plotly.js-cartesian-dist-min": {

View File

@@ -5,7 +5,7 @@
"main": "index.js",
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.0.1-beta.3",
"@azure/cosmos": "4.2.0-beta.1",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "1.5.2",
"@azure/ms-rest-nodeauth": "3.1.1",
@@ -117,7 +117,7 @@
"@babel/preset-env": "7.24.7",
"@babel/preset-react": "7.24.7",
"@babel/preset-typescript": "7.24.7",
"@playwright/test": "1.44.0",
"@playwright/test": "1.49.1",
"@testing-library/react": "11.2.3",
"@types/applicationinsights-js": "1.0.7",
"@types/codemirror": "0.0.56",
@@ -170,10 +170,10 @@
"jest": "29.7.0",
"jest-canvas-mock": "2.5.2",
"jest-circus": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jest-html-loader": "1.0.0",
"jest-react-hooks-shallow": "1.5.1",
"jest-trx-results-processor": "3.0.2",
"jest-environment-jsdom": "29.7.0",
"less": "3.8.1",
"less-loader": "11.1.3",
"less-vars-loader": "1.1.0",
@@ -247,4 +247,4 @@
"printWidth": 120,
"endOfLine": "auto"
}
}
}

View File

@@ -4,7 +4,7 @@ const port = process.env.PORT || 3000;
const fetch = require("node-fetch");
const api = createProxyMiddleware("/api", {
target: "https://main.documentdb.ext.azure.com",
target: "https://cdb-ms-mpac-pbe.cosmos.azure.com",
changeOrigin: true,
logLevel: "debug",
bypass: (req, res) => {
@@ -16,7 +16,7 @@ const api = createProxyMiddleware("/api", {
});
const proxy = createProxyMiddleware("/proxy", {
target: "https://main.documentdb.ext.azure.com",
target: "https://cdb-ms-mpac-pbe.cosmos.azure.com",
changeOrigin: true,
secure: false,
logLevel: "debug",

View File

@@ -89,6 +89,7 @@ export class CapabilityNames {
public static readonly EnableMongo: string = "EnableMongo";
public static readonly EnableServerless: string = "EnableServerless";
public static readonly EnableNoSQLVectorSearch: string = "EnableNoSQLVectorSearch";
public static readonly EnableNoSQLFullTextSearch: string = "EnableNoSQLFullTextSearch";
}
export enum CapacityMode {
@@ -136,6 +137,7 @@ export class BackendApi {
public static readonly AccountRestrictions: string = "AccountRestrictions";
public static readonly RuntimeProxy: string = "RuntimeProxy";
public static readonly DisallowedLocations: string = "DisallowedLocations";
public static readonly SampleData: string = "SampleData";
}
export class PortalBackendEndpoints {
@@ -147,13 +149,25 @@ export class PortalBackendEndpoints {
}
export class MongoProxyEndpoints {
public static readonly Local: string = "https://localhost:7238";
public static readonly Development: string = "https://localhost:7238";
public static readonly Mpac: string = "https://cdb-ms-mpac-mp.cosmos.azure.com";
public static readonly Prod: string = "https://cdb-ms-prod-mp.cosmos.azure.com";
public static readonly Fairfax: string = "https://cdb-ff-prod-mp.cosmos.azure.us";
public static readonly Mooncake: string = "https://cdb-mc-prod-mp.cosmos.azure.cn";
}
export class MongoProxyApi {
public static readonly ResourceList: string = "ResourceList";
public static readonly QueryDocuments: string = "QueryDocuments";
public static readonly CreateDocument: string = "CreateDocument";
public static readonly ReadDocument: string = "ReadDocument";
public static readonly UpdateDocument: string = "UpdateDocument";
public static readonly DeleteDocument: string = "DeleteDocument";
public static readonly CreateCollectionWithProxy: string = "CreateCollectionWithProxy";
public static readonly LegacyMongoShell: string = "LegacyMongoShell";
public static readonly BulkDelete: string = "BulkDelete";
}
export class CassandraProxyEndpoints {
public static readonly Development: string = "https://localhost:7240";
public static readonly Mpac: string = "https://cdb-ms-mpac-cp.cosmos.azure.com";
@@ -292,6 +306,7 @@ export class HttpStatusCodes {
public static readonly Accepted: number = 202;
public static readonly NoContent: number = 204;
public static readonly NotModified: number = 304;
public static readonly BadRequest: number = 400;
public static readonly Unauthorized: number = 401;
public static readonly Forbidden: number = 403;
public static readonly NotFound: number = 404;
@@ -503,7 +518,7 @@ export class PriorityLevel {
public static readonly Default = "low";
}
export const QueryCopilotSampleDatabaseId = "CopilotSampleDb";
export const QueryCopilotSampleDatabaseId = "CopilotSampleDB";
export const QueryCopilotSampleContainerId = "SampleContainer";
export const QueryCopilotSampleContainerSchema = {

View File

@@ -1,4 +1,5 @@
import { Platform, resetConfigContext, updateConfigContext } from "../ConfigContext";
import { PortalBackendEndpoints } from "Common/Constants";
import { configContext, Platform, resetConfigContext, updateConfigContext } from "../ConfigContext";
import { updateUserContext } from "../UserContext";
import { endpoint, getTokenFromAuthService, requestPlugin } from "./CosmosClient";
@@ -20,22 +21,22 @@ describe("getTokenFromAuthService", () => {
it("builds the correct URL in production", () => {
updateConfigContext({
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
});
getTokenFromAuthService("GET", "dbs", "foo");
expect(window.fetch).toHaveBeenCalledWith(
"https://main.documentdb.ext.azure.com/api/guest/runtimeproxy/authorizationTokens",
`${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/authorizationtokens`,
expect.any(Object),
);
});
it("builds the correct URL in dev", () => {
updateConfigContext({
BACKEND_ENDPOINT: "https://localhost:1234",
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Development,
});
getTokenFromAuthService("GET", "dbs", "foo");
expect(window.fetch).toHaveBeenCalledWith(
"https://localhost:1234/api/guest/runtimeproxy/authorizationTokens",
`${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/authorizationtokens`,
expect.any(Object),
);
});
@@ -78,7 +79,7 @@ describe("requestPlugin", () => {
const next = jest.fn();
updateConfigContext({
platform: Platform.Hosted,
BACKEND_ENDPOINT: "https://localhost:1234",
PORTAL_BACKEND_ENDPOINT: "https://localhost:1234",
PROXY_PATH: "/proxy",
});
const headers = {};

View File

@@ -8,7 +8,7 @@ import { AuthType } from "../AuthType";
import { BackendApi, PriorityLevel } from "../Common/Constants";
import * as Logger from "../Common/Logger";
import { Platform, configContext } from "../ConfigContext";
import { userContext } from "../UserContext";
import { updateUserContext, userContext } from "../UserContext";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
@@ -27,7 +27,7 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
);
if (!userContext.aadToken) {
logConsoleError(
`AAD token does not exist. Please click on "Login for Entra ID" button prior to performing Entra ID RBAC operations`,
`AAD token does not exist. Please use the "Login for Entra ID" button in the Toolbar prior to performing Entra ID RBAC operations`,
);
return null;
}
@@ -189,10 +189,19 @@ let _client: Cosmos.CosmosClient;
export function client(): Cosmos.CosmosClient {
if (_client) {
if (!userContext.hasDataPlaneRbacSettingChanged) {
if (!userContext.refreshCosmosClient) {
return _client;
}
_client.dispose();
_client = null;
}
if (userContext.refreshCosmosClient) {
updateUserContext({
refreshCosmosClient: false,
});
}
let _defaultHeaders: Cosmos.CosmosHeaders = {};
_defaultHeaders["x-ms-cosmos-sdk-supportedcapabilities"] =
SDKSupportedCapabilities.None | SDKSupportedCapabilities.PartitionMerge;

View File

@@ -0,0 +1,3 @@
export function getNewDatabaseSharedThroughputDefault(): boolean {
return false;
}

View File

@@ -10,6 +10,7 @@ export interface TableEntityProps {
isEntityValueDisable?: boolean;
entityTimeValue: string;
entityValueType: string;
entityProperty: string;
onEntityValueChange: (event: React.FormEvent<HTMLElement>, newInput?: string) => void;
onSelectDate: (date: Date | null | undefined) => void;
onEntityTimeValueChange: (event: React.FormEvent<HTMLElement>, newInput?: string) => void;
@@ -26,6 +27,7 @@ export const EntityValue: FunctionComponent<TableEntityProps> = ({
onSelectDate,
isEntityValueDisable,
onEntityTimeValueChange,
entityProperty,
}: TableEntityProps): JSX.Element => {
if (isEntityTypeDate) {
return (
@@ -51,15 +53,20 @@ export const EntityValue: FunctionComponent<TableEntityProps> = ({
}
return (
<TextField
label={entityValueLabel && entityValueLabel}
className="addEntityTextField"
disabled={isEntityValueDisable}
type={entityValueType}
placeholder={entityValuePlaceholder}
value={typeof entityValue === "string" ? entityValue : ""}
onChange={onEntityValueChange}
ariaLabel={attributeValueLabel}
/>
<>
<span id={entityProperty} className="screenReaderOnly">
Edit Property {entityProperty} {attributeValueLabel}
</span>
<TextField
label={entityValueLabel && entityValueLabel}
className="addEntityTextField"
disabled={isEntityValueDisable}
type={entityValueType}
placeholder={entityValuePlaceholder}
value={typeof entityValue === "string" ? entityValue : ""}
onChange={onEntityValueChange}
aria-labelledby={entityProperty}
/>
</>
);
};

View File

@@ -1,3 +1,5 @@
import { PortalBackendEndpoints } from "Common/Constants";
import { updateConfigContext } from "ConfigContext";
import * as EnvironmentUtility from "./EnvironmentUtility";
describe("Environment Utility Test", () => {
@@ -11,4 +13,18 @@ describe("Environment Utility Test", () => {
const expectedResult = "test/";
expect(EnvironmentUtility.normalizeArmEndpoint(uri)).toEqual(expectedResult);
});
it("Detect environment is Mpac", () => {
updateConfigContext({
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
});
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Mpac);
});
it("Detect environment is Development", () => {
updateConfigContext({
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Development,
});
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Development);
});
});

View File

@@ -1,6 +1,29 @@
import { PortalBackendEndpoints } from "Common/Constants";
import { configContext } from "ConfigContext";
export function normalizeArmEndpoint(uri: string): string {
if (uri && uri.slice(-1) !== "/") {
return `${uri}/`;
}
return uri;
}
export enum Environment {
Development = "Development",
Mpac = "MPAC",
Prod = "Prod",
Fairfax = "Fairfax",
Mooncake = "Mooncake",
}
export const getEnvironment = (): Environment => {
const environmentMap: { [key: string]: Environment } = {
[PortalBackendEndpoints.Development]: Environment.Development,
[PortalBackendEndpoints.Mpac]: Environment.Mpac,
[PortalBackendEndpoints.Prod]: Environment.Prod,
[PortalBackendEndpoints.Fairfax]: Environment.Fairfax,
[PortalBackendEndpoints.Mooncake]: Environment.Mooncake,
};
return environmentMap[configContext.PORTAL_BACKEND_ENDPOINT];
};

View File

@@ -1,4 +1,4 @@
import { QueryOperationOptions } from "@azure/cosmos";
// import { QueryOperationOptions } from "@azure/cosmos";
import { QueryResults } from "../Contracts/ViewModels";
interface QueryResponse {
@@ -11,17 +11,13 @@ interface QueryResponse {
}
export interface MinimalQueryIterator {
fetchNext: (queryOperationOptions?: QueryOperationOptions) => Promise<QueryResponse>;
fetchNext: () => Promise<QueryResponse>;
}
// Pick<QueryIterator<any>, "fetchNext">;
export function nextPage(
documentsIterator: MinimalQueryIterator,
firstItemIndex: number,
queryOperationOptions?: QueryOperationOptions,
): Promise<QueryResults> {
return documentsIterator.fetchNext(queryOperationOptions).then((response) => {
export function nextPage(documentsIterator: MinimalQueryIterator, firstItemIndex: number): Promise<QueryResults> {
return documentsIterator.fetchNext().then((response) => {
const documents = response.resources;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const headers = (response as any).headers || {}; // TODO this is a private key. Remove any

View File

@@ -1,5 +1,6 @@
import { MongoProxyEndpoints } from "Common/Constants";
import { AuthType } from "../AuthType";
import { resetConfigContext, updateConfigContext } from "../ConfigContext";
import { configContext, resetConfigContext, updateConfigContext } from "../ConfigContext";
import { DatabaseAccount } from "../Contracts/DataModels";
import { Collection } from "../Contracts/ViewModels";
import DocumentId from "../Explorer/Tree/DocumentId";
@@ -71,7 +72,8 @@ describe("MongoProxyClient", () => {
databaseAccount,
});
updateConfigContext({
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -82,16 +84,19 @@ describe("MongoProxyClient", () => {
it("builds the correct URL", () => {
queryDocuments(databaseId, collection, true, "{}");
expect(window.fetch).toHaveBeenCalledWith(
"https://main.documentdb.ext.azure.com/api/mongo/explorer/resourcelist?db=testDB&coll=testCollection&resourceUrl=bardbs%2FtestDB%2Fcolls%2FtestCollection%2Fdocs%2F&rid=testCollectionrid&rtype=docs&sid=&rg=&dba=foo&pk=pk",
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer/resourcelist`,
expect.any(Object),
);
});
it("builds the correct proxy URL in development", () => {
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
queryDocuments(databaseId, collection, true, "{}");
expect(window.fetch).toHaveBeenCalledWith(
"https://localhost:1234/api/mongo/explorer/resourcelist?db=testDB&coll=testCollection&resourceUrl=bardbs%2FtestDB%2Fcolls%2FtestCollection%2Fdocs%2F&rid=testCollectionrid&rtype=docs&sid=&rg=&dba=foo&pk=pk",
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer/resourcelist`,
expect.any(Object),
);
});
@@ -103,7 +108,8 @@ describe("MongoProxyClient", () => {
databaseAccount,
});
updateConfigContext({
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -114,16 +120,19 @@ describe("MongoProxyClient", () => {
it("builds the correct URL", () => {
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
"https://main.documentdb.ext.azure.com/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
expect.any(Object),
);
});
it("builds the correct proxy URL in development", () => {
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
"https://localhost:1234/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
expect.any(Object),
);
});
@@ -135,7 +144,8 @@ describe("MongoProxyClient", () => {
databaseAccount,
});
updateConfigContext({
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -146,16 +156,19 @@ describe("MongoProxyClient", () => {
it("builds the correct URL", () => {
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
"https://main.documentdb.ext.azure.com/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
expect.any(Object),
);
});
it("builds the correct proxy URL in development", () => {
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
"https://localhost:1234/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
expect.any(Object),
);
});
@@ -167,7 +180,8 @@ describe("MongoProxyClient", () => {
databaseAccount,
});
updateConfigContext({
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -178,16 +192,19 @@ describe("MongoProxyClient", () => {
it("builds the correct URL", () => {
updateDocument(databaseId, collection, documentId, "{}");
expect(window.fetch).toHaveBeenCalledWith(
"https://main.documentdb.ext.azure.com/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2Fdocs%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
expect.any(Object),
);
});
it("builds the correct proxy URL in development", () => {
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
updateConfigContext({
MONGO_BACKEND_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
updateDocument(databaseId, collection, documentId, "{}");
expect(window.fetch).toHaveBeenCalledWith(
"https://localhost:1234/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2Fdocs%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
expect.any(Object),
);
});
@@ -199,7 +216,8 @@ describe("MongoProxyClient", () => {
databaseAccount,
});
updateConfigContext({
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -210,16 +228,19 @@ describe("MongoProxyClient", () => {
it("builds the correct URL", () => {
deleteDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
"https://main.documentdb.ext.azure.com/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2Fdocs%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
expect.any(Object),
);
});
it("builds the correct proxy URL in development", () => {
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
deleteDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
"https://localhost:1234/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2Fdocs%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
expect.any(Object),
);
});
@@ -231,13 +252,14 @@ describe("MongoProxyClient", () => {
databaseAccount,
});
updateConfigContext({
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
});
it("returns a production endpoint", () => {
const endpoint = getEndpoint("https://main.documentdb.ext.azure.com");
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/mongo/explorer");
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`);
});
it("returns a development endpoint", () => {
@@ -249,18 +271,20 @@ describe("MongoProxyClient", () => {
updateUserContext({
authType: AuthType.EncryptedToken,
});
const endpoint = getEndpoint("https://main.documentdb.ext.azure.com");
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/guest/mongo/explorer");
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/connectionstring/mongo/explorer`);
});
});
describe("getFeatureEndpointOrDefault", () => {
beforeEach(() => {
resetConfigContext();
updateConfigContext({
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
const params = new URLSearchParams({
"feature.mongoProxyEndpoint": "https://localhost:12901",
"feature.mongoProxyEndpoint": MongoProxyEndpoints.Prod,
"feature.mongoProxyAPIs": "readDocument|createDocument",
});
const features = extractFeatures(params);
@@ -272,12 +296,12 @@ describe("MongoProxyClient", () => {
it("returns a local endpoint", () => {
const endpoint = getFeatureEndpointOrDefault("readDocument");
expect(endpoint).toEqual("https://localhost:12901/api/mongo/explorer");
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`);
});
it("returns a production endpoint", () => {
const endpoint = getFeatureEndpointOrDefault("deleteDocument");
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/mongo/explorer");
const endpoint = getFeatureEndpointOrDefault("DeleteDocument");
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`);
});
});
});

View File

@@ -1,7 +1,7 @@
import { Constants as CosmosSDKConstants } from "@azure/cosmos";
import {
allowedMongoProxyEndpoints,
allowedMongoProxyEndpoints_ToBeDeprecated,
defaultAllowedMongoProxyEndpoints,
validateEndpoint,
} from "Utils/EndpointUtils";
import queryString from "querystring";
@@ -14,7 +14,7 @@ import DocumentId from "../Explorer/Tree/DocumentId";
import { hasFlag } from "../Platform/Hosted/extractFeatures";
import { userContext } from "../UserContext";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import { ApiType, ContentType, HttpHeaders, HttpStatusCodes, MongoProxyEndpoints } from "./Constants";
import { ApiType, ContentType, HttpHeaders, HttpStatusCodes, MongoProxyApi, MongoProxyEndpoints } from "./Constants";
import { MinimalQueryIterator } from "./IteratorUtilities";
import { sendMessage } from "./MessageHandler";
@@ -67,7 +67,7 @@ export function queryDocuments(
query: string,
continuationToken?: string,
): Promise<QueryResponse> {
if (!useMongoProxyEndpoint("resourcelist") || !useMongoProxyEndpoint("queryDocuments")) {
if (!useMongoProxyEndpoint(MongoProxyApi.ResourceList) || !useMongoProxyEndpoint(MongoProxyApi.QueryDocuments)) {
return queryDocuments_ToBeDeprecated(databaseId, collection, isResourceList, query, continuationToken);
}
@@ -89,7 +89,7 @@ export function queryDocuments(
query,
};
const endpoint = getFeatureEndpointOrDefault("resourcelist") || "";
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.ResourceList) || "";
const headers = {
...defaultHeaders,
@@ -194,7 +194,7 @@ export function readDocument(
collection: Collection,
documentId: DocumentId,
): Promise<DataModels.DocumentId> {
if (!useMongoProxyEndpoint("readDocument")) {
if (!useMongoProxyEndpoint(MongoProxyApi.ReadDocument)) {
return readDocument_ToBeDeprecated(databaseId, collection, documentId);
}
const { databaseAccount } = userContext;
@@ -217,7 +217,7 @@ export function readDocument(
: "",
};
const endpoint = getFeatureEndpointOrDefault("readDocument");
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.ReadDocument);
return window
.fetch(endpoint, {
@@ -289,7 +289,7 @@ export function createDocument(
partitionKeyProperty: string,
documentContent: unknown,
): Promise<DataModels.DocumentId> {
if (!useMongoProxyEndpoint("createDocument")) {
if (!useMongoProxyEndpoint(MongoProxyApi.CreateDocument)) {
return createDocument_ToBeDeprecated(databaseId, collection, partitionKeyProperty, documentContent);
}
const { databaseAccount } = userContext;
@@ -308,7 +308,7 @@ export function createDocument(
documentContent: JSON.stringify(documentContent),
};
const endpoint = getFeatureEndpointOrDefault("createDocument");
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.CreateDocument);
return window
.fetch(`${endpoint}/createDocument`, {
@@ -373,7 +373,7 @@ export function updateDocument(
documentId: DocumentId,
documentContent: string,
): Promise<DataModels.DocumentId> {
if (!useMongoProxyEndpoint("updateDocument")) {
if (!useMongoProxyEndpoint(MongoProxyApi.UpdateDocument)) {
return updateDocument_ToBeDeprecated(databaseId, collection, documentId, documentContent);
}
const { databaseAccount } = userContext;
@@ -396,7 +396,7 @@ export function updateDocument(
: "",
documentContent,
};
const endpoint = getFeatureEndpointOrDefault("updateDocument");
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.UpdateDocument);
return window
.fetch(endpoint, {
@@ -464,7 +464,7 @@ export function updateDocument_ToBeDeprecated(
}
export function deleteDocument(databaseId: string, collection: Collection, documentId: DocumentId): Promise<void> {
if (!useMongoProxyEndpoint("deleteDocument")) {
if (!useMongoProxyEndpoint(MongoProxyApi.DeleteDocument)) {
return deleteDocument_ToBeDeprecated(databaseId, collection, documentId);
}
const { databaseAccount } = userContext;
@@ -486,7 +486,7 @@ export function deleteDocument(databaseId: string, collection: Collection, docum
? documentId.partitionKeyProperties?.[0]
: "",
};
const endpoint = getFeatureEndpointOrDefault("deleteDocument");
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.DeleteDocument);
return window
.fetch(endpoint, {
@@ -561,7 +561,10 @@ export function deleteDocuments(
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const rids = documentIds.map((documentId) => documentId.id());
const rids: string[] = documentIds.map((documentId) => {
const idComponents = documentId.self.split("/");
return idComponents[5];
});
const params = {
databaseID: databaseId,
@@ -572,7 +575,7 @@ export function deleteDocuments(
resourceGroup: userContext.resourceGroup,
databaseAccountName: databaseAccount.name,
};
const endpoint = getFeatureEndpointOrDefault("bulkdelete");
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.BulkDelete);
return window
.fetch(`${endpoint}/bulkdelete`, {
@@ -596,7 +599,7 @@ export function deleteDocuments(
export function createMongoCollectionWithProxy(
params: DataModels.CreateCollectionParams,
): Promise<DataModels.Collection> {
if (!useMongoProxyEndpoint("createCollectionWithProxy")) {
if (!useMongoProxyEndpoint(MongoProxyApi.CreateCollectionWithProxy)) {
return createMongoCollectionWithProxy_ToBeDeprecated(params);
}
const { databaseAccount } = userContext;
@@ -619,7 +622,7 @@ export function createMongoCollectionWithProxy(
isSharded: !!shardKey,
};
const endpoint = getFeatureEndpointOrDefault("createCollectionWithProxy");
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.CreateCollectionWithProxy);
return window
.fetch(`${endpoint}/createCollection`, {
@@ -689,12 +692,13 @@ export function getFeatureEndpointOrDefault(feature: string): string {
if (useMongoProxyEndpoint(feature)) {
endpoint = configContext.MONGO_PROXY_ENDPOINT;
} else {
const allowedMongoProxyEndpoints = configContext.allowedMongoProxyEndpoints || [
...defaultAllowedMongoProxyEndpoints,
...allowedMongoProxyEndpoints_ToBeDeprecated,
];
endpoint =
hasFlag(userContext.features.mongoProxyAPIs, feature) &&
validateEndpoint(userContext.features.mongoProxyEndpoint, [
...allowedMongoProxyEndpoints,
...allowedMongoProxyEndpoints_ToBeDeprecated,
])
validateEndpoint(userContext.features.mongoProxyEndpoint, allowedMongoProxyEndpoints)
? userContext.features.mongoProxyEndpoint
: configContext.MONGO_BACKEND_ENDPOINT || configContext.BACKEND_ENDPOINT;
}
@@ -715,19 +719,88 @@ export function getEndpoint(endpoint: string): string {
return url;
}
export function useMongoProxyEndpoint(api: string): boolean {
const activeMongoProxyEndpoints: string[] = [
MongoProxyEndpoints.Local,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
];
export function useMongoProxyEndpoint(mongoProxyApi: string): boolean {
const mongoProxyEnvironmentMap: { [key: string]: string[] } = {
[MongoProxyApi.ResourceList]: [
MongoProxyEndpoints.Development,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
],
[MongoProxyApi.QueryDocuments]: [
MongoProxyEndpoints.Development,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
],
[MongoProxyApi.CreateDocument]: [
MongoProxyEndpoints.Development,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
],
[MongoProxyApi.ReadDocument]: [
MongoProxyEndpoints.Development,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
],
[MongoProxyApi.UpdateDocument]: [
MongoProxyEndpoints.Development,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
],
[MongoProxyApi.DeleteDocument]: [
MongoProxyEndpoints.Development,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
],
[MongoProxyApi.CreateCollectionWithProxy]: [
MongoProxyEndpoints.Development,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
],
[MongoProxyApi.LegacyMongoShell]: [
MongoProxyEndpoints.Development,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
],
[MongoProxyApi.BulkDelete]: [
MongoProxyEndpoints.Development,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
],
};
return (
configContext.NEW_MONGO_APIS?.includes(api) &&
activeMongoProxyEndpoints.includes(configContext.MONGO_PROXY_ENDPOINT)
);
if (!mongoProxyEnvironmentMap[mongoProxyApi] || !configContext.MONGO_PROXY_ENDPOINT) {
return false;
}
if (configContext.globallyEnabledMongoAPIs.includes(mongoProxyApi)) {
return true;
}
return mongoProxyEnvironmentMap[mongoProxyApi].includes(configContext.MONGO_PROXY_ENDPOINT);
}
export class ThrottlingError extends Error {
constructor(message: string) {
super(message);
}
}
// TODO: This function throws most of the time except on Forbidden which is a bit strange
@@ -739,6 +812,14 @@ async function errorHandling(response: Response, action: string, params: unknown
if (response.status === HttpStatusCodes.Forbidden) {
sendMessage({ type: MessageTypes.ForbiddenError, reason: errorMessage });
return;
} else if (
response.status === HttpStatusCodes.BadRequest &&
errorMessage.includes("Error=16500") &&
errorMessage.includes("RetryAfterMs=")
) {
// If throttling is happening, Cosmos DB will return a 400 with a body of:
// A write operation resulted in an error. Error=16500, RetryAfterMs=4, Details='Batch write error.
throw new ThrottlingError(errorMessage);
}
throw new Error(errorMessage);
}

View File

@@ -1,39 +0,0 @@
import { configContext, Platform } from "../ConfigContext";
import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
import { userContext } from "../UserContext";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
const notificationsPath = () => {
switch (configContext.platform) {
case Platform.Hosted:
return "/api/guest/notifications";
case Platform.Portal:
return "/api/notifications";
default:
throw new Error(`Unknown platform: ${configContext.platform}`);
}
};
export const fetchPortalNotifications = async (): Promise<DataModels.Notification[]> => {
if (configContext.platform === Platform.Emulator || configContext.platform === Platform.Hosted) {
return [];
}
const { databaseAccount, resourceGroup, subscriptionId } = userContext;
const url = `${configContext.BACKEND_ENDPOINT}${notificationsPath()}?accountName=${
databaseAccount.name
}&subscriptionId=${subscriptionId}&resourceGroup=${resourceGroup}`;
const authorizationHeader: ViewModels.AuthorizationTokenHeaderMetadata = getAuthorizationHeader();
const headers = { [authorizationHeader.header]: authorizationHeader.token };
const response = await window.fetch(url, {
headers,
});
if (!response.ok) {
throw new Error(await response.text());
}
return (await response.json()) as DataModels.Notification[];
};

View File

@@ -0,0 +1,113 @@
import QueryError, { QueryErrorLocation, QueryErrorSeverity } from "Common/QueryError";
describe("QueryError.tryParse", () => {
const testErrorLocationResolver = ({ start, end }: { start: number; end: number }) =>
new QueryErrorLocation(
{ offset: start, lineNumber: start, column: start },
{ offset: end, lineNumber: end, column: end },
);
it("handles a string error", () => {
const error = "error";
const result = QueryError.tryParse(error, testErrorLocationResolver);
expect(result).toEqual([new QueryError("error", QueryErrorSeverity.Error)]);
});
it("handles an error object", () => {
const error = {
message: "error",
severity: "Warning",
location: { start: 0, end: 1 },
code: "code",
};
const result = QueryError.tryParse(error, testErrorLocationResolver);
expect(result).toEqual([
new QueryError(
"error",
QueryErrorSeverity.Warning,
"code",
new QueryErrorLocation({ offset: 0, lineNumber: 0, column: 0 }, { offset: 1, lineNumber: 1, column: 1 }),
),
]);
});
it("handles a JSON message without syntax errors", () => {
const innerError = {
code: "BadRequest",
message: "Your query is bad, and you should feel bad",
};
const message = `Message: ${JSON.stringify(innerError)}\r\nActivity ID: 42`;
const outerError = {
code: "BadRequest",
message,
};
const result = QueryError.tryParse(outerError, testErrorLocationResolver);
expect(result).toEqual([
new QueryError("Your query is bad, and you should feel bad", QueryErrorSeverity.Error, "BadRequest"),
]);
});
// Imitate the value coming from the backend, which has the syntax errors serialized as JSON in the message, along with a prefix and activity id.
it("handles single-nested error", () => {
const errors = [
{
message: "error1",
severity: "Warning",
location: { start: 0, end: 1 },
code: "code1",
},
{
message: "error2",
severity: "Error",
location: { start: 2, end: 3 },
code: "code2",
},
];
const innerError = {
code: "BadRequest",
message: "Your query is bad, and you should feel bad",
errors,
};
const message = `Message: ${JSON.stringify(innerError)}\r\nActivity ID: 42`;
const outerError = {
code: "BadRequest",
message,
};
const result = QueryError.tryParse(outerError, testErrorLocationResolver);
expect(result).toEqual([
new QueryError(
"error1",
QueryErrorSeverity.Warning,
"code1",
new QueryErrorLocation({ offset: 0, lineNumber: 0, column: 0 }, { offset: 1, lineNumber: 1, column: 1 }),
),
new QueryError(
"error2",
QueryErrorSeverity.Error,
"code2",
new QueryErrorLocation({ offset: 2, lineNumber: 2, column: 2 }, { offset: 3, lineNumber: 3, column: 3 }),
),
]);
});
// Imitate another value we've gotten from the backend, which has a doubly-nested JSON payload.
it("handles double-nested error", () => {
const outerError = {
code: "BadRequest",
message:
'{"code":"BadRequest","message":"{\\"errors\\":[{\\"severity\\":\\"Error\\",\\"location\\":{\\"start\\":7,\\"end\\":18},\\"code\\":\\"SC2005\\",\\"message\\":\\"\'nonexistent\' is not a recognized built-in function name.\\"}]}\\r\\nActivityId: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa, Windows/10.0.20348 cosmos-netstandard-sdk/3.18.0"}',
};
const result = QueryError.tryParse(outerError, testErrorLocationResolver);
expect(result).toEqual([
new QueryError(
"'nonexistent' is not a recognized built-in function name.",
QueryErrorSeverity.Error,
"SC2005",
new QueryErrorLocation({ offset: 7, lineNumber: 7, column: 7 }, { offset: 18, lineNumber: 18, column: 18 }),
),
]);
});
});

View File

@@ -1,5 +1,5 @@
import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { monaco } from "Explorer/LazyMonaco";
import { getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
export enum QueryErrorSeverity {
Error = "Error",
@@ -97,13 +97,44 @@ export const createMonacoMarkersForQueryErrors = (errors: QueryError[]) => {
.filter((marker) => !!marker);
};
export interface ErrorEnrichment {
title?: string;
message: string;
learnMoreUrl?: string;
}
const REPLACEMENT_MESSAGES: Record<string, (original: string) => string> = {
OPERATION_RU_LIMIT_EXCEEDED: (original) => {
if (ruThresholdEnabled()) {
const threshold = getRUThreshold();
return `Query exceeded the Request Unit (RU) limit of ${threshold} RUs. You can change this limit in Data Explorer settings.`;
}
return original;
},
};
const HELP_LINKS: Record<string, string> = {
OPERATION_RU_LIMIT_EXCEEDED:
"https://learn.microsoft.com/en-us/azure/cosmos-db/data-explorer#configure-request-unit-threshold",
};
export default class QueryError {
message: string;
helpLink?: string;
constructor(
public message: string,
message: string,
public severity: QueryErrorSeverity,
public code?: string,
public location?: QueryErrorLocation,
) {}
helpLink?: string,
) {
// Automatically replace the message with a more Data Explorer-specific message if we have for this error code.
this.message = REPLACEMENT_MESSAGES[code] ? REPLACEMENT_MESSAGES[code](message) : message;
// Automatically set the help link if we have one for this error code.
this.helpLink = helpLink ?? HELP_LINKS[code];
}
getMonacoSeverity(): monaco.MarkerSeverity {
// It's very difficult to use the monaco.MarkerSeverity enum from here, so we'll just use the numbers directly.
@@ -135,7 +166,7 @@ export default class QueryError {
return errors;
}
const errorMessage = getErrorMessage(error as string | Error);
const errorMessage = error as string;
// Map some well known messages to richer errors
const knownError = knownErrors[errorMessage];
@@ -160,7 +191,9 @@ export default class QueryError {
}
const severity =
"severity" in error && typeof error.severity === "string" ? (error.severity as QueryErrorSeverity) : undefined;
"severity" in error && typeof error.severity === "string"
? (error.severity as QueryErrorSeverity)
: QueryErrorSeverity.Error;
const location =
"location" in error && typeof error.location === "object"
? locationResolver(error.location as { start: number; end: number })
@@ -173,35 +206,49 @@ export default class QueryError {
error: unknown,
locationResolver: (location: { start: number; end: number }) => QueryErrorLocation,
): QueryError[] | null {
if (typeof error === "object" && "message" in error) {
error = error.message;
}
if (typeof error !== "string") {
let message: string | undefined;
if (typeof error === "object" && "message" in error && typeof error.message === "string") {
message = error.message;
} else {
// Unsupported error format.
return null;
}
// Assign to a new variable because of a TypeScript flow typing quirk, see below.
let message = error;
if (message.startsWith("Message: ")) {
// Reassigning this to 'error' restores the original type of 'error', which is 'unknown'.
// So we use a separate variable to avoid this.
message = message.substring("Message: ".length);
// Some newer backends produce a message that contains a doubly-nested JSON payload.
// In this case, the message we get is a fully-complete JSON object we can parse.
// So let's try that first
if (message.startsWith("{") && message.endsWith("}")) {
let outer: unknown = undefined;
try {
outer = JSON.parse(message);
if (typeof outer === "object" && "message" in outer && typeof outer.message === "string") {
message = outer.message;
}
} catch (e) {
// Just continue if the parsing fails. We'll use the fallback logic below.
}
}
const lines = message.split("\n");
message = lines[0].trim();
if (message.startsWith("Message: ")) {
message = message.substring("Message: ".length);
}
let parsed: unknown;
try {
parsed = JSON.parse(message);
} catch (e) {
// Not a query error.
return null;
// The message doesn't contain a nested error.
return [QueryError.read(error, locationResolver)];
}
if (typeof parsed === "object" && "errors" in parsed && Array.isArray(parsed.errors)) {
return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null);
if (typeof parsed === "object") {
if ("errors" in parsed && Array.isArray(parsed.errors)) {
return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null);
}
return [QueryError.read(parsed, locationResolver)];
}
return null;
}

View File

@@ -135,6 +135,7 @@ export const TableEntity: FunctionComponent<TableEntityProps> = ({
onEntityValueChange={onEntityValueChange}
onSelectDate={onSelectDate}
onEntityTimeValueChange={onEntityTimeValueChange}
entityProperty={entityProperty}
/>
{!isEntityValueDisable && (
<TooltipHost content="Edit property" id="editTooltip">

View File

@@ -99,6 +99,9 @@ const createSqlContainer = async (params: DataModels.CreateCollectionParams): Pr
if (params.vectorEmbeddingPolicy) {
resource.vectorEmbeddingPolicy = params.vectorEmbeddingPolicy;
}
if (params.fullTextPolicy) {
resource.fullTextPolicy = params.fullTextPolicy;
}
const rpPayload: ARMTypes.SqlDatabaseCreateUpdateParameters = {
properties: {
@@ -270,6 +273,7 @@ const createCollectionWithSDK = async (params: DataModels.CreateCollectionParams
uniqueKeyPolicy: params.uniqueKeyPolicy || undefined,
analyticalStorageTtl: params.analyticalStorageTtl,
vectorEmbeddingPolicy: params.vectorEmbeddingPolicy,
fullTextPolicy: params.fullTextPolicy,
} as ContainerRequest; // TODO: remove cast when https://github.com/Azure/azure-cosmos-js/issues/423 is fixed
const collectionOptions: RequestOptions = {};
const createDatabaseBody: DatabaseRequest = { id: params.databaseId };

View File

@@ -26,14 +26,23 @@ export const deleteDocument = async (collection: CollectionBase, documentId: Doc
}
};
export interface IBulkDeleteResult {
documentId: DocumentId;
requestCharge: number;
statusCode: number;
retryAfterMilliseconds?: number;
}
/**
* Bulk delete documents
* @param collection
* @param documentId
* @returns array of ids that were successfully deleted
* @returns array of results and status codes
*/
export const deleteDocuments = async (collection: CollectionBase, documentIds: DocumentId[]): Promise<DocumentId[]> => {
const nbDocuments = documentIds.length;
export const deleteDocuments = async (
collection: CollectionBase,
documentIds: DocumentId[],
): Promise<IBulkDeleteResult[]> => {
const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`);
try {
const v2Container = await client().database(collection.databaseId).container(collection.id());
@@ -56,18 +65,17 @@ export const deleteDocuments = async (collection: CollectionBase, documentIds: D
operationType: BulkOperationType.Delete,
}));
const promise = v2Container.items.bulk(operations).then((bulkResult) => {
return documentIdsChunk.filter((_, index) => bulkResult[index].statusCode === 204);
const promise = v2Container.items.bulk(operations).then((bulkResults) => {
return bulkResults.map((bulkResult, index) => {
const documentId = documentIdsChunk[index];
return { ...bulkResult, documentId };
});
});
promiseArray.push(promise);
}
const allResult = await Promise.all(promiseArray);
const flatAllResult = Array.prototype.concat.apply([], allResult);
logConsoleInfo(
`Successfully deleted ${getEntityName(flatAllResult.length > 1)}: ${flatAllResult.length} out of ${nbDocuments}`,
);
// TODO: handle case result.length != nbDocuments
return flatAllResult;
} catch (error) {
handleError(

View File

@@ -1,4 +1,4 @@
import { QueryOperationOptions } from "@azure/cosmos";
// import { QueryOperationOptions } from "@azure/cosmos";
import { QueryResults } from "../../Contracts/ViewModels";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { getEntityName } from "../DocumentUtility";
@@ -9,13 +9,13 @@ export const queryDocumentsPage = async (
resourceName: string,
documentsIterator: MinimalQueryIterator,
firstItemIndex: number,
queryOperationOptions?: QueryOperationOptions,
// queryOperationOptions?: QueryOperationOptions,
): Promise<QueryResults> => {
const entityName = getEntityName();
const clearMessage = logConsoleProgress(`Querying ${entityName} for container ${resourceName}`);
try {
const result: QueryResults = await nextPage(documentsIterator, firstItemIndex, queryOperationOptions);
const result: QueryResults = await nextPage(documentsIterator, firstItemIndex);
const itemCount = (result.documents && result.documents.length) || 0;
logConsoleInfo(`Successfully fetched ${itemCount} ${entityName} for container ${resourceName}`);
return result;

View File

@@ -8,16 +8,16 @@ import {
import {
allowedAadEndpoints,
allowedArcadiaEndpoints,
allowedCassandraProxyEndpoints,
allowedEmulatorEndpoints,
allowedGraphEndpoints,
allowedHostedExplorerEndpoints,
allowedJunoOrigins,
allowedMongoBackendEndpoints,
allowedMongoProxyEndpoints,
allowedMsalRedirectEndpoints,
defaultAllowedArmEndpoints,
defaultAllowedBackendEndpoints,
defaultAllowedCassandraProxyEndpoints,
defaultAllowedMongoProxyEndpoints,
validateEndpoint,
} from "Utils/EndpointUtils";
@@ -32,6 +32,8 @@ export interface ConfigContext {
platform: Platform;
allowedArmEndpoints: ReadonlyArray<string>;
allowedBackendEndpoints: ReadonlyArray<string>;
allowedCassandraProxyEndpoints: ReadonlyArray<string>;
allowedMongoProxyEndpoints: ReadonlyArray<string>;
allowedParentFrameOrigins: ReadonlyArray<string>;
gitSha?: string;
proxyPath?: string;
@@ -49,12 +51,11 @@ export interface ConfigContext {
ARCADIA_ENDPOINT: string;
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
BACKEND_ENDPOINT?: string;
PORTAL_BACKEND_ENDPOINT?: string;
PORTAL_BACKEND_ENDPOINT: string;
NEW_BACKEND_APIS?: BackendApi[];
MONGO_BACKEND_ENDPOINT?: string;
MONGO_PROXY_ENDPOINT?: string;
NEW_MONGO_APIS?: string[];
CASSANDRA_PROXY_ENDPOINT?: string;
MONGO_PROXY_ENDPOINT: string;
CASSANDRA_PROXY_ENDPOINT: string;
NEW_CASSANDRA_APIS?: string[];
PROXY_PATH?: string;
JUNO_ENDPOINT: string;
@@ -66,6 +67,8 @@ export interface ConfigContext {
hostedExplorerURL: string;
armAPIVersion?: string;
msalRedirectURI?: string;
globallyEnabledCassandraAPIs?: string[];
globallyEnabledMongoAPIs?: string[];
}
// Default configuration
@@ -73,9 +76,12 @@ let configContext: Readonly<ConfigContext> = {
platform: Platform.Portal,
allowedArmEndpoints: defaultAllowedArmEndpoints,
allowedBackendEndpoints: defaultAllowedBackendEndpoints,
allowedCassandraProxyEndpoints: defaultAllowedCassandraProxyEndpoints,
allowedMongoProxyEndpoints: defaultAllowedMongoProxyEndpoints,
allowedParentFrameOrigins: [
`^https:\\/\\/cosmos\\.azure\\.(com|cn|us)$`,
`^https:\\/\\/[\\.\\w]*portal\\.azure\\.(com|cn|us)$`,
`^https:\\/\\/cdb-(ms|ff|mc)-prod-pbe\\.cosmos\\.azure\\.(com|us|cn)$`,
`^https:\\/\\/[\\.\\w]*portal\\.microsoftazure\\.de$`,
`^https:\\/\\/[\\.\\w]*ext\\.azure\\.(com|cn|us)$`,
`^https:\\/\\/[\\.\\w]*\\.ext\\.microsoftazure\\.de$`,
@@ -106,21 +112,12 @@ let configContext: Readonly<ConfigContext> = {
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
NEW_MONGO_APIS: [
"resourcelist",
"queryDocuments",
"createDocument",
"readDocument",
"updateDocument",
"deleteDocument",
"createCollectionWithProxy",
"legacyMongoShell",
"bulkdelete",
],
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
NEW_CASSANDRA_APIS: ["postQuery", "createOrDelete", "getKeys", "getSchema"],
isTerminalEnabled: false,
isPhoenixEnabled: false,
globallyEnabledCassandraAPIs: [],
globallyEnabledMongoAPIs: [],
};
export function resetConfigContext(): void {
@@ -164,7 +161,12 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
delete newContext.BACKEND_ENDPOINT;
}
if (!validateEndpoint(newContext.MONGO_PROXY_ENDPOINT, allowedMongoProxyEndpoints)) {
if (
!validateEndpoint(
newContext.MONGO_PROXY_ENDPOINT,
configContext.allowedMongoProxyEndpoints || defaultAllowedMongoProxyEndpoints,
)
) {
delete newContext.MONGO_PROXY_ENDPOINT;
}
@@ -172,7 +174,12 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
delete newContext.MONGO_BACKEND_ENDPOINT;
}
if (!validateEndpoint(newContext.CASSANDRA_PROXY_ENDPOINT, allowedCassandraProxyEndpoints)) {
if (
!validateEndpoint(
newContext.CASSANDRA_PROXY_ENDPOINT,
configContext.allowedCassandraProxyEndpoints || defaultAllowedCassandraProxyEndpoints,
)
) {
delete newContext.CASSANDRA_PROXY_ENDPOINT;
}

View File

@@ -9,6 +9,7 @@ export enum TabKind {
Graph,
SQLQuery,
ScaleSettings,
MongoQuery,
}
/**
@@ -51,6 +52,8 @@ export interface OpenCollectionTab extends OpenTab {
*/
export interface OpenQueryTab extends OpenCollectionTab {
query: QueryInfo;
splitterDirection?: "vertical" | "horizontal";
queryViewSizePercent?: number;
}
/**

View File

@@ -159,6 +159,7 @@ export interface Collection extends Resource {
analyticalStorageTtl?: number;
geospatialConfig?: GeospatialConfig;
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
fullTextPolicy?: FullTextPolicy;
schema?: ISchema;
requestSchema?: () => void;
computedProperties?: ComputedProperties;
@@ -199,11 +200,19 @@ export interface IndexingPolicy {
compositeIndexes?: any[];
spatialIndexes?: any[];
vectorIndexes?: VectorIndex[];
fullTextIndexes?: FullTextIndex[];
}
export interface VectorIndex {
path: string;
type: "flat" | "diskANN" | "quantizedFlat";
diskANNShardKey?: string;
indexingSearchListSize?: number;
quantizationByteSize?: number;
}
export interface FullTextIndex {
path: string;
}
export interface ComputedProperty {
@@ -342,6 +351,7 @@ export interface CreateCollectionParams {
uniqueKeyPolicy?: UniqueKeyPolicy;
createMongoWildcardIndex?: boolean;
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
fullTextPolicy?: FullTextPolicy;
}
export interface VectorEmbeddingPolicy {
@@ -355,6 +365,16 @@ export interface VectorEmbedding {
path: string;
}
export interface FullTextPolicy {
defaultLanguage: string;
fullTextPaths: FullTextPath[];
}
export interface FullTextPath {
path: string;
language: string;
}
export interface ReadDatabaseOfferParams {
databaseId: string;
databaseResourceId?: string;

View File

@@ -98,7 +98,6 @@ export interface Database extends TreeNode {
openAddCollection(database: Database, event: MouseEvent): void;
onSettingsClick: () => void;
loadOffer(): Promise<void>;
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
}
export interface CollectionBase extends TreeNode {
@@ -116,7 +115,13 @@ export interface CollectionBase extends TreeNode {
isSampleCollection?: boolean;
onDocumentDBDocumentsClick(): void;
onNewQueryClick(source: any, event?: MouseEvent, queryText?: string): void;
onNewQueryClick(
source: any,
event?: MouseEvent,
queryText?: string,
splitterDirection?: "horizontal" | "vertical",
queryViewSizePercent?: number,
): void;
expandCollection(): void;
collapseCollection(): void;
getDatabase(): Database;
@@ -127,6 +132,8 @@ export interface Collection extends CollectionBase {
analyticalStorageTtl: ko.Observable<number>;
schema?: DataModels.ISchema;
requestSchema?: () => void;
vectorEmbeddingPolicy: ko.Observable<DataModels.VectorEmbeddingPolicy>;
fullTextPolicy: ko.Observable<DataModels.FullTextPolicy>;
indexingPolicy: ko.Observable<DataModels.IndexingPolicy>;
uniqueKeyPolicy: DataModels.UniqueKeyPolicy;
usageSizeInKB: ko.Observable<number>;
@@ -150,7 +157,13 @@ export interface Collection extends CollectionBase {
onSettingsClick: () => Promise<void>;
onNewGraphClick(): void;
onNewMongoQueryClick(source: any, event?: MouseEvent, queryText?: string): void;
onNewMongoQueryClick(
source: any,
event?: MouseEvent,
queryText?: string,
splitterDirection?: "horizontal" | "vertical",
queryViewSizePercent?: number,
): void;
onNewMongoShellClick(): void;
onNewStoredProcedureClick(source: Collection, event?: MouseEvent): void;
onNewUserDefinedFunctionClick(source: Collection, event?: MouseEvent): void;
@@ -191,8 +204,6 @@ export interface Collection extends CollectionBase {
onDragOver(source: Collection, event: { originalEvent: DragEvent }): void;
onDrop(source: Collection, event: { originalEvent: DragEvent }): void;
uploadFiles(fileList: FileList): Promise<{ data: UploadDetailsRecord[] }>;
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
}
/**
@@ -312,6 +323,8 @@ export interface QueryTabOptions extends TabOptions {
partitionKey?: DataModels.PartitionKey;
queryText?: string;
resourceTokenPartitionKey?: string;
splitterDirection?: "horizontal" | "vertical";
queryViewSizePercent?: number;
}
export interface ScriptTabOption extends TabOptions {
@@ -385,6 +398,8 @@ export interface DataExplorerInputsFrame {
databaseAccount: any;
subscriptionId?: string;
resourceGroup?: string;
tenantId?: string;
userName?: string;
masterKey?: string;
hasWriteAccess?: boolean;
authorizationToken?: string;

View File

@@ -56,13 +56,15 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin
if (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations) {
items.push({
iconSrc: DeleteDatabaseIcon,
onClick: () =>
useSidePanel
.getState()
.openSidePanel(
"Delete " + getDatabaseName(),
<DeleteDatabaseConfirmationPanel refreshDatabases={() => container.refreshAllDatabases()} />,
),
onClick: (lastFocusedElement?: React.RefObject<HTMLElement>) => {
(useSidePanel.getState().getRef = lastFocusedElement),
useSidePanel
.getState()
.openSidePanel(
"Delete " + getDatabaseName(),
<DeleteDatabaseConfirmationPanel refreshDatabases={() => container.refreshAllDatabases()} />,
);
},
label: `Delete ${getDatabaseName()}`,
styleClass: "deleteDatabaseMenuItem",
});
@@ -146,14 +148,15 @@ export const createCollectionContextMenuButton = (
if (configContext.platform !== Platform.Fabric) {
items.push({
iconSrc: DeleteCollectionIcon,
onClick: () => {
onClick: (lastFocusedElement?: React.RefObject<HTMLElement>) => {
useSelectedNode.getState().setSelectedNode(selectedCollection);
useSidePanel
.getState()
.openSidePanel(
"Delete " + getCollectionName(),
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
);
(useSidePanel.getState().getRef = lastFocusedElement),
useSidePanel
.getState()
.openSidePanel(
"Delete " + getCollectionName(),
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
);
},
label: `Delete ${getCollectionName()}`,
styleClass: "deleteCollectionMenuItem",

View File

@@ -1,4 +1,4 @@
import { DirectionalHint, Icon, Label, Stack, TooltipHost } from "@fluentui/react";
import { DirectionalHint, Icon, IconButton, Label, Stack, TooltipHost } from "@fluentui/react";
import * as React from "react";
import { NormalizedEventKey } from "../../../Common/Constants";
import { accordionStackTokens } from "../Settings/SettingsRenderUtils";
@@ -9,6 +9,9 @@ export interface CollapsibleSectionProps {
onExpand?: () => void;
children: JSX.Element;
tooltipContent?: string | JSX.Element | JSX.Element[];
showDelete?: boolean;
onDelete?: () => void;
disabled?: boolean;
}
export interface CollapsibleSectionState {
@@ -69,6 +72,20 @@ export class CollapsibleSectionComponent extends React.Component<CollapsibleSect
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} />
</TooltipHost>
)}
{this.props.showDelete && (
<Stack.Item style={{ marginLeft: "auto" }}>
<IconButton
disabled={this.props.disabled}
id={`delete-${this.props.title.split(" ").join("-")}`}
iconProps={{ iconName: "Delete" }}
style={{ height: 27, marginRight: "20px" }}
onClick={(event) => {
event.stopPropagation();
this.props.onDelete();
}}
/>
</Stack.Item>
)}
</Stack>
{this.state.isExpanded && this.props.children}
</>

View File

@@ -35,7 +35,7 @@ export interface DialogState {
textFieldProps?: TextFieldProps,
primaryButtonDisabled?: boolean,
) => void;
showOkModalDialog: (title: string, subText: string) => void;
showOkModalDialog: (title: string, subText: string, linkProps?: LinkProps) => void;
}
export const useDialog: UseStore<DialogState> = create((set, get) => ({
@@ -83,7 +83,7 @@ export const useDialog: UseStore<DialogState> = create((set, get) => ({
textFieldProps,
primaryButtonDisabled,
}),
showOkModalDialog: (title: string, subText: string): void =>
showOkModalDialog: (title: string, subText: string, linkProps?: LinkProps): void =>
get().openDialog({
isModal: true,
title,
@@ -94,6 +94,7 @@ export const useDialog: UseStore<DialogState> = create((set, get) => ({
get().closeDialog();
},
onSecondaryButtonClick: undefined,
linkProps,
}),
}));

View File

@@ -0,0 +1,6 @@
import "@testing-library/jest-dom";
describe("AddFullTextPolicyForm", () => {
//CTODO: add tests
it.skip("should render correctly", () => {});
});

View File

@@ -0,0 +1,239 @@
import {
DefaultButton,
Dropdown,
IDropdownOption,
IStyleFunctionOrObject,
ITextFieldStyleProps,
ITextFieldStyles,
Label,
Stack,
TextField,
} from "@fluentui/react";
import { FullTextIndex, FullTextPath, FullTextPolicy } from "Contracts/DataModels";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import * as React from "react";
export interface FullTextPoliciesComponentProps {
fullTextPolicy: FullTextPolicy;
onFullTextPathChange: (
fullTextPolicy: FullTextPolicy,
fullTextIndexes: FullTextIndex[],
validationPassed: boolean,
) => void;
discardChanges?: boolean;
onChangesDiscarded?: () => void;
}
export interface FullTextPolicyData {
path: string;
language: string;
pathError: string;
}
const labelStyles = {
root: {
fontSize: 12,
},
};
const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldStyles> = {
fieldGroup: {
height: 27,
},
field: {
fontSize: 12,
padding: "0 8px",
},
};
const dropdownStyles = {
title: {
height: 27,
lineHeight: "24px",
fontSize: 12,
},
dropdown: {
height: 27,
lineHeight: "24px",
},
dropdownItem: {
fontSize: 12,
},
};
export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPoliciesComponentProps> = ({
fullTextPolicy,
onFullTextPathChange,
discardChanges,
onChangesDiscarded,
}): JSX.Element => {
const getFullTextPathError = (path: string, index?: number): string => {
let error = "";
if (!path) {
error = "Full text path should not be empty";
}
if (
index >= 0 &&
fullTextPathData?.find(
(fullTextPath: FullTextPolicyData, dataIndex: number) => dataIndex !== index && fullTextPath.path === path,
)
) {
error = "Full text path is already defined";
}
return error;
};
const initializeData = (fullTextPolicy: FullTextPolicy): FullTextPolicyData[] => {
if (!fullTextPolicy) {
fullTextPolicy = { defaultLanguage: getFullTextLanguageOptions()[0].key as never, fullTextPaths: [] };
}
return fullTextPolicy.fullTextPaths.map((fullTextPath: FullTextPath) => ({
...fullTextPath,
pathError: getFullTextPathError(fullTextPath.path),
}));
};
const [fullTextPathData, setFullTextPathData] = React.useState<FullTextPolicyData[]>(initializeData(fullTextPolicy));
const [defaultLanguage, setDefaultLanguage] = React.useState<string>(
fullTextPolicy ? fullTextPolicy.defaultLanguage : (getFullTextLanguageOptions()[0].key as never),
);
React.useEffect(() => {
propagateData();
}, [fullTextPathData, defaultLanguage]);
React.useEffect(() => {
if (discardChanges) {
setFullTextPathData(initializeData(fullTextPolicy));
setDefaultLanguage(fullTextPolicy.defaultLanguage);
onChangesDiscarded();
}
}, [discardChanges]);
const propagateData = () => {
const newFullTextPolicy: FullTextPolicy = {
defaultLanguage: defaultLanguage,
fullTextPaths: fullTextPathData.map((policy: FullTextPolicyData) => ({
path: policy.path,
language: policy.language,
})),
};
const fullTextIndexes: FullTextIndex[] = fullTextPathData.map((policy) => ({
path: policy.path,
}));
const validationPassed = fullTextPathData.every((policy: FullTextPolicyData) => policy.pathError === "");
onFullTextPathChange(newFullTextPolicy, fullTextIndexes, validationPassed);
};
const onFullTextPathValueChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trim();
const fullTextPaths = [...fullTextPathData];
if (!fullTextPaths[index]?.path && !value.startsWith("/")) {
fullTextPaths[index].path = "/" + value;
} else {
fullTextPaths[index].path = value;
}
fullTextPaths[index].pathError = getFullTextPathError(value, index);
setFullTextPathData(fullTextPaths);
};
const onFullTextPathPolicyChange = (index: number, option: IDropdownOption): void => {
const policies = [...fullTextPathData];
policies[index].language = option.key as never;
setFullTextPathData(policies);
};
const onAdd = () => {
setFullTextPathData([
...fullTextPathData,
{
path: "",
language: defaultLanguage,
pathError: getFullTextPathError(""),
},
]);
};
const onDelete = (index: number) => {
const policies = fullTextPathData.filter((_uniqueKey, j) => index !== j);
setFullTextPathData(policies);
};
return (
<Stack tokens={{ childrenGap: 4 }}>
<Stack style={{ marginBottom: 10 }}>
<Label styles={labelStyles}>Default language</Label>
<Dropdown
required={true}
styles={dropdownStyles}
options={getFullTextLanguageOptions()}
selectedKey={defaultLanguage}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
setDefaultLanguage(option.key as never)
}
></Dropdown>
</Stack>
{fullTextPathData &&
fullTextPathData.length > 0 &&
fullTextPathData.map((fullTextPolicy: FullTextPolicyData, index: number) => (
<CollapsibleSectionComponent
key={index}
isExpandedByDefault={true}
title={`Full text path ${index + 1}`}
showDelete={true}
onDelete={() => onDelete(index)}
>
<Stack horizontal tokens={{ childrenGap: 4 }}>
<Stack
styles={{
root: {
margin: "0 0 6px 20px !important",
paddingLeft: 20,
width: "80%",
borderLeft: "1px solid",
},
}}
>
<Stack>
<Label styles={labelStyles}>Path</Label>
<TextField
id={`full-text-policy-path-${index + 1}`}
required={true}
placeholder="/fullTextPath1"
styles={textFieldStyles}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onFullTextPathValueChange(index, event)}
value={fullTextPolicy.path || ""}
errorMessage={fullTextPolicy.pathError}
/>
</Stack>
<Stack>
<Label styles={labelStyles}>Language</Label>
<Dropdown
required={true}
styles={dropdownStyles}
options={getFullTextLanguageOptions()}
selectedKey={fullTextPolicy.language}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onFullTextPathPolicyChange(index, option)
}
></Dropdown>
</Stack>
</Stack>
</Stack>
</CollapsibleSectionComponent>
))}
<DefaultButton id={`add-vector-policy`} styles={{ root: { maxWidth: 170, fontSize: 12 } }} onClick={onAdd}>
Add full text path
</DefaultButton>
</Stack>
);
};
export const getFullTextLanguageOptions = (): IDropdownOption[] => {
return [
{
key: "en-US",
text: "English (US)",
},
];
};

View File

@@ -0,0 +1,314 @@
// This component is used to create a dropdown list of options for the user to select from.
// The options are displayed in a dropdown list when the user clicks on the input field.
// The user can then select an option from the list. The selected option is then displayed in the input field.
import { getTheme } from "@fluentui/react";
import {
Button,
Divider,
Input,
Link,
makeStyles,
Popover,
PopoverProps,
PopoverSurface,
PositioningImperativeRef,
} from "@fluentui/react-components";
import { ArrowDownRegular, DismissRegular } from "@fluentui/react-icons";
import { NormalizedEventKey } from "Common/Constants";
import { tokens } from "Explorer/Theme/ThemeUtil";
import React, { FC, useEffect, useRef } from "react";
const useStyles = makeStyles({
container: {
padding: 0,
},
input: {
flexGrow: 1,
paddingRight: 0,
outline: "none",
"& input:focus": {
outline: "none", // Undo body :focus dashed outline
},
},
inputButton: {
border: 0,
},
dropdownHeader: {
width: "100%",
fontSize: tokens.fontSizeBase300,
fontWeight: 600,
padding: `${tokens.spacingVerticalM} 0 0 ${tokens.spacingVerticalM}`,
},
dropdownStack: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
marginTop: tokens.spacingVerticalS,
marginBottom: "1px",
},
dropdownOption: {
fontSize: tokens.fontSizeBase300,
fontWeight: 400,
justifyContent: "left",
padding: `${tokens.spacingHorizontalXS} ${tokens.spacingHorizontalS} ${tokens.spacingHorizontalXS} ${tokens.spacingHorizontalL}`,
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
border: 0,
":hover": {
outline: `1px dashed ${tokens.colorNeutralForeground1Hover}`,
backgroundColor: tokens.colorNeutralBackground2Hover,
color: tokens.colorNeutralForeground1,
},
},
bottomSection: {
fontSize: tokens.fontSizeBase300,
fontWeight: 400,
padding: `${tokens.spacingHorizontalXS} ${tokens.spacingHorizontalS} ${tokens.spacingHorizontalXS} ${tokens.spacingHorizontalL}`,
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
},
});
export interface InputDatalistDropdownOptionSection {
label: string;
options: string[];
}
export interface InputDataListProps {
dropdownOptions: InputDatalistDropdownOptionSection[];
placeholder?: string;
title?: string;
value: string;
onChange: (value: string) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
autofocus?: boolean; // true: acquire focus on first render
bottomLink?: {
text: string;
url: string;
};
}
export const InputDataList: FC<InputDataListProps> = ({
dropdownOptions,
placeholder,
title,
value,
onChange,
onKeyDown,
autofocus,
bottomLink,
}) => {
const styles = useStyles();
const [showDropdown, setShowDropdown] = React.useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const positioningRef = React.useRef<PositioningImperativeRef>(null);
const [isInputFocused, setIsInputFocused] = React.useState(autofocus);
const [autofocusFirstDropdownItem, setAutofocusFirstDropdownItem] = React.useState(false);
const theme = getTheme();
const itemRefs = useRef([]);
useEffect(() => {
if (inputRef.current) {
positioningRef.current?.setTarget(inputRef.current);
}
}, [inputRef, positioningRef]);
useEffect(() => {
if (isInputFocused) {
inputRef.current?.focus();
}
}, [isInputFocused]);
useEffect(() => {
if (autofocusFirstDropdownItem && showDropdown) {
// Autofocus on first item if input isn't focused
itemRefs.current[0]?.focus();
setAutofocusFirstDropdownItem(false);
}
}, [autofocusFirstDropdownItem, showDropdown]);
const handleOpenChange: PopoverProps["onOpenChange"] = (e, data) => {
if (isInputFocused && !data.open) {
// Don't close if input is focused and we're opening the dropdown (which will steal the focus)
return;
}
setShowDropdown(data.open || false);
if (data.open) {
setIsInputFocused(true);
}
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === NormalizedEventKey.Escape) {
setShowDropdown(false);
} else if (e.key === NormalizedEventKey.DownArrow) {
setShowDropdown(true);
setAutofocusFirstDropdownItem(true);
}
onKeyDown(e);
};
const handleDownDropdownItemKeyDown = (
e: React.KeyboardEvent<HTMLButtonElement | HTMLAnchorElement>,
index: number,
) => {
if (e.key === NormalizedEventKey.Enter) {
e.currentTarget.click();
} else if (e.key === NormalizedEventKey.Escape) {
setShowDropdown(false);
inputRef.current?.focus();
} else if (e.key === NormalizedEventKey.DownArrow) {
if (index + 1 < itemRefs.current.length) {
itemRefs.current[index + 1].focus();
} else {
setIsInputFocused(true);
}
} else if (e.key === NormalizedEventKey.UpArrow) {
if (index - 1 >= 0) {
itemRefs.current[index - 1].focus();
} else {
// Last item, focus back to input
setIsInputFocused(true);
}
}
};
// Flatten dropdownOptions to better manage refs and focus
let flatIndex = 0;
const indexMap = new Map<string, number>();
for (let sectionIndex = 0; sectionIndex < dropdownOptions.length; sectionIndex++) {
const section = dropdownOptions[sectionIndex];
for (let optionIndex = 0; optionIndex < section.options.length; optionIndex++) {
indexMap.set(`${sectionIndex}-${optionIndex}`, flatIndex);
flatIndex++;
}
}
return (
<>
<Input
id="filterInput"
ref={inputRef}
type="text"
size="small"
autoComplete="off"
className={`filterInput ${styles.input}`}
title={title}
placeholder={placeholder}
value={value}
autoFocus
onKeyDown={handleInputKeyDown}
onChange={(e) => {
const newValue = e.target.value;
// Don't show dropdown if there is already a value in the input field (when user is typing)
setShowDropdown(!(newValue.length > 0));
onChange(newValue);
}}
onClick={(e) => {
e.stopPropagation();
}}
onFocus={() => {
// Don't show dropdown if there is already a value in the input field
// or isInputFocused is undefined which means component is mounting
setShowDropdown(!(value.length > 0) && isInputFocused !== undefined);
setIsInputFocused(true);
}}
onBlur={() => {
setIsInputFocused(false);
}}
contentAfter={
value.length > 0 ? (
<Button
aria-label="Clear filter"
className={styles.inputButton}
size="small"
icon={<DismissRegular />}
onClick={() => {
onChange("");
setIsInputFocused(true);
}}
/>
) : (
<Button
aria-label="Open dropdown"
className={styles.inputButton}
size="small"
icon={<ArrowDownRegular />}
onClick={() => {
setShowDropdown(true);
setAutofocusFirstDropdownItem(true);
}}
/>
)
}
/>
<Popover
inline
unstable_disableAutoFocus
// trapFocus
open={showDropdown}
onOpenChange={handleOpenChange}
positioning={{ positioningRef, position: "below", align: "start", offset: 4 }}
>
<PopoverSurface className={styles.container}>
{dropdownOptions.map((section, sectionIndex) => (
<div key={section.label}>
<div className={styles.dropdownHeader} style={{ color: theme.palette.themePrimary }}>
{section.label}
</div>
<div className={styles.dropdownStack}>
{section.options.map((option, index) => (
<Button
key={option}
ref={(el) => (itemRefs.current[indexMap.get(`${sectionIndex}-${index}`)] = el)}
appearance="transparent"
shape="square"
className={styles.dropdownOption}
onClick={() => {
onChange(option);
setShowDropdown(false);
setIsInputFocused(true);
}}
onBlur={() =>
!bottomLink &&
sectionIndex === dropdownOptions.length - 1 &&
index === section.options.length - 1 &&
setShowDropdown(false)
}
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) =>
handleDownDropdownItemKeyDown(e, indexMap.get(`${sectionIndex}-${index}`))
}
>
{option}
</Button>
))}
</div>
</div>
))}
{bottomLink && (
<>
<Divider />
<div className={styles.bottomSection}>
<Link
ref={(el) => (itemRefs.current[flatIndex] = el)}
href={bottomLink.url}
target="_blank"
onBlur={() => setShowDropdown(false)}
onKeyDown={(e: React.KeyboardEvent<HTMLAnchorElement>) => handleDownDropdownItemKeyDown(e, flatIndex)}
>
{bottomLink.text}
</Link>
</div>
</>
)}
</PopoverSurface>
</Popover>
</>
);
};

View File

@@ -0,0 +1,79 @@
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
DialogTrigger,
Field,
ProgressBar,
} from "@fluentui/react-components";
import * as React from "react";
interface ProgressModalDialogProps {
isOpen: boolean;
title: string;
message: string;
maxValue: number;
value: number;
dismissText: string;
onDismiss: () => void;
onCancel?: () => void;
/* mode drives the state of the action buttons
* inProgress: Show cancel button
* completed: Show close button
* aborting: Show cancel button, but disabled
* aborted: Show close button
*/
mode?: "inProgress" | "completed" | "aborting" | "aborted";
}
/**
* React component that renders a modal dialog with a progress bar.
*/
export const ProgressModalDialog: React.FC<ProgressModalDialogProps> = ({
isOpen,
title,
message,
maxValue,
value,
dismissText,
onCancel,
onDismiss,
children,
mode = "completed",
}) => (
<Dialog
open={isOpen}
onOpenChange={(event, data) => {
if (!data.open) {
onDismiss();
}
}}
>
<DialogSurface>
<DialogBody>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Field validationMessage={message} validationState="none">
<ProgressBar max={maxValue} value={value} />
</Field>
{children}
</DialogContent>
<DialogActions>
{mode === "inProgress" || mode === "aborting" ? (
<Button appearance="secondary" onClick={onCancel} disabled={mode === "aborting"}>
{dismissText}
</Button>
) : (
<DialogTrigger disableButtonEnhancement>
<Button appearance="primary">Close</Button>
</DialogTrigger>
)}
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);

View File

@@ -134,7 +134,6 @@ describe("SettingsComponent", () => {
readSettings: undefined,
onSettingsClick: undefined,
loadOffer: undefined,
getPendingThroughputSplitNotification: undefined,
} as ViewModels.Database;
newCollection.getDatabase = () => newDatabase;
newCollection.offer = ko.observable(undefined);

View File

@@ -4,11 +4,11 @@ import {
ComputedPropertiesComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent";
import {
ContainerVectorPolicyComponent,
ContainerVectorPolicyComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ContainerVectorPolicyComponent";
ContainerPolicyComponent,
ContainerPolicyComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent";
import { useDatabases } from "Explorer/useDatabases";
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isFullTextSearchEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
import * as React from "react";
import DiscardIcon from "../../../../images/discard.svg";
@@ -105,6 +105,13 @@ export interface SettingsComponentState {
isSubSettingsSaveable: boolean;
isSubSettingsDiscardable: boolean;
vectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy;
vectorEmbeddingPolicyBaseline: DataModels.VectorEmbeddingPolicy;
fullTextPolicy: DataModels.FullTextPolicy;
fullTextPolicyBaseline: DataModels.FullTextPolicy;
shouldDiscardContainerPolicies: boolean;
isContainerPolicyDirty: boolean;
indexingPolicyContent: DataModels.IndexingPolicy;
indexingPolicyContentBaseline: DataModels.IndexingPolicy;
shouldDiscardIndexingPolicy: boolean;
@@ -130,7 +137,6 @@ export interface SettingsComponentState {
conflictResolutionPolicyProcedureBaseline: string;
isConflictResolutionDirty: boolean;
initialNotification: DataModels.Notification;
selectedTab: SettingsV2TabTypes;
}
@@ -150,6 +156,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private shouldShowIndexingPolicyEditor: boolean;
private shouldShowPartitionKeyEditor: boolean;
private isVectorSearchEnabled: boolean;
private isFullTextSearchEnabled: boolean;
private totalThroughputUsed: number;
public mongoDBCollectionResource: MongoDBCollectionResource;
@@ -165,6 +172,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.shouldShowIndexingPolicyEditor = userContext.apiType !== "Cassandra" && userContext.apiType !== "Mongo";
this.shouldShowPartitionKeyEditor = userContext.apiType === "SQL" && isRunningOnPublicCloud();
this.isVectorSearchEnabled = isVectorSearchEnabled() && !hasDatabaseSharedThroughput(this.collection);
this.isFullTextSearchEnabled = isFullTextSearchEnabled() && !hasDatabaseSharedThroughput(this.collection);
this.changeFeedPolicyVisible = userContext.features.enableChangeFeedPolicy;
@@ -204,6 +212,13 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
isSubSettingsSaveable: false,
isSubSettingsDiscardable: false,
vectorEmbeddingPolicy: undefined,
vectorEmbeddingPolicyBaseline: undefined,
fullTextPolicy: undefined,
fullTextPolicyBaseline: undefined,
shouldDiscardContainerPolicies: false,
isContainerPolicyDirty: false,
indexingPolicyContent: undefined,
indexingPolicyContentBaseline: undefined,
shouldDiscardIndexingPolicy: false,
@@ -229,7 +244,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
conflictResolutionPolicyProcedureBaseline: undefined,
isConflictResolutionDirty: false,
initialNotification: undefined,
selectedTab: SettingsV2TabTypes.ScaleTab,
};
@@ -309,6 +323,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
return (
this.state.isScaleSaveable ||
this.state.isSubSettingsSaveable ||
this.state.isContainerPolicyDirty ||
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty ||
@@ -320,6 +335,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
return (
this.state.isScaleDiscardable ||
this.state.isSubSettingsDiscardable ||
this.state.isContainerPolicyDirty ||
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty ||
@@ -407,6 +423,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
timeToLiveSeconds: this.state.timeToLiveSecondsBaseline,
displayedTtlSeconds: this.state.displayedTtlSecondsBaseline,
geospatialConfigType: this.state.geospatialConfigTypeBaseline,
vectorEmbeddingPolicy: this.state.vectorEmbeddingPolicyBaseline,
fullTextPolicy: this.state.fullTextPolicyBaseline,
indexingPolicyContent: this.state.indexingPolicyContentBaseline,
indexesToAdd: [],
indexesToDrop: [],
@@ -418,11 +436,13 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
changeFeedPolicy: this.state.changeFeedPolicyBaseline,
autoPilotThroughput: this.state.autoPilotThroughputBaseline,
isAutoPilotSelected: this.state.wasAutopilotOriginallySet,
shouldDiscardContainerPolicies: true,
shouldDiscardIndexingPolicy: true,
isScaleSaveable: false,
isScaleDiscardable: false,
isSubSettingsSaveable: false,
isSubSettingsDiscardable: false,
isContainerPolicyDirty: false,
isIndexingPolicyDirty: false,
isMongoIndexingPolicySaveable: false,
isMongoIndexingPolicyDiscardable: false,
@@ -450,9 +470,17 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private onScaleDiscardableChange = (isScaleDiscardable: boolean): void =>
this.setState({ isScaleDiscardable: isScaleDiscardable });
private onVectorEmbeddingPolicyChange = (newVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy): void =>
this.setState({ vectorEmbeddingPolicy: newVectorEmbeddingPolicy });
private onFullTextPolicyChange = (newFullTextPolicy: DataModels.FullTextPolicy): void =>
this.setState({ fullTextPolicy: newFullTextPolicy });
private onIndexingPolicyContentChange = (newIndexingPolicy: DataModels.IndexingPolicy): void =>
this.setState({ indexingPolicyContent: newIndexingPolicy });
private resetShouldDiscardContainerPolicies = (): void => this.setState({ shouldDiscardContainerPolicies: false });
private resetShouldDiscardIndexingPolicy = (): void => this.setState({ shouldDiscardIndexingPolicy: false });
private logIndexingPolicySuccessMessage = (): void => {
@@ -540,6 +568,12 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private onSubSettingsDiscardableChange = (isSubSettingsDiscardable: boolean): void =>
this.setState({ isSubSettingsDiscardable: isSubSettingsDiscardable });
private onVectorEmbeddingPolicyDirtyChange = (isVectorEmbeddingPolicyDirty: boolean): void =>
this.setState({ isContainerPolicyDirty: isVectorEmbeddingPolicyDirty });
private onFullTextPolicyDirtyChange = (isFullTextPolicyDirty: boolean): void =>
this.setState({ isContainerPolicyDirty: isFullTextPolicyDirty });
private onIndexingPolicyDirtyChange = (isIndexingPolicyDirty: boolean): void =>
this.setState({ isIndexingPolicyDirty: isIndexingPolicyDirty });
@@ -693,6 +727,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
const changeFeedPolicy = this.collection.rawDataModel?.changeFeedPolicy
? ChangeFeedPolicyState.On
: ChangeFeedPolicyState.Off;
const vectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy =
this.collection.vectorEmbeddingPolicy && this.collection.vectorEmbeddingPolicy();
const fullTextPolicy: DataModels.FullTextPolicy =
this.collection.fullTextPolicy && this.collection.fullTextPolicy();
const indexingPolicyContent = this.collection.indexingPolicy();
const conflictResolutionPolicy: DataModels.ConflictResolutionPolicy =
this.collection.conflictResolutionPolicy && this.collection.conflictResolutionPolicy();
@@ -726,6 +764,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
analyticalStorageTtlSelectionBaseline: analyticalStorageTtlSelection,
analyticalStorageTtlSeconds: analyticalStorageTtlSeconds,
analyticalStorageTtlSecondsBaseline: analyticalStorageTtlSeconds,
vectorEmbeddingPolicy: vectorEmbeddingPolicy,
vectorEmbeddingPolicyBaseline: vectorEmbeddingPolicy,
fullTextPolicy: fullTextPolicy,
fullTextPolicyBaseline: fullTextPolicy,
indexingPolicyContent: indexingPolicyContent,
indexingPolicyContentBaseline: indexingPolicyContent,
conflictResolutionPolicyMode: conflictResolutionPolicyMode,
@@ -856,6 +898,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
if (
this.state.isSubSettingsSaveable ||
this.state.isContainerPolicyDirty ||
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty
@@ -877,6 +920,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
const wasIndexingPolicyModified = this.state.isIndexingPolicyDirty;
newCollection.defaultTtl = defaultTtl;
newCollection.vectorEmbeddingPolicy = this.state.vectorEmbeddingPolicy;
newCollection.fullTextPolicy = this.state.fullTextPolicy;
newCollection.indexingPolicy = this.state.indexingPolicyContent;
newCollection.changeFeedPolicy =
@@ -915,6 +962,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.collection.changeFeedPolicy(updatedCollection.changeFeedPolicy);
this.collection.geospatialConfig(updatedCollection.geospatialConfig);
this.collection.computedProperties(updatedCollection.computedProperties);
this.collection.vectorEmbeddingPolicy(updatedCollection.vectorEmbeddingPolicy);
this.collection.fullTextPolicy(updatedCollection.fullTextPolicy);
if (wasIndexingPolicyModified) {
await this.refreshIndexTransformationProgress();
@@ -923,6 +972,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.setState({
isSubSettingsSaveable: false,
isSubSettingsDiscardable: false,
isContainerPolicyDirty: false,
isIndexingPolicyDirty: false,
isConflictResolutionDirty: false,
isComputedPropertiesDirty: false,
@@ -1052,7 +1102,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onMaxAutoPilotThroughputChange: this.onMaxAutoPilotThroughputChange,
onScaleSaveableChange: this.onScaleSaveableChange,
onScaleDiscardableChange: this.onScaleDiscardableChange,
initialNotification: this.props.settingsTab.pendingNotification(),
throughputError: this.state.throughputError,
};
@@ -1094,6 +1143,21 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onSubSettingsDiscardableChange: this.onSubSettingsDiscardableChange,
};
const containerPolicyComponentProps: ContainerPolicyComponentProps = {
vectorEmbeddingPolicy: this.state.vectorEmbeddingPolicy,
vectorEmbeddingPolicyBaseline: this.state.vectorEmbeddingPolicyBaseline,
onVectorEmbeddingPolicyChange: this.onVectorEmbeddingPolicyChange,
onVectorEmbeddingPolicyDirtyChange: this.onVectorEmbeddingPolicyDirtyChange,
isVectorSearchEnabled: this.isVectorSearchEnabled,
fullTextPolicy: this.state.fullTextPolicy,
fullTextPolicyBaseline: this.state.fullTextPolicyBaseline,
onFullTextPolicyChange: this.onFullTextPolicyChange,
onFullTextPolicyDirtyChange: this.onFullTextPolicyDirtyChange,
isFullTextSearchEnabled: this.isFullTextSearchEnabled,
shouldDiscardContainerPolicies: this.state.shouldDiscardContainerPolicies,
resetShouldDiscardContainerPolicyChange: this.resetShouldDiscardContainerPolicies,
};
const indexingPolicyComponentProps: IndexingPolicyComponentProps = {
shouldDiscardIndexingPolicy: this.state.shouldDiscardIndexingPolicy,
resetShouldDiscardIndexingPolicy: this.resetShouldDiscardIndexingPolicy,
@@ -1151,10 +1215,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
explorer: this.props.settingsTab.getContainer(),
};
const containerVectorPolicyProps: ContainerVectorPolicyComponentProps = {
vectorEmbeddingPolicy: this.collection.rawDataModel?.vectorEmbeddingPolicy,
};
const tabs: SettingsV2TabInfo[] = [];
if (!hasDatabaseSharedThroughput(this.collection) && this.offer) {
tabs.push({
@@ -1168,10 +1228,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
content: <SubSettingsComponent {...subSettingsComponentProps} />,
});
if (this.isVectorSearchEnabled) {
if (this.isVectorSearchEnabled || this.isFullTextSearchEnabled) {
tabs.push({
tab: SettingsV2TabTypes.ContainerVectorPolicyTab,
content: <ContainerVectorPolicyComponent {...containerVectorPolicyProps} />,
content: <ContainerPolicyComponent {...containerPolicyComponentProps} />,
});
}

View File

@@ -0,0 +1,6 @@
import "@testing-library/jest-dom";
describe("ContainerPolicyComponent", () => {
//CTODO: add tests
it.skip("should render correctly", () => {});
});

View File

@@ -0,0 +1,163 @@
import { DefaultButton, Pivot, PivotItem, Stack } from "@fluentui/react";
import { FullTextPolicy, VectorEmbedding, VectorEmbeddingPolicy } from "Contracts/DataModels";
import {
FullTextPoliciesComponent,
getFullTextLanguageOptions,
} from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
import { titleAndInputStackProps } from "Explorer/Controls/Settings/SettingsRenderUtils";
import { ContainerPolicyTabTypes, isDirty } from "Explorer/Controls/Settings/SettingsUtils";
import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent";
import React from "react";
export interface ContainerPolicyComponentProps {
vectorEmbeddingPolicy: VectorEmbeddingPolicy;
vectorEmbeddingPolicyBaseline: VectorEmbeddingPolicy;
onVectorEmbeddingPolicyChange: (newVectorEmbeddingPolicy: VectorEmbeddingPolicy) => void;
onVectorEmbeddingPolicyDirtyChange: (isVectorEmbeddingPolicyDirty: boolean) => void;
isVectorSearchEnabled: boolean;
fullTextPolicy: FullTextPolicy;
fullTextPolicyBaseline: FullTextPolicy;
onFullTextPolicyChange: (newFullTextPolicy: FullTextPolicy) => void;
onFullTextPolicyDirtyChange: (isFullTextPolicyDirty: boolean) => void;
isFullTextSearchEnabled: boolean;
shouldDiscardContainerPolicies: boolean;
resetShouldDiscardContainerPolicyChange: () => void;
}
export const ContainerPolicyComponent: React.FC<ContainerPolicyComponentProps> = ({
vectorEmbeddingPolicy,
vectorEmbeddingPolicyBaseline,
onVectorEmbeddingPolicyChange,
onVectorEmbeddingPolicyDirtyChange,
isVectorSearchEnabled,
fullTextPolicy,
fullTextPolicyBaseline,
onFullTextPolicyChange,
onFullTextPolicyDirtyChange,
isFullTextSearchEnabled,
shouldDiscardContainerPolicies,
resetShouldDiscardContainerPolicyChange,
}) => {
const [selectedTab, setSelectedTab] = React.useState<ContainerPolicyTabTypes>(
ContainerPolicyTabTypes.VectorPolicyTab,
);
const [vectorEmbeddings, setVectorEmbeddings] = React.useState<VectorEmbedding[]>();
const [vectorEmbeddingsBaseline, setVectorEmbeddingsBaseline] = React.useState<VectorEmbedding[]>();
const [discardVectorChanges, setDiscardVectorChanges] = React.useState<boolean>(false);
const [fullTextSearchPolicy, setFullTextSearchPolicy] = React.useState<FullTextPolicy>();
const [fullTextSearchPolicyBaseline, setFullTextSearchPolicyBaseline] = React.useState<FullTextPolicy>();
const [discardFullTextChanges, setDiscardFullTextChanges] = React.useState<boolean>(false);
React.useEffect(() => {
setVectorEmbeddings(vectorEmbeddingPolicy?.vectorEmbeddings);
setVectorEmbeddingsBaseline(vectorEmbeddingPolicyBaseline?.vectorEmbeddings);
}, [vectorEmbeddingPolicy]);
React.useEffect(() => {
setFullTextSearchPolicy(fullTextPolicy);
setFullTextSearchPolicyBaseline(fullTextPolicyBaseline);
}, [fullTextPolicy, fullTextPolicyBaseline]);
React.useEffect(() => {
if (shouldDiscardContainerPolicies) {
setVectorEmbeddings(vectorEmbeddingPolicyBaseline?.vectorEmbeddings);
setDiscardVectorChanges(true);
setFullTextSearchPolicy(fullTextPolicyBaseline);
setDiscardFullTextChanges(true);
resetShouldDiscardContainerPolicyChange();
}
});
const checkAndSendVectorEmbeddingPoliciesToSettings = (newVectorEmbeddings: VectorEmbedding[]): void => {
if (isDirty(newVectorEmbeddings, vectorEmbeddingsBaseline)) {
onVectorEmbeddingPolicyDirtyChange(true);
onVectorEmbeddingPolicyChange({ vectorEmbeddings: newVectorEmbeddings });
} else {
resetShouldDiscardContainerPolicyChange();
}
};
const checkAndSendFullTextPolicyToSettings = (newFullTextPolicy: FullTextPolicy): void => {
if (isDirty(newFullTextPolicy, fullTextSearchPolicyBaseline)) {
onFullTextPolicyDirtyChange(true);
onFullTextPolicyChange(newFullTextPolicy);
} else {
resetShouldDiscardContainerPolicyChange();
}
};
const onVectorChangesDiscarded = (): void => {
setDiscardVectorChanges(false);
};
const onFullTextChangesDiscarded = (): void => {
setDiscardFullTextChanges(false);
};
const onPivotChange = (item: PivotItem): void => {
const selectedTab = ContainerPolicyTabTypes[item.props.itemKey as keyof typeof ContainerPolicyTabTypes];
setSelectedTab(selectedTab);
};
return (
<div>
<Pivot onLinkClick={onPivotChange} selectedKey={ContainerPolicyTabTypes[selectedTab]}>
{isVectorSearchEnabled && (
<PivotItem
itemKey={ContainerPolicyTabTypes[ContainerPolicyTabTypes.VectorPolicyTab]}
style={{ marginTop: 20 }}
headerText="Vector Policy"
>
<Stack {...titleAndInputStackProps} styles={{ root: { position: "relative", maxWidth: "400px" } }}>
{vectorEmbeddings && (
<VectorEmbeddingPoliciesComponent
disabled={true}
vectorEmbeddings={vectorEmbeddings}
vectorIndexes={undefined}
onVectorEmbeddingChange={(vectorEmbeddings: VectorEmbedding[]) =>
checkAndSendVectorEmbeddingPoliciesToSettings(vectorEmbeddings)
}
discardChanges={discardVectorChanges}
onChangesDiscarded={onVectorChangesDiscarded}
/>
)}
</Stack>
</PivotItem>
)}
{isFullTextSearchEnabled && (
<PivotItem
itemKey={ContainerPolicyTabTypes[ContainerPolicyTabTypes.FullTextPolicyTab]}
style={{ marginTop: 20 }}
headerText="Full Text Policy"
>
<Stack {...titleAndInputStackProps} styles={{ root: { position: "relative", maxWidth: "400px" } }}>
{fullTextSearchPolicy ? (
<FullTextPoliciesComponent
fullTextPolicy={fullTextSearchPolicy}
onFullTextPathChange={(newFullTextPolicy: FullTextPolicy) =>
checkAndSendFullTextPolicyToSettings(newFullTextPolicy)
}
discardChanges={discardFullTextChanges}
onChangesDiscarded={onFullTextChangesDiscarded}
/>
) : (
<DefaultButton
id={"create-full-text-policy"}
styles={{ root: { fontSize: 12 } }}
onClick={() => {
checkAndSendFullTextPolicyToSettings({
defaultLanguage: getFullTextLanguageOptions()[0].key as never,
fullTextPaths: [],
});
}}
>
Create new full text search policy
</DefaultButton>
)}
</Stack>
</PivotItem>
)}
</Pivot>
</div>
);
};

View File

@@ -1,30 +0,0 @@
import { Stack } from "@fluentui/react";
import { VectorEmbeddingPolicy } from "Contracts/DataModels";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { titleAndInputStackProps } from "Explorer/Controls/Settings/SettingsRenderUtils";
import React from "react";
export interface ContainerVectorPolicyComponentProps {
vectorEmbeddingPolicy: VectorEmbeddingPolicy;
}
export const ContainerVectorPolicyComponent: React.FC<ContainerVectorPolicyComponentProps> = ({
vectorEmbeddingPolicy,
}) => {
return (
<Stack {...titleAndInputStackProps} styles={{ root: { position: "relative" } }}>
<EditorReact
language={"json"}
content={JSON.stringify(vectorEmbeddingPolicy || {}, null, 4)}
isReadOnly={true}
wordWrap={"on"}
ariaLabel={"Container vector policy"}
lineNumbers={"on"}
scrollBeyondLastLine={false}
className={"settingsV2Editor"}
spinnerClassName={"settingsV2EditorSpinner"}
fontSize={14}
/>
</Stack>
);
};

View File

@@ -120,11 +120,6 @@ export class IndexingPolicyComponent extends React.Component<
indexTransformationProgress={this.props.indexTransformationProgress}
refreshIndexTransformationProgress={this.props.refreshIndexTransformationProgress}
/>
{this.props.isVectorSearchEnabled && (
<MessageBar messageBarType={MessageBarType.severeWarning}>
Container vector policies and vector indexes are not modifiable after container creation
</MessageBar>
)}
{isDirty(this.props.indexingPolicyContent, this.props.indexingPolicyContentBaseline) && (
<MessageBar messageBarType={MessageBarType.warning}>{unsavedEditorWarningMessage("indexPolicy")}</MessageBar>
)}

View File

@@ -1,18 +1,10 @@
import { shallow } from "enzyme";
import ko from "knockout";
import React from "react";
import * as Constants from "../../../../Common/Constants";
import * as DataModels from "../../../../Contracts/DataModels";
import { updateUserContext } from "../../../../UserContext";
import Explorer from "../../../Explorer";
import { throughputUnit } from "../SettingsRenderUtils";
import { collection } from "../TestUtils";
import { ScaleComponent, ScaleComponentProps } from "./ScaleComponent";
import { ThroughputInputAutoPilotV3Component } from "./ThroughputInputComponents/ThroughputInputAutoPilotV3Component";
describe("ScaleComponent", () => {
const targetThroughput = 6000;
const baseProps: ScaleComponentProps = {
collection: collection,
database: undefined,
@@ -36,39 +28,8 @@ describe("ScaleComponent", () => {
onScaleDiscardableChange: () => {
return;
},
initialNotification: {
description: `Throughput update for ${targetThroughput} ${throughputUnit}`,
} as DataModels.Notification,
};
it("renders with correct initial notification", () => {
let wrapper = shallow(<ScaleComponent {...baseProps} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.exists(ThroughputInputAutoPilotV3Component)).toEqual(true);
expect(wrapper.exists("#throughputApplyLongDelayMessage")).toEqual(true);
expect(wrapper.exists("#throughputApplyShortDelayMessage")).toEqual(false);
expect(wrapper.find("#throughputApplyLongDelayMessage").html()).toContain(`${targetThroughput}`);
const newCollection = { ...collection };
const maxThroughput = 5000;
newCollection.offer = ko.observable({
manualThroughput: undefined,
autoscaleMaxThroughput: maxThroughput,
minimumThroughput: 400,
id: "offer",
offerReplacePending: true,
});
const newProps = {
...baseProps,
initialNotification: undefined as DataModels.Notification,
collection: newCollection,
};
wrapper = shallow(<ScaleComponent {...newProps} />);
expect(wrapper.exists("#throughputApplyShortDelayMessage")).toEqual(true);
expect(wrapper.exists("#throughputApplyLongDelayMessage")).toEqual(false);
expect(wrapper.find("#throughputApplyShortDelayMessage").html()).toContain(`${maxThroughput}`);
});
it("autoScale disabled", () => {
const scaleComponent = new ScaleComponent(baseProps);
expect(scaleComponent.isAutoScaleEnabled()).toEqual(false);

View File

@@ -10,7 +10,6 @@ import * as AutoPilotUtils from "../../../../Utils/AutoPilotUtils";
import { isRunningOnNationalCloud } from "../../../../Utils/CloudUtils";
import {
getTextFieldStyles,
getThroughputApplyLongDelayMessage,
getThroughputApplyShortDelayMessage,
subComponentStackProps,
throughputUnit,
@@ -34,7 +33,6 @@ export interface ScaleComponentProps {
onMaxAutoPilotThroughputChange: (newThroughput: number) => void;
onScaleSaveableChange: (isScaleSaveable: boolean) => void;
onScaleDiscardableChange: (isScaleDiscardable: boolean) => void;
initialNotification: DataModels.Notification;
throughputError?: string;
}
@@ -102,10 +100,6 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
};
public getInitialNotificationElement = (): JSX.Element => {
if (this.props.initialNotification) {
return this.getLongDelayMessage();
}
if (this.offer?.offerReplacePending) {
const throughput = this.offer.manualThroughput || this.offer.autoscaleMaxThroughput;
return getThroughputApplyShortDelayMessage(
@@ -120,26 +114,6 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
return undefined;
};
public getLongDelayMessage = (): JSX.Element => {
const matches: string[] = this.props.initialNotification?.description.match(
`Throughput update for (.*) ${throughputUnit}`,
);
const throughput = this.props.throughputBaseline;
const targetThroughput: number = matches.length > 1 && Number(matches[1]);
if (targetThroughput) {
return getThroughputApplyLongDelayMessage(
this.props.wasAutopilotOriginallySet,
throughput,
throughputUnit,
this.databaseId,
this.collectionId,
targetThroughput,
);
}
return <></>;
};
private getThroughputInputComponent = (): JSX.Element => (
<ThroughputInputAutoPilotV3Component
databaseAccount={userContext?.databaseAccount}

View File

@@ -1,64 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ScaleComponent renders with correct initial notification 1`] = `
<Stack
tokens={
{
"childrenGap": 20,
}
}
>
<StyledMessageBar
messageBarType={5}
>
<Text
id="throughputApplyLongDelayMessage"
styles={
{
"root": {
"color": "windowtext",
"fontSize": 14,
},
}
}
>
A request to increase the throughput is currently in progress. This operation will take 1-3 business days to complete. View the latest status in Notifications.
<br />
Database: test, Container: test
, Current autoscale throughput: 100 - 1000 RU/s, Target autoscale throughput: 600 - 6000 RU/s
</Text>
</StyledMessageBar>
<Stack
tokens={
{
"childrenGap": 20,
}
}
>
<ThroughputInputAutoPilotV3Component
canExceedMaximumValue={true}
collectionName="test"
databaseName="test"
isAutoPilotSelected={false}
isEmulator={false}
isEnabled={true}
isFixed={false}
label="Throughput (6,000 - unlimited RU/s)"
maxAutoPilotThroughput={4000}
maxAutoPilotThroughputBaseline={4000}
maximum={1000000}
minimum={6000}
onAutoPilotSelected={[Function]}
onMaxAutoPilotThroughputChange={[Function]}
onScaleDiscardableChange={[Function]}
onScaleSaveableChange={[Function]}
onThroughputChange={[Function]}
spendAckChecked={false}
throughput={1000}
throughputBaseline={1000}
usageSizeInKB={100}
wasAutopilotOriginallySet={true}
/>
</Stack>
</Stack>
`;

View File

@@ -44,7 +44,6 @@ describe("SettingsUtils", () => {
readSettings: undefined,
onSettingsClick: undefined,
loadOffer: undefined,
getPendingThroughputSplitNotification: undefined,
} as ViewModels.Database;
};
newCollection.offer(undefined);

View File

@@ -4,7 +4,14 @@ import * as ViewModels from "../../../Contracts/ViewModels";
import { MongoIndex } from "../../../Utils/arm/generatedClients/cosmos/types";
const zeroValue = 0;
export type isDirtyTypes = boolean | string | number | DataModels.IndexingPolicy | DataModels.ComputedProperties;
export type isDirtyTypes =
| boolean
| string
| number
| DataModels.IndexingPolicy
| DataModels.ComputedProperties
| DataModels.VectorEmbedding[]
| DataModels.FullTextPolicy;
export const TtlOff = "off";
export const TtlOn = "on";
export const TtlOnNoDefault = "on-nodefault";
@@ -50,6 +57,11 @@ export enum SettingsV2TabTypes {
ContainerVectorPolicyTab,
}
export enum ContainerPolicyTabTypes {
VectorPolicyTab,
FullTextPolicyTab,
}
export interface IsComponentDirtyResult {
isSaveable: boolean;
isDiscardable: boolean;
@@ -154,7 +166,7 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => {
case SettingsV2TabTypes.ComputedPropertiesTab:
return "Computed Properties";
case SettingsV2TabTypes.ContainerVectorPolicyTab:
return "Container Vector Policy (preview)";
return "Container Policies";
default:
throw new Error(`Unknown tab ${tab}`);
}

View File

@@ -46,6 +46,8 @@ export const collection = {
query: "query",
},
]),
vectorEmbeddingPolicy: ko.observable<DataModels.VectorEmbeddingPolicy>({} as DataModels.VectorEmbeddingPolicy),
fullTextPolicy: ko.observable<DataModels.FullTextPolicy>({} as DataModels.FullTextPolicy),
readSettings: () => {
return;
},

View File

@@ -55,6 +55,7 @@ exports[`SettingsComponent renders 1`] = `
},
"databaseId": "test",
"defaultTtl": [Function],
"fullTextPolicy": [Function],
"geospatialConfig": [Function],
"getDatabase": [Function],
"id": [Function],
@@ -71,6 +72,7 @@ exports[`SettingsComponent renders 1`] = `
"readSettings": [Function],
"uniqueKeyPolicy": {},
"usageSizeInKB": [Function],
"vectorEmbeddingPolicy": [Function],
}
}
isAutoPilotSelected={false}
@@ -132,6 +134,7 @@ exports[`SettingsComponent renders 1`] = `
},
"databaseId": "test",
"defaultTtl": [Function],
"fullTextPolicy": [Function],
"geospatialConfig": [Function],
"getDatabase": [Function],
"id": [Function],
@@ -148,6 +151,7 @@ exports[`SettingsComponent renders 1`] = `
"readSettings": [Function],
"uniqueKeyPolicy": {},
"usageSizeInKB": [Function],
"vectorEmbeddingPolicy": [Function],
}
}
displayedTtlSeconds="5"
@@ -249,6 +253,7 @@ exports[`SettingsComponent renders 1`] = `
},
"databaseId": "test",
"defaultTtl": [Function],
"fullTextPolicy": [Function],
"geospatialConfig": [Function],
"getDatabase": [Function],
"id": [Function],
@@ -265,6 +270,7 @@ exports[`SettingsComponent renders 1`] = `
"readSettings": [Function],
"uniqueKeyPolicy": {},
"usageSizeInKB": [Function],
"vectorEmbeddingPolicy": [Function],
}
}
explorer={

View File

@@ -23,7 +23,7 @@ import { useCallback } from "react";
export interface TreeNodeMenuItem {
label: string;
onClick: () => void;
onClick: (value?: React.RefObject<HTMLElement>) => void;
iconSrc?: string;
isDisabled?: boolean;
styleClass?: string;
@@ -74,6 +74,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
openItems,
}: TreeNodeComponentProps): JSX.Element => {
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const contextMenuRef = React.useRef<HTMLButtonElement>(null);
const treeStyles = useTreeStyles();
const getSortedChildren = (treeNode: TreeNode): TreeNode[] => {
@@ -141,7 +142,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
data-test={`TreeNode/ContextMenuItem:${menuItem.label}`}
disabled={menuItem.isDisabled}
key={menuItem.label}
onClick={menuItem.onClick}
onClick={() => menuItem.onClick(contextMenuRef)}
>
{menuItem.label}
</MenuItem>
@@ -190,6 +191,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
className={mergeClasses(treeStyles.actionsButton, shouldShowAsSelected && treeStyles.selectedItem)}
data-test="TreeNode/ContextMenuTrigger"
appearance="subtle"
ref={contextMenuRef}
icon={<MoreHorizontal20Regular />}
/>
</MenuTrigger>

View File

@@ -1478,14 +1478,14 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
<MenuList>
<MenuItem
data-test="TreeNode/ContextMenuItem:enabledItem"
onClick={[MockFunction enabledItemClick]}
onClick={[Function]}
>
enabledItem
</MenuItem>
<MenuItem
data-test="TreeNode/ContextMenuItem:disabledItem"
disabled={true}
onClick={[MockFunction disabledItemClick]}
onClick={[Function]}
>
disabledItem
</MenuItem>
@@ -1518,7 +1518,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
<MenuItem
data-test="TreeNode/ContextMenuItem:enabledItem"
key="enabledItem"
onClick={[MockFunction enabledItemClick]}
onClick={[Function]}
>
enabledItem
</MenuItem>
@@ -1526,7 +1526,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
data-test="TreeNode/ContextMenuItem:disabledItem"
disabled={true}
key="disabledItem"
onClick={[MockFunction disabledItemClick]}
onClick={[Function]}
>
disabledItem
</MenuItem>

View File

@@ -2,7 +2,7 @@ import "@testing-library/jest-dom";
import { RenderResult, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
import React from "react";
import { AddVectorEmbeddingPolicyForm } from "./AddVectorEmbeddingPolicyForm";
import { VectorEmbeddingPoliciesComponent } from "./VectorEmbeddingPoliciesComponent";
const mockVectorEmbedding: VectorEmbedding[] = [
{ path: "/vector1", dataType: "float32", distanceFunction: "euclidean", dimensions: 0 },
@@ -17,9 +17,9 @@ describe("AddVectorEmbeddingPolicyForm", () => {
beforeEach(() => {
component = render(
<AddVectorEmbeddingPolicyForm
vectorEmbedding={mockVectorEmbedding}
vectorIndex={mockVectorIndex}
<VectorEmbeddingPoliciesComponent
vectorEmbeddings={mockVectorEmbedding}
vectorIndexes={mockVectorIndex}
onVectorEmbeddingChange={mockOnVectorEmbeddingChange}
/>,
);
@@ -36,7 +36,7 @@ describe("AddVectorEmbeddingPolicyForm", () => {
});
test("calls onDelete when delete button is clicked", async () => {
const deleteButton = component.container.querySelector("#delete-vector-policy-1");
const deleteButton = component.container.querySelector("#delete-Vector-embedding-1");
fireEvent.click(deleteButton);
expect(mockOnVectorEmbeddingChange).toHaveBeenCalled();
expect(screen.queryByText("Vector embedding 1")).toBeNull();
@@ -49,21 +49,19 @@ describe("AddVectorEmbeddingPolicyForm", () => {
test("validates input correctly", async () => {
fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "" } });
await waitFor(() => expect(screen.getByText("Vector embedding path should not be empty")).toBeInTheDocument(), {
await waitFor(() => expect(screen.getByText("Path should not be empty")).toBeInTheDocument(), {
timeout: 1500,
});
await waitFor(
() =>
expect(
screen.getByText("Vector embedding dimension must be greater than 0 and less than or equal 4096"),
).toBeInTheDocument(),
expect(screen.getByText("Dimension must be greater than 0 and less than or equal 4096")).toBeInTheDocument(),
{
timeout: 1500,
},
);
fireEvent.change(component.container.querySelector("#vector-policy-dimension-1"), { target: { value: "4096" } });
fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "/vector1" } });
await waitFor(() => expect(screen.queryByText("Vector embedding path should not be empty")).toBeNull(), {
await waitFor(() => expect(screen.queryByText("Path should not be empty")).toBeNull(), {
timeout: 1500,
});
await waitFor(

View File

@@ -0,0 +1,470 @@
import {
DefaultButton,
Dropdown,
IDropdownOption,
IStyleFunctionOrObject,
ITextFieldStyleProps,
ITextFieldStyles,
Label,
Stack,
TextField,
} from "@fluentui/react";
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import {
getDataTypeOptions,
getDistanceFunctionOptions,
getIndexTypeOptions,
} from "Explorer/Controls/VectorSearch/VectorSearchUtils";
import React, { FunctionComponent, useState } from "react";
export interface IVectorEmbeddingPoliciesComponentProps {
vectorEmbeddings: VectorEmbedding[];
onVectorEmbeddingChange: (
vectorEmbeddings: VectorEmbedding[],
vectorIndexingPolicies: VectorIndex[],
validationPassed: boolean,
) => void;
vectorIndexes?: VectorIndex[];
discardChanges?: boolean;
onChangesDiscarded?: () => void;
disabled?: boolean;
}
export interface VectorEmbeddingPolicyData {
path: string;
dataType: VectorEmbedding["dataType"];
distanceFunction: VectorEmbedding["distanceFunction"];
dimensions: number;
indexType: VectorIndex["type"] | "none";
pathError: string;
dimensionsError: string;
diskANNShardKey?: string;
diskANNShardKeyError?: string;
indexingSearchListSize?: number;
indexingSearchListSizeError?: string;
quantizationByteSize?: number;
quantizationByteSizeError?: string;
}
type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexType";
const labelStyles = {
root: {
fontSize: 12,
},
};
const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldStyles> = {
fieldGroup: {
height: 27,
},
field: {
fontSize: 12,
padding: "0 8px",
},
};
const dropdownStyles = {
title: {
height: 27,
lineHeight: "24px",
fontSize: 12,
},
dropdown: {
height: 27,
lineHeight: "24px",
},
dropdownItem: {
fontSize: 12,
},
};
export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddingPoliciesComponentProps> = ({
vectorEmbeddings,
vectorIndexes,
onVectorEmbeddingChange,
discardChanges,
onChangesDiscarded,
disabled,
}): JSX.Element => {
const onVectorEmbeddingPathError = (path: string, index?: number): string => {
let error = "";
if (!path) {
error = "Path should not be empty";
}
if (
index >= 0 &&
vectorEmbeddingPolicyData?.find(
(vectorEmbedding: VectorEmbeddingPolicyData, dataIndex: number) =>
dataIndex !== index && vectorEmbedding.path === path,
)
) {
error = "Path is already defined";
}
return error;
};
const onVectorEmbeddingDimensionError = (dimension: number, indexType: VectorIndex["type"] | "none"): string => {
let error = "";
if (dimension <= 0 || dimension > 4096) {
error = "Dimension must be greater than 0 and less than or equal 4096";
}
if (indexType === "flat" && dimension > 505) {
error = "Maximum allowed dimension for flat index is 505";
}
return error;
};
const onQuantizationByteSizeError = (size: number): string => {
let error = "";
if (size < 1 || size > 512) {
error = "Quantization byte size must be greater than 0 and less than or equal to 512";
}
return error;
};
const onIndexingSearchListSizeError = (size: number): string => {
let error = "";
if (size < 25 || size > 500) {
error = "Indexing search list size must be greater than or equal to 25 and less than or equal to 500";
}
return error;
};
//TODO: no restrictions yet due to this field being removed for now.
// Uncomment and replace with validation code when field is reinstated
// const onDiskANNShardKeyError = (shardKey: string): string => {
// return "";
// };
const initializeData = (vectorEmbeddings: VectorEmbedding[], vectorIndexes: VectorIndex[]) => {
const mergedData: VectorEmbeddingPolicyData[] = [];
vectorEmbeddings.forEach((embedding) => {
const matchingIndex = displayIndexes ? vectorIndexes.find((index) => index.path === embedding.path) : undefined;
mergedData.push({
...embedding,
indexType: matchingIndex?.type || "none",
indexingSearchListSize: matchingIndex?.indexingSearchListSize || undefined,
quantizationByteSize: matchingIndex?.quantizationByteSize || undefined,
pathError: onVectorEmbeddingPathError(embedding.path),
dimensionsError: onVectorEmbeddingDimensionError(embedding.dimensions, matchingIndex?.type || "none"),
});
});
return mergedData;
};
const [displayIndexes] = useState<boolean>(!!vectorIndexes);
const [vectorEmbeddingPolicyData, setVectorEmbeddingPolicyData] = useState<VectorEmbeddingPolicyData[]>(
initializeData(vectorEmbeddings, vectorIndexes),
);
React.useEffect(() => {
propagateData();
}, [vectorEmbeddingPolicyData]);
React.useEffect(() => {
if (discardChanges) {
setVectorEmbeddingPolicyData(initializeData(vectorEmbeddings, vectorIndexes));
onChangesDiscarded();
}
}, [discardChanges]);
const propagateData = () => {
const vectorEmbeddings: VectorEmbedding[] = vectorEmbeddingPolicyData.map((policy: VectorEmbeddingPolicyData) => ({
path: policy.path,
dataType: policy.dataType,
dimensions: policy.dimensions,
distanceFunction: policy.distanceFunction,
}));
const vectorIndexes: VectorIndex[] = vectorEmbeddingPolicyData
.filter((policy: VectorEmbeddingPolicyData) => policy.indexType !== "none")
.map(
(policy) =>
({
path: policy.path,
type: policy.indexType,
indexingSearchListSize: policy.indexingSearchListSize,
quantizationByteSize: policy.quantizationByteSize,
}) as VectorIndex,
);
const validationPassed = vectorEmbeddingPolicyData.every(
(policy: VectorEmbeddingPolicyData) => policy.pathError === "" && policy.dimensionsError === "",
);
onVectorEmbeddingChange(vectorEmbeddings, vectorIndexes, validationPassed);
};
const onVectorEmbeddingPathChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trim();
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
if (!vectorEmbeddings[index]?.path && !value.startsWith("/")) {
vectorEmbeddings[index].path = "/" + value;
} else {
vectorEmbeddings[index].path = value;
}
const error = onVectorEmbeddingPathError(value, index);
vectorEmbeddings[index].pathError = error;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onVectorEmbeddingDimensionsChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value.trim()) || 0;
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
const vectorEmbedding = vectorEmbeddings[index];
vectorEmbeddings[index].dimensions = value;
const error = onVectorEmbeddingDimensionError(value, vectorEmbedding.indexType);
vectorEmbeddings[index].dimensionsError = error;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onVectorEmbeddingIndexTypeChange = (index: number, option: IDropdownOption): void => {
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
const vectorEmbedding = vectorEmbeddings[index];
vectorEmbeddings[index].indexType = option.key as never;
const error = onVectorEmbeddingDimensionError(vectorEmbedding.dimensions, vectorEmbedding.indexType);
vectorEmbeddings[index].dimensionsError = error;
if (vectorEmbedding.indexType === "diskANN") {
vectorEmbedding.indexingSearchListSize = 100;
} else {
vectorEmbedding.indexingSearchListSize = undefined;
}
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onQuantizationByteSizeChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value.trim()) || 0;
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
vectorEmbeddings[index].quantizationByteSize = value;
vectorEmbeddings[index].quantizationByteSizeError = onQuantizationByteSizeError(value);
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onIndexingSearchListSizeChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value.trim()) || 0;
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
vectorEmbeddings[index].indexingSearchListSize = value;
vectorEmbeddings[index].indexingSearchListSizeError = onIndexingSearchListSizeError(value);
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
// TODO: uncomment after Ignite
// DiskANNShardKey was removed for Ignite due to backend problems. Leaving this here as it will be reinstated immediately after Ignite
// const onDiskANNShardKeyChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
// const value = event.target.value.trim();
// const vectorEmbeddings = [...vectorEmbeddingPolicyData];
// if (!vectorEmbeddings[index]?.diskANNShardKey && !value.startsWith("/")) {
// vectorEmbeddings[index].diskANNShardKey = "/" + value;
// } else {
// vectorEmbeddings[index].diskANNShardKey = value;
// }
// const error = onDiskANNShardKeyError(value);
// vectorEmbeddings[index].diskANNShardKeyError = error;
// setVectorEmbeddingPolicyData(vectorEmbeddings);
// }
const onVectorEmbeddingPolicyChange = (
index: number,
option: IDropdownOption,
property: VectorEmbeddingPolicyProperty,
): void => {
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
vectorEmbeddings[index][property] = option.key as never;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onAdd = () => {
setVectorEmbeddingPolicyData([
...vectorEmbeddingPolicyData,
{
path: "",
dataType: "float32",
distanceFunction: "euclidean",
dimensions: 0,
indexType: "none",
pathError: onVectorEmbeddingPathError(""),
dimensionsError: onVectorEmbeddingDimensionError(0, "none"),
},
]);
};
const onDelete = (index: number) => {
const vectorEmbeddings = vectorEmbeddingPolicyData.filter((_uniqueKey, j) => index !== j);
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
return (
<Stack tokens={{ childrenGap: 4 }}>
{vectorEmbeddingPolicyData &&
vectorEmbeddingPolicyData.length > 0 &&
vectorEmbeddingPolicyData.map((vectorEmbeddingPolicy: VectorEmbeddingPolicyData, index: number) => (
<CollapsibleSectionComponent
disabled={disabled}
key={index}
isExpandedByDefault={true}
title={`Vector embedding ${index + 1}`}
showDelete={true}
onDelete={() => onDelete(index)}
>
<Stack horizontal tokens={{ childrenGap: 4 }}>
<Stack
styles={{
root: {
margin: "0 0 6px 20px !important",
paddingLeft: 20,
width: "80%",
borderLeft: "1px solid",
},
}}
>
<Stack>
<Label disabled={disabled} styles={labelStyles}>
Path
</Label>
<TextField
disabled={disabled}
id={`vector-policy-path-${index + 1}`}
required={true}
placeholder="/vector1"
styles={textFieldStyles}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onVectorEmbeddingPathChange(index, event)}
value={vectorEmbeddingPolicy.path || ""}
errorMessage={vectorEmbeddingPolicy.pathError}
/>
</Stack>
<Stack>
<Label disabled={disabled} styles={labelStyles}>
Data type
</Label>
<Dropdown
disabled={disabled}
required={true}
styles={dropdownStyles}
options={getDataTypeOptions()}
selectedKey={vectorEmbeddingPolicy.dataType}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onVectorEmbeddingPolicyChange(index, option, "dataType")
}
></Dropdown>
</Stack>
<Stack>
<Label disabled={disabled} styles={labelStyles}>
Distance function
</Label>
<Dropdown
disabled={disabled}
required={true}
styles={dropdownStyles}
options={getDistanceFunctionOptions()}
selectedKey={vectorEmbeddingPolicy.distanceFunction}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onVectorEmbeddingPolicyChange(index, option, "distanceFunction")
}
></Dropdown>
</Stack>
<Stack>
<Label disabled={disabled} styles={labelStyles}>
Dimensions
</Label>
<TextField
disabled={disabled}
id={`vector-policy-dimension-${index + 1}`}
required={true}
styles={textFieldStyles}
value={String(vectorEmbeddingPolicy.dimensions || 0)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onVectorEmbeddingDimensionsChange(index, event)
}
errorMessage={vectorEmbeddingPolicy.dimensionsError}
/>
</Stack>
{displayIndexes && (
<Stack>
<Label disabled={disabled} styles={labelStyles}>
Index type
</Label>
<Dropdown
disabled={disabled}
required={true}
styles={dropdownStyles}
options={getIndexTypeOptions()}
selectedKey={vectorEmbeddingPolicy.indexType}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onVectorEmbeddingIndexTypeChange(index, option)
}
></Dropdown>
<Stack style={{ marginLeft: "10px" }}>
<Label
disabled={
disabled ||
(vectorEmbeddingPolicy.indexType !== "quantizedFlat" &&
vectorEmbeddingPolicy.indexType !== "diskANN")
}
styles={labelStyles}
>
Quantization byte size
</Label>
<TextField
disabled={
disabled ||
(vectorEmbeddingPolicy.indexType !== "quantizedFlat" &&
vectorEmbeddingPolicy.indexType !== "diskANN")
}
id={`vector-policy-quantizationByteSize-${index + 1}`}
styles={textFieldStyles}
value={String(vectorEmbeddingPolicy.quantizationByteSize || "")}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onQuantizationByteSizeChange(index, event)
}
/>
</Stack>
<Stack style={{ marginLeft: "10px" }}>
<Label disabled={disabled || vectorEmbeddingPolicy.indexType !== "diskANN"} styles={labelStyles}>
Indexing search list size
</Label>
<TextField
disabled={disabled || vectorEmbeddingPolicy.indexType !== "diskANN"}
id={`vector-policy-indexingSearchListSize-${index + 1}`}
styles={textFieldStyles}
value={String(vectorEmbeddingPolicy.indexingSearchListSize || "")}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onIndexingSearchListSizeChange(index, event)
}
/>
</Stack>
{/*TODO: uncomment after Ignite */}
{/* DiskANNShardKey was removed for Ignite due to backend problems. Leaving this here as it will be reinstated immediately after Ignite
<Stack
style={{ marginLeft: "10px" }}
>
<Label
disabled={disabled || vectorEmbeddingPolicy.indexType !== "diskANN"}
styles={labelStyles}
>DiskANN shard key</Label>
<TextField
disabled={disabled || vectorEmbeddingPolicy.indexType !== "diskANN"}
id={`vector-policy-diskANNShardKey-${index + 1}`}
styles={textFieldStyles}
value={String(vectorEmbeddingPolicy.diskANNShardKey || "")}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onDiskANNShardKeyChange(index, event)
}
/>
</Stack>
*/}
</Stack>
)}
</Stack>
</Stack>
</CollapsibleSectionComponent>
))}
<DefaultButton
disabled={disabled}
id={`add-vector-policy`}
styles={{ root: { maxWidth: 170, fontSize: 12 } }}
onClick={onAdd}
>
Add vector embedding
</DefaultButton>
</Stack>
);
};

View File

@@ -1,6 +1,7 @@
import * as msal from "@azure/msal-browser";
import { Link } from "@fluentui/react/lib/Link";
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { Environment, getEnvironment } from "Common/EnvironmentUtility";
import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext } from "ConfigContext";
import { MessageTypes } from "Contracts/ExplorerContracts";
@@ -9,7 +10,7 @@ import { getCopilotEnabled, isCopilotFeatureRegistered } from "Explorer/QueryCop
import { IGalleryItem } from "Juno/JunoClient";
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { acquireTokenWithMsal, getMsalInstance } from "Utils/AuthorizationUtils";
import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
import { useQueryCopilot } from "hooks/useQueryCopilot";
@@ -258,25 +259,8 @@ export default class Explorer {
public async openLoginForEntraIDPopUp(): Promise<void> {
if (userContext.databaseAccount.properties?.documentEndpoint) {
const hrefEndpoint = new URL(userContext.databaseAccount.properties.documentEndpoint).href.replace(
/\/$/,
"/.default",
);
const msalInstance = await getMsalInstance();
try {
const response = await msalInstance.loginPopup({
redirectUri: configContext.msalRedirectURI,
scopes: [],
});
localStorage.setItem("cachedTenantId", response.tenantId);
const cachedAccount = msalInstance.getAllAccounts()?.[0];
msalInstance.setActiveAccount(cachedAccount);
const aadToken = await acquireTokenWithMsal(msalInstance, {
forceRefresh: true,
scopes: [hrefEndpoint],
authority: `${configContext.AAD_ENDPOINT}${localStorage.getItem("cachedTenantId")}`,
});
const aadToken = await acquireMsalTokenForAccount(userContext.databaseAccount, false);
updateUserContext({ aadToken: aadToken });
useDataPlaneRbac.setState({ aadTokenUpdated: true });
} catch (error) {
@@ -1150,7 +1134,7 @@ export default class Explorer {
if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") {
userContext.authType === AuthType.ResourceToken
? this.refreshDatabaseForResourceToken()
: this.refreshAllDatabases();
: await this.refreshAllDatabases(); // await: we rely on the databases to be loaded before restoring the tabs further in the flow
}
await useNotebook.getState().refreshNotebooksEnabledStateForAccount();
@@ -1178,7 +1162,11 @@ export default class Explorer {
}
public async configureCopilot(): Promise<void> {
if (userContext.apiType !== "SQL" || !userContext.subscriptionId) {
if (
userContext.apiType !== "SQL" ||
!userContext.subscriptionId ||
![Environment.Development, Environment.Mpac, Environment.Prod].includes(getEnvironment())
) {
return;
}
const copilotEnabledPromise = getCopilotEnabled();

View File

@@ -1,13 +1,13 @@
import { clear, collectionWasOpened, getItems, Type } from "Explorer/MostRecentActivity/MostRecentActivity";
import { observable } from "knockout";
import { mostRecentActivity } from "./MostRecentActivity";
describe("MostRecentActivity", () => {
const accountId = "some account";
const accountName = "some account";
beforeEach(() => mostRecentActivity.clear(accountId));
beforeEach(() => clear(accountName));
it("Has no items at first", () => {
expect(mostRecentActivity.getItems(accountId)).toStrictEqual([]);
expect(getItems(accountName)).toStrictEqual([]);
});
it("Can record collections being opened", () => {
@@ -18,9 +18,9 @@ describe("MostRecentActivity", () => {
databaseId,
};
mostRecentActivity.collectionWasOpened(accountId, collection);
collectionWasOpened(accountName, collection);
const activity = mostRecentActivity.getItems(accountId);
const activity = getItems(accountName);
expect(activity).toEqual([
expect.objectContaining({
collectionId,
@@ -29,58 +29,24 @@ describe("MostRecentActivity", () => {
]);
});
it("Can record notebooks being opened", () => {
const name = "some notebook";
const path = "some path";
const notebook = { name, path };
it("Does not store duplicate entries", () => {
const collectionId = "some collection";
const databaseId = "some database";
const collection = {
id: observable(collectionId),
databaseId,
};
mostRecentActivity.notebookWasItemOpened(accountId, notebook);
collectionWasOpened(accountName, collection);
collectionWasOpened(accountName, collection);
const activity = mostRecentActivity.getItems(accountId);
expect(activity).toEqual([expect.objectContaining(notebook)]);
});
it("Filters out duplicates", () => {
const name = "some notebook";
const path = "some path";
const notebook = { name, path };
const sameNotebook = { name, path };
mostRecentActivity.notebookWasItemOpened(accountId, notebook);
mostRecentActivity.notebookWasItemOpened(accountId, sameNotebook);
const activity = mostRecentActivity.getItems(accountId);
expect(activity.length).toEqual(1);
expect(activity).toEqual([expect.objectContaining(notebook)]);
});
it("Allows for multiple accounts", () => {
const name = "some notebook";
const path = "some path";
const notebook = { name, path };
const anotherNotebook = { name: "Another " + name, path };
const anotherAccountId = "Another " + accountId;
mostRecentActivity.notebookWasItemOpened(accountId, notebook);
mostRecentActivity.notebookWasItemOpened(anotherAccountId, anotherNotebook);
expect(mostRecentActivity.getItems(accountId)).toEqual([expect.objectContaining(notebook)]);
expect(mostRecentActivity.getItems(anotherAccountId)).toEqual([expect.objectContaining(anotherNotebook)]);
});
it("Can store multiple distinct elements, in FIFO order", () => {
const name = "some notebook";
const path = "some path";
const first = { name, path };
const second = { name: "Another " + name, path };
const third = { name, path: "Another " + path };
mostRecentActivity.notebookWasItemOpened(accountId, first);
mostRecentActivity.notebookWasItemOpened(accountId, second);
mostRecentActivity.notebookWasItemOpened(accountId, third);
const activity = mostRecentActivity.getItems(accountId);
expect(activity).toEqual([third, second, first].map(expect.objectContaining));
const activity = getItems(accountName);
expect(activity).toEqual([
expect.objectContaining({
type: Type.OpenCollection,
collectionId,
databaseId,
}),
]);
});
});

View File

@@ -1,10 +1,10 @@
import { AppStateComponentNames, deleteState, loadState, saveState } from "Shared/AppStatePersistenceUtility";
import { CollectionBase } from "../../Contracts/ViewModels";
import { StorageKey, LocalStorageUtility } from "../../Shared/StorageUtility";
import { NotebookContentItem } from "../Notebook/NotebookContentItem";
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
export enum Type {
OpenCollection,
OpenNotebook,
OpenCollection = "OpenCollection",
OpenNotebook = "OpenNotebook",
}
export interface OpenNotebookItem {
@@ -21,158 +21,174 @@ export interface OpenCollectionItem {
type Item = OpenNotebookItem | OpenCollectionItem;
// Update schemaVersion if you are going to change this interface
interface StoredData {
schemaVersion: string;
itemsMap: { [accountId: string]: Item[] }; // FIFO
}
const itemsMaxNumber: number = 5;
/**
* Stores most recent activity
* Migrate old data to new AppState
*/
class MostRecentActivity {
private static readonly schemaVersion: string = "2";
private static itemsMaxNumber: number = 5;
private storedData: StoredData;
constructor() {
// Retrieve from local storage
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
const rawData = LocalStorageUtility.getEntryString(StorageKey.MostRecentActivity);
if (!rawData) {
this.storedData = MostRecentActivity.createEmptyData();
} else {
try {
this.storedData = JSON.parse(rawData);
} catch (e) {
console.error("Unable to parse stored most recent activity. Use empty data:", rawData);
this.storedData = MostRecentActivity.createEmptyData();
}
// If version doesn't match or schema broke, nuke it!
if (
!this.storedData.hasOwnProperty("schemaVersion") ||
this.storedData["schemaVersion"] !== MostRecentActivity.schemaVersion
) {
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity);
this.storedData = MostRecentActivity.createEmptyData();
}
const migrateOldData = () => {
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
const oldDataSchemaVersion: string = "2";
const rawData = LocalStorageUtility.getEntryString(StorageKey.MostRecentActivity);
if (rawData) {
const oldData = JSON.parse(rawData);
if (oldData.schemaVersion === oldDataSchemaVersion) {
const itemsMap: Record<string, Item[]> = oldData.itemsMap;
Object.keys(itemsMap).forEach((accountId: string) => {
const accountName = accountId.split("/").pop();
if (accountName) {
saveState(
{
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
},
itemsMap[accountId].map((item) => {
if ((item.type as unknown as number) === 0) {
item.type = Type.OpenCollection;
} else if ((item.type as unknown as number) === 1) {
item.type = Type.OpenNotebook;
}
return item;
}),
);
}
});
}
} else {
this.storedData = MostRecentActivity.createEmptyData();
}
for (let p in this.storedData.itemsMap) {
this.cleanupItems(p);
// Remove old data
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity);
}
};
const addItem = (accountName: string, newItem: Item): void => {
// When debugging, accountId is "undefined": most recent activity cannot be saved by account. Uncomment to disable.
// if (!accountId) {
// return;
// }
let items =
(loadState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
}) as Item[]) || [];
// Remove duplicate
items = removeDuplicate(newItem, items);
items.unshift(newItem);
items = cleanupItems(items, accountName);
saveState(
{
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
},
items,
);
};
export const getItems = (accountName: string): Item[] => {
if (!accountName) {
return [];
}
return (
(loadState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
}) as Item[]) || []
);
};
export const collectionWasOpened = (
accountName: string,
{ id, databaseId }: Pick<CollectionBase, "id" | "databaseId">,
) => {
if (accountName === undefined) {
return;
}
const collectionId = id();
addItem(accountName, {
type: Type.OpenCollection,
databaseId,
collectionId,
});
};
export const clear = (accountName: string): void => {
if (!accountName) {
return;
}
deleteState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
});
};
// Sort object by key
const sortObjectKeys = (unordered: Record<string, unknown>): Record<string, unknown> => {
return Object.keys(unordered)
.sort()
.reduce((obj: Record<string, unknown>, key: string) => {
obj[key] = unordered[key];
return obj;
}, {});
};
/**
* Find items by doing strict comparison and remove from array if duplicate is found.
* Modifies the array.
* @param item
* @param itemsArray
* @returns new array
*/
const removeDuplicate = (item: Item, itemsArray: Item[]): Item[] => {
if (!itemsArray) {
return itemsArray;
}
const result: Item[] = [...itemsArray];
let index = -1;
for (let i = 0; i < result.length; i++) {
const currentItem = result[i];
if (
JSON.stringify(sortObjectKeys(currentItem as unknown as Record<string, unknown>)) ===
JSON.stringify(sortObjectKeys(item as unknown as Record<string, unknown>))
) {
index = i;
break;
}
this.saveToLocalStorage();
}
private static createEmptyData(): StoredData {
return {
schemaVersion: MostRecentActivity.schemaVersion,
itemsMap: {},
};
if (index !== -1) {
result.splice(index, 1);
}
private static isEmpty(object: any) {
return Object.keys(object).length === 0 && object.constructor === Object;
return result;
};
/**
* Remove unknown types
* Limit items to max number
* Modifies the array.
*/
const cleanupItems = (items: Item[], accountName: string): Item[] => {
if (accountName === undefined) {
return [];
}
private saveToLocalStorage() {
if (MostRecentActivity.isEmpty(this.storedData.itemsMap)) {
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity);
}
// Don't save if empty
return;
}
LocalStorageUtility.setEntryString(StorageKey.MostRecentActivity, JSON.stringify(this.storedData));
}
private addItem(accountId: string, newItem: Item): void {
// When debugging, accountId is "undefined": most recent activity cannot be saved by account. Uncomment to disable.
// if (!accountId) {
// return;
// }
// Remove duplicate
MostRecentActivity.removeDuplicate(newItem, this.storedData.itemsMap[accountId]);
this.storedData.itemsMap[accountId] = this.storedData.itemsMap[accountId] || [];
this.storedData.itemsMap[accountId].unshift(newItem);
this.cleanupItems(accountId);
this.saveToLocalStorage();
}
public getItems(accountId: string): Item[] {
return this.storedData.itemsMap[accountId] || [];
}
public collectionWasOpened(accountId: string, { id, databaseId }: Pick<CollectionBase, "id" | "databaseId">) {
const collectionId = id();
this.addItem(accountId, {
type: Type.OpenCollection,
databaseId,
collectionId,
const itemsArray = items.filter((item) => item.type in Type).slice(0, itemsMaxNumber);
if (itemsArray.length === 0) {
deleteState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
});
}
return itemsArray;
};
public notebookWasItemOpened(accountId: string, { name, path }: Pick<NotebookContentItem, "name" | "path">) {
this.addItem(accountId, {
type: Type.OpenNotebook,
name,
path,
});
}
public clear(accountId: string): void {
delete this.storedData.itemsMap[accountId];
this.saveToLocalStorage();
}
/**
* Find items by doing strict comparison and remove from array if duplicate is found
* @param item
*/
private static removeDuplicate(item: Item, itemsArray: Item[]): void {
if (!itemsArray) {
return;
}
let index = -1;
for (let i = 0; i < itemsArray.length; i++) {
const currentItem = itemsArray[i];
if (JSON.stringify(currentItem) === JSON.stringify(item)) {
index = i;
break;
}
}
if (index !== -1) {
itemsArray.splice(index, 1);
}
}
/**
* Remove unknown types
* Limit items to max number
*/
private cleanupItems(accountId: string): void {
if (!this.storedData.itemsMap.hasOwnProperty(accountId)) {
return;
}
const itemsArray = this.storedData.itemsMap[accountId]
.filter((item) => item.type in Type)
.slice(0, MostRecentActivity.itemsMaxNumber);
if (itemsArray.length === 0) {
delete this.storedData.itemsMap[accountId];
} else {
this.storedData.itemsMap[accountId] = itemsArray;
}
}
}
export const mostRecentActivity = new MostRecentActivity();
migrateOldData();

View File

@@ -1,4 +1,5 @@
// TODO convert this file to an action registry in order to have actions and their handlers be more tightly coupled.
import { configContext, Platform } from "ConfigContext";
import { useDatabases } from "Explorer/useDatabases";
import React from "react";
import { ActionContracts } from "../../Contracts/ExplorerContracts";
@@ -56,6 +57,19 @@ function openCollectionTab(
continue;
}
if (
configContext.platform === Platform.Fabric &&
!(
// whitelist the tab kinds that are allowed to be opened in Fabric
(
action.tabKind === ActionContracts.TabKind.SQLDocuments ||
action.tabKind === ActionContracts.TabKind.SQLQuery
)
)
) {
continue;
}
//expand database first if not expanded to load the collections
if (!database.isDatabaseExpanded?.()) {
database.expandDatabase?.();
@@ -121,10 +135,28 @@ function openCollectionTab(
action.tabKind === ActionContracts.TabKind.SQLQuery ||
action.tabKind === ActionContracts.TabKind[ActionContracts.TabKind.SQLQuery]
) {
const openQueryTabAction = action as ActionContracts.OpenQueryTab;
collection.onNewQueryClick(
collection,
undefined,
generateQueryText(action as ActionContracts.OpenQueryTab, collection.partitionKeyProperties),
generateQueryText(openQueryTabAction, collection.partitionKeyProperties),
openQueryTabAction.splitterDirection,
openQueryTabAction.queryViewSizePercent,
);
break;
}
if (
action.tabKind === ActionContracts.TabKind.MongoQuery ||
action.tabKind === ActionContracts.TabKind[ActionContracts.TabKind.MongoQuery]
) {
const openQueryTabAction = action as ActionContracts.OpenQueryTab;
collection.onNewMongoQueryClick(
collection,
undefined,
generateQueryText(openQueryTabAction, collection.partitionKeyProperties),
openQueryTabAction.splitterDirection,
openQueryTabAction.queryViewSizePercent,
);
break;
}

View File

@@ -17,11 +17,15 @@ import {
} from "@fluentui/react";
import * as Constants from "Common/Constants";
import { createCollection } from "Common/dataAccess/createCollection";
import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import { configContext, Platform } from "ConfigContext";
import * as DataModels from "Contracts/DataModels";
import { SubscriptionType } from "Contracts/SubscriptionType";
import { AddVectorEmbeddingPolicyForm } from "Explorer/Panes/VectorSearchPanel/AddVectorEmbeddingPolicyForm";
import {
FullTextPoliciesComponent,
getFullTextLanguageOptions,
} from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent";
import { useSidePanel } from "hooks/useSidePanel";
import { useTeachingBubble } from "hooks/useTeachingBubble";
import React from "react";
@@ -30,7 +34,12 @@ import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
import { userContext } from "UserContext";
import { getCollectionName } from "Utils/APITypeUtils";
import { isCapabilityEnabled, isServerlessAccount, isVectorSearchEnabled } from "Utils/CapabilityUtils";
import {
isCapabilityEnabled,
isFullTextSearchEnabled,
isServerlessAccount,
isVectorSearchEnabled,
} from "Utils/CapabilityUtils";
import { getUpsellMessage } from "Utils/PricingUtils";
import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent";
import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput";
@@ -109,6 +118,9 @@ export interface AddCollectionPanelState {
vectorIndexingPolicy: DataModels.VectorIndex[];
vectorEmbeddingPolicy: DataModels.VectorEmbedding[];
vectorPolicyValidated: boolean;
fullTextPolicy: DataModels.FullTextPolicy;
fullTextIndexes: DataModels.FullTextIndex[];
fullTextPolicyValidated: boolean;
}
export class AddCollectionPanel extends React.Component<AddCollectionPanelProps, AddCollectionPanelState> {
@@ -125,7 +137,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
createNewDatabase:
userContext.apiType !== "Tables" && configContext.platform !== Platform.Fabric && !this.props.databaseId,
newDatabaseId: props.isQuickstart ? this.getSampleDBName() : "",
isSharedThroughputChecked: this.getSharedThroughputDefault(),
isSharedThroughputChecked: getNewDatabaseSharedThroughputDefault(),
selectedDatabaseId:
userContext.apiType === "Tables" ? CollectionCreation.TablesAPIDefaultDatabase : this.props.databaseId,
collectionId: props.isQuickstart ? `Sample${getCollectionName()}` : "",
@@ -147,6 +159,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
vectorEmbeddingPolicy: [],
vectorIndexingPolicy: [],
vectorPolicyValidated: true,
fullTextPolicy: { defaultLanguage: getFullTextLanguageOptions()[0].key as never, fullTextPaths: [] },
fullTextIndexes: [],
fullTextPolicyValidated: true,
};
}
@@ -804,22 +819,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{this.shouldShowAnalyticalStoreOptions() && (
<Stack className="panelGroupSpacing">
<Stack horizontal>
<Text className="panelTextBold" variant="small">
Analytical store
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={this.getAnalyticalStorageTooltipContent()}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel="Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads."
/>
</TooltipHost>
</Stack>
<Text className="panelTextBold" variant="small">
{this.getAnalyticalStorageContent()}
</Text>
<Stack horizontal verticalAlign="center">
<div role="radiogroup">
@@ -890,9 +892,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
>
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
<Stack styles={{ root: { paddingLeft: 40 } }}>
<AddVectorEmbeddingPolicyForm
vectorEmbedding={this.state.vectorEmbeddingPolicy}
vectorIndex={this.state.vectorIndexingPolicy}
<VectorEmbeddingPoliciesComponent
vectorEmbeddings={this.state.vectorEmbeddingPolicy}
vectorIndexes={this.state.vectorIndexingPolicy}
onVectorEmbeddingChange={(
vectorEmbeddingPolicy: DataModels.VectorEmbedding[],
vectorIndexingPolicy: DataModels.VectorIndex[],
@@ -906,6 +908,34 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</CollapsibleSectionComponent>
</Stack>
)}
{this.shouldShowFullTextSearchParameters() && (
<Stack>
<CollapsibleSectionComponent
title="Container Full Text Search Policy"
isExpandedByDefault={false}
onExpand={() => {
this.scrollToSection("collapsibleFullTextPolicySectionContent");
}}
//TODO: uncomment when learn more text becomes available
// tooltipContent={this.getContainerFullTextPolicyTooltipContent()}
>
<Stack id="collapsibleFullTextPolicySectionContent" styles={{ root: { position: "relative" } }}>
<Stack styles={{ root: { paddingLeft: 40 } }}>
<FullTextPoliciesComponent
fullTextPolicy={this.state.fullTextPolicy}
onFullTextPathChange={(
fullTextPolicy: DataModels.FullTextPolicy,
fullTextIndexes: DataModels.FullTextIndex[],
fullTextPolicyValidated: boolean,
) => {
this.setState({ fullTextPolicy, fullTextIndexes, fullTextPolicyValidated });
}}
/>
</Stack>
</Stack>
</CollapsibleSectionComponent>
</Stack>
)}
{userContext.apiType !== "Tables" && (
<CollapsibleSectionComponent
title="Advanced"
@@ -1138,10 +1168,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return userContext.databaseAccount?.properties?.enableFreeTier;
}
private getSharedThroughputDefault(): boolean {
return userContext.subscriptionType !== SubscriptionType.EA && !isServerlessAccount();
}
private getFreeTierIndexingText(): string {
return this.state.enableIndexing
? "All properties in your documents will be indexed by default for flexible and efficient queries."
@@ -1191,7 +1217,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return "";
}
private getAnalyticalStorageTooltipContent(): JSX.Element {
private getAnalyticalStorageContent(): JSX.Element {
return (
<Text variant="small">
Enable analytical store capability to perform near real-time analytics on your operational data, without
@@ -1215,6 +1241,19 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
);
}
//TODO: uncomment when learn more text becomes available
// private getContainerFullTextPolicyTooltipContent(): JSX.Element {
// return (
// <Text variant="small">
// Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
// magna aliqua.{" "}
// <Link target="_blank" href="https://aka.ms/CosmosFullTextSearch">
// Learn more
// </Link>
// </Text>
// );
// }
private shouldShowCollectionThroughputInput(): boolean {
if (isServerlessAccount()) {
return false;
@@ -1278,6 +1317,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return isVectorSearchEnabled() && (isServerlessAccount() || this.shouldShowCollectionThroughputInput());
}
private shouldShowFullTextSearchParameters() {
return isFullTextSearchEnabled() && (isServerlessAccount() || this.shouldShowCollectionThroughputInput());
}
private parseUniqueKeys(): DataModels.UniqueKeyPolicy {
if (this.state.uniqueKeys?.length === 0) {
return undefined;
@@ -1334,9 +1377,16 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return false;
}
if (this.shouldShowVectorSearchParameters() && !this.state.vectorPolicyValidated) {
this.setState({ errorMessage: "Please fix errors in container vector policy" });
return false;
if (this.shouldShowVectorSearchParameters()) {
if (!this.state.vectorPolicyValidated) {
this.setState({ errorMessage: "Please fix errors in container vector policy" });
return false;
}
if (!this.state.fullTextPolicyValidated) {
this.setState({ errorMessage: "Please fix errors in container full text search polilcy" });
return false;
}
}
return true;
@@ -1427,6 +1477,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
};
}
if (this.shouldShowFullTextSearchParameters()) {
indexingPolicy.fullTextIndexes = this.state.fullTextIndexes;
}
const telemetryData = {
database: {
id: databaseId,
@@ -1486,6 +1540,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
uniqueKeyPolicy,
createMongoWildcardIndex: this.state.createMongoWildCardIndex,
vectorEmbeddingPolicy,
fullTextPolicy: this.state.fullTextPolicy,
};
this.setState({ isExecuting: true });

View File

@@ -1,4 +1,5 @@
import { Checkbox, Stack, Text, TextField } from "@fluentui/react";
import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as Constants from "../../../Common/Constants";
import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils";
@@ -48,7 +49,7 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
const databaseLevelThroughputTooltipText = `Provisioned throughput at the ${databaseLabel} level will be shared across all ${collectionsLabel} within the ${databaseLabel}.`;
const [databaseCreateNewShared, setDatabaseCreateNewShared] = useState<boolean>(
subscriptionType !== SubscriptionType.EA && !isServerlessAccount(),
getNewDatabaseSharedThroughputDefault(),
);
const [formErrors, setFormErrors] = useState<string>("");
const [isExecuting, setIsExecuting] = useState<boolean>(false);

View File

@@ -65,7 +65,7 @@ exports[`AddDatabasePane Pane should render Default properly 1`] = `
horizontal={true}
>
<StyledCheckboxBase
checked={true}
checked={false}
label="Provision throughput"
onChange={[Function]}
styles={
@@ -90,14 +90,6 @@ exports[`AddDatabasePane Pane should render Default properly 1`] = `
</InfoTooltip>
</Stack>
</Stack>
<ThroughputInput
isDatabase={true}
isSharded={true}
onCostAcknowledgeChange={[Function]}
setIsAutoscale={[Function]}
setIsThroughputCapExceeded={[Function]}
setThroughputValue={[Function]}
/>
</div>
</RightPaneForm>
`;

View File

@@ -124,7 +124,7 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
message:
"Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.",
};
const confirmDatabase = `Confirm by typing the ${getDatabaseName()} id`;
const confirmDatabase = `Confirm by typing the ${getDatabaseName()} id (name)`;
const reasonInfo = `Help us improve Azure Cosmos DB! What is the reason why you are deleting this ${getDatabaseName()}?`;
return (
<RightPaneForm {...props}>
@@ -132,7 +132,7 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
<div className="panelMainContent">
<div className="confirmDeleteInput">
<span className="mandatoryStar">* </span>
<Text variant="small">Confirm by typing the {getDatabaseName()} id</Text>
<Text variant="small">{confirmDatabase}</Text>
<TextField
id="confirmDatabaseId"
data-test="Input:confirmDatabaseId"

View File

@@ -1,3 +1,7 @@
import {
AuthError as msalAuthError,
BrowserAuthErrorMessage as msalBrowserAuthErrorMessage,
} from "@azure/msal-browser";
import {
Checkbox,
ChoiceGroup,
@@ -5,8 +9,6 @@ import {
IChoiceGroupOption,
ISpinButtonStyles,
IToggleStyles,
MessageBar,
MessageBarType,
Position,
SpinButton,
Toggle,
@@ -30,6 +32,7 @@ import {
} from "Shared/StorageUtility";
import * as StringUtility from "Shared/StringUtility";
import { updateUserContext, userContext } from "UserContext";
import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
@@ -108,7 +111,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
? LocalStorageUtility.getEntryString(StorageKey.DataPlaneRbacEnabled)
: Constants.RBACOptions.setAutomaticRBACOption,
);
const [showDataPlaneRBACWarning, setShowDataPlaneRBACWarning] = useState<boolean>(false);
const [ruThresholdEnabled, setRUThresholdEnabled] = useState<boolean>(isRUThresholdEnabled());
const [ruThreshold, setRUThreshold] = useState<number>(getRUThreshold());
@@ -191,6 +193,17 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage);
if (
enableDataPlaneRBACOption !== LocalStorageUtility.getEntryString(StorageKey.DataPlaneRbacEnabled) ||
retryAttempts !== LocalStorageUtility.getEntryNumber(StorageKey.RetryAttempts) ||
retryInterval !== LocalStorageUtility.getEntryNumber(StorageKey.RetryInterval) ||
MaxWaitTimeInSeconds !== LocalStorageUtility.getEntryNumber(StorageKey.MaxWaitTimeInSeconds)
) {
updateUserContext({
refreshCosmosClient: true,
});
}
if (configContext.platform !== Platform.Fabric) {
LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption);
if (
@@ -200,13 +213,29 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
) {
updateUserContext({
dataPlaneRbacEnabled: true,
hasDataPlaneRbacSettingChanged: true,
});
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true });
try {
const aadToken = await acquireMsalTokenForAccount(userContext.databaseAccount, true);
updateUserContext({ aadToken: aadToken });
useDataPlaneRbac.setState({ aadTokenUpdated: true });
} catch (authError) {
if (
authError instanceof msalAuthError &&
authError.errorCode === msalBrowserAuthErrorMessage.popUpWindowError.code
) {
logConsoleError(
`We were unable to establish authorization for this account, due to pop-ups being disabled in the browser.\nPlease enable pop-ups for this site and click on "Login for Entra ID" button`,
);
} else {
logConsoleError(
`"Failed to acquire authorization token automatically. Please click on "Login for Entra ID" button to enable Entra ID RBAC operations`,
);
}
}
} else {
updateUserContext({
dataPlaneRbacEnabled: false,
hasDataPlaneRbacSettingChanged: true,
});
const { databaseAccount: account, subscriptionId, resourceGroup } = userContext;
if (!userContext.features.enableAadDataPlane && !userContext.masterKey) {
@@ -347,13 +376,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
option: IChoiceGroupOption,
): void => {
setEnableDataPlaneRBACOption(option.key);
const shouldShowWarning =
(option.key === Constants.RBACOptions.setTrueRBACOption ||
(option.key === Constants.RBACOptions.setAutomaticRBACOption &&
userContext.databaseAccount.properties.disableLocalAuth === true)) &&
!useDataPlaneRbac.getState().aadTokenUpdated;
setShowDataPlaneRBACWarning(shouldShowWarning);
};
const handleOnRUThresholdToggleChange = (ev: React.MouseEvent<HTMLElement>, checked?: boolean): void => {
@@ -528,17 +550,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
{showDataPlaneRBACWarning && configContext.platform === Platform.Portal && (
<MessageBar
messageBarType={MessageBarType.warning}
isMultiline={true}
onDismiss={() => setShowDataPlaneRBACWarning(false)}
dismissButtonAriaLabel="Close"
>
Please click on &quot;Login for Entra ID RBAC&quot; button prior to performing Entra ID RBAC
operations
</MessageBar>
)}
<div className={styles.settingsSectionDescription}>
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra
ID RBAC.
@@ -562,7 +573,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionPanel>
</AccordionItem>
)}
{userContext.apiType === "SQL" && (
<>
<AccordionItem value="3">
@@ -608,16 +618,16 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
<AccordionItem value="4">
<AccordionHeader>
<div className={styles.header}>RU Threshold</div>
<div className={styles.header}>RU Limit</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
If a query exceeds a configured RU threshold, the query will be aborted.
If a query exceeds a configured RU limit, the query will be aborted.
</div>
<Toggle
styles={toggleStyles}
label="Enable RU threshold"
label="Enable RU limit"
onChange={handleOnRUThresholdToggleChange}
defaultChecked={ruThresholdEnabled}
/>
@@ -625,7 +635,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{ruThresholdEnabled && (
<div className={styles.settingsSectionContainer}>
<SpinButton
label="RU Threshold (RU)"
label="RU Limit (RU)"
labelPosition={Position.top}
defaultValue={(ruThreshold || DefaultRUThreshold).toString()}
min={1}
@@ -661,78 +671,79 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem>
</>
)}
<AccordionItem value="6">
<AccordionHeader>
<div className={styles.header}>Retry Settings</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
Retry policy associated with throttled requests during CosmosDB queries.
{(userContext.apiType === "SQL" || userContext.apiType === "Tables" || userContext.apiType === "Gremlin") && (
<AccordionItem value="6">
<AccordionHeader>
<div className={styles.header}>Retry Settings</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
Retry policy associated with throttled requests during CosmosDB queries.
</div>
<div>
<span className={styles.subHeader}>Max retry attempts</span>
<InfoTooltip className={styles.headerIcon}>
Max number of retries to be performed for a request. Default value 9.
</InfoTooltip>
</div>
<SpinButton
labelPosition={Position.top}
min={1}
step={1}
value={"" + retryAttempts}
onChange={handleOnQueryRetryAttemptsSpinButtonChange}
incrementButtonAriaLabel="Increase value by 1"
decrementButtonAriaLabel="Decrease value by 1"
onIncrement={(newValue) => setRetryAttempts(parseInt(newValue) + 1 || retryAttempts)}
onDecrement={(newValue) => setRetryAttempts(parseInt(newValue) - 1 || retryAttempts)}
onValidate={(newValue) => setRetryAttempts(parseInt(newValue) || retryAttempts)}
styles={spinButtonStyles}
/>
<div>
<span className={styles.subHeader}>Fixed retry interval (ms)</span>
<InfoTooltip className={styles.headerIcon}>
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned
as part of the response. Default value is 0 milliseconds.
</InfoTooltip>
</div>
<SpinButton
labelPosition={Position.top}
min={1000}
step={1000}
value={"" + retryInterval}
onChange={handleOnRetryIntervalSpinButtonChange}
incrementButtonAriaLabel="Increase value by 1000"
decrementButtonAriaLabel="Decrease value by 1000"
onIncrement={(newValue) => setRetryInterval(parseInt(newValue) + 1000 || retryInterval)}
onDecrement={(newValue) => setRetryInterval(parseInt(newValue) - 1000 || retryInterval)}
onValidate={(newValue) => setRetryInterval(parseInt(newValue) || retryInterval)}
styles={spinButtonStyles}
/>
<div>
<span className={styles.subHeader}>Max wait time (s)</span>
<InfoTooltip className={styles.headerIcon}>
Max wait time in seconds to wait for a request while the retries are happening. Default value 30
seconds.
</InfoTooltip>
</div>
<SpinButton
labelPosition={Position.top}
min={1}
step={1}
value={"" + MaxWaitTimeInSeconds}
onChange={handleOnMaxWaitTimeSpinButtonChange}
incrementButtonAriaLabel="Increase value by 1"
decrementButtonAriaLabel="Decrease value by 1"
onIncrement={(newValue) => setMaxWaitTimeInSeconds(parseInt(newValue) + 1 || MaxWaitTimeInSeconds)}
onDecrement={(newValue) => setMaxWaitTimeInSeconds(parseInt(newValue) - 1 || MaxWaitTimeInSeconds)}
onValidate={(newValue) => setMaxWaitTimeInSeconds(parseInt(newValue) || MaxWaitTimeInSeconds)}
styles={spinButtonStyles}
/>
</div>
<div>
<span className={styles.subHeader}>Max retry attempts</span>
<InfoTooltip className={styles.headerIcon}>
Max number of retries to be performed for a request. Default value 9.
</InfoTooltip>
</div>
<SpinButton
labelPosition={Position.top}
min={1}
step={1}
value={"" + retryAttempts}
onChange={handleOnQueryRetryAttemptsSpinButtonChange}
incrementButtonAriaLabel="Increase value by 1"
decrementButtonAriaLabel="Decrease value by 1"
onIncrement={(newValue) => setRetryAttempts(parseInt(newValue) + 1 || retryAttempts)}
onDecrement={(newValue) => setRetryAttempts(parseInt(newValue) - 1 || retryAttempts)}
onValidate={(newValue) => setRetryAttempts(parseInt(newValue) || retryAttempts)}
styles={spinButtonStyles}
/>
<div>
<span className={styles.subHeader}>Fixed retry interval (ms)</span>
<InfoTooltip className={styles.headerIcon}>
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as
part of the response. Default value is 0 milliseconds.
</InfoTooltip>
</div>
<SpinButton
labelPosition={Position.top}
min={1000}
step={1000}
value={"" + retryInterval}
onChange={handleOnRetryIntervalSpinButtonChange}
incrementButtonAriaLabel="Increase value by 1000"
decrementButtonAriaLabel="Decrease value by 1000"
onIncrement={(newValue) => setRetryInterval(parseInt(newValue) + 1000 || retryInterval)}
onDecrement={(newValue) => setRetryInterval(parseInt(newValue) - 1000 || retryInterval)}
onValidate={(newValue) => setRetryInterval(parseInt(newValue) || retryInterval)}
styles={spinButtonStyles}
/>
<div>
<span className={styles.subHeader}>Max wait time (s)</span>
<InfoTooltip className={styles.headerIcon}>
Max wait time in seconds to wait for a request while the retries are happening. Default value 30
seconds.
</InfoTooltip>
</div>
<SpinButton
labelPosition={Position.top}
min={1}
step={1}
value={"" + MaxWaitTimeInSeconds}
onChange={handleOnMaxWaitTimeSpinButtonChange}
incrementButtonAriaLabel="Increase value by 1"
decrementButtonAriaLabel="Decrease value by 1"
onIncrement={(newValue) => setMaxWaitTimeInSeconds(parseInt(newValue) + 1 || MaxWaitTimeInSeconds)}
onDecrement={(newValue) => setMaxWaitTimeInSeconds(parseInt(newValue) - 1 || MaxWaitTimeInSeconds)}
onValidate={(newValue) => setMaxWaitTimeInSeconds(parseInt(newValue) || MaxWaitTimeInSeconds)}
styles={spinButtonStyles}
/>
</div>
</AccordionPanel>
</AccordionItem>
</AccordionPanel>
</AccordionItem>
)}
<AccordionItem value="7">
<AccordionHeader>
@@ -756,7 +767,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</div>
</AccordionPanel>
</AccordionItem>
{shouldShowCrossPartitionOption && (
<AccordionItem value="8">
<AccordionHeader>
@@ -782,7 +792,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionPanel>
</AccordionItem>
)}
{shouldShowParallelismOption && (
<AccordionItem value="9">
<AccordionHeader>
@@ -816,7 +825,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionPanel>
</AccordionItem>
)}
{shouldShowPriorityLevelOption && (
<AccordionItem value="10">
<AccordionHeader>
@@ -840,7 +848,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionPanel>
</AccordionItem>
)}
{shouldShowGraphAutoVizOption && (
<AccordionItem value="11">
<AccordionHeader>
@@ -862,7 +869,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionPanel>
</AccordionItem>
)}
{shouldShowCopilotSampleDBOption && (
<AccordionItem value="12">
<AccordionHeader>

View File

@@ -154,7 +154,7 @@ exports[`Settings Pane should render Default properly 1`] = `
<div
className="___15c001r_0000000 fq02s40"
>
RU Threshold
RU Limit
</div>
</AccordionHeader>
<AccordionPanel>
@@ -164,11 +164,11 @@ exports[`Settings Pane should render Default properly 1`] = `
<div
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
>
If a query exceeds a configured RU threshold, the query will be aborted.
If a query exceeds a configured RU limit, the query will be aborted.
</div>
<StyledToggleBase
defaultChecked={true}
label="Enable RU threshold"
label="Enable RU limit"
onChange={[Function]}
styles={
{
@@ -193,7 +193,7 @@ exports[`Settings Pane should render Default properly 1`] = `
decrementButtonAriaLabel="Decrease value by 1000"
defaultValue="5000"
incrementButtonAriaLabel="Increase value by 1000"
label="RU Threshold (RU)"
label="RU Limit (RU)"
labelPosition={0}
min={1}
onChange={[Function]}

View File

@@ -61,7 +61,15 @@ export const TableColumnSelectionPane: React.FC<TableColumnSelectionPaneProps> =
if (checked) {
selectedColumnIdsSet.add(id);
} else {
if (selectedColumnIdsSet.size === 1 && selectedColumnIdsSet.has(id)) {
/* selectedColumnIds may contain ids that are not in columnDefinitions, because the selected
* ids may have been loaded from persistence, but don't exist in the current retrieved documents.
*/
if (
Array.from(selectedColumnIdsSet).filter((id) => columnDefinitions.find((def) => def.id === id) !== undefined)
.length === 1 &&
selectedColumnIdsSet.has(id)
) {
// Don't allow unchecking the last column
return;
}

View File

@@ -1,300 +0,0 @@
import {
DefaultButton,
Dropdown,
IDropdownOption,
IStyleFunctionOrObject,
ITextFieldStyleProps,
ITextFieldStyles,
IconButton,
Label,
Stack,
TextField,
} from "@fluentui/react";
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import {
getDataTypeOptions,
getDistanceFunctionOptions,
getIndexTypeOptions,
} from "Explorer/Panes/VectorSearchPanel/VectorSearchUtils";
import React, { FunctionComponent, useState } from "react";
export interface IAddVectorEmbeddingPolicyFormProps {
vectorEmbedding: VectorEmbedding[];
vectorIndex: VectorIndex[];
onVectorEmbeddingChange: (
vectorEmbeddings: VectorEmbedding[],
vectorIndexingPolicies: VectorIndex[],
validationPassed: boolean,
) => void;
}
export interface VectorEmbeddingPolicyData {
path: string;
dataType: VectorEmbedding["dataType"];
distanceFunction: VectorEmbedding["distanceFunction"];
dimensions: number;
indexType: VectorIndex["type"] | "none";
pathError: string;
dimensionsError: string;
}
type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexType";
const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldStyles> = {
fieldGroup: {
height: 27,
},
field: {
fontSize: 12,
padding: "0 8px",
},
};
const dropdownStyles = {
title: {
height: 27,
lineHeight: "24px",
fontSize: 12,
},
dropdown: {
height: 27,
lineHeight: "24px",
},
dropdownItem: {
fontSize: 12,
},
};
export const AddVectorEmbeddingPolicyForm: FunctionComponent<IAddVectorEmbeddingPolicyFormProps> = ({
vectorEmbedding,
vectorIndex,
onVectorEmbeddingChange,
}): JSX.Element => {
const onVectorEmbeddingPathError = (path: string, index?: number): string => {
let error = "";
if (!path) {
error = "Vector embedding path should not be empty";
}
if (
index >= 0 &&
vectorEmbeddingPolicyData?.find(
(vectorEmbedding: VectorEmbeddingPolicyData, dataIndex: number) =>
dataIndex !== index && vectorEmbedding.path === path,
)
) {
error = "Vector embedding path is already defined";
}
return error;
};
const onVectorEmbeddingDimensionError = (dimension: number, indexType: VectorIndex["type"] | "none"): string => {
let error = "";
if (dimension <= 0 || dimension > 4096) {
error = "Vector embedding dimension must be greater than 0 and less than or equal 4096";
}
if (indexType === "flat" && dimension > 505) {
error = "Maximum allowed dimension for flat index is 505";
}
return error;
};
const initializeData = (vectorEmbedding: VectorEmbedding[], vectorIndex: VectorIndex[]) => {
const mergedData: VectorEmbeddingPolicyData[] = [];
vectorEmbedding.forEach((embedding) => {
const matchingIndex = vectorIndex.find((index) => index.path === embedding.path);
mergedData.push({
...embedding,
indexType: matchingIndex?.type || "none",
pathError: onVectorEmbeddingPathError(embedding.path),
dimensionsError: onVectorEmbeddingDimensionError(embedding.dimensions, matchingIndex?.type || "none"),
});
});
return mergedData;
};
const [vectorEmbeddingPolicyData, setVectorEmbeddingPolicyData] = useState<VectorEmbeddingPolicyData[]>(
initializeData(vectorEmbedding, vectorIndex),
);
React.useEffect(() => {
propagateData();
}, [vectorEmbeddingPolicyData]);
const propagateData = () => {
const vectorEmbeddings: VectorEmbedding[] = vectorEmbeddingPolicyData.map((policy: VectorEmbeddingPolicyData) => ({
dataType: policy.dataType,
dimensions: policy.dimensions,
distanceFunction: policy.distanceFunction,
path: policy.path,
}));
const vectorIndexingPolicies: VectorIndex[] = vectorEmbeddingPolicyData
.filter((policy: VectorEmbeddingPolicyData) => policy.indexType !== "none")
.map(
(policy) =>
({
path: policy.path,
type: policy.indexType,
}) as VectorIndex,
);
const validationPassed = vectorEmbeddingPolicyData.every(
(policy: VectorEmbeddingPolicyData) => policy.pathError === "" && policy.dimensionsError === "",
);
onVectorEmbeddingChange(vectorEmbeddings, vectorIndexingPolicies, validationPassed);
};
const onVectorEmbeddingPathChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trim();
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
if (!vectorEmbeddings[index]?.path && !value.startsWith("/")) {
vectorEmbeddings[index].path = "/" + value;
} else {
vectorEmbeddings[index].path = value;
}
const error = onVectorEmbeddingPathError(value, index);
vectorEmbeddings[index].pathError = error;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onVectorEmbeddingDimensionsChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value.trim()) || 0;
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
const vectorEmbedding = vectorEmbeddings[index];
vectorEmbeddings[index].dimensions = value;
const error = onVectorEmbeddingDimensionError(value, vectorEmbedding.indexType);
vectorEmbeddings[index].dimensionsError = error;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onVectorEmbeddingIndexTypeChange = (index: number, option: IDropdownOption): void => {
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
const vectorEmbedding = vectorEmbeddings[index];
vectorEmbeddings[index].indexType = option.key as never;
const error = onVectorEmbeddingDimensionError(vectorEmbedding.dimensions, vectorEmbedding.indexType);
vectorEmbeddings[index].dimensionsError = error;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onVectorEmbeddingPolicyChange = (
index: number,
option: IDropdownOption,
property: VectorEmbeddingPolicyProperty,
): void => {
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
vectorEmbeddings[index][property] = option.key as never;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
const onAdd = () => {
setVectorEmbeddingPolicyData([
...vectorEmbeddingPolicyData,
{
path: "",
dataType: "float32",
distanceFunction: "euclidean",
dimensions: 0,
indexType: "none",
pathError: onVectorEmbeddingPathError(""),
dimensionsError: onVectorEmbeddingDimensionError(0, "none"),
},
]);
};
const onDelete = (index: number) => {
const vectorEmbeddings = vectorEmbeddingPolicyData.filter((_uniqueKey, j) => index !== j);
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
return (
<Stack tokens={{ childrenGap: 4 }}>
{vectorEmbeddingPolicyData.length > 0 &&
vectorEmbeddingPolicyData.map((vectorEmbeddingPolicy: VectorEmbeddingPolicyData, index: number) => (
<CollapsibleSectionComponent key={index} isExpandedByDefault={true} title={`Vector embedding ${index + 1}`}>
<Stack horizontal tokens={{ childrenGap: 4 }}>
<Stack
styles={{
root: {
margin: "0 0 6px 20px !important",
paddingLeft: 20,
width: "80%",
borderLeft: "1px solid",
},
}}
>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Path</Label>
<TextField
id={`vector-policy-path-${index + 1}`}
required={true}
placeholder="/vector1"
styles={textFieldStyles}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onVectorEmbeddingPathChange(index, event)}
value={vectorEmbeddingPolicy.path || ""}
errorMessage={vectorEmbeddingPolicy.pathError}
/>
</Stack>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Data type</Label>
<Dropdown
required={true}
styles={dropdownStyles}
options={getDataTypeOptions()}
selectedKey={vectorEmbeddingPolicy.dataType}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onVectorEmbeddingPolicyChange(index, option, "dataType")
}
></Dropdown>
</Stack>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Distance function</Label>
<Dropdown
required={true}
styles={dropdownStyles}
options={getDistanceFunctionOptions()}
selectedKey={vectorEmbeddingPolicy.distanceFunction}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onVectorEmbeddingPolicyChange(index, option, "distanceFunction")
}
></Dropdown>
</Stack>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Dimensions</Label>
<TextField
id={`vector-policy-dimension-${index + 1}`}
required={true}
styles={textFieldStyles}
value={String(vectorEmbeddingPolicy.dimensions || 0)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onVectorEmbeddingDimensionsChange(index, event)
}
errorMessage={vectorEmbeddingPolicy.dimensionsError}
/>
</Stack>
<Stack>
<Label styles={{ root: { fontSize: 12 } }}>Index type</Label>
<Dropdown
required={true}
styles={dropdownStyles}
options={getIndexTypeOptions()}
selectedKey={vectorEmbeddingPolicy.indexType}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onVectorEmbeddingIndexTypeChange(index, option)
}
></Dropdown>
</Stack>
</Stack>
<IconButton
id={`delete-vector-policy-${index + 1}`}
iconProps={{ iconName: "Delete" }}
style={{ height: 27, margin: "auto" }}
onClick={() => onDelete(index)}
/>
</Stack>
</CollapsibleSectionComponent>
))}
<DefaultButton id={`add-vector-policy`} styles={{ root: { maxWidth: 170, fontSize: 12 } }} onClick={onAdd}>
Add vector embedding
</DefaultButton>
</Stack>
);
};

View File

@@ -106,7 +106,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
horizontal={true}
>
<StyledCheckboxBase
checked={true}
checked={false}
label="Share throughput across containers"
onChange={[Function]}
styles={
@@ -137,14 +137,6 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
/>
</StyledTooltipHostBase>
</Stack>
<ThroughputInput
isDatabase={true}
isSharded={true}
onCostAcknowledgeChange={[Function]}
setIsAutoscale={[Function]}
setIsThroughputCapExceeded={[Function]}
setThroughputValue={[Function]}
/>
</Stack>
<Separator
className="panelSeparator"
@@ -263,6 +255,14 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
</CustomizedDefaultButton>
</Stack>
</Stack>
<ThroughputInput
isDatabase={false}
isSharded={true}
onCostAcknowledgeChange={[Function]}
setIsAutoscale={[Function]}
setIsThroughputCapExceeded={[Function]}
setThroughputValue={[Function]}
/>
<Stack>
<Stack
horizontal={true}
@@ -309,40 +309,23 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
<Stack
className="panelGroupSpacing"
>
<Stack
horizontal={true}
<Text
className="panelTextBold"
variant="small"
>
<Text
className="panelTextBold"
variant="small"
>
Analytical store
Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads.
<StyledLinkBase
href="https://aka.ms/analytical-store-overview"
target="_blank"
>
Learn more
</StyledLinkBase>
</Text>
<StyledTooltipHostBase
content={
<Text
variant="small"
>
Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads.
<StyledLinkBase
href="https://aka.ms/analytical-store-overview"
target="_blank"
>
Learn more
</StyledLinkBase>
</Text>
}
directionalHint={4}
>
<Icon
ariaLabel="Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads."
className="panelInfoIcon"
iconName="Info"
tabIndex={0}
/>
</StyledTooltipHostBase>
</Stack>
</Text>
<Stack
horizontal={true}
verticalAlign="center"

View File

@@ -361,13 +361,11 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
<span
className="css-113"
>
Confirm by typing the
Database
id
Confirm by typing the Database id (name)
</span>
</Text>
<StyledTextFieldBase
ariaLabel="Confirm by typing the Database id"
ariaLabel="Confirm by typing the Database id (name)"
autoFocus={true}
data-test="Input:confirmDatabaseId"
id="confirmDatabaseId"
@@ -382,7 +380,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
}
>
<TextFieldBase
ariaLabel="Confirm by typing the Database id"
ariaLabel="Confirm by typing the Database id (name)"
autoFocus={true}
data-test="Input:confirmDatabaseId"
deferredValidationTime={200}
@@ -677,7 +675,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
>
<input
aria-invalid={false}
aria-label="Confirm by typing the Database id"
aria-label="Confirm by typing the Database id (name)"
autoFocus={true}
className="ms-TextField-field field-117"
data-test="Input:confirmDatabaseId"

View File

@@ -171,7 +171,7 @@ export const QueryCopilotCarousel: React.FC<QueryCopilotCarouselProps> = ({
the query builder.
</Text>
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 24 }}>Database Id</Text>
<Text style={{ fontSize: 13 }}>CopilotSampleDb</Text>
<Text style={{ fontSize: 13 }}>CopilotSampleDB</Text>
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 16 }}>Database throughput (autoscale)</Text>
<Text style={{ fontSize: 13 }}>Autoscale</Text>
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 16 }}>Database Max RU/s</Text>

View File

@@ -79,9 +79,13 @@ export const QueryCopilotFeedbackModal = ({
readOnly
/>
<Text style={{ fontSize: 12, marginBottom: 14 }}>
By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the{" "}
Microsoft will process the feedback you submit pursuant to your organizations instructions in order to
improve your and your organizations experience with this product. If you have any questions about the use
of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the
Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the
feedback you submit is considered Personal Data under that addendum. Please see the{" "}
{
<Link href="https://privacy.microsoft.com/privacystatement" target="_blank">
<Link href="https://go.microsoft.com/fwlink/?LinkId=521839" target="_blank">
Privacy statement
</Link>
}{" "}

View File

@@ -99,10 +99,10 @@ exports[`Query Copilot Feedback Modal snapshot test shoud render and match snaps
}
}
>
By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the
Microsoft will process the feedback you submit pursuant to your organizations instructions in order to improve your and your organizations experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the
<StyledLinkBase
href="https://privacy.microsoft.com/privacystatement"
href="https://go.microsoft.com/fwlink/?LinkId=521839"
target="_blank"
>
Privacy statement
@@ -236,10 +236,10 @@ exports[`Query Copilot Feedback Modal snapshot test should cancel submission 1`]
}
}
>
By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the
Microsoft will process the feedback you submit pursuant to your organizations instructions in order to improve your and your organizations experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the
<StyledLinkBase
href="https://privacy.microsoft.com/privacystatement"
href="https://go.microsoft.com/fwlink/?LinkId=521839"
target="_blank"
>
Privacy statement
@@ -373,10 +373,10 @@ exports[`Query Copilot Feedback Modal snapshot test should close on cancel click
}
}
>
By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the
Microsoft will process the feedback you submit pursuant to your organizations instructions in order to improve your and your organizations experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the
<StyledLinkBase
href="https://privacy.microsoft.com/privacystatement"
href="https://go.microsoft.com/fwlink/?LinkId=521839"
target="_blank"
>
Privacy statement
@@ -510,10 +510,10 @@ exports[`Query Copilot Feedback Modal snapshot test should get user unput 1`] =
}
}
>
By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the
Microsoft will process the feedback you submit pursuant to your organizations instructions in order to improve your and your organizations experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the
<StyledLinkBase
href="https://privacy.microsoft.com/privacystatement"
href="https://go.microsoft.com/fwlink/?LinkId=521839"
target="_blank"
>
Privacy statement
@@ -647,10 +647,10 @@ exports[`Query Copilot Feedback Modal snapshot test should not render dont show
}
}
>
By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the
Microsoft will process the feedback you submit pursuant to your organizations instructions in order to improve your and your organizations experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the
<StyledLinkBase
href="https://privacy.microsoft.com/privacystatement"
href="https://go.microsoft.com/fwlink/?LinkId=521839"
target="_blank"
>
Privacy statement
@@ -784,10 +784,10 @@ exports[`Query Copilot Feedback Modal snapshot test should render dont show agai
}
}
>
By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the
Microsoft will process the feedback you submit pursuant to your organizations instructions in order to improve your and your organizations experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the
<StyledLinkBase
href="https://privacy.microsoft.com/privacystatement"
href="https://go.microsoft.com/fwlink/?LinkId=521839"
target="_blank"
>
Privacy statement
@@ -936,10 +936,10 @@ exports[`Query Copilot Feedback Modal snapshot test should submit submission 1`]
}
}
>
By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the
Microsoft will process the feedback you submit pursuant to your organizations instructions in order to improve your and your organizations experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the
<StyledLinkBase
href="https://privacy.microsoft.com/privacystatement"
href="https://go.microsoft.com/fwlink/?LinkId=521839"
target="_blank"
>
Privacy statement

View File

@@ -28,6 +28,8 @@ import {
SuggestedPrompt,
getSampleDatabaseSuggestedPrompts,
getSuggestedPrompts,
readPromptHistory,
savePromptHistory,
} from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { SubmitFeedback, allocatePhoenixContainer } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { GenerateSQLQueryResponse, QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
@@ -136,9 +138,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
};
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
const cachedHistoriesString = localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotHistories`);
const cachedHistories = cachedHistoriesString?.split("|");
const [histories, setHistories] = useState<string[]>(cachedHistories || []);
const [histories, setHistories] = useState<string[]>(() => readPromptHistory(userContext.databaseAccount));
const suggestedPrompts: SuggestedPrompt[] = isSampleCopilotActive
? getSampleDatabaseSuggestedPrompts()
: getSuggestedPrompts();
@@ -172,7 +172,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
const newHistories = [formattedUserPrompt, ...updatedHistories.slice(0, 2)];
setHistories(newHistories);
localStorage.setItem(`${userContext.databaseAccount.id}-queryCopilotHistories`, newHistories.join("|"));
savePromptHistory(userContext.databaseAccount, newHistories);
};
const resetMessageStates = (): void => {

View File

@@ -1,10 +1,39 @@
import { shallow } from "enzyme";
import { CopilotSubComponentNames } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import React from "react";
import { AppStateComponentNames, StorePath } from "Shared/AppStatePersistenceUtility";
import { updateUserContext } from "UserContext";
import Explorer from "../Explorer";
import { QueryCopilotTab } from "./QueryCopilotTab";
describe("Query copilot tab snapshot test", () => {
it("should render with initial input", () => {
updateUserContext({
databaseAccount: {
name: "name",
properties: undefined,
id: "",
location: "",
type: "",
kind: "",
},
});
const loadState = (path: StorePath) => {
if (
path.componentName === AppStateComponentNames.QueryCopilot &&
path.subComponentName === CopilotSubComponentNames.toggleStatus
) {
return { enabled: true };
} else {
return undefined;
}
};
jest.mock("Shared/AppStatePersistenceUtility", () => ({
loadState,
}));
const wrapper = shallow(<QueryCopilotTab explorer={new Explorer()} />);
expect(wrapper).toMatchSnapshot();
});

View File

@@ -6,6 +6,7 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { SaveQueryPane } from "Explorer/Panes/SaveQueryPane/SaveQueryPane";
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
import { readCopilotToggleStatus, saveCopilotToggleStatus } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { OnExecuteQueryClick } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { QueryCopilotResults } from "Explorer/QueryCopilot/Shared/QueryCopilotResults";
@@ -18,18 +19,13 @@ import SplitterLayout from "react-splitter-layout";
import QueryCommandIcon from "../../../images/CopilotCommand.svg";
import ExecuteQueryIcon from "../../../images/ExecuteQuery.svg";
import SaveQueryIcon from "../../../images/save-cosmos.svg";
import * as StringUtility from "../../Shared/StringUtility";
export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: QueryCopilotProps): JSX.Element => {
const { query, setQuery, selectedQuery, setSelectedQuery, isGeneratingQuery } = useQueryCopilot();
const cachedCopilotToggleStatus: string = localStorage.getItem(
`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`,
const [copilotActive, setCopilotActive] = useState<boolean>(() =>
readCopilotToggleStatus(userContext.databaseAccount),
);
const copilotInitialActive: boolean = cachedCopilotToggleStatus
? StringUtility.toBoolean(cachedCopilotToggleStatus)
: true;
const [copilotActive, setCopilotActive] = useState<boolean>(copilotInitialActive);
const [tabActive, setTabActive] = useState<boolean>(true);
const getCommandbarButtons = (): CommandButtonComponentProps[] => {
@@ -88,7 +84,7 @@ export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: Query
const toggleCopilot = (toggle: boolean) => {
setCopilotActive(toggle);
localStorage.setItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, toggle.toString());
saveCopilotToggleStatus(userContext.databaseAccount, toggle);
};
return (

View File

@@ -90,7 +90,7 @@ describe("QueryCopilotUtilities", () => {
// Mock the items.query method to return the mockResult
(
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").items.query as jest.Mock
sampleDataClient().database("CopilotSampleDB").container("SampleContainer").items.query as jest.Mock
).mockReturnValue(mockResult);
const result = querySampleDocuments(query, options);
@@ -119,10 +119,10 @@ describe("QueryCopilotUtilities", () => {
const result = await readSampleDocument(documentId);
expect(sampleDataClient).toHaveBeenCalled();
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDb");
expect(sampleDataClient().database("CopilotSampleDb").container).toHaveBeenCalledWith("SampleContainer");
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDB");
expect(sampleDataClient().database("CopilotSampleDB").container).toHaveBeenCalledWith("SampleContainer");
expect(
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").item("DocumentId", undefined).read,
sampleDataClient().database("CopilotSampleDB").container("SampleContainer").item("DocumentId", undefined).read,
).toHaveBeenCalled();
expect(result).toEqual(expectedResponse);
});
@@ -144,10 +144,10 @@ describe("QueryCopilotUtilities", () => {
await expect(readSampleDocument(documentId)).rejects.toStrictEqual(errorMock);
expect(sampleDataClient).toHaveBeenCalled();
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDb");
expect(sampleDataClient().database("CopilotSampleDb").container).toHaveBeenCalledWith("SampleContainer");
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDB");
expect(sampleDataClient().database("CopilotSampleDB").container).toHaveBeenCalledWith("SampleContainer");
expect(
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").item("DocumentId", undefined).read,
sampleDataClient().database("CopilotSampleDB").container("SampleContainer").item("DocumentId", undefined).read,
).toHaveBeenCalled();
expect(handleError).toHaveBeenCalledWith(errorMock, "ReadDocument", expect.any(String));
});

View File

@@ -4,8 +4,11 @@ import { handleError } from "Common/ErrorHandlingUtils";
import { sampleDataClient } from "Common/SampleDataClient";
import { getPartitionKeyValue } from "Common/dataAccess/getPartitionKeyValue";
import { getCommonQueryOptions } from "Common/dataAccess/queryDocuments";
import { DatabaseAccount } from "Contracts/DataModels";
import DocumentId from "Explorer/Tree/DocumentId";
import { AppStateComponentNames, loadState, saveState } from "Shared/AppStatePersistenceUtility";
import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
import * as StringUtility from "../../Shared/StringUtility";
export interface SuggestedPrompt {
id: number;
@@ -54,3 +57,110 @@ export const getSuggestedPrompts = (): SuggestedPrompt[] => {
{ id: 3, text: "Find the oldest item added to my collection" },
];
};
// Prompt history persistence
export enum CopilotSubComponentNames {
promptHistory = "PromptHistory",
toggleStatus = "ToggleStatus",
}
const getLegacyHistoryKey = (databaseAccount: DatabaseAccount): string =>
`${databaseAccount?.id}-queryCopilotHistories`;
const getLegacyToggleStatusKey = (databaseAccount: DatabaseAccount): string =>
`${databaseAccount?.id}-queryCopilotToggleStatus`;
// Migration only needs to run once
let hasMigrated = false;
// Migrate old prompt history to new format
export const migrateCopilotPersistence = (databaseAccount: DatabaseAccount): void => {
if (hasMigrated) {
return;
}
let key = getLegacyHistoryKey(databaseAccount);
let item = localStorage.getItem(key);
if (item !== undefined && item !== null) {
const historyItems = item.split("|");
saveState(
{
componentName: AppStateComponentNames.QueryCopilot,
subComponentName: CopilotSubComponentNames.promptHistory,
globalAccountName: databaseAccount.name,
databaseName: undefined,
containerName: undefined,
},
historyItems,
);
localStorage.removeItem(key);
}
key = getLegacyToggleStatusKey(databaseAccount);
item = localStorage.getItem(key);
if (item !== undefined && item !== null) {
saveState(
{
componentName: AppStateComponentNames.QueryCopilot,
subComponentName: CopilotSubComponentNames.toggleStatus,
globalAccountName: databaseAccount.name,
databaseName: undefined,
containerName: undefined,
},
StringUtility.toBoolean(item),
);
localStorage.removeItem(key);
}
hasMigrated = true;
};
export const readPromptHistory = (databaseAccount: DatabaseAccount): string[] => {
migrateCopilotPersistence(databaseAccount);
return (
(loadState({
componentName: AppStateComponentNames.QueryCopilot,
subComponentName: CopilotSubComponentNames.promptHistory,
globalAccountName: databaseAccount.name,
databaseName: undefined,
containerName: undefined,
}) as string[]) || []
);
};
export const savePromptHistory = (databaseAccount: DatabaseAccount, historyItems: string[]): void => {
saveState(
{
componentName: AppStateComponentNames.QueryCopilot,
subComponentName: CopilotSubComponentNames.promptHistory,
globalAccountName: databaseAccount.name,
databaseName: undefined,
containerName: undefined,
},
historyItems,
);
};
export const readCopilotToggleStatus = (databaseAccount: DatabaseAccount): boolean => {
migrateCopilotPersistence(databaseAccount);
return !!loadState({
componentName: AppStateComponentNames.QueryCopilot,
subComponentName: CopilotSubComponentNames.toggleStatus,
globalAccountName: databaseAccount.name,
databaseName: undefined,
containerName: undefined,
}) as boolean;
};
export const saveCopilotToggleStatus = (databaseAccount: DatabaseAccount, status: boolean): void => {
saveState(
{
componentName: AppStateComponentNames.QueryCopilot,
subComponentName: CopilotSubComponentNames.toggleStatus,
globalAccountName: databaseAccount.name,
databaseName: undefined,
containerName: undefined,
},
status,
);
};

View File

@@ -26,7 +26,7 @@ import {
import { AuthorizationTokenHeaderMetadata, QueryResults } from "Contracts/ViewModels";
import { useDialog } from "Explorer/Controls/Dialog";
import Explorer from "Explorer/Explorer";
import { querySampleDocuments } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { querySampleDocuments, readCopilotToggleStatus } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { FeedbackParams, GenerateSQLQueryResponse } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceStart, traceSuccess } from "Shared/Telemetry/TelemetryProcessor";
@@ -36,7 +36,6 @@ import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { queryPagesUntilContentPresent } from "Utils/QueryUtils";
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
import { useTabs } from "hooks/useTabs";
import * as StringUtility from "../../../Shared/StringUtility";
async function fetchWithTimeout(
url: string,
@@ -361,9 +360,7 @@ export const QueryDocumentsPerPage = async (
correlationId: useQueryCopilot.getState().correlationId,
});
} catch (error) {
const isCopilotActive = StringUtility.toBoolean(
localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`),
);
const isCopilotActive = readCopilotToggleStatus(userContext.databaseAccount);
const errorMessage = getErrorMessage(error);
traceFailure(Action.ExecuteQueryGeneratedFromQueryCopilot, {
correlationId: useQueryCopilot.getState().correlationId,

View File

@@ -17,38 +17,6 @@ exports[`Query copilot tab snapshot test should render with initial input 1`] =
}
}
>
<QueryCopilotPromptbar
containerId="SampleContainer"
databaseId="CopilotSampleDb"
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],
},
}
}
toggleCopilot={[Function]}
/>
<Stack
className="tabPaneContentContainer"
>

View File

@@ -282,67 +282,69 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
);
return (
<Allotment ref={allotment} onChange={onChange} onDragEnd={onDragEnd} className="resourceTreeAndTabs">
{/* Collections Tree - Start */}
{hasSidebar && (
// When collapsed, we force the pane to 24 pixels wide and make it non-resizable.
<Allotment.Pane minSize={24} preferredSize={250}>
<CosmosFluentProvider className={mergeClasses(styles.sidebar)}>
<div className={styles.sidebarContainer}>
{loading && (
// The Fluent UI progress bar has some issues in reduced-motion environments so we use a simple CSS animation here.
// https://github.com/microsoft/fluentui/issues/29076
<div className={styles.loadingProgressBar} title="Refreshing tree..." />
)}
{expanded ? (
<>
<div className={styles.floatingControlsContainer}>
<div className={styles.floatingControls}>
<button
type="button"
data-test="Sidebar/RefreshButton"
className={styles.floatingControlButton}
disabled={loading}
title="Refresh"
onClick={onRefreshClick}
>
<ArrowSync12Regular />
</button>
<button
type="button"
className={styles.floatingControlButton}
title="Collapse sidebar"
onClick={() => collapse()}
>
<ChevronLeft12Regular />
</button>
<div className="sidebarContainer">
<Allotment ref={allotment} onChange={onChange} onDragEnd={onDragEnd} className="resourceTreeAndTabs">
{/* Collections Tree - Start */}
{hasSidebar && (
// When collapsed, we force the pane to 24 pixels wide and make it non-resizable.
<Allotment.Pane minSize={24} preferredSize={250}>
<CosmosFluentProvider className={mergeClasses(styles.sidebar)}>
<div className={styles.sidebarContainer}>
{loading && (
// The Fluent UI progress bar has some issues in reduced-motion environments so we use a simple CSS animation here.
// https://github.com/microsoft/fluentui/issues/29076
<div className={styles.loadingProgressBar} title="Refreshing tree..." />
)}
{expanded ? (
<>
<div className={styles.floatingControlsContainer}>
<div className={styles.floatingControls}>
<button
type="button"
data-test="Sidebar/RefreshButton"
className={styles.floatingControlButton}
disabled={loading}
title="Refresh"
onClick={onRefreshClick}
>
<ArrowSync12Regular />
</button>
<button
type="button"
className={styles.floatingControlButton}
title="Collapse sidebar"
onClick={() => collapse()}
>
<ChevronLeft12Regular />
</button>
</div>
</div>
</div>
<div
className={styles.expandedContent}
style={!hasGlobalCommands ? { gridTemplateRows: "1fr" } : undefined}
<div
className={styles.expandedContent}
style={!hasGlobalCommands ? { gridTemplateRows: "1fr" } : undefined}
>
{hasGlobalCommands && <GlobalCommands explorer={explorer} />}
<ResourceTree explorer={explorer} />
</div>
</>
) : (
<button
type="button"
className={styles.floatingControlButton}
title="Expand sidebar"
onClick={() => expand()}
>
{hasGlobalCommands && <GlobalCommands explorer={explorer} />}
<ResourceTree explorer={explorer} />
</div>
</>
) : (
<button
type="button"
className={styles.floatingControlButton}
title="Expand sidebar"
onClick={() => expand()}
>
<ChevronRight12Regular />
</button>
)}
</div>
</CosmosFluentProvider>
<ChevronRight12Regular />
</button>
)}
</div>
</CosmosFluentProvider>
</Allotment.Pane>
)}
<Allotment.Pane minSize={200}>
<Tabs explorer={explorer} />
</Allotment.Pane>
)}
<Allotment.Pane minSize={200}>
<Tabs explorer={explorer} />
</Allotment.Pane>
</Allotment>
</Allotment>
</div>
);
};

View File

@@ -114,7 +114,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
}
private clearMostRecent = (): void => {
MostRecentActivity.mostRecentActivity.clear(userContext.databaseAccount?.id);
MostRecentActivity.clear(userContext.databaseAccount?.name);
this.setState({});
};
@@ -498,7 +498,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
}
private createRecentItems(): SplashScreenItem[] {
return MostRecentActivity.mostRecentActivity.getItems(userContext.databaseAccount?.id).map((activity) => {
return MostRecentActivity.getItems(userContext.databaseAccount?.name).map((activity) => {
switch (activity.type) {
default: {
const unknownActivity: never = activity;

View File

@@ -155,6 +155,7 @@ export const htmlAttributeNames = {
dataTableContentTypeAttr: "contentType_attr",
dataTableSnapshotAttr: "snapshot_attr",
dataTableRowKeyAttr: "rowKey_attr",
dataTablePartitionKeyAttr: "partKey_attr",
dataTableMessageIdAttr: "messageId_attr",
dataTableHeaderIndex: "data-column-index",
};

View File

@@ -193,6 +193,9 @@ function getServerData(sSource: any, aoData: any, fnCallback: any, oSettings: an
* from UI elements.
*/
function bindClientId(nRow: Node, aData: Entities.ITableEntity) {
if (aData.PartitionKey && aData.PartitionKey._) {
$(nRow).attr(Constants.htmlAttributeNames.dataTablePartitionKeyAttr, aData.PartitionKey._);
}
$(nRow).attr(Constants.htmlAttributeNames.dataTableRowKeyAttr, aData.RowKey._);
return nRow;
}
@@ -205,6 +208,10 @@ function selectionChanged(element: any, valueAccessor: any, allBindings: any, vi
selected &&
selected.forEach((b: Entities.ITableEntity) => {
var sel = DataTableOperations.getRowSelector([
{
key: Constants.htmlAttributeNames.dataTablePartitionKeyAttr,
value: b.PartitionKey && b.PartitionKey._ && b.PartitionKey._.toString(),
},
{
key: Constants.htmlAttributeNames.dataTableRowKeyAttr,
value: b.RowKey && b.RowKey._ && b.RowKey._.toString(),
@@ -370,8 +377,9 @@ function updateSelectionStatus(oSettings: any): void {
for (var i = 0; i < $dataTableRows.length; i++) {
var $row: JQuery = $dataTableRows.eq(i);
var rowKey: string = $row.attr(Constants.htmlAttributeNames.dataTableRowKeyAttr);
var partitionKey: string = $row.attr(Constants.htmlAttributeNames.dataTablePartitionKeyAttr);
var table = tableEntityListViewModelMap[oSettings.ajax].tableViewModel;
if (table.isItemSelected(table.getTableEntityKeys(rowKey))) {
if (table.isItemSelected(table.getTableEntityKeys(rowKey, partitionKey))) {
$row.attr("tabindex", "0");
}
}

View File

@@ -56,7 +56,10 @@ export default class DataTableOperationManager {
// Simply select the first item in this case.
var lastSelectedItemIndex = lastSelectedItem
? this._tableEntityListViewModel.getItemIndexFromCurrentPage(
this._tableEntityListViewModel.getTableEntityKeys(lastSelectedItem.RowKey._),
this._tableEntityListViewModel.getTableEntityKeys(
lastSelectedItem.RowKey._,
lastSelectedItem.PartitionKey && lastSelectedItem.PartitionKey._,
),
)
: -1;
var nextIndex: number = isUpArrowKey ? lastSelectedItemIndex - 1 : lastSelectedItemIndex + 1;
@@ -147,13 +150,14 @@ export default class DataTableOperationManager {
private getEntityIdentity($elem: JQuery<Element>): Entities.ITableEntityIdentity {
return {
RowKey: $elem.attr(Constants.htmlAttributeNames.dataTableRowKeyAttr),
PartitionKey: $elem.attr(Constants.htmlAttributeNames.dataTablePartitionKeyAttr),
};
}
private updateLastSelectedItem($elem: JQuery<Element>, isShiftSelect: boolean) {
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
var entity = this._tableEntityListViewModel.getItemFromCurrentPage(
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
);
this._tableEntityListViewModel.lastSelectedItem = entity;
@@ -168,7 +172,7 @@ export default class DataTableOperationManager {
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
this._tableEntityListViewModel.clearSelection();
this.addToSelection(entityIdentity.RowKey);
this.addToSelection(entityIdentity.RowKey, entityIdentity.PartitionKey);
}
}
@@ -190,11 +194,11 @@ export default class DataTableOperationManager {
if (
!this._tableEntityListViewModel.isItemSelected(
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
)
) {
// Adding item not previously in selection
this.addToSelection(entityIdentity.RowKey);
this.addToSelection(entityIdentity.RowKey, entityIdentity.PartitionKey);
} else {
koSelected.remove((item: Entities.ITableEntity) => item.RowKey._ === entityIdentity.RowKey);
}
@@ -212,10 +216,10 @@ export default class DataTableOperationManager {
if (anchorItem) {
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
var elementIndex = this._tableEntityListViewModel.getItemIndexFromAllPages(
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
);
var anchorIndex = this._tableEntityListViewModel.getItemIndexFromAllPages(
this._tableEntityListViewModel.getTableEntityKeys(anchorItem.RowKey._),
this._tableEntityListViewModel.getTableEntityKeys(anchorItem.PartitionKey._, anchorItem.RowKey._),
);
var startIndex = Math.min(elementIndex, anchorIndex);
@@ -234,24 +238,25 @@ export default class DataTableOperationManager {
if (
!this._tableEntityListViewModel.isItemSelected(
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
)
) {
if (this._tableEntityListViewModel.selected().length) {
this._tableEntityListViewModel.clearSelection();
}
this.addToSelection(entityIdentity.RowKey);
this.addToSelection(entityIdentity.RowKey, entityIdentity.PartitionKey);
}
}
private addToSelection(rowKey: string) {
private addToSelection(rowKey: string, partitionKey?: string) {
var selectedEntity: Entities.ITableEntity = this._tableEntityListViewModel.getItemFromCurrentPage(
this._tableEntityListViewModel.getTableEntityKeys(rowKey),
this._tableEntityListViewModel.getTableEntityKeys(rowKey, partitionKey),
);
if (selectedEntity != null) {
this._tableEntityListViewModel.selected.push(selectedEntity);
}
console.log(this._tableEntityListViewModel.selected().length);
}
// Selecting first row if the selection is empty.
@@ -269,7 +274,7 @@ export default class DataTableOperationManager {
// Clear last selection: lastSelectedItem and lastSelectedAnchorItem
this._tableEntityListViewModel.clearLastSelected();
this.addToSelection(firstEntity.RowKey._);
this.addToSelection(firstEntity.RowKey._, firstEntity.PartitionKey && firstEntity.PartitionKey._);
// Update last selection
this._tableEntityListViewModel.lastSelectedItem = firstEntity;

View File

@@ -128,8 +128,14 @@ export default class TableEntityListViewModel extends DataTableViewModel {
this.sqlQuery = ko.observable<string>("SELECT * FROM c");
}
public getTableEntityKeys(rowKey: string): Entities.IProperty[] {
return [{ key: Constants.EntityKeyNames.RowKey, value: rowKey }];
public getTableEntityKeys(rowKey: string, partitionKey: string): Entities.IProperty[] {
const properties: Entities.IProperty[] = [{ key: Constants.EntityKeyNames.RowKey, value: rowKey }];
if (partitionKey) {
properties.push({ key: Constants.EntityKeyNames.PartitionKey, value: partitionKey });
}
return properties;
}
public reloadTable(useSetting: boolean = true, resetHeaders: boolean = true): DataTables.Api<Element> {
@@ -261,7 +267,8 @@ export default class TableEntityListViewModel extends DataTableViewModel {
}
var oldEntityIndex: number = _.findIndex(
this.cache.data,
(data: Entities.ITableEntity) => data.RowKey._ === entity.RowKey._,
(data: Entities.ITableEntity) =>
data.RowKey._ === entity.RowKey._ && data.PartitionKey._ === entity.PartitionKey._,
);
this.cache.data.splice(oldEntityIndex, 1, entity);
@@ -285,7 +292,7 @@ export default class TableEntityListViewModel extends DataTableViewModel {
entities.forEach((entity: Entities.ITableEntity) => {
var cachedIndex: number = _.findIndex(
this.cache.data,
(e: Entities.ITableEntity) => e.RowKey._ === entity.RowKey._,
(e: Entities.ITableEntity) => e.RowKey._ === entity.RowKey._ && e.PartitionKey._ === entity.PartitionKey._,
);
if (cachedIndex >= 0) {
this.cache.data.splice(cachedIndex, 1);
@@ -393,6 +400,16 @@ export default class TableEntityListViewModel extends DataTableViewModel {
});
}
// Override as Tables can have the same Row key in different Partition keys
/**
* @override
*/
public getItemFromCurrentPage(itemKeys: Entities.IProperty[]): Entities.ITableEntity {
return _.find(this.items(), (item: Entities.ITableEntity) => {
return this.matchesKeys(item, itemKeys);
});
}
private prefetchAndRender(
tableQuery: Entities.ITableQuery,
tablePageStartIndex: number,

View File

@@ -36,4 +36,5 @@ export interface ITableQuery {
export interface ITableEntityIdentity {
RowKey: string;
PartitionKey?: string;
}

View File

@@ -757,6 +757,10 @@ export class CassandraAPIDataClient extends TableDataClient {
CassandraProxyEndpoints.Mooncake,
];
if (configContext.globallyEnabledCassandraAPIs.includes(api)) {
return true;
}
return (
configContext.NEW_CASSANDRA_APIS?.includes(api) &&
activeCassandraProxyEndpoints.includes(configContext.CASSANDRA_PROXY_ENDPOINT)

View File

@@ -1,19 +1,21 @@
// Definitions of State data
import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent";
import { deleteState, loadState, saveState, saveStateDebounced } from "Shared/AppStatePersistenceUtility";
import { userContext } from "UserContext";
import {
AppStateComponentNames,
deleteSubComponentState,
readSubComponentState,
saveSubComponentState,
} from "Shared/AppStatePersistenceUtility";
import * as ViewModels from "../../../Contracts/ViewModels";
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
const componentName = "DocumentsTab";
export enum SubComponentName {
ColumnSizes = "ColumnSizes",
FilterHistory = "FilterHistory",
MainTabDivider = "MainTabDivider",
ColumnsSelection = "ColumnsSelection",
ColumnSort = "ColumnSort",
CurrentFilter = "CurrentFilter",
}
export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
@@ -23,84 +25,22 @@ export type TabDivider = { leftPaneWidthPercent: number };
export type ColumnsSelection = { selectedColumnIds: string[]; columnDefinitions: ColumnDefinition[] };
export type ColumnSort = { columnId: string; direction: "ascending" | "descending" };
/**
*
* @param subComponentName
* @param collection
* @param defaultValue Will be returned if persisted state is not found
* @returns
*/
export const readSubComponentState = <T>(
// Wrap the ...SubComponentState functions for type safety
export const readDocumentsTabSubComponentState = <T>(
subComponentName: SubComponentName,
collection: ViewModels.CollectionBase,
defaultValue: T,
): T => {
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.ReadPersistedTabState, { message, componentName });
return defaultValue;
}
): T => readSubComponentState<T>(AppStateComponentNames.DocumentsTab, subComponentName, collection, defaultValue);
const state = loadState({
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
}) as T;
return state || defaultValue;
};
/**
*
* @param subComponentName
* @param collection
* @param state State to save
* @param debounce true for high-frequency calls (e.g mouse drag events)
*/
export const saveSubComponentState = <T>(
export const saveDocumentsTabSubComponentState = <T>(
subComponentName: SubComponentName,
collection: ViewModels.CollectionBase,
state: T,
debounce?: boolean,
): void => {
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.SavePersistedTabState, { message, componentName });
return;
}
): void => saveSubComponentState<T>(AppStateComponentNames.DocumentsTab, subComponentName, collection, state, debounce);
(debounce ? saveStateDebounced : saveState)(
{
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
},
state,
);
};
export const deleteSubComponentState = (subComponentName: SubComponentName, collection: ViewModels.CollectionBase) => {
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.DeletePersistedTabState, { message, componentName });
return;
}
deleteState({
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
});
};
export const deleteDocumentsTabSubComponentState = (
subComponentName: SubComponentName,
collection: ViewModels.CollectionBase,
) => deleteSubComponentState(AppStateComponentNames.DocumentsTab, subComponentName, collection);

View File

@@ -1,7 +1,10 @@
import { FeedResponse, ItemDefinition, Resource } from "@azure/cosmos";
import { waitFor } from "@testing-library/react";
import { deleteDocuments } from "Common/dataAccess/deleteDocument";
import { Platform, updateConfigContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog";
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
import { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import {
ButtonsDependencies,
@@ -46,6 +49,7 @@ jest.mock("Common/dataAccess/queryDocuments", () => ({
requestCharge: 1,
activityId: "activityId",
indexMetrics: "indexMetrics",
correlatedActivityId: undefined,
}),
})),
}));
@@ -65,12 +69,14 @@ jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
EditorReact: (props: EditorReactProps) => <>{props.content}</>,
}));
const mockDialogState = {
showOkCancelModalDialog: jest.fn((title: string, subText: string, okLabel: string, onOk: () => void) => onOk()),
showOkModalDialog: () => {},
};
jest.mock("Explorer/Controls/Dialog", () => ({
useDialog: {
getState: jest.fn(() => ({
showOkCancelModalDialog: (title: string, subText: string, okLabel: string, onOk: () => void) => onOk(),
showOkModalDialog: () => {},
})),
getState: jest.fn(() => mockDialogState),
},
}));
@@ -80,6 +86,10 @@ jest.mock("Common/dataAccess/deleteDocument", () => ({
),
}));
jest.mock("Explorer/Controls/ProgressModalDialog", () => ({
ProgressModalDialog: jest.fn(() => <></>),
}));
async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | ShallowWrapper<P>, amount = 0) {
let newWrapper;
await act(async () => {
@@ -376,22 +386,6 @@ describe("Documents tab (noSql API)", () => {
it("should render the page", () => {
expect(wrapper).toMatchSnapshot();
});
it("clicking on Edit filter should render the Apply Filter button", () => {
wrapper
.findWhere((node) => node.text() === "Edit Filter")
.at(0)
.simulate("click");
expect(wrapper.findWhere((node) => node.text() === "Apply Filter").exists()).toBeTruthy();
});
it("clicking on Edit filter should render input for filter", () => {
wrapper
.findWhere((node) => node.text() === "Edit Filter")
.at(0)
.simulate("click");
expect(wrapper.find("Input.filterInput").exists()).toBeTruthy();
});
});
describe("Command bar buttons", () => {
@@ -469,7 +463,29 @@ describe("Documents tab (noSql API)", () => {
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined();
});
it("clicking Delete Document asks for confirmation", () => {
it("clicking Delete Document asks for confirmation", async () => {
act(async () => {
await useCommandBar
.getState()
.contextButtons.find((button) => button.id === DELETE_BUTTON_ID)
.onCommandClick(undefined);
});
expect(useDialog.getState().showOkCancelModalDialog).toHaveBeenCalled();
});
it("clicking Delete Document for NoSql shows progress dialog", () => {
act(() => {
useCommandBar
.getState()
.contextButtons.find((button) => button.id === DELETE_BUTTON_ID)
.onCommandClick(undefined);
});
expect(ProgressModalDialog).toHaveBeenCalled();
});
it("clicking Delete Document eventually calls delete client api", () => {
const mockDeleteDocuments = deleteDocuments as jest.Mock;
mockDeleteDocuments.mockClear();
@@ -480,7 +496,8 @@ describe("Documents tab (noSql API)", () => {
.onCommandClick(undefined);
});
expect(mockDeleteDocuments).toHaveBeenCalled();
// The implementation uses setTimeout, so wait for it to finish
waitFor(() => expect(mockDeleteDocuments).toHaveBeenCalled());
});
});
});

View File

@@ -1,7 +1,15 @@
import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { Button, Input, TableRowId, makeStyles, shorthands } from "@fluentui/react-components";
import { Dismiss16Filled } from "@fluentui/react-icons";
import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants";
import {
Button,
Link,
MessageBar,
MessageBarBody,
MessageBarTitle,
TableRowId,
makeStyles,
shorthands,
} from "@fluentui/react-components";
import { QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants";
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import MongoUtility from "Common/MongoUtility";
import { createDocument } from "Common/dataAccess/createDocument";
@@ -13,9 +21,12 @@ import { queryDocuments } from "Common/dataAccess/queryDocuments";
import { readDocument } from "Common/dataAccess/readDocument";
import { updateDocument } from "Common/dataAccess/updateDocument";
import { Platform, configContext } from "ConfigContext";
import { ActionType, OpenCollectionTab, TabKind } from "Contracts/ActionContracts";
import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent";
import { useDialog } from "Explorer/Controls/Dialog";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { InputDataList, InputDatalistDropdownOptionSection } from "Explorer/Controls/InputDataList/InputDataList";
import { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog";
import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
@@ -24,8 +35,9 @@ import {
FilterHistory,
SubComponentName,
TabDivider,
readSubComponentState,
saveSubComponentState,
deleteDocumentsTabSubComponentState,
readDocumentsTabSubComponentState,
saveDocumentsTabSubComponentState,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
import { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
@@ -35,7 +47,7 @@ import { QueryConstants } from "Shared/Constants";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { userContext } from "UserContext";
import { logConsoleError } from "Utils/NotificationConsoleUtils";
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
import { Allotment } from "allotment";
import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { format } from "react-string-format";
@@ -60,6 +72,10 @@ import TabsBase from "../TabsBase";
import { ColumnDefinition, DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent";
const MAX_FILTER_HISTORY_COUNT = 100; // Datalist will become scrollable, so we can afford to keep more items than fit on the screen
const NO_SQL_THROTTLING_DOC_URL =
"https://learn.microsoft.com/azure/cosmos-db/nosql/troubleshoot-request-rate-too-large";
const MONGO_THROTTLING_DOC_URL = "https://learn.microsoft.com/azure/cosmos-db/mongodb/prevent-rate-limiting-errors";
const DATA_EXPLORER_DOC_URL = "https://learn.microsoft.com/en-us/azure/cosmos-db/data-explorer";
const loadMoreHeight = LayoutConstants.rowHeight;
export const useDocumentsTabStyles = makeStyles({
@@ -76,12 +92,6 @@ export const useDocumentsTabStyles = makeStyles({
alignItems: "center",
...cosmosShorthands.borderBottom(),
},
filterInput: {
flexGrow: 1,
},
appliedFilter: {
flexGrow: 1,
},
tableContainer: {
marginRight: tokens.spacingHorizontalXXXL,
},
@@ -91,6 +101,13 @@ export const useDocumentsTabStyles = makeStyles({
tableCell: {
...cosmosShorthands.borderLeft(),
},
tableHeader: {
display: "flex",
},
tableHeaderFiller: {
width: "20px",
boxShadow: `0px -1px ${tokens.colorNeutralStroke2} inset`,
},
loadMore: {
...cosmosShorthands.borderTop(),
display: "grid",
@@ -103,6 +120,20 @@ export const useDocumentsTabStyles = makeStyles({
...shorthands.outline("1px", "dotted"),
},
},
floatingControlsContainer: {
position: "relative",
},
floatingControls: {
position: "absolute",
top: "6px",
right: 0,
float: "right",
backgroundColor: "white",
zIndex: 1,
},
deleteProgressContent: {
paddingTop: tokens.spacingVerticalL,
},
});
export class DocumentsTabV2 extends TabsBase {
@@ -111,6 +142,8 @@ export class DocumentsTabV2 extends TabsBase {
private title: string;
private resourceTokenPartitionKey: string;
protected persistedState: OpenCollectionTab;
constructor(options: ViewModels.DocumentsTabOptions) {
super(options);
@@ -118,6 +151,13 @@ export class DocumentsTabV2 extends TabsBase {
this.title = options.title;
this.partitionKey = options.partitionKey;
this.resourceTokenPartitionKey = options.resourceTokenPartitionKey;
this.persistedState = {
actionType: ActionType.OpenCollectionTab,
tabKind: options.isPreferredApiMongoDB ? TabKind.MongoDocuments : TabKind.SQLDocuments,
databaseResourceId: options.collection.databaseId,
collectionResourceId: options.collection.id(),
};
}
public render(): JSX.Element {
@@ -521,8 +561,6 @@ export interface IDocumentsTabComponentProps {
isTabActive: boolean;
}
const getUniqueId = (collection: ViewModels.CollectionBase): string => `${collection.databaseId}-${collection.id()}`;
const getDefaultSqlFilters = (partitionKeys: string[]) =>
['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC', "ORDER BY c._ts ASC"].concat(
partitionKeys.map((partitionKey) => `WHERE c.${partitionKey} = "foo"`),
@@ -533,7 +571,7 @@ const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
type ExtendedDocumentId = DocumentId & { tableFields?: DocumentsTableComponentItem };
// This is based on some heuristics
const calculateOffset = (columnNumber: number): number => columnNumber * 16 - 29;
const calculateOffset = (columnNumber: number): number => columnNumber * 16 - 27;
// Export to expose to unit tests
export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
@@ -548,14 +586,12 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
onIsExecutingChange,
isTabActive,
}): JSX.Element => {
const [isFilterCreated, setIsFilterCreated] = useState<boolean>(true);
const [isFilterExpanded, setIsFilterExpanded] = useState<boolean>(false);
const [isFilterFocused, setIsFilterFocused] = useState<boolean>(false);
const [appliedFilter, setAppliedFilter] = useState<string>("");
const [filterContent, setFilterContent] = useState<string>("");
const [filterContent, setFilterContent] = useState<string>(() =>
readDocumentsTabSubComponentState<string>(SubComponentName.CurrentFilter, _collection, ""),
);
const [documentIds, setDocumentIds] = useState<ExtendedDocumentId[]>([]);
const [isExecuting, setIsExecuting] = useState<boolean>(false);
const filterInput = useRef<HTMLInputElement>(null);
const styles = useDocumentsTabStyles();
// Query
@@ -575,7 +611,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// Table user clicked on this row
const [clickedRowIndex, setClickedRowIndex] = useState<number>(RESET_INDEX);
// Table multiple selection
const [selectedRows, setSelectedRows] = React.useState<Set<TableRowId>>(() => new Set<TableRowId>([0]));
const [selectedRows, setSelectedRows] = React.useState<Set<TableRowId>>(() => new Set<TableRowId>());
// Command buttons
const [editorState, setEditorState] = useState<ViewModels.DocumentExplorerState>(
@@ -584,7 +620,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// State
const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
readSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, {
readDocumentsTabSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, {
leftPaneWidthPercent: 35,
}),
);
@@ -599,38 +635,121 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// User's filter history
const [lastFilterContents, setLastFilterContents] = useState<FilterHistory>(() =>
readSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, [] as FilterHistory),
readDocumentsTabSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, [] as FilterHistory),
);
// For progress bar for bulk delete (noSql)
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = React.useState(false);
const [bulkDeleteProcess, setBulkDeleteProcess] = useState<{
pendingIds: DocumentId[];
successfulIds: DocumentId[];
throttledIds: DocumentId[];
failedIds: DocumentId[];
beforeExecuteMs: number; // Delay before executing delete. Used for retrying throttling after a specified delay
hasBeenThrottled: boolean; // Keep track if the operation has been throttled at least once
}>(undefined);
const [bulkDeleteOperation, setBulkDeleteOperation] = useState<{
onCompleted: (documentIds: DocumentId[]) => void;
onFailed: (reason?: unknown) => void;
count: number;
collection: CollectionBase;
}>(undefined);
const [bulkDeleteMode, setBulkDeleteMode] = useState<"inProgress" | "completed" | "aborting" | "aborted">(undefined);
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
/**
* Recursively delete all documents by retrying throttled requests (429).
* This only works for NoSQL, because the bulk response includes status for each delete document request.
* Recursion is implemented using React useEffect (as opposed to recursively calling setTimeout), because it
* has to update the <ProgressModalDialog> or check if the user is aborting the operation via state React
* variables.
*
* Inputs are the bulkDeleteOperation, bulkDeleteProcess and bulkDeleteMode state variables.
* When the bulkDeleteProcess changes, the function in the useEffect is triggered and checks if the process
* was aborted or completed, which will resolve the promise.
* Otherwise, it will attempt to delete documents of the pending and throttled ids arrays.
* Once deletion is completed, the function updates bulkDeleteProcess with the results, which will trigger
* the function to be called again.
*/
useEffect(() => {
if (isFilterFocused) {
filterInput.current?.focus();
if (!bulkDeleteOperation || !bulkDeleteProcess || !bulkDeleteMode) {
return;
}
}, [isFilterFocused]);
// Clicked row must be defined
useEffect(() => {
if (documentIds.length > 0) {
let currentClickedRowIndex = clickedRowIndex;
if (
(currentClickedRowIndex === RESET_INDEX &&
editorState === ViewModels.DocumentExplorerState.noDocumentSelected) ||
currentClickedRowIndex > documentIds.length - 1
) {
// reset clicked row or the current clicked row is out of bounds
currentClickedRowIndex = INITIAL_SELECTED_ROW_INDEX;
setSelectedRows(new Set([INITIAL_SELECTED_ROW_INDEX]));
onDocumentClicked(currentClickedRowIndex, documentIds);
}
if (bulkDeleteMode === "completed" || bulkDeleteMode === "aborted") {
// no op in the case function is called again
return;
}
}, [documentIds, clickedRowIndex, editorState]);
const applyFilterButton = {
enabled: true,
visible: true,
};
if (
(bulkDeleteProcess.pendingIds.length === 0 && bulkDeleteProcess.throttledIds.length === 0) ||
bulkDeleteMode === "aborting"
) {
// Successfully deleted all documents or operation was aborted
bulkDeleteOperation.onCompleted(bulkDeleteProcess.successfulIds);
setBulkDeleteMode(bulkDeleteMode === "aborting" ? "aborted" : "completed");
return;
}
// Start deleting documents or retry throttled requests
const newPendingIds = bulkDeleteProcess.pendingIds.concat(bulkDeleteProcess.throttledIds);
const timeout = bulkDeleteProcess.beforeExecuteMs || 0;
setTimeout(() => {
deleteNoSqlDocuments(bulkDeleteOperation.collection, [...newPendingIds])
.then((deleteResult) => {
let retryAfterMilliseconds = 0;
const newSuccessful: DocumentId[] = [];
const newThrottled: DocumentId[] = [];
const newFailed: DocumentId[] = [];
deleteResult.forEach((result) => {
if (result.statusCode === Constants.HttpStatusCodes.NoContent) {
newSuccessful.push(result.documentId);
} else if (result.statusCode === Constants.HttpStatusCodes.TooManyRequests) {
newThrottled.push(result.documentId);
retryAfterMilliseconds = Math.max(result.retryAfterMilliseconds, retryAfterMilliseconds);
} else if (result.statusCode >= 400) {
newFailed.push(result.documentId);
logConsoleError(
`Failed to delete document ${result.documentId.id} with status code ${result.statusCode}`,
);
}
});
logConsoleInfo(`Successfully deleted ${newSuccessful.length} document(s)`);
if (newThrottled.length > 0) {
logConsoleError(
`Failed to delete ${newThrottled.length} document(s) due to "Request too large" (429) error. Retrying...`,
);
}
// Update result of the bulk delete: method is called again, because the state variables changed
// it will decide at the next call what to do
setBulkDeleteProcess((prev) => ({
pendingIds: [],
successfulIds: prev.successfulIds.concat(newSuccessful),
throttledIds: newThrottled,
failedIds: prev.failedIds.concat(newFailed),
beforeExecuteMs: retryAfterMilliseconds,
hasBeenThrottled: prev.hasBeenThrottled || newThrottled.length > 0,
}));
})
.catch((error) => {
console.error("Error deleting documents", error);
setBulkDeleteProcess((prev) => ({
pendingIds: [],
throttledIds: [],
successfulIds: prev.successfulIds,
failedIds: prev.failedIds.concat(prev.pendingIds),
beforeExecuteMs: undefined,
hasBeenThrottled: prev.hasBeenThrottled,
}));
bulkDeleteOperation.onFailed(error);
});
}, timeout);
}, [bulkDeleteOperation, bulkDeleteProcess, bulkDeleteMode]);
const partitionKey: DataModels.PartitionKey = useMemo(
() => _partitionKey || (_collection && _collection.partitionKey),
@@ -658,7 +777,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
};
const [selectedColumnIds, setSelectedColumnIds] = useState<string[]>(() => {
const persistedColumnsSelection = readSubComponentState<ColumnsSelection>(
const persistedColumnsSelection = readDocumentsTabSubComponentState<ColumnsSelection>(
SubComponentName.ColumnsSelection,
_collection,
undefined,
@@ -702,12 +821,8 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// This is executed in onActivate() in the original code.
useEffect(() => {
setKeyboardActions({
[KeyboardAction.SEARCH]: () => {
onShowFilterClick();
return true;
},
[KeyboardAction.CLEAR_SEARCH]: () => {
setFilterContent("");
updateFilterContent("");
refreshDocumentsGrid(true);
return true;
},
@@ -882,7 +997,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
);
},
)
.then(() => setSelectedRows(new Set([documentIds.length - 1])))
.then(() => {
setSelectedRows(new Set([documentIds.length - 1]));
setClickedRowIndex(documentIds.length - 1);
})
.finally(() => setIsExecuting(false));
}, [
onExecutionErrorChange,
@@ -976,8 +1094,36 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setSelectedDocumentContent(selectedDocumentContentBaseline);
}, [initialDocumentContent, selectedDocumentContentBaseline, setSelectedDocumentContent]);
/**
* Trigger a useEffect() to bulk delete noSql documents
* @param collection
* @param documentIds
* @returns
*/
const _bulkDeleteNoSqlDocuments = (collection: CollectionBase, documentIds: DocumentId[]): Promise<DocumentId[]> =>
new Promise<DocumentId[]>((resolve, reject) => {
setBulkDeleteOperation({
onCompleted: resolve,
onFailed: reject,
count: documentIds.length,
collection,
});
setBulkDeleteProcess({
pendingIds: [...documentIds],
throttledIds: [],
successfulIds: [],
failedIds: [],
beforeExecuteMs: 0,
hasBeenThrottled: false,
});
setIsBulkDeleteDialogOpen(true);
setBulkDeleteMode("inProgress");
});
/**
* Implementation using bulk delete NoSQL API
* @param list of document ids to delete
* @returns Promise of list of deleted document ids
*/
const _deleteDocuments = useCallback(
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
@@ -988,20 +1134,33 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
});
setIsExecuting(true);
// TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released:
// Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should always be called.
const _deleteNoSqlDocuments = async (
collection: CollectionBase,
toDeleteDocumentIds: DocumentId[],
): Promise<DocumentId[]> => {
return partitionKey.systemKey
? deleteNoSqlDocument(collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]])
: deleteNoSqlDocuments(collection, toDeleteDocumentIds);
};
const deletePromise = !isPreferredApiMongoDB
? _deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
: MongoProxyClient.deleteDocuments(
let deletePromise;
if (!isPreferredApiMongoDB) {
if (partitionKey.systemKey) {
// ----------------------------------------------------------------------------------------------------
// TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released:
// Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should
// always be called for NoSQL.
deletePromise = deleteNoSqlDocument(_collection, toDeleteDocumentIds[0]).then(() => {
useDialog.getState().showOkModalDialog("Delete document", "Document successfully deleted.");
return [toDeleteDocumentIds[0]];
});
// ----------------------------------------------------------------------------------------------------
} else {
deletePromise = _bulkDeleteNoSqlDocuments(_collection, toDeleteDocumentIds);
}
} else {
if (isMongoBulkDeleteDisabled) {
// TODO: Once new mongo proxy is available for all users, remove the call for MongoProxyClient.deleteDocument().
// MongoProxyClient.deleteDocuments() should be called for all users.
deletePromise = MongoProxyClient.deleteDocument(
_collection.databaseId,
_collection as ViewModels.Collection,
toDeleteDocumentIds[0],
).then(() => [toDeleteDocumentIds[0]]);
// ----------------------------------------------------------------------------------------------------
} else {
deletePromise = MongoProxyClient.deleteDocuments(
_collection.databaseId,
_collection as ViewModels.Collection,
toDeleteDocumentIds,
@@ -1011,6 +1170,8 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}
throw new Error(`Delete failed with deletedCount: ${deletedCount} and isAcknowledged: ${isAcknowledged}`);
});
}
}
return deletePromise
.then(
@@ -1041,9 +1202,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
throw error;
},
)
.finally(() => setIsExecuting(false));
.finally(() => {
setIsExecuting(false);
});
},
[_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle],
[_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle, partitionKey.systemKey],
);
const deleteDocuments = useCallback(
@@ -1061,14 +1224,25 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setClickedRowIndex(undefined);
setSelectedRows(new Set());
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
useDialog
.getState()
.showOkModalDialog("Delete documents", `${deletedIds.length} document(s) successfully deleted.`);
},
(error: Error) =>
useDialog
.getState()
.showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`),
(error: Error) => {
if (error instanceof MongoProxyClient.ThrottlingError) {
useDialog
.getState()
.showOkModalDialog(
"Delete documents",
`Some documents failed to delete due to a rate limiting error. Please try again later. To prevent this in the future, consider increasing the throughput on your container or database.`,
{
linkText: "Learn More",
linkUrl: MONGO_THROTTLING_DOC_URL,
},
);
} else {
useDialog
.getState()
.showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`);
}
},
)
.finally(() => setIsExecuting(false));
},
@@ -1129,12 +1303,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
],
);
const onShowFilterClick = () => {
setIsFilterCreated(true);
setIsFilterExpanded(true);
setIsFilterFocused(true);
};
const queryTimeoutEnabled = useCallback(
(): boolean => !isPreferredApiMongoDB && LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled),
[isPreferredApiMongoDB],
@@ -1176,19 +1344,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
selectedColumnIds,
]);
const onHideFilterClick = (): void => {
setIsFilterExpanded(false);
};
const onCloseButtonKeyDown: KeyboardEventHandler<HTMLSpanElement> = (event) => {
if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) {
onHideFilterClick();
event.stopPropagation();
return false;
}
return true;
};
const updateDocumentIds = (newDocumentsIds: DocumentId[]): void => {
setDocumentIds(newDocumentsIds);
@@ -1330,14 +1485,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
};
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") {
if (e.key === Constants.NormalizedEventKey.Enter) {
onApplyFilterClick();
// Suppress the default behavior of the key
e.preventDefault();
} else if (e.key === "Escape") {
onHideFilterClick();
// Suppress the default behavior of the key
e.preventDefault();
}
@@ -1509,7 +1659,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// Column definition is a map<id, ColumnDefinition> to garantee uniqueness
const [columnDefinitions, setColumnDefinitions] = useState<ColumnDefinition[]>(() => {
const persistedColumnsSelection = readSubComponentState<ColumnsSelection>(
const persistedColumnsSelection = readDocumentsTabSubComponentState<ColumnsSelection>(
SubComponentName.ColumnsSelection,
_collection,
undefined,
@@ -1820,7 +1970,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const limitedLastFilterContents = lastFilterContents.slice(0, MAX_FILTER_HISTORY_COUNT);
setLastFilterContents(limitedLastFilterContents);
saveSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, lastFilterContents);
saveDocumentsTabSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, lastFilterContents);
};
const refreshDocumentsGrid = useCallback(
@@ -1835,10 +1985,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
applyFilterButtonPressed,
});
// collapse filter
setAppliedFilter(filterContent);
setIsFilterExpanded(false);
// If apply filter is pressed, reset current selected document
if (applyFilterButtonPressed) {
setClickedRowIndex(RESET_INDEX);
@@ -1853,6 +1999,26 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
[createIterator, filterContent],
);
/**
* While retrying, display: retrying now.
* If completed and all documents were deleted, display: all documents deleted.
* @returns 429 warning message
*/
const get429WarningMessageNoSql = (): string => {
let message = 'Some delete requests failed due to a "Request too large" exception (429)';
if (bulkDeleteOperation.count === bulkDeleteProcess.successfulIds.length) {
message += ", but were successfully retried.";
} else if (bulkDeleteMode === "inProgress" || bulkDeleteMode === "aborting") {
message += ". Retrying now.";
} else {
message += ".";
}
return (message +=
" To prevent this in the future, consider increasing the throughput on your container or database.");
};
const onColumnSelectionChange = (newSelectedColumnIds: string[]): void => {
// Do not allow to unselecting all columns
if (newSelectedColumnIds.length === 0) {
@@ -1861,7 +2027,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setSelectedColumnIds(newSelectedColumnIds);
saveSubComponentState<ColumnsSelection>(SubComponentName.ColumnsSelection, _collection, {
saveDocumentsTabSubComponentState<ColumnsSelection>(SubComponentName.ColumnsSelection, _collection, {
selectedColumnIds: newSelectedColumnIds,
columnDefinitions,
});
@@ -1888,169 +2054,192 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}
}, [prevSelectedColumnIds, refreshDocumentsGrid, selectedColumnIds]);
// TODO: remove isMongoBulkDeleteDisabled when new mongo proxy is enabled for all users
// TODO: remove partitionKey.systemKey when JS SDK bug is fixed
const isMongoBulkDeleteDisabled = !MongoProxyClient.useMongoProxyEndpoint(Constants.MongoProxyApi.BulkDelete);
const isBulkDeleteDisabled =
(partitionKey.systemKey && !isPreferredApiMongoDB) || (isPreferredApiMongoDB && isMongoBulkDeleteDisabled);
// -------------------------------------------------------
const getFilterChoices = (): InputDatalistDropdownOptionSection[] => {
const options: InputDatalistDropdownOptionSection[] = [];
const nonBlankLastFilters = lastFilterContents.filter((filter) => filter.trim() !== "");
if (nonBlankLastFilters.length > 0) {
options.push({
label: "Saved filters",
options: nonBlankLastFilters,
});
}
options.push({
label: "Default filters",
options: isPreferredApiMongoDB ? defaultMongoFilters : getDefaultSqlFilters(partitionKeyProperties),
});
return options;
};
const updateFilterContent = (filter: string): void => {
if (filter === "" || filter === undefined) {
deleteDocumentsTabSubComponentState(SubComponentName.CurrentFilter, _collection);
} else {
saveDocumentsTabSubComponentState<string>(SubComponentName.CurrentFilter, _collection, filter, true);
}
setFilterContent(filter);
};
return (
<CosmosFluentProvider className={styles.container}>
<div className="tab-pane active" role="tabpanel" style={{ display: "flex" }}>
{isFilterCreated && (
<>
{!isFilterExpanded && !isPreferredApiMongoDB && (
<div className={styles.filterRow}>
<span>SELECT * FROM c</span>
<span className={styles.appliedFilter}>{appliedFilter}</span>
<Button appearance="primary" size="small" onClick={onShowFilterClick}>
Edit Filter
</Button>
</div>
)}
{!isFilterExpanded && isPreferredApiMongoDB && (
<div className={styles.filterRow}>
{appliedFilter.length > 0 && <span>Filter :</span>}
{!(appliedFilter.length > 0) && <span className="noFilterApplied">No filter applied</span>}
<span className={styles.appliedFilter}>{appliedFilter}</span>
<Button appearance="primary" size="small" onClick={onShowFilterClick}>
Edit Filter
</Button>
</div>
)}
{isFilterExpanded && (
<div className={styles.filterRow}>
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
<Input
ref={filterInput}
type="text"
size="small"
list={`filtersList-${getUniqueId(_collection)}`}
className={`filterInput ${styles.filterInput}`}
title="Type a query predicate or choose one from the list."
placeholder={
isPreferredApiMongoDB
? "Type a query predicate (e.g., {´a´:´foo´}), or choose one from the drop down list, or leave empty to query all documents."
: "Type a query predicate (e.g., WHERE c.id=´1´), or choose one from the drop down list, or leave empty to query all documents."
}
value={filterContent}
autoFocus={true}
onKeyDown={onFilterKeyDown}
onChange={(e) => setFilterContent(e.target.value)}
onBlur={() => setIsFilterFocused(false)}
/>
<datalist id={`filtersList-${getUniqueId(_collection)}`}>
{addStringsNoDuplicate(
lastFilterContents,
isPreferredApiMongoDB ? defaultMongoFilters : getDefaultSqlFilters(partitionKeyProperties),
).map((filter) => (
<option key={filter} value={filter} />
))}
</datalist>
<Button
appearance="primary"
size="small"
onClick={onApplyFilterClick}
disabled={!applyFilterButton.enabled}
aria-label="Apply filter"
tabIndex={0}
>
Apply Filter
</Button>
{!isPreferredApiMongoDB && isExecuting && (
<Button
appearance="primary"
size="small"
aria-label="Cancel Query"
onClick={() => queryAbortController.abort()}
tabIndex={0}
>
Cancel Query
</Button>
)}
<Button
aria-label="close filter"
tabIndex={0}
onClick={onHideFilterClick}
onKeyDown={onCloseButtonKeyDown}
appearance="transparent"
size="small"
icon={<Dismiss16Filled />}
/>
</div>
)}
</>
)}
{/* <Split> doesn't like to be a flex child */}
<div style={{ overflow: "hidden", height: "100%" }}>
<Allotment
onDragEnd={(sizes: number[]) => {
tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
saveSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, tabStateData);
setTabStateData(tabStateData);
<div className={styles.filterRow}>
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
<InputDataList
dropdownOptions={getFilterChoices()}
placeholder={
isPreferredApiMongoDB
? "Type a query predicate (e.g., {´a´:´foo´}), or choose one from the drop down list, or leave empty to query all documents."
: "Type a query predicate (e.g., WHERE c.id=´1´), or choose one from the drop down list, or leave empty to query all documents."
}
title="Type a query predicate or choose one from the list."
value={filterContent}
onChange={updateFilterContent}
onKeyDown={onFilterKeyDown}
bottomLink={{ text: "Learn more", url: DATA_EXPLORER_DOC_URL }}
/>
<Button
appearance="primary"
size="small"
onClick={() => {
if (isExecuting) {
if (!isPreferredApiMongoDB) {
queryAbortController.abort();
}
} else {
onApplyFilterClick();
}
}}
disabled={isExecuting && isPreferredApiMongoDB}
aria-label={!isExecuting || isPreferredApiMongoDB ? "Apply filter" : "Cancel"}
tabIndex={0}
>
<Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}>
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
<div className={styles.tableContainer}>
<div
style={
{
height: "100%",
width: `calc(100% + ${calculateOffset(selectedColumnIds.length)}px)`,
} /* Fix to make table not resize beyond parent's width */
}
>
<DocumentsTableComponent
onRefreshTable={() => refreshDocumentsGrid(false)}
items={tableItems}
onItemClicked={(index) => onDocumentClicked(index, documentIds)}
onSelectedRowsChange={onSelectedRowsChange}
selectedRows={selectedRows}
size={tableContainerSizePx}
selectedColumnIds={selectedColumnIds}
columnDefinitions={columnDefinitions}
isRowSelectionDisabled={
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
}
onColumnSelectionChange={onColumnSelectionChange}
defaultColumnSelection={getInitialColumnSelection()}
collection={_collection}
isColumnSelectionDisabled={isPreferredApiMongoDB}
/>
</div>
</div>
{tableItems.length > 0 && (
<a
className={styles.loadMore}
role="button"
tabIndex={0}
onClick={() => loadNextPage(documentsIterator.iterator, false)}
onKeyDown={onLoadMoreKeyInput}
>
Load more
</a>
)}
</div>
</Allotment.Pane>
<Allotment.Pane minSize={30}>
<div style={{ height: "100%", width: "100%" }}>
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
<EditorReact
language={"json"}
content={selectedDocumentContent}
isReadOnly={false}
ariaLabel={"Document editor"}
lineNumbers={"on"}
theme={"_theme"}
onContentChanged={_onEditorContentChange}
enableWordWrapContextMenuItem={true}
/>
)}
{selectedRows.size > 1 && (
<span style={{ margin: 10 }}>Number of selected documents: {selectedRows.size}</span>
)}
</div>
</Allotment.Pane>
</Allotment>
{!isExecuting || isPreferredApiMongoDB ? "Apply Filter" : "Cancel"}
</Button>
</div>
<Allotment
onDragEnd={(sizes: number[]) => {
tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
saveDocumentsTabSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, tabStateData);
setTabStateData(tabStateData);
}}
>
<Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}>
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
<div className={styles.tableContainer}>
<div
style={
{
height: "100%",
width: `calc(100% + ${calculateOffset(selectedColumnIds.length)}px)`,
} /* Fix to make table not resize beyond parent's width */
}
>
<DocumentsTableComponent
onRefreshTable={() => refreshDocumentsGrid(false)}
items={tableItems}
onSelectedRowsChange={onSelectedRowsChange}
selectedRows={selectedRows}
size={tableContainerSizePx}
selectedColumnIds={selectedColumnIds}
columnDefinitions={columnDefinitions}
isRowSelectionDisabled={
isBulkDeleteDisabled ||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
}
onColumnSelectionChange={onColumnSelectionChange}
defaultColumnSelection={getInitialColumnSelection()}
collection={_collection}
isColumnSelectionDisabled={isPreferredApiMongoDB}
/>
</div>
</div>
{tableItems.length > 0 && (
<a
className={styles.loadMore}
role="button"
tabIndex={0}
onClick={() => loadNextPage(documentsIterator.iterator, false)}
onKeyDown={onLoadMoreKeyInput}
>
Load more
</a>
)}
</div>
</Allotment.Pane>
<Allotment.Pane minSize={30}>
<div style={{ height: "100%", width: "100%" }}>
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
<EditorReact
language={"json"}
content={selectedDocumentContent}
isReadOnly={false}
ariaLabel={"Document editor"}
lineNumbers={"on"}
theme={"_theme"}
onContentChanged={_onEditorContentChange}
enableWordWrapContextMenuItem={true}
/>
)}
{selectedRows.size > 1 && (
<span style={{ margin: 10 }}>Number of selected documents: {selectedRows.size}</span>
)}
</div>
</Allotment.Pane>
</Allotment>
</div>
{bulkDeleteOperation && (
<ProgressModalDialog
isOpen={isBulkDeleteDialogOpen}
dismissText="Abort"
onDismiss={() => {
setIsBulkDeleteDialogOpen(false);
setBulkDeleteOperation(undefined);
}}
onCancel={() => setBulkDeleteMode("aborting")}
title={`Deleting ${bulkDeleteOperation.count} document(s)`}
message={`Successfully deleted ${bulkDeleteProcess.successfulIds.length} document(s).`}
maxValue={bulkDeleteOperation.count}
value={bulkDeleteProcess.successfulIds.length}
mode={bulkDeleteMode}
>
<div className={styles.deleteProgressContent}>
{(bulkDeleteMode === "aborting" || bulkDeleteMode === "aborted") && (
<div style={{ paddingBottom: tokens.spacingVerticalL }}>Deleting document(s) was aborted.</div>
)}
{(bulkDeleteProcess.failedIds.length > 0 ||
(bulkDeleteProcess.throttledIds.length > 0 && bulkDeleteMode !== "inProgress")) && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalL }}>
<MessageBarBody>
<MessageBarTitle>Error</MessageBarTitle>
Failed to delete{" "}
{bulkDeleteMode === "inProgress"
? bulkDeleteProcess.failedIds.length
: bulkDeleteProcess.failedIds.length + bulkDeleteProcess.throttledIds.length}{" "}
document(s).
</MessageBarBody>
</MessageBar>
)}
{bulkDeleteProcess.hasBeenThrottled && (
<MessageBar intent="warning">
<MessageBarBody>
<MessageBarTitle>Warning</MessageBarTitle>
{get429WarningMessageNoSql()}{" "}
<Link href={NO_SQL_THROTTLING_DOC_URL} target="_blank">
Learn More
</Link>
</MessageBarBody>
</MessageBar>
)}
</div>
</ProgressModalDialog>
)}
</CosmosFluentProvider>
);
};

View File

@@ -49,7 +49,9 @@ jest.mock("Common/MongoProxyClient", () => ({
id: "id1",
}),
),
deleteDocuments: jest.fn(() => Promise.resolve()),
deleteDocuments: jest.fn(() => Promise.resolve({ deleteCount: 0, isAcknowledged: true })),
ThrottlingError: Error,
useMongoProxyEndpoint: jest.fn(() => true),
}));
jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
@@ -65,6 +67,13 @@ jest.mock("Explorer/Controls/Dialog", () => ({
},
}));
// Added as recent change to @azure/core-util would cause randomUUID() to throw an error during jest tests.
// TODO: when not using beta version of @azure/cosmos sdk try removing this
jest.mock("@azure/core-util", () => ({
...jest.requireActual("@azure/core-util"),
randomUUID: jest.fn(),
}));
async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | ShallowWrapper<P>, amount = 0) {
let newWrapper;
await act(async () => {
@@ -178,7 +187,7 @@ describe("Documents tab (Mongo API)", () => {
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined();
});
it("clicking Delete Document asks for confirmation", () => {
it("clicking Delete Document eventually calls delete client api", () => {
const mockDeleteDocuments = deleteDocuments as jest.Mock;
mockDeleteDocuments.mockClear();

View File

@@ -14,7 +14,6 @@ describe("DocumentsTableComponent", () => {
{ [ID_HEADER]: "2", [PARTITION_KEY_HEADER]: "pk2" },
{ [ID_HEADER]: "3", [PARTITION_KEY_HEADER]: "pk3" },
],
onItemClicked: (): void => {},
onSelectedRowsChange: (): void => {},
selectedRows: new Set<TableRowId>(),
size: {

View File

@@ -42,15 +42,14 @@ import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPan
import {
ColumnSizesMap,
ColumnSort,
deleteSubComponentState,
readSubComponentState,
saveSubComponentState,
deleteDocumentsTabSubComponentState,
readDocumentsTabSubComponentState,
saveDocumentsTabSubComponentState,
SubComponentName,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
import { userContext } from "UserContext";
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
import { useSidePanel } from "hooks/useSidePanel";
import React, { useCallback, useMemo } from "react";
@@ -69,7 +68,6 @@ export type ColumnDefinition = {
export interface IDocumentsTableComponentProps {
onRefreshTable: () => void;
items: DocumentsTableComponentItem[];
onItemClicked: (index: number) => void;
onSelectedRowsChange: (selectedItemsIndices: Set<TableRowId>) => void;
selectedRows: Set<TableRowId>;
size: { height: number; width: number };
@@ -99,6 +97,7 @@ const defaultSize = {
idealWidth: 200,
minWidth: 50,
};
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
onRefreshTable,
items,
@@ -116,8 +115,14 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
}: IDocumentsTableComponentProps) => {
const styles = useDocumentsTabStyles();
const sortedRowsRef = React.useRef(null);
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {});
const columnSizesMap: ColumnSizesMap = readDocumentsTabSubComponentState(
SubComponentName.ColumnSizes,
collection,
{},
);
const columnSizesPx: TableColumnSizingOptions = {};
selectedColumnIds.forEach((columnId) => {
if (
@@ -141,7 +146,7 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
sortDirection: "ascending" | "descending";
sortColumn: TableColumnId | undefined;
}>(() => {
const sort = readSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, undefined);
const sort = readDocumentsTabSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, undefined);
if (!sort) {
return {
@@ -173,7 +178,12 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
return acc;
}, {} as ColumnSizesMap);
saveSubComponentState<ColumnSizesMap>(SubComponentName.ColumnSizes, collection, persistentSizes, true);
saveDocumentsTabSubComponentState<ColumnSizesMap>(
SubComponentName.ColumnSizes,
collection,
persistentSizes,
true,
);
return newSizingOptions;
});
@@ -185,11 +195,14 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
setColumnSort(event, columnId, direction);
if (columnId === undefined || direction === undefined) {
deleteSubComponentState(SubComponentName.ColumnSort, collection);
deleteDocumentsTabSubComponentState(SubComponentName.ColumnSort, collection);
return;
}
saveSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, { columnId, direction });
saveDocumentsTabSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, {
columnId,
direction,
});
};
// Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes
@@ -228,55 +241,53 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
<MenuItem key="refresh" icon={<ArrowClockwise16Regular />} onClick={onRefreshTable}>
Refresh
</MenuItem>
{userContext.features.enableDocumentsTableColumnSelection && (
<>
<MenuItem
icon={<TextSortAscendingRegular />}
onClick={(e) => onSortClick(e, column.id, "ascending")}
>
Sort ascending
<>
<MenuItem
icon={<TextSortAscendingRegular />}
onClick={(e) => onSortClick(e, column.id, "ascending")}
>
Sort ascending
</MenuItem>
<MenuItem
icon={<TextSortDescendingRegular />}
onClick={(e) => onSortClick(e, column.id, "descending")}
>
Sort descending
</MenuItem>
<MenuItem icon={<ArrowResetRegular />} onClick={(e) => onSortClick(e, undefined, undefined)}>
Reset sorting
</MenuItem>
{!isColumnSelectionDisabled && (
<MenuItem key="editcolumns" icon={<EditRegular />} onClick={openColumnSelectionPane}>
Edit columns
</MenuItem>
<MenuItem
icon={<TextSortDescendingRegular />}
onClick={(e) => onSortClick(e, column.id, "descending")}
>
Sort descending
</MenuItem>
<MenuItem icon={<ArrowResetRegular />} onClick={(e) => onSortClick(e, undefined, undefined)}>
Reset sorting
</MenuItem>
{!isColumnSelectionDisabled && (
<MenuItem key="editcolumns" icon={<EditRegular />} onClick={openColumnSelectionPane}>
Edit columns
</MenuItem>
)}
<MenuDivider />
<MenuItem
key="keyboardresize"
icon={<TableResizeColumnRegular />}
onClick={columnSizing.enableKeyboardMode(column.id)}
>
Resize with left/right arrow keys
</MenuItem>
{!isColumnSelectionDisabled && (
<MenuItem
key="remove"
icon={<DeleteRegular />}
onClick={() => {
// Remove column id from selectedColumnIds
const index = selectedColumnIds.indexOf(column.id);
if (index === -1) {
return;
}
const newSelectedColumnIds = [...selectedColumnIds];
newSelectedColumnIds.splice(index, 1);
onColumnSelectionChange(newSelectedColumnIds);
}}
>
Remove column
</MenuItem>
)}
</>
)}
<MenuDivider />
</>
<MenuItem
key="keyboardresize"
icon={<TableResizeColumnRegular />}
onClick={columnSizing.enableKeyboardMode(column.id)}
>
Resize with left/right arrow keys
</MenuItem>
{!isColumnSelectionDisabled && (
<MenuItem
key="remove"
icon={<DeleteRegular />}
onClick={() => {
// Remove column id from selectedColumnIds
const index = selectedColumnIds.indexOf(column.id);
if (index === -1) {
return;
}
const newSelectedColumnIds = [...selectedColumnIds];
newSelectedColumnIds.splice(index, 1);
onColumnSelectionChange(newSelectedColumnIds);
}}
>
Remove column
</MenuItem>
)}
</MenuList>
</MenuPopover>
@@ -294,22 +305,42 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
const [selectionStartIndex, setSelectionStartIndex] = React.useState<number>(INITIAL_SELECTED_ROW_INDEX);
const onTableCellClicked = useCallback(
(e: React.MouseEvent, index: number) => {
(e: React.MouseEvent | undefined, index: number, rowId: TableRowId) => {
if (isSelectionDisabled) {
// Only allow click
onSelectedRowsChange(new Set<TableRowId>([index]));
onSelectedRowsChange(new Set<TableRowId>([rowId]));
setSelectionStartIndex(index);
return;
}
// The selection helper computes in the index space (what's visible to the user in the table, ie the sorted array).
// selectedRows is in the rowId space (the index of the original unsorted array), so it must be converted to the index space.
const selectedRowsIndex = new Set<number>();
selectedRows.forEach((rowId) => {
const index = sortedRowsRef.current.findIndex((row: TableRowData) => row.rowId === rowId);
if (index !== -1) {
selectedRowsIndex.add(index);
} else {
// This should never happen
console.error(`Row with rowId ${rowId} not found in sorted rows`);
}
});
const result = selectionHelper(
selectedRows as Set<number>,
selectedRowsIndex,
index,
isEnvironmentShiftPressed(e),
isEnvironmentCtrlPressed(e),
e && isEnvironmentShiftPressed(e),
e && isEnvironmentCtrlPressed(e),
selectionStartIndex,
);
onSelectedRowsChange(result.selection);
// Convert selectionHelper result from index space back to rowId space
const selectedRowIds = new Set<TableRowId>();
result.selection.forEach((index) => {
selectedRowIds.add(sortedRowsRef.current[index].rowId);
});
onSelectedRowsChange(selectedRowIds);
if (result.selectionStartIndex !== undefined) {
setSelectionStartIndex(result.selectionStartIndex);
}
@@ -323,16 +354,20 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
* - a key is down and the cell is clicked by the mouse
*/
const onIdClicked = useCallback(
(e: React.KeyboardEvent<Element>, index: number) => {
(e: React.KeyboardEvent<Element>, rowId: TableRowId) => {
if (e.key === NormalizedEventKey.Enter || e.key === NormalizedEventKey.Space) {
onSelectedRowsChange(new Set<TableRowId>([index]));
onSelectedRowsChange(new Set<TableRowId>([rowId]));
}
},
[onSelectedRowsChange],
);
const RenderRow = ({ index, style, data }: ReactWindowRenderFnProps) => {
const { item, selected, appearance, onClick, onKeyDown } = data[index];
// WARNING: because the table sorts the data, 'index' is not the same as 'rowId'
// The rowId is the index of the item in the original array,
// while the index is the index of the item in the sorted array
const { item, selected, appearance, onClick, onKeyDown, rowId } = data[index];
return (
<TableRow
aria-rowindex={index + 2}
@@ -362,8 +397,8 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
key={column.columnId}
className={styles.tableCell}
// When clicking on a cell with shift/ctrl key, onKeyDown is called instead of onClick.
onClick={(e: React.MouseEvent<Element, MouseEvent>) => onTableCellClicked(e, index)}
onKeyPress={(e: React.KeyboardEvent<Element>) => onIdClicked(e, index)}
onClick={(e: React.MouseEvent<Element, MouseEvent>) => onTableCellClicked(e, index, rowId)}
onKeyPress={(e: React.KeyboardEvent<Element>) => onIdClicked(e, rowId)}
{...columnSizing.getTableCellProps(column.columnId)}
tabIndex={column.columnId === "id" ? 0 : -1}
>
@@ -423,6 +458,19 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
}),
);
// Store the sorted rows in a ref which won't trigger a re-render (as opposed to a state)
sortedRowsRef.current = rows;
// If there are no selected rows, auto select the first row
const [autoSelectFirstDoc, setAutoSelectFirstDoc] = React.useState<boolean>(true);
React.useEffect(() => {
if (autoSelectFirstDoc && sortedRowsRef.current?.length > 0 && selectedRows.size === 0) {
setAutoSelectFirstDoc(false);
const DOC_INDEX_TO_SELECT = 0;
onTableCellClicked(undefined, DOC_INDEX_TO_SELECT, sortedRowsRef.current[DOC_INDEX_TO_SELECT].rowId);
}
}, [selectedRows, onTableCellClicked, autoSelectFirstDoc]);
const toggleAllKeydown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === " ") {
@@ -471,8 +519,8 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
};
return (
<Table noNativeElements sortable {...tableProps}>
<TableHeader>
<Table noNativeElements {...tableProps}>
<TableHeader className={styles.tableHeader}>
<TableRow className={styles.tableRow} style={{ width: size ? size.width - 15 : "100%" }}>
{!isSelectionDisabled && (
<TableSelectionCell
@@ -494,6 +542,7 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
</TableHeaderCell>
))}
</TableRow>
<div className={styles.tableHeaderFiller}></div>
</TableHeader>
<TableBody>
<List

View File

@@ -17,111 +17,124 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
className="___11ktxfv_0000000 f1o614cb fy9rknc f22iagw fsnqrgy f1f5gg8d fjodcmx f122n59 f1f09k3d fg706s2 frpde29"
>
<span>
SELECT * FROM c
SELECT * FROM c
</span>
<span
className="___r7kt3y0_0000000 fqerorx"
<InputDataList
bottomLink={
{
"text": "Learn more",
"url": "https://learn.microsoft.com/en-us/azure/cosmos-db/data-explorer",
}
}
dropdownOptions={
[
{
"label": "Default filters",
"options": [
"WHERE c.id = "foo"",
"ORDER BY c._ts DESC",
"WHERE c.id = "foo" ORDER BY c._ts DESC",
"ORDER BY c._ts ASC",
"WHERE c.foo = "foo"",
],
},
]
}
onChange={[Function]}
onKeyDown={[Function]}
placeholder="Type a query predicate (e.g., WHERE c.id=´1´), or choose one from the drop down list, or leave empty to query all documents."
title="Type a query predicate or choose one from the list."
value=""
/>
<Button
appearance="primary"
aria-label="Apply filter"
disabled={false}
onClick={[Function]}
size="small"
tabIndex={0}
>
Edit Filter
Apply Filter
</Button>
</div>
<div
style={
{
"height": "100%",
"overflow": "hidden",
}
}
<Allotment
onDragEnd={[Function]}
>
<Allotment
onDragEnd={[Function]}
<Allotment.Pane
minSize={55}
preferredSize="35%"
>
<Allotment.Pane
minSize={55}
preferredSize="35%"
<div
style={
{
"height": "100%",
"overflow": "hidden",
"width": "100%",
}
}
>
<div
style={
{
"height": "100%",
"overflow": "hidden",
"width": "100%",
}
}
className="___9o87uj0_0000000 ffefeo0"
>
<div
className="___9o87uj0_0000000 ffefeo0"
style={
{
"height": "100%",
"width": "calc(100% + -11px)",
}
}
>
<div
style={
<DocumentsTableComponent
collection={
{
"height": "100%",
"width": "calc(100% + -13px)",
"databaseId": "databaseId",
"id": [Function],
}
}
>
<DocumentsTableComponent
collection={
columnDefinitions={
[
{
"databaseId": "databaseId",
"id": [Function],
}
}
columnDefinitions={
[
{
"id": "id",
"isPartitionKey": false,
"label": "id",
},
]
}
defaultColumnSelection={
[
"id",
]
}
isColumnSelectionDisabled={false}
isRowSelectionDisabled={true}
items={[]}
onColumnSelectionChange={[Function]}
onItemClicked={[Function]}
onRefreshTable={[Function]}
onSelectedRowsChange={[Function]}
selectedColumnIds={
[
"id",
]
}
selectedRows={
Set {
0,
}
}
/>
</div>
"id": "id",
"isPartitionKey": false,
"label": "id",
},
]
}
defaultColumnSelection={
[
"id",
]
}
isColumnSelectionDisabled={false}
isRowSelectionDisabled={true}
items={[]}
onColumnSelectionChange={[Function]}
onRefreshTable={[Function]}
onSelectedRowsChange={[Function]}
selectedColumnIds={
[
"id",
]
}
selectedRows={Set {}}
/>
</div>
</div>
</Allotment.Pane>
<Allotment.Pane
minSize={30}
>
<div
style={
{
"height": "100%",
"width": "100%",
}
</div>
</Allotment.Pane>
<Allotment.Pane
minSize={30}
>
<div
style={
{
"height": "100%",
"width": "100%",
}
/>
</Allotment.Pane>
</Allotment>
</div>
}
/>
</Allotment.Pane>
</Allotment>
</div>
</CosmosFluentProvider>
`;

View File

@@ -39,7 +39,6 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
},
]
}
onItemClicked={[Function]}
onRefreshTable={[Function]}
onSelectedRowsChange={[Function]}
selectedColumnIds={[]}
@@ -57,7 +56,6 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
noNativeElements={true}
role="grid"
size="small"
sortable={true}
style={
{
"minWidth": "fit-content",
@@ -75,9 +73,11 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
}
}
>
<TableHeader>
<TableHeader
className="___1gzszts_0000000 f22iagw"
>
<div
className="fui-TableHeader ___oeyxrt0_1baslyg ftgm304"
className="fui-TableHeader ___1gzszts_39qb7g0 f22iagw"
role="rowgroup"
>
<TableRow
@@ -98,6 +98,9 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
}
/>
</TableRow>
<div
className="___1ndi7nn_0000000 f64fuq3 f1ppkcfa"
/>
</div>
</TableHeader>
<TableBody>
@@ -500,7 +503,6 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
},
]
}
onItemClicked={[Function]}
onRefreshTable={[Function]}
onSelectedRowsChange={[Function]}
selectedColumnIds={[]}
@@ -518,7 +520,6 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
noNativeElements={true}
role="grid"
size="small"
sortable={true}
style={
{
"minWidth": "fit-content",
@@ -536,9 +537,11 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
}
}
>
<TableHeader>
<TableHeader
className="___1gzszts_0000000 f22iagw"
>
<div
className="fui-TableHeader ___oeyxrt0_1baslyg ftgm304"
className="fui-TableHeader ___1gzszts_39qb7g0 f22iagw"
role="rowgroup"
>
<TableRow
@@ -601,6 +604,9 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
</TableSelectionCell>
</div>
</TableRow>
<div
className="___1ndi7nn_0000000 f64fuq3 f1ppkcfa"
/>
</div>
</TableHeader>
<TableBody>

View File

@@ -1,3 +1,4 @@
import { ActionType, TabKind } from "Contracts/ActionContracts";
import React from "react";
import MongoUtility from "../../../Common/MongoUtility";
import * as ViewModels from "../../../Contracts/ViewModels";
@@ -20,7 +21,7 @@ export class NewMongoQueryTab extends NewQueryTab {
private mongoQueryTabProps: IMongoQueryTabProps,
) {
super(options, mongoQueryTabProps);
this.queryText = "";
this.queryText = options.queryText ?? "";
this.iMongoQueryTabComponentProps = {
collection: options.collection,
isExecutionError: this.isExecutionError(),
@@ -28,6 +29,8 @@ export class NewMongoQueryTab extends NewQueryTab {
tabsBaseInstance: this,
queryText: this.queryText,
partitionKey: this.partitionKey,
splitterDirection: options.splitterDirection,
queryViewSizePercent: options.queryViewSizePercent,
container: this.mongoQueryTabProps.container,
onTabAccessor: (instance: ITabAccessor): void => {
this.iTabAccessor = instance;
@@ -35,6 +38,26 @@ export class NewMongoQueryTab extends NewQueryTab {
isPreferredApiMongoDB: true,
monacoEditorSetting: "plaintext",
viewModelcollection: this.mongoQueryTabProps.viewModelcollection,
onUpdatePersistedState: (state: {
queryText: string;
splitterDirection: string;
queryViewSizePercent: number;
}): void => {
this.persistedState = {
actionType: ActionType.OpenCollectionTab,
tabKind: TabKind.SQLQuery,
databaseResourceId: options.collection.databaseId,
collectionResourceId: options.collection.id(),
query: {
text: state.queryText,
},
splitterDirection: state.splitterDirection as "vertical" | "horizontal",
queryViewSizePercent: state.queryViewSizePercent,
};
if (this.triggerPersistState) {
this.triggerPersistState();
}
},
};
}

View File

@@ -55,7 +55,7 @@ export default class MongoShellTabComponent extends Component<
constructor(props: IMongoShellTabComponentProps) {
super(props);
this._logTraces = new Map();
this._useMongoProxyEndpoint = useMongoProxyEndpoint("legacyMongoShell");
this._useMongoProxyEndpoint = useMongoProxyEndpoint(Constants.MongoProxyApi.LegacyMongoShell);
this.state = {
url: getMongoShellUrl(this._useMongoProxyEndpoint),

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