Compare commits

..

42 Commits

Author SHA1 Message Date
Laurent Nguyen
04c01256a6 Cleanup checkbox styles 2024-09-05 11:40:14 +02:00
Laurent Nguyen
1795b8e2e9 Merge branch 'master' into users/languy/save-documentstab-prefs 2024-09-05 09:41:58 +02:00
Laurent Nguyen
e23ba02561 Move column selection and sorting behind feature flag enableDocumentsTableColumnSelection 2024-09-02 12:31:39 +02:00
Laurent Nguyen
85352b74a3 Update unit test snapshot 2024-08-23 18:07:59 +02:00
Laurent Nguyen
26645f8360 Label to indicate which field is a partition key in Column Selection Pane 2024-08-23 17:59:16 +02:00
Laurent Nguyen
777b695051 Add background color to "..." button for column selection 2024-08-23 17:47:02 +02:00
Laurent Nguyen
1f300e32fe Fix table width 2024-08-23 17:32:00 +02:00
Laurent Nguyen
e81408560e Remove unused function 2024-08-23 17:26:22 +02:00
Laurent Nguyen
ed1e2990d0 Update test snapshots 2024-08-23 17:21:40 +02:00
Laurent Nguyen
5e92a0c5d7 Disable column selection for Mongo. Remove extra refresh button 2024-08-23 17:16:43 +02:00
Laurent Nguyen
26b6de4c53 Merge branch 'master' into users/languy/save-documentstab-prefs 2024-08-23 09:57:52 +02:00
Laurent Nguyen
f308cabeaa Revert "Merge branch 'master' into users/languy/save-documentstab-prefs"
This reverts commit e5a82fd356.
2024-08-23 09:53:45 +02:00
Laurent Nguyen
e5a82fd356 Merge branch 'master' into users/languy/save-documentstab-prefs 2024-08-23 09:47:19 +02:00
Laurent Nguyen
4778183e50 Save columns definition (schema) along with selected columns. 2024-08-23 09:23:38 +02:00
Laurent Nguyen
b1d9570a95 Persist column sorting 2024-08-23 08:21:40 +02:00
Laurent Nguyen
2397283649 Persist column selection 2024-08-22 16:19:23 +02:00
Laurent Nguyen
905aa26f27 Fix unit test 2024-08-22 14:30:35 +02:00
Laurent Nguyen
a2556dad06 Fix unit tests 2024-08-22 14:24:44 +02:00
Laurent Nguyen
c9398e303b Merge branch 'master' into users/languy/save-documentstab-prefs 2024-08-22 11:48:38 +02:00
Laurent Nguyen
9d4a9c0601 Add reset button to column selection and fix naming of openUploadItemsPanePane() 2024-08-21 16:39:21 +02:00
Laurent Nguyen
1e10273510 Fix format, update snapshots 2024-08-07 09:36:48 +02:00
Laurent Nguyen
c141e2612b Merge branch 'master' into users/languy/save-documentstab-prefs 2024-08-07 09:27:13 +02:00
Laurent Nguyen
7a179ff34a Fix format 2024-08-06 18:28:52 +02:00
Laurent Nguyen
4e71e340e3 Implement column sorting 2024-07-11 14:33:55 +02:00
Laurent Nguyen
9efbe7d056 Don't allow unselecting last column 2024-07-11 10:04:43 +02:00
Laurent Nguyen
ea2ab19518 Fix heuristic for size update 2024-07-11 10:04:21 +02:00
Laurent Nguyen
5d59c47979 Fix table size issue with some heuristics 2024-07-10 19:14:13 +02:00
Laurent Nguyen
fa460bfba2 Rework column selection UI 2024-07-10 17:20:24 +02:00
Laurent Nguyen
f1dcf1c548 Update choices of column when creating new or updating document 2024-06-25 10:15:41 +02:00
Laurent Nguyen
88f38d6522 Move table values under its own property 2024-06-24 13:38:32 +02:00
Laurent Nguyen
658e2ffe85 Do not allow deselecting all columns 2024-06-21 13:12:50 +02:00
Laurent Nguyen
bea3aa8b55 Accumulate properties rather than replace for column definitions 2024-06-21 12:05:50 +02:00
Laurent Nguyen
ce0cfed128 Only allow data fields that can be rendered (string and numbers) in column selection 2024-06-21 11:42:02 +02:00
Laurent Nguyen
c0a79c1e67 Search uses string includes instead of startsWith 2024-06-20 16:40:04 +02:00
Laurent Nguyen
9945304e18 Switch icons and search compare with lowercase. 2024-06-20 16:36:01 +02:00
Laurent Nguyen
0ce9acdfdf Implement new menu for column selection with search. 2024-06-20 16:23:36 +02:00
Laurent Nguyen
b096fa9bf8 Add column selection from right-click 2024-06-19 13:01:23 +02:00
Laurent Nguyen
55df5cb121 Merge branch 'master' into users/languy/save-documentstab-prefs 2024-06-13 09:14:11 +02:00
Laurent Nguyen
e36853c100 Save column width 2024-06-12 12:26:28 +02:00
Laurent Nguyen
996f785aac Merge branch 'master' into users/languy/save-documentstab-prefs 2024-06-11 17:02:04 +02:00
Laurent Nguyen
6c67f3b2e5 Make table columns generic (no more id and partition keys) 2024-06-11 16:57:17 +02:00
Laurent Nguyen
1ee79881ef Initial implementation of saving split value to local storage 2024-06-10 14:25:58 +02:00
265 changed files with 6800 additions and 47940 deletions

View File

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

@@ -1,32 +0,0 @@
// 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"
}

View File

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

View File

@@ -1 +1 @@
[Preview this branch](https://dataexplorer-preview.azurewebsites.net/pull/EDIT_THIS_NUMBER_IN_THE_PR_DESCRIPTION?feature.someFeatureFlagYouMightNeed=true)
[Preview this branch](https://cosmos-explorer-preview.azurewebsites.net/pull/EDIT_THIS_NUMBER_IN_THE_PR_DESCRIPTION?feature.someFeatureFlagYouMightNeed=true)

View File

@@ -83,7 +83,7 @@ jobs:
- run: npm ci
- run: npm run build:contracts
- name: Restore Build Cache
uses: actions/cache@v4
uses: actions/cache@v2
with:
path: .cache
key: ${{ runner.os }}-build-cache
@@ -92,20 +92,18 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=4096"
- run: cp -r ./Contracts ./dist/contracts
- run: cp -r ./configs ./dist/configs
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v3
with:
name: dist
path: dist/
- name: "Az CLI login"
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.PREVIEW_SUBSCRIPTION_ID }}
- name: Upload build to preview blob storage
run: az storage blob upload-batch -d '$web' -s 'dist' --account-name ${{ secrets.PREVIEW_STORAGE_ACCOUNT_NAME }} --destination-path "${{github.event.pull_request.head.sha || github.sha}}" --auth-mode login --overwrite true
run: az storage blob upload-batch -d '$web' -s 'dist' --account-name cosmosexplorerpreview --destination-path "${{github.event.pull_request.head.sha || github.sha}}" --account-key="${PREVIEW_STORAGE_KEY}" --overwrite true
env:
PREVIEW_STORAGE_KEY: ${{ secrets.PREVIEW_STORAGE_KEY }}
- name: Upload preview config to blob storage
run: az storage blob upload -c '$web' -f ./preview/config.json --account-name ${{ secrets.PREVIEW_STORAGE_ACCOUNT_NAME }} --name "${{github.event.pull_request.head.sha || github.sha}}/config.json" --auth-mode login --overwrite true
run: az storage blob upload -c '$web' -f ./preview/config.json --account-name cosmosexplorerpreview --name "${{github.event.pull_request.head.sha || github.sha}}/config.json" --account-key="${PREVIEW_STORAGE_KEY}" --overwrite true
env:
PREVIEW_STORAGE_KEY: ${{ secrets.PREVIEW_STORAGE_KEY }}
nuget:
name: Publish Nuget
if: github.ref == 'refs/heads/master' || contains(github.ref, 'hotfix/') || contains(github.ref, 'release/')
@@ -115,21 +113,21 @@ jobs:
NUGET_SOURCE: ${{ secrets.NUGET_SOURCE }}
AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }}
steps:
- uses: nuget/setup-nuget@v1
with:
nuget-api-key: ${{ secrets.NUGET_API_KEY }}
- name: Download Dist Folder
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: dist
- run: cp ./configs/prod.json config.json
- run: dotnet nuget add source "$NUGET_SOURCE" --name "ADO" --username "jawelton@microsoft.com" --password "$AZURE_DEVOPS_PAT" --store-password-in-clear-text
- run: dotnet pack DataExplorer.proj /p:PackageVersion="2.0.0-github-${GITHUB_SHA}"
- run: dotnet nuget push "bin/Release/*.nupkg" --skip-duplicate --api-key Az --source="$NUGET_SOURCE"
- run: dotnet nuget remove source "ADO"
- uses: actions/upload-artifact@v4
name: Upload package to Artifacts
- 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
name: packages
with:
name: prod-package
path: "bin/Release/*.nupkg"
path: "*.nupkg"
nugetmpac:
name: Publish Nuget MPAC
if: github.ref == 'refs/heads/master' || contains(github.ref, 'hotfix/') || contains(github.ref, 'release/')
@@ -139,21 +137,22 @@ jobs:
NUGET_SOURCE: ${{ secrets.NUGET_SOURCE }}
AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }}
steps:
- uses: nuget/setup-nuget@v1
with:
nuget-api-key: ${{ secrets.NUGET_API_KEY }}
- name: Download Dist Folder
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: dist
- run: cp ./configs/mpac.json config.json
- run: sed -i 's/Azure.Cosmos.DB.Data.Explorer/Azure.Cosmos.DB.Data.Explorer.MPAC/g' DataExplorer.nuspec
- run: dotnet nuget add source "$NUGET_SOURCE" --name "ADO" --username "jawelton@microsoft.com" --password "$AZURE_DEVOPS_PAT" --store-password-in-clear-text
- run: dotnet pack DataExplorer.proj /p:PackageVersion="2.0.0-github-${GITHUB_SHA}"
- run: dotnet nuget push "bin/Release/*.nupkg" --skip-duplicate --api-key Az --source="$NUGET_SOURCE"
- run: dotnet nuget remove source "ADO"
- uses: actions/upload-artifact@v4
name: Upload package to Artifacts
- 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
name: packages
with:
name: mpac-package
path: "bin/Release/*.nupkg"
path: "*.nupkg"
playwright-tests:
name: "Run Playwright Tests (Shard ${{ matrix.shardIndex }} of ${{ matrix.shardTotal }})"
@@ -164,8 +163,8 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
shardTotal: [10]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- uses: actions/checkout@v4
- name: "Az CLI login"
@@ -186,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"
@@ -198,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

@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<NoBuild>true</NoBuild>
<IncludeBuildOutput>false</IncludeBuildOutput>
<NuspecFile>DataExplorer.nuspec</NuspecFile>
<NuspecProperties>version=$(PackageVersion)</NuspecProperties>
</PropertyGroup>
</Project>

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://cdb-ms-mpac-pbe.cosmos.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://main.documentdb.ext.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://cdb-ms-mpac-pbe.cosmos.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://main.documentdb.ext.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,11 +174,7 @@ 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|(.*)/dist/browser)/",
"/node_modules/plotly.js-cartesian-dist-min",
"/externals/",
],
transformIgnorePatterns: ["/node_modules/(?!@fluentui/react-icons)", "/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

@@ -61,8 +61,6 @@
@GalleryBackgroundColor: #fdfdfd;
@LinkColor: #2d6da4;
//Icons
@InfoIconColor: #0072c6;
@WarningIconColor: #db7500;
@@ -248,10 +246,6 @@
outline: 1px dashed @FocusColor;
}
.focusedBorder() {
border: 1px dashed @FocusColor;
}
/************************************************************************************************
Common Toggle Switch
*************************************************************************************************/

View File

@@ -1830,14 +1830,6 @@ input::-webkit-calendar-picker-indicator::after {
transform: rotate(90deg);
}
.customAccordion button:focus {
.focus();
}
.customAccordion {
margin-top: 1px;
}
.datalist-arrow:after:hover {
content: "\276F";
position: absolute;
@@ -1914,20 +1906,13 @@ input::-webkit-calendar-picker-indicator::after {
}
.nav-tabs-margin {
height: 32px;
background-color: #f2f2f2;
.nav-tabs {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
height: 100%;
margin-bottom: -0.5px;
li {
// Override the bootstrap defaults here to align with our layout constants.
margin-bottom: 0px;
height: 32px;
}
}
}
@@ -3132,7 +3117,3 @@ a:link {
background: white;
height: 100%;
}
.sidebarContainer {
height: 100%;
}

View File

@@ -20,10 +20,6 @@ a:focus {
text-decoration: underline;
}
.splashLoaderContainer {
background-color: #f5f5f5;
}
#divExplorer {
background-color: #f5f5f5;
}
@@ -31,24 +27,26 @@ 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;
@@ -67,16 +65,17 @@ 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;
}
@@ -95,10 +94,10 @@ a:focus {
padding-bottom: @SmallSpace;
.contentWrapper {
.statusIconContainer {
margin-left: 0px;
.statusIconContainer {
margin-left: 0px;
}
}
}
.tabIconSection {
.cancelButton {
@@ -120,6 +119,7 @@ a:focus {
}
}
.resourceTree {
padding: 12px;
}
@@ -156,21 +156,25 @@ 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;
@@ -196,10 +200,12 @@ a:focus {
border: solid 1px #d1d1d1;
}
.gridRowSelected .tabdocumentsGridElement:hover {
background-color: @FabricAccentLight !important;
}
.refreshcol {
filter: brightness(0) saturate(100%);
}
@@ -210,4 +216,4 @@ a:focus {
.fileImportImg img {
filter: brightness(0) saturate(100%);
}
}

812
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,10 @@
"main": "index.js",
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.2.0-beta.1",
"@azure/cosmos": "4.0.1-beta.3",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
"@azure/identity": "1.5.2",
"@azure/ms-rest-nodeauth": "3.1.1",
"@azure/msal-browser": "2.14.2",
"@babel/plugin-proposal-class-properties": "7.12.1",
"@babel/plugin-proposal-decorators": "7.12.12",
@@ -81,7 +82,7 @@
"mkdirp": "1.0.4",
"monaco-editor": "0.44.0",
"ms": "2.1.3",
"p-retry": "6.2.1",
"p-retry": "4.6.2",
"patch-package": "8.0.0",
"plotly.js-cartesian-dist-min": "1.52.3",
"post-robot": "10.0.42",
@@ -116,7 +117,7 @@
"@babel/preset-env": "7.24.7",
"@babel/preset-react": "7.24.7",
"@babel/preset-typescript": "7.24.7",
"@playwright/test": "1.49.1",
"@playwright/test": "1.44.0",
"@testing-library/react": "11.2.3",
"@types/applicationinsights-js": "1.0.7",
"@types/codemirror": "0.0.56",
@@ -169,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",
@@ -246,4 +247,4 @@
"printWidth": 120,
"endOfLine": "auto"
}
}
}

View File

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

View File

@@ -1,7 +1,7 @@
[defaults]
group = dataexplorer-preview
sku = P1V2
appserviceplan = dataexplorer-preview
location = westus2
web = dataexplorer-preview
group = stfaul
sku = P1v2
appserviceplan = stfaul_asp_Linux_centralus_0
location = centralus
web = cosmos-explorer-preview

View File

@@ -4,8 +4,8 @@ Cosmos Explorer Preview makes it possible to try a working version of any commit
Initial support is for Hosted (Connection string only) or the Azure Portal. Examples:
Connection string URLs: https://dataexplorer-preview.azurewebsites.net/commit/COMMIT_SHA/hostedExplorer.html
Portal URLs: https://ms.portal.azure.com/?dataExplorerSource=https://dataexplorer-preview.azurewebsites.net/commit/COMMIT_SHA/explorer.html#home
Connection string URLs: https://cosmos-explorer-preview.azurewebsites.net/commit/COMMIT_SHA/hostedExplorer.html
Portal URLs: https://ms.portal.azure.com/?dataExplorerSource=https://cosmos-explorer-preview.azurewebsites.net/commit/COMMIT_SHA/explorer.html#home
In both cases replace `COMMIT_SHA` with the commit you want to view. It must have already completed its build on GitHub Actions.

View File

@@ -1,4 +1,4 @@
{
"PROXY_PATH": "/proxy",
"msalRedirectURI": "https://dataexplorer-preview.azurewebsites.net/"
"msalRedirectURI": "https://cosmos-explorer-preview.azurewebsites.net/"
}

View File

@@ -3,15 +3,8 @@ const { createProxyMiddleware } = require("http-proxy-middleware");
const port = process.env.PORT || 3000;
const fetch = require("node-fetch");
const backendEndpoint = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
const previewSiteEndpoint = "https://dataexplorer-preview.azurewebsites.net";
const previewStorageWebsiteEndpoint = "https://dataexplorerpreview.z5.web.core.windows.net/";
const githubApiUrl = "https://api.github.com/repos/Azure/cosmos-explorer";
const githubPullRequestUrl = "https://github.com/Azure/cosmos-explorer/pull";
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
const api = createProxyMiddleware({
target: backendEndpoint,
const api = createProxyMiddleware("/api", {
target: "https://main.documentdb.ext.azure.com",
changeOrigin: true,
logLevel: "debug",
bypass: (req, res) => {
@@ -22,8 +15,8 @@ const api = createProxyMiddleware({
},
});
const proxy = createProxyMiddleware({
target: backendEndpoint,
const proxy = createProxyMiddleware("/proxy", {
target: "https://main.documentdb.ext.azure.com",
changeOrigin: true,
secure: false,
logLevel: "debug",
@@ -34,38 +27,35 @@ const proxy = createProxyMiddleware({
},
});
const commit = createProxyMiddleware({
target: previewStorageWebsiteEndpoint,
const commit = createProxyMiddleware("/commit", {
target: "https://cosmosexplorerpreview.blob.core.windows.net",
changeOrigin: true,
secure: false,
logLevel: "debug",
pathRewrite: { "^/commit": "/" },
pathRewrite: { "^/commit": "$web/" },
});
const app = express();
app.use("/api", api);
app.use("/proxy", proxy);
app.use("/commit", commit);
app.use(api);
app.use(proxy);
app.use(commit);
app.get("/pull/:pr(\\d+)", (req, res) => {
const pr = req.params.pr;
if (!/^\d+$/.test(pr)) {
return res.status(400).send("Invalid pull request number");
}
const [, query] = req.originalUrl.split("?");
const search = new URLSearchParams(query);
fetch(`${githubApiUrl}/pulls/${pr}`)
fetch("https://api.github.com/repos/Azure/cosmos-explorer/pulls/" + pr)
.then((response) => response.json())
.then(({ head: { ref, sha } }) => {
const prUrl = new URL(`${githubPullRequestUrl}/${pr}`);
const prUrl = new URL("https://github.com/Azure/cosmos-explorer/pull/" + pr);
prUrl.hash = ref;
search.set("feature.pr", prUrl.href);
const explorer = new URL(`${previewSiteEndpoint}/commit/${sha}/explorer.html`);
const explorer = new URL("https://cosmos-explorer-preview.azurewebsites.net/commit/" + sha + "/explorer.html");
explorer.search = search.toString();
const portal = new URL(azurePortalMpacEndpoint);
const portal = new URL("https://ms.portal.azure.com/");
portal.searchParams.set("dataExplorerSource", explorer.href);
return res.redirect(portal.href);
@@ -73,10 +63,12 @@ app.get("/pull/:pr(\\d+)", (req, res) => {
.catch(() => res.sendStatus(500));
});
app.get("/", (req, res) => {
fetch(`${githubApiUrl}/branches/master`)
fetch("https://api.github.com/repos/Azure/cosmos-explorer/branches/master")
.then((response) => response.json())
.then(({ commit: { sha } }) => {
const explorer = new URL(`${previewSiteEndpoint}/commit/${sha}/hostedExplorer.html`);
const explorer = new URL(
"https://cosmos-explorer-preview.azurewebsites.net/commit/" + sha + "/hostedExplorer.html"
);
return res.redirect(explorer.href);
})
.catch(() => res.sendStatus(500));

37133
preview/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"deploy": "az webapp up --name \"dataexplorer-preview\" --subscription \"cosmosdb-portalteam-runners\" --resource-group \"dataexplorer-preview\" --runtime \"NODE:18-lts\" --sku P1V2",
"deploy": "az webapp up --name \"cosmos-explorer-preview\" --subscription \"cosmosdb-portalteam-generaltest-msft\" --resource-group \"stfaul\"",
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
@@ -12,8 +12,7 @@
"author": "Microsoft Corporation",
"dependencies": {
"express": "^4.17.1",
"http-proxy-middleware": "^3.0.3",
"node": "^18.20.6",
"http-proxy-middleware": "^1.1.0",
"node-fetch": "^2.6.1"
}
}
}

View File

@@ -89,7 +89,6 @@ 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 {
@@ -97,12 +96,6 @@ export enum CapacityMode {
Serverless = "Serverless",
}
export enum WorkloadType {
Learning = "Learning",
DevelopmentTesting = "Development/Testing",
Production = "Production",
None = "None",
}
// flight names returned from the portal are always lowercase
export class Flights {
public static readonly SettingsV2 = "settingsv2";
@@ -125,7 +118,6 @@ export class AfecFeatures {
export class TagNames {
public static defaultExperience: string = "defaultExperience";
public static WorkloadType: string = "hidden-workload-type";
}
export class MongoDBAccounts {
@@ -144,7 +136,6 @@ 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 {
@@ -156,25 +147,13 @@ export class PortalBackendEndpoints {
}
export class MongoProxyEndpoints {
public static readonly Development: string = "https://localhost:7238";
public static readonly Local: 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";
@@ -313,7 +292,6 @@ 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;
@@ -525,19 +503,7 @@ export class PriorityLevel {
public static readonly Default = "low";
}
export class ariaLabelForLearnMoreLink {
public static readonly AnalyticalStore = "Learn more about analytical store.";
public static readonly AzureSynapseLink = "Learn more about Azure Synapse Link.";
}
export class GlobalSecondaryIndexLabels {
public static readonly NewGlobalSecondaryIndex: string = "New Global Secondary Index";
}
export class FeedbackLabels {
public static readonly provideFeedback: string = "Provide feedback";
}
export const QueryCopilotSampleDatabaseId = "CopilotSampleDB";
export const QueryCopilotSampleDatabaseId = "CopilotSampleDb";
export const QueryCopilotSampleContainerId = "SampleContainer";
export const QueryCopilotSampleContainerSchema = {

View File

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

View File

@@ -1,15 +1,14 @@
import * as Cosmos from "@azure/cosmos";
import { getAuthorizationTokenUsingResourceTokens } from "Common/getAuthorizationTokenUsingResourceTokens";
import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
import { AuthorizationToken } from "Contracts/FabricMessageTypes";
import { checkDatabaseResourceTokensValidity, isFabricMirroredKey } from "Platform/Fabric/FabricUtil";
import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { AuthType } from "../AuthType";
import { PriorityLevel } from "../Common/Constants";
import { BackendApi, PriorityLevel } from "../Common/Constants";
import * as Logger from "../Common/Logger";
import { Platform, configContext } from "../ConfigContext";
import { FabricArtifactInfo, updateUserContext, userContext } from "../UserContext";
import { isDataplaneRbacSupported } from "../Utils/APITypeUtils";
import { userContext } from "../UserContext";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
@@ -20,7 +19,7 @@ const _global = typeof self === "undefined" ? window : self;
export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
const { verb, resourceId, resourceType, headers } = requestInfo;
const dataPlaneRBACOptionEnabled = userContext.dataPlaneRbacEnabled && isDataplaneRbacSupported(userContext.apiType);
const dataPlaneRBACOptionEnabled = userContext.dataPlaneRbacEnabled && userContext.apiType === "SQL";
if (userContext.features.enableAadDataPlane || dataPlaneRBACOptionEnabled) {
Logger.logInfo(
`AAD Data Plane Feature flag set to ${userContext.features.enableAadDataPlane} for account with disable local auth ${userContext.databaseAccount.properties.disableLocalAuth} `,
@@ -28,7 +27,7 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
);
if (!userContext.aadToken) {
logConsoleError(
`AAD token does not exist. Please use the "Login for Entra ID" button in the Toolbar prior to performing Entra ID RBAC operations`,
`AAD token does not exist. Please click on "Login for Entra ID" button prior to performing Entra ID RBAC operations`,
);
return null;
}
@@ -43,7 +42,7 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
return decodeURIComponent(headers.authorization);
}
if (isFabricMirroredKey()) {
if (configContext.platform === Platform.Fabric) {
switch (requestInfo.resourceType) {
case Cosmos.ResourceType.conflicts:
case Cosmos.ResourceType.container:
@@ -55,13 +54,8 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
// User resource tokens
// TODO userContext.fabricContext.databaseConnectionInfo can be undefined
headers[HttpHeaders.msDate] = new Date().toUTCString();
const resourceTokens = (
userContext.fabricContext.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY]
).resourceTokenInfo.resourceTokens;
checkDatabaseResourceTokensValidity(
(userContext.fabricContext.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY])
.resourceTokenInfo.resourceTokensTimestamp,
);
const resourceTokens = userContext.fabricContext.databaseConnectionInfo.resourceTokens;
checkDatabaseResourceTokensValidity(userContext.fabricContext.databaseConnectionInfo.resourceTokensTimestamp);
return getAuthorizationTokenUsingResourceTokens(resourceTokens, requestInfo.path, requestInfo.resourceId);
case Cosmos.ResourceType.none:
@@ -72,9 +66,7 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
// For now, these operations aren't used, so fetching the authorization token is commented out.
// This provider must return a real token to pass validation by the client, so we return the cached resource token
// (which is a valid token, but won't work for these operations).
const resourceTokens2 = (
userContext.fabricContext.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY]
).resourceTokenInfo.resourceTokens;
const resourceTokens2 = userContext.fabricContext.databaseConnectionInfo.resourceTokens;
return getAuthorizationTokenUsingResourceTokens(resourceTokens2, requestInfo.path, requestInfo.resourceId);
/* ************** TODO: Uncomment this code if we need to support these operations **************
@@ -125,11 +117,7 @@ export const endpoint = () => {
const location = _global.parent ? _global.parent.location : _global.location;
return configContext.EMULATOR_ENDPOINT || location.origin;
}
return (
userContext.selectedRegionalEndpoint ||
userContext.endpoint ||
userContext?.databaseAccount?.properties?.documentEndpoint
);
return userContext.endpoint || userContext?.databaseAccount?.properties?.documentEndpoint;
};
export async function getTokenFromAuthService(
@@ -137,6 +125,10 @@ export async function getTokenFromAuthService(
resourceType: string,
resourceId?: string,
): Promise<AuthorizationToken> {
if (!useNewPortalBackendEndpoint(BackendApi.RuntimeProxy)) {
return getTokenFromAuthService_ToBeDeprecated(verb, resourceType, resourceId);
}
try {
const host: string = configContext.PORTAL_BACKEND_ENDPOINT;
const response: Response = await _global.fetch(host + "/api/connectionstring/runtimeproxy/authorizationtokens", {
@@ -159,6 +151,34 @@ export async function getTokenFromAuthService(
}
}
export async function getTokenFromAuthService_ToBeDeprecated(
verb: string,
resourceType: string,
resourceId?: string,
): Promise<AuthorizationToken> {
try {
const host = configContext.BACKEND_ENDPOINT;
const response = await _global.fetch(host + "/api/guest/runtimeproxy/authorizationTokens", {
method: "POST",
headers: {
"content-type": "application/json",
"x-ms-encrypted-auth-token": userContext.accessToken,
},
body: JSON.stringify({
verb,
resourceType,
resourceId,
}),
});
//TODO I am not sure why we have to parse the JSON again here. fetch should do it for us when we call .json()
const result = JSON.parse(await response.json());
return result;
} catch (error) {
logConsoleError(`Failed to get authorization headers for ${resourceType}: ${getErrorMessage(error)}`);
return Promise.reject(error);
}
}
// The Capability is a bitmap, which cosmosdb backend decodes as per the below enum
enum SDKSupportedCapabilities {
None = 0,
@@ -169,24 +189,13 @@ let _client: Cosmos.CosmosClient;
export function client(): Cosmos.CosmosClient {
if (_client) {
if (!userContext.refreshCosmosClient) {
if (!userContext.hasDataPlaneRbacSettingChanged) {
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;
_defaultHeaders["x-ms-cosmos-throughput-bucket"] = 1;
if (
userContext.authType === AuthType.ConnectionString ||
@@ -207,7 +216,6 @@ export function client(): Cosmos.CosmosClient {
userAgentSuffix: "Azure Portal",
defaultHeaders: _defaultHeaders,
connectionPolicy: {
enableEndpointDiscovery: !userContext.selectedRegionalEndpoint,
retryOptions: {
maxRetryAttemptCount: LocalStorageUtility.getEntryNumber(StorageKey.RetryAttempts),
fixedRetryIntervalInMilliseconds: LocalStorageUtility.getEntryNumber(StorageKey.RetryInterval),

View File

@@ -1,34 +0,0 @@
import { WorkloadType } from "Common/Constants";
import { getWorkloadType } from "Common/DatabaseAccountUtility";
import { DatabaseAccount, Tags } from "Contracts/DataModels";
import { updateUserContext } from "UserContext";
describe("Database Account Utility", () => {
describe("Workload Type", () => {
beforeEach(() => {
updateUserContext({
databaseAccount: {
tags: {} as Tags,
} as DatabaseAccount,
});
});
it("Workload Type should return Learning", () => {
updateUserContext({
databaseAccount: {
tags: {
"hidden-workload-type": WorkloadType.Learning,
} as Tags,
} as DatabaseAccount,
});
const workloadType: WorkloadType = getWorkloadType();
expect(workloadType).toBe(WorkloadType.Learning);
});
it("Workload Type should return None", () => {
const workloadType: WorkloadType = getWorkloadType();
expect(workloadType).toBe(WorkloadType.None);
});
});
});

View File

@@ -1,6 +1,3 @@
import { TagNames, WorkloadType } from "Common/Constants";
import { Tags } from "Contracts/DataModels";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { userContext } from "../UserContext";
function isVirtualNetworkFilterEnabled() {
@@ -18,18 +15,3 @@ function isPrivateEndpointConnectionsEnabled() {
export function isPublicInternetAccessAllowed(): boolean {
return !isVirtualNetworkFilterEnabled() && !isIpRulesEnabled() && !isPrivateEndpointConnectionsEnabled();
}
export function getWorkloadType(): WorkloadType {
const tags: Tags = userContext?.databaseAccount?.tags;
const workloadType: WorkloadType = tags && (tags[TagNames.WorkloadType] as WorkloadType);
if (!workloadType) {
return WorkloadType.None;
}
return workloadType;
}
export function isGlobalSecondaryIndexEnabled(): boolean {
return (
!isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews
);
}

View File

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

View File

@@ -10,7 +10,6 @@ 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;
@@ -27,7 +26,6 @@ export const EntityValue: FunctionComponent<TableEntityProps> = ({
onSelectDate,
isEntityValueDisable,
onEntityTimeValueChange,
entityProperty,
}: TableEntityProps): JSX.Element => {
if (isEntityTypeDate) {
return (
@@ -53,20 +51,15 @@ export const EntityValue: FunctionComponent<TableEntityProps> = ({
}
return (
<>
<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}
/>
</>
<TextField
label={entityValueLabel && entityValueLabel}
className="addEntityTextField"
disabled={isEntityValueDisable}
type={entityValueType}
placeholder={entityValuePlaceholder}
value={typeof entityValue === "string" ? entityValue : ""}
onChange={onEntityValueChange}
ariaLabel={attributeValueLabel}
/>
);
};

View File

@@ -1,5 +1,3 @@
import { PortalBackendEndpoints } from "Common/Constants";
import { updateConfigContext } from "ConfigContext";
import * as EnvironmentUtility from "./EnvironmentUtility";
describe("Environment Utility Test", () => {
@@ -13,18 +11,4 @@ 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,29 +1,6 @@
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,8 +1,5 @@
import { QueryOperationOptions } from "@azure/cosmos";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as Constants from "../Common/Constants";
import { QueryResults } from "../Contracts/ViewModels";
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
interface QueryResponse {
// [Todo] remove any
@@ -24,9 +21,7 @@ export function nextPage(
firstItemIndex: number,
queryOperationOptions?: QueryOperationOptions,
): Promise<QueryResults> {
TelemetryProcessor.traceStart(Action.ExecuteQuery);
return documentsIterator.fetchNext(queryOperationOptions).then((response) => {
TelemetryProcessor.traceSuccess(Action.ExecuteQuery, { dataExplorerArea: Constants.Areas.Tab });
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,11 +1,18 @@
import { MongoProxyEndpoints } from "Common/Constants";
import { AuthType } from "../AuthType";
import { configContext, resetConfigContext, updateConfigContext } from "../ConfigContext";
import { resetConfigContext, updateConfigContext } from "../ConfigContext";
import { DatabaseAccount } from "../Contracts/DataModels";
import { Collection } from "../Contracts/ViewModels";
import DocumentId from "../Explorer/Tree/DocumentId";
import { extractFeatures } from "../Platform/Hosted/extractFeatures";
import { updateUserContext } from "../UserContext";
import { deleteDocuments, getEndpoint, queryDocuments, readDocument, updateDocument } from "./MongoProxyClient";
import {
deleteDocument,
getEndpoint,
getFeatureEndpointOrDefault,
queryDocuments,
readDocument,
updateDocument,
} from "./MongoProxyClient";
const databaseId = "testDB";
@@ -64,8 +71,7 @@ describe("MongoProxyClient", () => {
databaseAccount,
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -76,19 +82,16 @@ describe("MongoProxyClient", () => {
it("builds the correct URL", () => {
queryDocuments(databaseId, collection, true, "{}");
expect(window.fetch).toHaveBeenCalledWith(
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer/resourcelist`,
"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",
expect.any(Object),
);
});
it("builds the correct proxy URL in development", () => {
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
queryDocuments(databaseId, collection, true, "{}");
expect(window.fetch).toHaveBeenCalledWith(
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer/resourcelist`,
"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",
expect.any(Object),
);
});
@@ -100,8 +103,7 @@ describe("MongoProxyClient", () => {
databaseAccount,
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -112,19 +114,16 @@ describe("MongoProxyClient", () => {
it("builds the correct URL", () => {
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
"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",
expect.any(Object),
);
});
it("builds the correct proxy URL in development", () => {
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
"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",
expect.any(Object),
);
});
@@ -136,8 +135,7 @@ describe("MongoProxyClient", () => {
databaseAccount,
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -148,19 +146,16 @@ describe("MongoProxyClient", () => {
it("builds the correct URL", () => {
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
"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",
expect.any(Object),
);
});
it("builds the correct proxy URL in development", () => {
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
"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",
expect.any(Object),
);
});
@@ -172,8 +167,7 @@ describe("MongoProxyClient", () => {
databaseAccount,
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -184,20 +178,28 @@ describe("MongoProxyClient", () => {
it("builds the correct URL", () => {
updateDocument(databaseId, collection, documentId, "{}");
expect(window.fetch).toHaveBeenCalledWith(
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
"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",
expect.any(Object),
);
});
it("builds the correct proxy URL in development", () => {
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
updateDocument(databaseId, collection, documentId, "{}");
expect(window.fetch).toHaveBeenCalledWith(
"https://localhost:1234/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2Fdocs%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
expect.any(Object),
);
});
});
describe("deleteDocuments", () => {
describe("deleteDocument", () => {
beforeEach(() => {
resetConfigContext();
updateUserContext({
databaseAccount,
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -206,21 +208,18 @@ describe("MongoProxyClient", () => {
});
it("builds the correct URL", () => {
deleteDocuments(databaseId, collection, [documentId]);
deleteDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer/bulkdelete`,
"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",
expect.any(Object),
);
});
it("builds the correct proxy URL in development", () => {
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
deleteDocuments(databaseId, collection, [documentId]);
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
deleteDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer/bulkdelete`,
"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",
expect.any(Object),
);
});
@@ -232,14 +231,13 @@ describe("MongoProxyClient", () => {
databaseAccount,
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
});
});
it("returns a production endpoint", () => {
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`);
const endpoint = getEndpoint("https://main.documentdb.ext.azure.com");
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/mongo/explorer");
});
it("returns a development endpoint", () => {
@@ -251,8 +249,35 @@ describe("MongoProxyClient", () => {
updateUserContext({
authType: AuthType.EncryptedToken,
});
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/connectionstring/mongo/explorer`);
const endpoint = getEndpoint("https://main.documentdb.ext.azure.com");
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/guest/mongo/explorer");
});
});
describe("getFeatureEndpointOrDefault", () => {
beforeEach(() => {
resetConfigContext();
updateConfigContext({
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
});
const params = new URLSearchParams({
"feature.mongoProxyEndpoint": "https://localhost:12901",
"feature.mongoProxyAPIs": "readDocument|createDocument",
});
const features = extractFeatures(params);
updateUserContext({
authType: AuthType.AAD,
features: features,
});
});
it("returns a local endpoint", () => {
const endpoint = getFeatureEndpointOrDefault("readDocument");
expect(endpoint).toEqual("https://localhost:12901/api/mongo/explorer");
});
it("returns a production endpoint", () => {
const endpoint = getFeatureEndpointOrDefault("deleteDocument");
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/mongo/explorer");
});
});
});

View File

@@ -1,13 +1,20 @@
import { Constants as CosmosSDKConstants } from "@azure/cosmos";
import {
allowedMongoProxyEndpoints,
allowedMongoProxyEndpoints_ToBeDeprecated,
validateEndpoint,
} from "Utils/EndpointUtils";
import queryString from "querystring";
import { AuthType } from "../AuthType";
import { configContext } from "../ConfigContext";
import * as DataModels from "../Contracts/DataModels";
import { MessageTypes } from "../Contracts/ExplorerContracts";
import { Collection } from "../Contracts/ViewModels";
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 } from "./Constants";
import { ApiType, ContentType, HttpHeaders, HttpStatusCodes, MongoProxyEndpoints } from "./Constants";
import { MinimalQueryIterator } from "./IteratorUtilities";
import { sendMessage } from "./MessageHandler";
@@ -60,6 +67,10 @@ export function queryDocuments(
query: string,
continuationToken?: string,
): Promise<QueryResponse> {
if (!useMongoProxyEndpoint("resourcelist") || !useMongoProxyEndpoint("queryDocuments")) {
return queryDocuments_ToBeDeprecated(databaseId, collection, isResourceList, query, continuationToken);
}
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const params = {
@@ -78,7 +89,7 @@ export function queryDocuments(
query,
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT) || "";
const endpoint = getFeatureEndpointOrDefault("resourcelist") || "";
const headers = {
...defaultHeaders,
@@ -116,11 +127,76 @@ export function queryDocuments(
});
}
function queryDocuments_ToBeDeprecated(
databaseId: string,
collection: Collection,
isResourceList: boolean,
query: string,
continuationToken?: string,
): Promise<QueryResponse> {
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const params = {
db: databaseId,
coll: collection.id(),
resourceUrl: `${resourceEndpoint}dbs/${databaseId}/colls/${collection.id()}/docs/`,
rid: collection.rid,
rtype: "docs",
sid: userContext.subscriptionId,
rg: userContext.resourceGroup,
dba: databaseAccount.name,
pk:
collection && collection.partitionKey && !collection.partitionKey.systemKey
? collection.partitionKeyProperties?.[0]
: "",
};
const endpoint = getFeatureEndpointOrDefault("resourcelist") || "";
const headers = {
...defaultHeaders,
...authHeaders(),
[CosmosSDKConstants.HttpHeaders.IsQuery]: "true",
[CosmosSDKConstants.HttpHeaders.PopulateQueryMetrics]: "true",
[CosmosSDKConstants.HttpHeaders.EnableScanInQuery]: "true",
[CosmosSDKConstants.HttpHeaders.EnableCrossPartitionQuery]: "true",
[CosmosSDKConstants.HttpHeaders.ParallelizeCrossPartitionQuery]: "true",
[HttpHeaders.contentType]: "application/query+json",
};
if (continuationToken) {
headers[CosmosSDKConstants.HttpHeaders.Continuation] = continuationToken;
}
const path = isResourceList ? "/resourcelist" : "";
return window
.fetch(`${endpoint}${path}?${queryString.stringify(params)}`, {
method: "POST",
body: JSON.stringify({ query }),
headers,
})
.then(async (response) => {
if (response.ok) {
return {
continuationToken: response.headers.get(CosmosSDKConstants.HttpHeaders.Continuation),
documents: (await response.json()).Documents as DataModels.DocumentId[],
headers: response.headers,
};
}
await errorHandling(response, "querying documents", params);
return undefined;
});
}
export function readDocument(
databaseId: string,
collection: Collection,
documentId: DocumentId,
): Promise<DataModels.DocumentId> {
if (!useMongoProxyEndpoint("readDocument")) {
return readDocument_ToBeDeprecated(databaseId, collection, documentId);
}
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const idComponents = documentId.self.split("/");
@@ -141,7 +217,7 @@ export function readDocument(
: "",
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
const endpoint = getFeatureEndpointOrDefault("readDocument");
return window
.fetch(endpoint, {
@@ -161,12 +237,61 @@ export function readDocument(
});
}
export function readDocument_ToBeDeprecated(
databaseId: string,
collection: Collection,
documentId: DocumentId,
): Promise<DataModels.DocumentId> {
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const idComponents = documentId.self.split("/");
const path = idComponents.slice(0, 4).join("/");
const rid = encodeURIComponent(idComponents[5]);
const params = {
db: databaseId,
coll: collection.id(),
resourceUrl: `${resourceEndpoint}${path}/${rid}`,
rid,
rtype: "docs",
sid: userContext.subscriptionId,
rg: userContext.resourceGroup,
dba: databaseAccount.name,
pk:
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey
? documentId.partitionKeyProperties?.[0]
: "",
};
const endpoint = getFeatureEndpointOrDefault("readDocument");
return window
.fetch(`${endpoint}?${queryString.stringify(params)}`, {
method: "GET",
headers: {
...defaultHeaders,
...authHeaders(),
[CosmosSDKConstants.HttpHeaders.PartitionKey]: encodeURIComponent(
JSON.stringify(documentId.partitionKeyHeader()),
),
},
})
.then(async (response) => {
if (response.ok) {
return response.json();
}
return await errorHandling(response, "reading document", params);
});
}
export function createDocument(
databaseId: string,
collection: Collection,
partitionKeyProperty: string,
documentContent: unknown,
): Promise<DataModels.DocumentId> {
if (!useMongoProxyEndpoint("createDocument")) {
return createDocument_ToBeDeprecated(databaseId, collection, partitionKeyProperty, documentContent);
}
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const params = {
@@ -183,7 +308,7 @@ export function createDocument(
documentContent: JSON.stringify(documentContent),
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
const endpoint = getFeatureEndpointOrDefault("createDocument");
return window
.fetch(`${endpoint}/createDocument`, {
@@ -203,12 +328,54 @@ export function createDocument(
});
}
export function createDocument_ToBeDeprecated(
databaseId: string,
collection: Collection,
partitionKeyProperty: string,
documentContent: unknown,
): Promise<DataModels.DocumentId> {
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const params = {
db: databaseId,
coll: collection.id(),
resourceUrl: `${resourceEndpoint}dbs/${databaseId}/colls/${collection.id()}/docs/`,
rid: collection.rid,
rtype: "docs",
sid: userContext.subscriptionId,
rg: userContext.resourceGroup,
dba: databaseAccount.name,
pk: collection && collection.partitionKey && !collection.partitionKey.systemKey ? partitionKeyProperty : "",
};
const endpoint = getFeatureEndpointOrDefault("createDocument");
return window
.fetch(`${endpoint}/resourcelist?${queryString.stringify(params)}`, {
method: "POST",
body: JSON.stringify(documentContent),
headers: {
...defaultHeaders,
...authHeaders(),
},
})
.then(async (response) => {
if (response.ok) {
return response.json();
}
return await errorHandling(response, "creating document", params);
});
}
export function updateDocument(
databaseId: string,
collection: Collection,
documentId: DocumentId,
documentContent: string,
): Promise<DataModels.DocumentId> {
if (!useMongoProxyEndpoint("updateDocument")) {
return updateDocument_ToBeDeprecated(databaseId, collection, documentId, documentContent);
}
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const idComponents = documentId.self.split("/");
@@ -229,7 +396,7 @@ export function updateDocument(
: "",
documentContent,
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
const endpoint = getFeatureEndpointOrDefault("updateDocument");
return window
.fetch(endpoint, {
@@ -250,6 +417,139 @@ export function updateDocument(
});
}
export function updateDocument_ToBeDeprecated(
databaseId: string,
collection: Collection,
documentId: DocumentId,
documentContent: string,
): Promise<DataModels.DocumentId> {
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const idComponents = documentId.self.split("/");
const path = idComponents.slice(0, 5).join("/");
const rid = encodeURIComponent(idComponents[5]);
const params = {
db: databaseId,
coll: collection.id(),
resourceUrl: `${resourceEndpoint}${path}/${rid}`,
rid,
rtype: "docs",
sid: userContext.subscriptionId,
rg: userContext.resourceGroup,
dba: databaseAccount.name,
pk:
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey
? documentId.partitionKeyProperties?.[0]
: "",
};
const endpoint = getFeatureEndpointOrDefault("updateDocument");
return window
.fetch(`${endpoint}?${queryString.stringify(params)}`, {
method: "PUT",
body: documentContent,
headers: {
...defaultHeaders,
...authHeaders(),
[HttpHeaders.contentType]: ContentType.applicationJson,
[CosmosSDKConstants.HttpHeaders.PartitionKey]: JSON.stringify(documentId.partitionKeyHeader()),
},
})
.then(async (response) => {
if (response.ok) {
return response.json();
}
return await errorHandling(response, "updating document", params);
});
}
export function deleteDocument(databaseId: string, collection: Collection, documentId: DocumentId): Promise<void> {
if (!useMongoProxyEndpoint("deleteDocument")) {
return deleteDocument_ToBeDeprecated(databaseId, collection, documentId);
}
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const idComponents = documentId.self.split("/");
const path = idComponents.slice(0, 5).join("/");
const rid = encodeURIComponent(idComponents[5]);
const params = {
databaseID: databaseId,
collectionID: collection.id(),
resourceUrl: `${resourceEndpoint}${path}/${rid}`,
resourceID: rid,
resourceType: "docs",
subscriptionID: userContext.subscriptionId,
resourceGroup: userContext.resourceGroup,
databaseAccountName: databaseAccount.name,
partitionKey:
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey
? documentId.partitionKeyProperties?.[0]
: "",
};
const endpoint = getFeatureEndpointOrDefault("deleteDocument");
return window
.fetch(endpoint, {
method: "DELETE",
body: JSON.stringify(params),
headers: {
...defaultHeaders,
...authHeaders(),
[HttpHeaders.contentType]: ContentType.applicationJson,
},
})
.then(async (response) => {
if (response.ok) {
return undefined;
}
return await errorHandling(response, "deleting document", params);
});
}
export function deleteDocument_ToBeDeprecated(
databaseId: string,
collection: Collection,
documentId: DocumentId,
): Promise<void> {
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const idComponents = documentId.self.split("/");
const path = idComponents.slice(0, 5).join("/");
const rid = encodeURIComponent(idComponents[5]);
const params = {
db: databaseId,
coll: collection.id(),
resourceUrl: `${resourceEndpoint}${path}/${rid}`,
rid,
rtype: "docs",
sid: userContext.subscriptionId,
rg: userContext.resourceGroup,
dba: databaseAccount.name,
pk:
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey
? documentId.partitionKeyProperties?.[0]
: "",
};
const endpoint = getFeatureEndpointOrDefault("deleteDocument");
return window
.fetch(`${endpoint}?${queryString.stringify(params)}`, {
method: "DELETE",
headers: {
...defaultHeaders,
...authHeaders(),
[HttpHeaders.contentType]: ContentType.applicationJson,
[CosmosSDKConstants.HttpHeaders.PartitionKey]: JSON.stringify(documentId.partitionKeyHeader()),
},
})
.then(async (response) => {
if (response.ok) {
return undefined;
}
return await errorHandling(response, "deleting document", params);
});
}
export function deleteDocuments(
databaseId: string,
collection: Collection,
@@ -261,10 +561,7 @@ export function deleteDocuments(
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const rids: string[] = documentIds.map((documentId) => {
const idComponents = documentId.self.split("/");
return idComponents[5];
});
const rids = documentIds.map((documentId) => documentId.id());
const params = {
databaseID: databaseId,
@@ -275,7 +572,7 @@ export function deleteDocuments(
resourceGroup: userContext.resourceGroup,
databaseAccountName: databaseAccount.name,
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
const endpoint = getFeatureEndpointOrDefault("bulkdelete");
return window
.fetch(`${endpoint}/bulkdelete`, {
@@ -299,6 +596,9 @@ export function deleteDocuments(
export function createMongoCollectionWithProxy(
params: DataModels.CreateCollectionParams,
): Promise<DataModels.Collection> {
if (!useMongoProxyEndpoint("createCollectionWithProxy")) {
return createMongoCollectionWithProxy_ToBeDeprecated(params);
}
const { databaseAccount } = userContext;
const shardKey: string = params.partitionKey?.paths[0];
@@ -319,7 +619,7 @@ export function createMongoCollectionWithProxy(
isSharded: !!shardKey,
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
const endpoint = getFeatureEndpointOrDefault("createCollectionWithProxy");
return window
.fetch(`${endpoint}/createCollection`, {
@@ -339,6 +639,69 @@ export function createMongoCollectionWithProxy(
});
}
export function createMongoCollectionWithProxy_ToBeDeprecated(
params: DataModels.CreateCollectionParams,
): Promise<DataModels.Collection> {
const { databaseAccount } = userContext;
const shardKey: string = params.partitionKey?.paths[0];
const mongoParams: DataModels.MongoParameters = {
resourceUrl: databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint,
db: params.databaseId,
coll: params.collectionId,
pk: shardKey,
offerThroughput: params.autoPilotMaxThroughput || params.offerThroughput,
cd: params.createNewDatabase,
st: params.databaseLevelThroughput,
is: !!shardKey,
rid: "",
rtype: "colls",
sid: userContext.subscriptionId,
rg: userContext.resourceGroup,
dba: databaseAccount.name,
isAutoPilot: !!params.autoPilotMaxThroughput,
};
const endpoint = getFeatureEndpointOrDefault("createCollectionWithProxy");
return window
.fetch(
`${endpoint}/createCollection?${queryString.stringify(
mongoParams as unknown as queryString.ParsedUrlQueryInput,
)}`,
{
method: "POST",
headers: {
...defaultHeaders,
...authHeaders(),
[HttpHeaders.contentType]: "application/json",
},
},
)
.then(async (response) => {
if (response.ok) {
return response.json();
}
return await errorHandling(response, "creating collection", mongoParams);
});
}
export function getFeatureEndpointOrDefault(feature: string): string {
let endpoint;
if (useMongoProxyEndpoint(feature)) {
endpoint = configContext.MONGO_PROXY_ENDPOINT;
} else {
endpoint =
hasFlag(userContext.features.mongoProxyAPIs, feature) &&
validateEndpoint(userContext.features.mongoProxyEndpoint, [
...allowedMongoProxyEndpoints,
...allowedMongoProxyEndpoints_ToBeDeprecated,
])
? userContext.features.mongoProxyEndpoint
: configContext.MONGO_BACKEND_ENDPOINT || configContext.BACKEND_ENDPOINT;
}
return getEndpoint(endpoint);
}
export function getEndpoint(endpoint: string): string {
let url = endpoint + "/api/mongo/explorer";
@@ -352,10 +715,27 @@ export function getEndpoint(endpoint: string): string {
return url;
}
export class ThrottlingError extends Error {
constructor(message: string) {
super(message);
export function useMongoProxyEndpoint(api: string): boolean {
const activeMongoProxyEndpoints: string[] = [
MongoProxyEndpoints.Local,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
];
let canAccessMongoProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
if (
configContext.MONGO_PROXY_ENDPOINT !== MongoProxyEndpoints.Local &&
userContext.databaseAccount.properties.ipRules?.length > 0
) {
canAccessMongoProxy = canAccessMongoProxy && configContext.MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED;
}
return (
canAccessMongoProxy &&
configContext.NEW_MONGO_APIS?.includes(api) &&
activeMongoProxyEndpoints.includes(configContext.MONGO_PROXY_ENDPOINT)
);
}
// TODO: This function throws most of the time except on Forbidden which is a bit strange
@@ -367,14 +747,6 @@ 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

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

@@ -1,113 +0,0 @@
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,44 +97,13 @@ 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(
message: string,
public 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.
@@ -166,7 +135,7 @@ export default class QueryError {
return errors;
}
const errorMessage = error as string;
const errorMessage = getErrorMessage(error as string | Error);
// Map some well known messages to richer errors
const knownError = knownErrors[errorMessage];
@@ -191,9 +160,7 @@ export default class QueryError {
}
const severity =
"severity" in error && typeof error.severity === "string"
? (error.severity as QueryErrorSeverity)
: QueryErrorSeverity.Error;
"severity" in error && typeof error.severity === "string" ? (error.severity as QueryErrorSeverity) : undefined;
const location =
"location" in error && typeof error.location === "object"
? locationResolver(error.location as { start: number; end: number })
@@ -206,49 +173,35 @@ export default class QueryError {
error: unknown,
locationResolver: (location: { start: number; end: number }) => QueryErrorLocation,
): QueryError[] | null {
let message: string | undefined;
if (typeof error === "object" && "message" in error && typeof error.message === "string") {
message = error.message;
} else {
// Unsupported error format.
if (typeof error === "object" && "message" in error) {
error = error.message;
}
if (typeof error !== "string") {
return null;
}
// 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.
}
// 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);
}
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) {
// The message doesn't contain a nested error.
return [QueryError.read(error, locationResolver)];
// Not a query error.
return 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)];
if (typeof parsed === "object" && "errors" in parsed && Array.isArray(parsed.errors)) {
return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null);
}
return null;
}

View File

@@ -1,10 +1,10 @@
import { isFabric } from "Platform/Fabric/FabricUtil";
import { Platform, configContext } from "../ConfigContext";
// eslint-disable-next-line @typescript-eslint/no-var-requires
export const StyleConstants = require("less-vars-loader!../../less/Common/Constants.less");
export function updateStyles(): void {
if (isFabric()) {
if (configContext.platform === Platform.Fabric) {
StyleConstants.AccentMediumHigh = StyleConstants.FabricAccentMediumHigh;
StyleConstants.AccentMedium = StyleConstants.FabricAccentMedium;
StyleConstants.AccentLight = StyleConstants.FabricAccentLight;

View File

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

View File

@@ -3,12 +3,11 @@ import * as React from "react";
export interface TooltipProps {
children: string;
className?: string;
}
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children, className }: TooltipProps) => {
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children }: TooltipProps) => {
return (
<span className={className}>
<span>
<TooltipHost content={children}>
<Icon iconName="Info" ariaLabel={children} className="panelInfoIcon" tabIndex={0} />
</TooltipHost>

View File

@@ -1,5 +1,4 @@
import { ContainerRequest, ContainerResponse, DatabaseRequest, DatabaseResponse, RequestOptions } from "@azure/cosmos";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import * as DataModels from "../../Contracts/DataModels";
import { useDatabases } from "../../Explorer/useDatabases";
@@ -25,7 +24,7 @@ export const createCollection = async (params: DataModels.CreateCollectionParams
);
try {
let collection: DataModels.Collection;
if (!isFabricNative() && userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations) {
if (userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations) {
if (params.createNewDatabase) {
const createDatabaseParams: DataModels.CreateDatabaseParams = {
autoPilotMaxThroughput: params.autoPilotMaxThroughput,
@@ -100,9 +99,6 @@ 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: {
@@ -274,7 +270,6 @@ 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

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

View File

@@ -1,4 +1,3 @@
import { isFabric } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { userContext } from "../../UserContext";
import { deleteCassandraTable } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
@@ -13,7 +12,7 @@ import { handleError } from "../ErrorHandlingUtils";
export async function deleteCollection(databaseId: string, collectionId: string): Promise<void> {
const clearMessage = logConsoleProgress(`Deleting container ${collectionId}`);
try {
if (userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations && !isFabric()) {
if (userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations) {
await deleteCollectionWithARM(databaseId, collectionId);
} else {
await client().database(databaseId).container(collectionId).delete();

View File

@@ -26,23 +26,14 @@ 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 results and status codes
* @returns array of ids that were successfully deleted
*/
export const deleteDocuments = async (
collection: CollectionBase,
documentIds: DocumentId[],
): Promise<IBulkDeleteResult[]> => {
export const deleteDocuments = async (collection: CollectionBase, documentIds: DocumentId[]): Promise<DocumentId[]> => {
const nbDocuments = documentIds.length;
const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`);
try {
const v2Container = await client().database(collection.databaseId).container(collection.id());
@@ -65,17 +56,18 @@ export const deleteDocuments = async (
operationType: BulkOperationType.Delete,
}));
const promise = v2Container.items.bulk(operations).then((bulkResults) => {
return bulkResults.map((bulkResult, index) => {
const documentId = documentIdsChunk[index];
return { ...bulkResult, documentId };
});
const promise = v2Container.items.bulk(operations).then((bulkResult) => {
return documentIdsChunk.filter((_, index) => bulkResult[index].statusCode === 204);
});
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,3 @@
import { isFabric } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { Offer, ReadCollectionOfferParams } from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
@@ -14,11 +13,6 @@ import { readOfferWithSDK } from "./readOfferWithSDK";
export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise<Offer> => {
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
if (isFabric()) {
// Not exposing offers in Fabric
return undefined;
}
try {
if (
userContext.authType === AuthType.AAD &&
@@ -111,8 +105,6 @@ const readCollectionOfferWithARM = async (databaseId: string, collectionId: stri
? parseInt(resource.softAllowedMaximumThroughput)
: resource.softAllowedMaximumThroughput;
const throughputBuckets = resource?.throughputBuckets;
if (autoscaleSettings) {
return {
id: offerId,
@@ -122,7 +114,6 @@ const readCollectionOfferWithARM = async (databaseId: string, collectionId: stri
offerReplacePending: resource.offerReplacePending === "true",
instantMaximumThroughput,
softAllowedMaximumThroughput,
throughputBuckets,
};
}
@@ -134,7 +125,6 @@ const readCollectionOfferWithARM = async (databaseId: string, collectionId: stri
offerReplacePending: resource.offerReplacePending === "true",
instantMaximumThroughput,
softAllowedMaximumThroughput,
throughputBuckets,
};
}

View File

@@ -1,10 +1,9 @@
import { ContainerResponse } from "@azure/cosmos";
import { Queries } from "Common/Constants";
import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
import { isFabric, isFabricMirroredKey } from "Platform/Fabric/FabricUtil";
import { Platform, configContext } from "ConfigContext";
import { AuthType } from "../../AuthType";
import * as DataModels from "../../Contracts/DataModels";
import { FabricArtifactInfo, userContext } from "../../UserContext";
import { userContext } from "../../UserContext";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { listCassandraTables } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
import { listGremlinGraphs } from "../../Utils/arm/generatedClients/cosmos/gremlinResources";
@@ -17,13 +16,15 @@ import { handleError } from "../ErrorHandlingUtils";
export async function readCollections(databaseId: string): Promise<DataModels.Collection[]> {
const clearMessage = logConsoleProgress(`Querying containers for database ${databaseId}`);
if (isFabricMirroredKey() && userContext.fabricContext?.databaseName === databaseId) {
if (
configContext.platform === Platform.Fabric &&
userContext.fabricContext &&
userContext.fabricContext.databaseConnectionInfo.databaseId === databaseId
) {
const collections: DataModels.Collection[] = [];
const promises: Promise<ContainerResponse>[] = [];
for (const collectionResourceId in (
userContext.fabricContext.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY]
).resourceTokenInfo.resourceTokens) {
for (const collectionResourceId in userContext.fabricContext.databaseConnectionInfo.resourceTokens) {
// Dictionary key looks like this: dbs/SampleDB/colls/Container
const resourceIdObj = collectionResourceId.split("/");
const tokenDatabaseId = resourceIdObj[1];
@@ -55,8 +56,7 @@ export async function readCollections(databaseId: string): Promise<DataModels.Co
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
userContext.apiType !== "Tables" &&
!isFabric()
userContext.apiType !== "Tables"
) {
return await readCollectionsWithARM(databaseId);
}
@@ -126,12 +126,5 @@ async function readCollectionsWithARM(databaseId: string): Promise<DataModels.Co
throw new Error(`Unsupported default experience type: ${apiType}`);
}
// TO DO: Remove when we get RP API Spec with materializedViews
/* eslint-disable @typescript-eslint/no-explicit-any */
return rpResponse?.value?.map((collection: any) => {
const collectionDataModel: DataModels.Collection = collection.properties?.resource as DataModels.Collection;
collectionDataModel.materializedViews = collection.properties?.resource?.materializedViews;
collectionDataModel.materializedViewDefinition = collection.properties?.resource?.materializedViewDefinition;
return collectionDataModel;
});
return rpResponse?.value?.map((collection) => collection.properties?.resource as DataModels.Collection);
}

View File

@@ -1,4 +1,4 @@
import { isFabric, isFabricMirroredKey, isFabricNative } from "Platform/Fabric/FabricUtil";
import { Platform, configContext } from "ConfigContext";
import { AuthType } from "../../AuthType";
import { Offer, ReadDatabaseOfferParams } from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
@@ -11,9 +11,8 @@ import { handleError } from "../ErrorHandlingUtils";
import { readOfferWithSDK } from "./readOfferWithSDK";
export const readDatabaseOffer = async (params: ReadDatabaseOfferParams): Promise<Offer> => {
if (isFabricMirroredKey() || isFabricNative()) {
// For Fabric Mirroring, it is slow, because it requests the token and we don't need it.
// For Fabric Native, it is not supported.
if (configContext.platform === Platform.Fabric) {
// TODO This works, but is very slow, because it requests the token, so we skip for now
console.error("Skiping readDatabaseOffer for Fabric");
return undefined;
}
@@ -24,8 +23,7 @@ export const readDatabaseOffer = async (params: ReadDatabaseOfferParams): Promis
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
userContext.apiType !== "Tables" &&
!isFabric()
userContext.apiType !== "Tables"
) {
return await readDatabaseOfferWithARM(params.databaseId);
}

View File

@@ -1,8 +1,7 @@
import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
import { isFabric, isFabricMirroredKey, isFabricNative } from "Platform/Fabric/FabricUtil";
import { Platform, configContext } from "ConfigContext";
import { AuthType } from "../../AuthType";
import * as DataModels from "../../Contracts/DataModels";
import { FabricArtifactInfo, userContext } from "../../UserContext";
import { userContext } from "../../UserContext";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { listCassandraKeyspaces } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
import { listGremlinDatabases } from "../../Utils/arm/generatedClients/cosmos/gremlinResources";
@@ -15,13 +14,8 @@ export async function readDatabases(): Promise<DataModels.Database[]> {
let databases: DataModels.Database[];
const clearMessage = logConsoleProgress(`Querying databases`);
if (
isFabricMirroredKey() &&
(userContext.fabricContext?.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY]).resourceTokenInfo
.resourceTokens
) {
const tokensData = (userContext.fabricContext.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY])
.resourceTokenInfo;
if (configContext.platform === Platform.Fabric && userContext.fabricContext?.databaseConnectionInfo.resourceTokens) {
const tokensData = userContext.fabricContext.databaseConnectionInfo;
const databaseIdsSet = new Set<string>(); // databaseId
@@ -52,28 +46,13 @@ export async function readDatabases(): Promise<DataModels.Database[]> {
}));
clearMessage();
return databases;
} else if (isFabricNative() && userContext.fabricContext?.databaseName) {
const databaseId = userContext.fabricContext.databaseName;
databases = [
{
_rid: "",
_self: "",
_etag: "",
_ts: 0,
id: databaseId,
collections: [],
},
];
clearMessage();
return databases;
}
try {
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
userContext.apiType !== "Tables" &&
!isFabric()
userContext.apiType !== "Tables"
) {
databases = await readDatabasesWithARM();
} else {

View File

@@ -1,5 +1,4 @@
import { ContainerDefinition, RequestOptions } from "@azure/cosmos";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { Collection } from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
@@ -37,8 +36,7 @@ export async function updateCollection(
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
userContext.apiType !== "Tables" &&
!isFabric()
userContext.apiType !== "Tables"
) {
collection = await updateCollectionWithARM(databaseId, collectionId, newCollection);
} else {

View File

@@ -1,7 +1,6 @@
import { OfferDefinition, RequestOptions } from "@azure/cosmos";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { Offer, SDKOfferDefinition, ThroughputBucket, UpdateOfferParams } from "../../Contracts/DataModels";
import { Offer, SDKOfferDefinition, UpdateOfferParams } from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
import {
migrateCassandraKeyspaceToAutoscale,
@@ -57,7 +56,7 @@ export const updateOffer = async (params: UpdateOfferParams): Promise<Offer> =>
const clearMessage = logConsoleProgress(`Updating offer for ${offerResourceText}`);
try {
if (userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations && !isFabric()) {
if (userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations) {
if (params.collectionId) {
updatedOffer = await updateCollectionOfferWithARM(params);
} else if (userContext.apiType === "Tables") {
@@ -360,13 +359,6 @@ const createUpdateOfferBody = (params: UpdateOfferParams): ThroughputSettingsUpd
body.properties.resource.throughput = params.manualThroughput;
}
if (params.throughputBuckets) {
const throughputBuckets = params.throughputBuckets.filter(
(bucket: ThroughputBucket) => bucket.maxThroughputPercentage !== 100,
);
body.properties.resource.throughputBuckets = throughputBuckets;
}
return body;
};

View File

@@ -8,15 +8,16 @@ import {
import {
allowedAadEndpoints,
allowedArcadiaEndpoints,
allowedCassandraProxyEndpoints,
allowedEmulatorEndpoints,
allowedGraphEndpoints,
allowedHostedExplorerEndpoints,
allowedJunoOrigins,
allowedMongoBackendEndpoints,
allowedMongoProxyEndpoints,
allowedMsalRedirectEndpoints,
defaultAllowedArmEndpoints,
defaultAllowedBackendEndpoints,
defaultAllowedCassandraProxyEndpoints,
defaultAllowedMongoProxyEndpoints,
validateEndpoint,
} from "Utils/EndpointUtils";
@@ -31,8 +32,6 @@ 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,10 +48,15 @@ export interface ConfigContext {
CATALOG_API_KEY: string;
ARCADIA_ENDPOINT: string;
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
PORTAL_BACKEND_ENDPOINT: string;
BACKEND_ENDPOINT?: string;
PORTAL_BACKEND_ENDPOINT?: string;
NEW_BACKEND_APIS?: BackendApi[];
MONGO_PROXY_ENDPOINT: string;
CASSANDRA_PROXY_ENDPOINT: string;
MONGO_BACKEND_ENDPOINT?: string;
MONGO_PROXY_ENDPOINT?: string;
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED?: boolean;
NEW_MONGO_APIS?: string[];
CASSANDRA_PROXY_ENDPOINT?: string;
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: boolean;
NEW_CASSANDRA_APIS?: string[];
PROXY_PATH?: string;
JUNO_ENDPOINT: string;
@@ -64,8 +68,6 @@ export interface ConfigContext {
hostedExplorerURL: string;
armAPIVersion?: string;
msalRedirectURI?: string;
globallyEnabledCassandraAPIs?: string[];
globallyEnabledMongoAPIs?: string[];
}
// Default configuration
@@ -73,12 +75,9 @@ 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$`,
@@ -88,7 +87,7 @@ let configContext: Readonly<ConfigContext> = {
`^https:\\/\\/.*\\.analysis-df\\.net$`,
`^https:\\/\\/.*\\.analysis-df\\.windows\\.net$`,
`^https:\\/\\/.*\\.azure-test\\.net$`,
`^https:\\/\\/dataexplorer-preview\\.azurewebsites\\.net$`,
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net$`,
], // Webpack injects this at build time
gitSha: process.env.GIT_SHA,
hostedExplorerURL: "https://cosmos.azure.com/",
@@ -106,14 +105,26 @@ let configContext: Readonly<ConfigContext> = {
GITHUB_CLIENT_ID: "6cb2f63cf6f7b5cbdeca", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1189306
GITHUB_TEST_ENV_CLIENT_ID: "b63fc8cbf87fd3c6e2eb", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1777772
JUNO_ENDPOINT: JunoEndpoints.Prod,
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",
],
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
NEW_CASSANDRA_APIS: ["postQuery", "createOrDelete", "getKeys", "getSchema"],
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
isTerminalEnabled: false,
isPhoenixEnabled: false,
globallyEnabledCassandraAPIs: [],
globallyEnabledMongoAPIs: [],
};
export function resetConfigContext(): void {
@@ -150,19 +161,22 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
if (
!validateEndpoint(
newContext.MONGO_PROXY_ENDPOINT,
configContext.allowedMongoProxyEndpoints || defaultAllowedMongoProxyEndpoints,
newContext.BACKEND_ENDPOINT,
configContext.allowedBackendEndpoints || defaultAllowedBackendEndpoints,
)
) {
delete newContext.BACKEND_ENDPOINT;
}
if (!validateEndpoint(newContext.MONGO_PROXY_ENDPOINT, allowedMongoProxyEndpoints)) {
delete newContext.MONGO_PROXY_ENDPOINT;
}
if (
!validateEndpoint(
newContext.CASSANDRA_PROXY_ENDPOINT,
configContext.allowedCassandraProxyEndpoints || defaultAllowedCassandraProxyEndpoints,
)
) {
if (!validateEndpoint(newContext.MONGO_BACKEND_ENDPOINT, allowedMongoBackendEndpoints)) {
delete newContext.MONGO_BACKEND_ENDPOINT;
}
if (!validateEndpoint(newContext.CASSANDRA_PROXY_ENDPOINT, allowedCassandraProxyEndpoints)) {
delete newContext.CASSANDRA_PROXY_ENDPOINT;
}

View File

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

View File

@@ -6,7 +6,6 @@ export interface ArmEntity {
location: string;
type: string;
kind: string;
tags?: Tags;
}
export interface DatabaseAccount extends ArmEntity {
@@ -32,7 +31,6 @@ export interface DatabaseAccountExtendedProperties {
writeLocations?: DatabaseAccountResponseLocation[];
enableFreeTier?: boolean;
enableAnalyticalStorage?: boolean;
enableMaterializedViews?: boolean;
isVirtualNetworkFilterEnabled?: boolean;
ipRules?: IpRule[];
privateEndpointConnections?: unknown[];
@@ -161,12 +159,9 @@ export interface Collection extends Resource {
analyticalStorageTtl?: number;
geospatialConfig?: GeospatialConfig;
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
fullTextPolicy?: FullTextPolicy;
schema?: ISchema;
requestSchema?: () => void;
computedProperties?: ComputedProperties;
materializedViews?: MaterializedView[];
materializedViewDefinition?: MaterializedViewDefinition;
}
export interface CollectionsWithPagination {
@@ -204,19 +199,11 @@ 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 {
@@ -226,17 +213,6 @@ export interface ComputedProperty {
export type ComputedProperties = ComputedProperty[];
export interface MaterializedView {
id: string;
_rid: string;
}
export interface MaterializedViewDefinition {
definition: string;
sourceCollectionId: string;
sourceCollectionRid?: string;
}
export interface PartitionKey {
paths: string[];
kind: "Hash" | "Range" | "MultiHash";
@@ -289,12 +265,6 @@ export interface Offer {
offerReplacePending: boolean;
instantMaximumThroughput?: number;
softAllowedMaximumThroughput?: number;
throughputBuckets?: ThroughputBucket[];
}
export interface ThroughputBucket {
id: number;
maxThroughputPercentage: number;
}
export interface SDKOfferDefinition extends Resource {
@@ -359,7 +329,9 @@ export interface CreateDatabaseParams {
offerThroughput?: number;
}
export interface CreateCollectionParamsBase {
export interface CreateCollectionParams {
createNewDatabase: boolean;
collectionId: string;
databaseId: string;
databaseLevelThroughput: boolean;
offerThroughput?: number;
@@ -370,17 +342,6 @@ export interface CreateCollectionParamsBase {
uniqueKeyPolicy?: UniqueKeyPolicy;
createMongoWildcardIndex?: boolean;
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
fullTextPolicy?: FullTextPolicy;
}
export interface CreateCollectionParams extends CreateCollectionParamsBase {
createNewDatabase: boolean;
collectionId: string;
}
export interface CreateMaterializedViewsParams extends CreateCollectionParamsBase {
materializedViewId: string;
materializedViewDefinition: MaterializedViewDefinition;
}
export interface VectorEmbeddingPolicy {
@@ -394,16 +355,6 @@ 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;
@@ -425,7 +376,6 @@ export interface UpdateOfferParams {
collectionId?: string;
migrateToAutoPilot?: boolean;
migrateToManual?: boolean;
throughputBuckets?: ThroughputBucket[];
}
export interface Notification {
@@ -693,5 +643,3 @@ export interface FeatureRegistration {
state: string;
};
}
export type Tags = { [key: string]: string };

View File

@@ -4,7 +4,6 @@
export enum FabricMessageTypes {
GetAuthorizationToken = "GetAuthorizationToken",
GetAllResourceTokens = "GetAllResourceTokens",
GetAccessToken = "GetAccessToken",
Ready = "Ready",
}

View File

@@ -1,9 +1,47 @@
import { AuthorizationToken } from "./FabricMessageTypes";
import { AuthorizationToken } from "Contracts/FabricMessageTypes";
// This is the version of these messages
export const FABRIC_RPC_VERSION = "FabricMessageV3";
export const FABRIC_RPC_VERSION = "2";
// Fabric to Data Explorer
// TODO Deprecated. Remove this section once DE is updated
export type FabricMessageV1 =
| {
type: "newContainer";
databaseName: string;
}
| {
type: "initialize";
message: {
endpoint: string | undefined;
databaseId: string | undefined;
resourceTokens: unknown | undefined;
resourceTokensTimestamp: number | undefined;
error: string | undefined;
};
}
| {
type: "authorizationToken";
message: {
id: string;
error: string | undefined;
data: AuthorizationToken | undefined;
};
}
| {
type: "allResourceTokens";
message: {
id: string;
error: string | undefined;
endpoint: string | undefined;
databaseId: string | undefined;
resourceTokens: unknown | undefined;
resourceTokensTimestamp: number | undefined;
};
};
// -----------------------------
export type FabricMessageV2 =
| {
type: "newContainer";
@@ -31,7 +69,7 @@ export type FabricMessageV2 =
message: {
id: string;
error: string | undefined;
data: ResourceTokenInfo | undefined;
data: FabricDatabaseConnectionInfo | undefined;
};
}
| {
@@ -41,88 +79,17 @@ export type FabricMessageV2 =
};
};
export type FabricMessageV3 =
| {
type: "newContainer";
databaseName: string;
}
| {
type: "initialize";
version: string;
id: string;
message: InitializeMessageV3<CosmosDbArtifactType>;
}
| {
type: "authorizationToken";
message: {
id: string;
error: string | undefined;
data: AuthorizationToken | undefined;
};
}
| {
type: "allResourceTokens_v2";
message: {
id: string;
error: string | undefined;
data: ResourceTokenInfo | undefined;
};
}
| {
type: "explorerVisible";
message: {
visible: boolean;
};
}
| {
type: "accessToken";
message: {
id: string;
error: string | undefined;
data: { accessToken: string };
};
}
| {
type: "refreshResourceTree";
message: {
id: string;
error: string | undefined;
};
};
export type CosmosDBTokenResponse = {
token: string;
date: string;
};
export enum CosmosDbArtifactType {
MIRRORED_KEY = "MIRRORED_KEY",
MIRRORED_AAD = "MIRRORED_AAD",
NATIVE = "NATIVE",
}
export interface ArtifactConnectionInfo {
[CosmosDbArtifactType.MIRRORED_KEY]: { connectionId: string };
[CosmosDbArtifactType.MIRRORED_AAD]: AccessTokenConnectionInfo;
[CosmosDbArtifactType.NATIVE]: AccessTokenConnectionInfo;
}
export interface AccessTokenConnectionInfo {
accessToken: string;
databaseName: string;
accountEndpoint: string;
}
export interface InitializeMessageV3<T extends CosmosDbArtifactType> {
connectionId: string;
isVisible: boolean;
isReadOnly: boolean;
artifactType: T;
artifactConnectionInfo: ArtifactConnectionInfo[T];
}
export interface CosmosDBConnectionInfoResponse {
export type CosmosDBConnectionInfoResponse = {
endpoint: string;
databaseId: string;
resourceTokens: Record<string, string> | undefined;
accessToken: string | undefined;
isReadOnly: boolean;
credentialType: "Key" | "OAuth2" | undefined;
}
resourceTokens: { [resourceId: string]: string };
};
export interface ResourceTokenInfo extends CosmosDBConnectionInfoResponse {
export interface FabricDatabaseConnectionInfo extends CosmosDBConnectionInfoResponse {
resourceTokensTimestamp: number;
}

View File

@@ -41,7 +41,7 @@ export enum MessageTypes {
OpenPostgreSQLPasswordReset,
OpenPostgresNetworkingBlade,
OpenCosmosDBNetworkingBlade,
DisplayNPSSurvey, // unused
DisplayNPSSurvey,
OpenVCoreMongoNetworkingBlade,
OpenVCoreMongoConnectionStringsBlade,
GetAuthorizationToken, // unused. Can be removed if the portal uses the same list of enums.

View File

@@ -1,5 +1,4 @@
import {
JSONObject,
QueryMetrics,
Resource,
StoredProcedureDefinition,
@@ -99,6 +98,7 @@ 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,13 +116,7 @@ export interface CollectionBase extends TreeNode {
isSampleCollection?: boolean;
onDocumentDBDocumentsClick(): void;
onNewQueryClick(
source: any,
event?: MouseEvent,
queryText?: string,
splitterDirection?: "horizontal" | "vertical",
queryViewSizePercent?: number,
): void;
onNewQueryClick(source: any, event?: MouseEvent, queryText?: string): void;
expandCollection(): void;
collapseCollection(): void;
getDatabase(): Database;
@@ -133,8 +127,6 @@ 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>;
@@ -144,8 +136,6 @@ export interface Collection extends CollectionBase {
geospatialConfig: ko.Observable<DataModels.GeospatialConfig>;
documentIds: ko.ObservableArray<DocumentId>;
computedProperties: ko.Observable<DataModels.ComputedProperties>;
materializedViews: ko.Observable<DataModels.MaterializedView[]>;
materializedViewDefinition: ko.Observable<DataModels.MaterializedViewDefinition>;
cassandraKeys: CassandraTableKeys;
cassandraSchema: CassandraTableKey[];
@@ -160,13 +150,7 @@ export interface Collection extends CollectionBase {
onSettingsClick: () => Promise<void>;
onNewGraphClick(): void;
onNewMongoQueryClick(
source: any,
event?: MouseEvent,
queryText?: string,
splitterDirection?: "horizontal" | "vertical",
queryViewSizePercent?: number,
): void;
onNewMongoQueryClick(source: any, event?: MouseEvent, queryText?: string): void;
onNewMongoShellClick(): void;
onNewStoredProcedureClick(source: Collection, event?: MouseEvent): void;
onNewUserDefinedFunctionClick(source: Collection, event?: MouseEvent): void;
@@ -207,12 +191,8 @@ 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[] }>;
bulkInsertDocuments(documents: JSONObject[]): Promise<{
numSucceeded: number;
numFailed: number;
numThrottled: number;
errors: string[];
}>;
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
}
/**
@@ -332,8 +312,6 @@ export interface QueryTabOptions extends TabOptions {
partitionKey?: DataModels.PartitionKey;
queryText?: string;
resourceTokenPartitionKey?: string;
splitterDirection?: "horizontal" | "vertical";
queryViewSizePercent?: number;
}
export interface ScriptTabOption extends TabOptions {
@@ -407,14 +385,13 @@ export interface DataExplorerInputsFrame {
databaseAccount: any;
subscriptionId?: string;
resourceGroup?: string;
tenantId?: string;
userName?: string;
masterKey?: string;
hasWriteAccess?: boolean;
authorizationToken?: string;
csmEndpoint?: string;
dnsSuffix?: string;
serverId?: string;
extensionEndpoint?: string;
portalBackendEndpoint?: string;
mongoProxyEndpoint?: string;
cassandraProxyEndpoint?: string;

View File

@@ -1,13 +1,5 @@
import { GlobalSecondaryIndexLabels } from "Common/Constants";
import { isGlobalSecondaryIndexEnabled } from "Common/DatabaseAccountUtility";
import { configContext, Platform } from "ConfigContext";
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import {
AddGlobalSecondaryIndexPanel,
AddGlobalSecondaryIndexPanelProps,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { useDatabases } from "Explorer/useDatabases";
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
import { ReactTabKind, useTabs } from "hooks/useTabs";
@@ -27,6 +19,7 @@ import * as ViewModels from "../Contracts/ViewModels";
import { userContext } from "../UserContext";
import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils";
import { useSidePanel } from "../hooks/useSidePanel";
import { Platform, configContext } from "./../ConfigContext";
import Explorer from "./Explorer";
import { useNotebook } from "./Notebook/useNotebook";
import { DeleteCollectionConfirmationPane } from "./Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane";
@@ -48,7 +41,7 @@ export interface DatabaseContextMenuButtonParams {
* New resource tree (in ReactJS)
*/
export const createDatabaseContextMenu = (container: Explorer, databaseId: string): TreeNodeMenuItem[] => {
if (isFabric() && userContext.fabricContext?.isReadOnly) {
if (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) {
return undefined;
}
@@ -60,18 +53,16 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin
},
];
if (!isFabricNative() && (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations)) {
if (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations) {
items.push({
iconSrc: DeleteDatabaseIcon,
onClick: (lastFocusedElement?: React.RefObject<HTMLElement>) => {
(useSidePanel.getState().getRef = lastFocusedElement),
useSidePanel
.getState()
.openSidePanel(
"Delete " + getDatabaseName(),
<DeleteDatabaseConfirmationPanel refreshDatabases={() => container.refreshAllDatabases()} />,
);
},
onClick: () =>
useSidePanel
.getState()
.openSidePanel(
"Delete " + getDatabaseName(),
<DeleteDatabaseConfirmationPanel refreshDatabases={() => container.refreshAllDatabases()} />,
),
label: `Delete ${getDatabaseName()}`,
styleClass: "deleteDatabaseMenuItem",
});
@@ -152,39 +143,20 @@ export const createCollectionContextMenuButton = (
});
}
if (!isFabric() || (isFabric() && !userContext.fabricContext?.isReadOnly)) {
if (configContext.platform !== Platform.Fabric) {
items.push({
iconSrc: DeleteCollectionIcon,
onClick: (lastFocusedElement?: React.RefObject<HTMLElement>) => {
useSelectedNode.getState().setSelectedNode(selectedCollection);
(useSidePanel.getState().getRef = lastFocusedElement),
useSidePanel
.getState()
.openSidePanel(
"Delete " + getCollectionName(),
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
);
},
label: `Delete ${getCollectionName()}`,
styleClass: "deleteCollectionMenuItem",
});
}
if (isGlobalSecondaryIndexEnabled() && !selectedCollection.materializedViewDefinition()) {
items.push({
label: GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
onClick: () => {
const addMaterializedViewPanelProps: AddGlobalSecondaryIndexPanelProps = {
explorer: container,
sourceContainer: selectedCollection,
};
useSelectedNode.getState().setSelectedNode(selectedCollection);
useSidePanel
.getState()
.openSidePanel(
GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex,
<AddGlobalSecondaryIndexPanel {...addMaterializedViewPanelProps} />,
"Delete " + getCollectionName(),
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
);
},
label: `Delete ${getCollectionName()}`,
styleClass: "deleteCollectionMenuItem",
});
}

View File

@@ -1,4 +1,4 @@
import { DirectionalHint, Icon, IconButton, Label, Stack, TooltipHost } from "@fluentui/react";
import { DirectionalHint, Icon, Label, Stack, TooltipHost } from "@fluentui/react";
import * as React from "react";
import { NormalizedEventKey } from "../../../Common/Constants";
import { accordionStackTokens } from "../Settings/SettingsRenderUtils";
@@ -9,9 +9,6 @@ export interface CollapsibleSectionProps {
onExpand?: () => void;
children: JSX.Element;
tooltipContent?: string | JSX.Element | JSX.Element[];
showDelete?: boolean;
onDelete?: () => void;
disabled?: boolean;
}
export interface CollapsibleSectionState {
@@ -72,20 +69,6 @@ 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, linkProps?: LinkProps) => void;
showOkModalDialog: (title: string, subText: string) => 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, linkProps?: LinkProps): void =>
showOkModalDialog: (title: string, subText: string): void =>
get().openDialog({
isModal: true,
title,
@@ -94,7 +94,6 @@ export const useDialog: UseStore<DialogState> = create((set, get) => ({
get().closeDialog();
},
onSecondaryButtonClick: undefined,
linkProps,
}),
}));
@@ -214,10 +213,8 @@ export const Dialog: FC = () => {
{contentHtml}
{progressIndicatorProps && <ProgressIndicator {...progressIndicatorProps} />}
<DialogFooter>
<PrimaryButton {...primaryButtonProps} data-test={`DialogButton:${primaryButtonText}`} />
{secondaryButtonProps && (
<DefaultButton {...secondaryButtonProps} data-test={`DialogButton:${secondaryButtonText}`} />
)}
<PrimaryButton {...primaryButtonProps} />
{secondaryButtonProps && <DefaultButton {...secondaryButtonProps} />}
</DialogFooter>
</FluentDialog>
) : (

View File

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

View File

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

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

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

@@ -1,7 +1,5 @@
import { AuthType } from "AuthType";
import { shallow } from "enzyme";
import ko from "knockout";
import { Features } from "Platform/Hosted/extractFeatures";
import React from "react";
import { updateCollection } from "../../../Common/dataAccess/updateCollection";
import { updateOffer } from "../../../Common/dataAccess/updateOffer";
@@ -136,6 +134,7 @@ describe("SettingsComponent", () => {
readSettings: undefined,
onSettingsClick: undefined,
loadOffer: undefined,
getPendingThroughputSplitNotification: undefined,
} as ViewModels.Database;
newCollection.getDatabase = () => newDatabase;
newCollection.offer = ko.observable(undefined);
@@ -249,42 +248,4 @@ describe("SettingsComponent", () => {
expect(conflictResolutionPolicy.mode).toEqual(DataModels.ConflictResolutionMode.Custom);
expect(conflictResolutionPolicy.conflictResolutionProcedure).toEqual(expectSprocPath);
});
it("should save throughput bucket changes when Save button is clicked", async () => {
updateUserContext({
apiType: "SQL",
features: { enableThroughputBuckets: true } as Features,
authType: AuthType.AAD,
});
const wrapper = shallow(<SettingsComponent {...baseProps} />);
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
const isEnabled = settingsComponentInstance["throughputBucketsEnabled"];
expect(isEnabled).toBe(true);
wrapper.setState({
isThroughputBucketsSaveable: true,
throughputBuckets: [
{ id: 1, maxThroughputPercentage: 70 },
{ id: 2, maxThroughputPercentage: 60 },
],
});
await settingsComponentInstance.onSaveClick();
expect(updateOffer).toHaveBeenCalledWith({
databaseId: collection.databaseId,
collectionId: collection.id(),
currentOffer: expect.any(Object),
autopilotThroughput: collection.offer().autoscaleMaxThroughput,
manualThroughput: collection.offer().manualThroughput,
throughputBuckets: [
{ id: 1, maxThroughputPercentage: 70 },
{ id: 2, maxThroughputPercentage: 60 },
],
});
expect(wrapper.state("isThroughputBucketsSaveable")).toBe(false);
});
});

View File

@@ -4,16 +4,11 @@ import {
ComputedPropertiesComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent";
import {
ContainerPolicyComponent,
ContainerPolicyComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent";
import {
ThroughputBucketsComponent,
ThroughputBucketsComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
ContainerVectorPolicyComponent,
ContainerVectorPolicyComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ContainerVectorPolicyComponent";
import { useDatabases } from "Explorer/useDatabases";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { isFullTextSearchEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
import * as React from "react";
import DiscardIcon from "../../../../images/discard.svg";
@@ -45,10 +40,6 @@ import {
ConflictResolutionComponent,
ConflictResolutionComponentProps,
} from "./SettingsSubComponents/ConflictResolutionComponent";
import {
GlobalSecondaryIndexComponent,
GlobalSecondaryIndexComponentProps,
} from "./SettingsSubComponents/GlobalSecondaryIndexComponent";
import { IndexingPolicyComponent, IndexingPolicyComponentProps } from "./SettingsSubComponents/IndexingPolicyComponent";
import {
MongoIndexingPolicyComponent,
@@ -95,8 +86,6 @@ export interface SettingsComponentState {
wasAutopilotOriginallySet: boolean;
isScaleSaveable: boolean;
isScaleDiscardable: boolean;
throughputBuckets: DataModels.ThroughputBucket[];
throughputBucketsBaseline: DataModels.ThroughputBucket[];
throughputError: string;
timeToLive: TtlType;
@@ -115,14 +104,6 @@ export interface SettingsComponentState {
changeFeedPolicyBaseline: ChangeFeedPolicyState;
isSubSettingsSaveable: boolean;
isSubSettingsDiscardable: boolean;
isThroughputBucketsSaveable: boolean;
vectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy;
vectorEmbeddingPolicyBaseline: DataModels.VectorEmbeddingPolicy;
fullTextPolicy: DataModels.FullTextPolicy;
fullTextPolicyBaseline: DataModels.FullTextPolicy;
shouldDiscardContainerPolicies: boolean;
isContainerPolicyDirty: boolean;
indexingPolicyContent: DataModels.IndexingPolicy;
indexingPolicyContentBaseline: DataModels.IndexingPolicy;
@@ -149,6 +130,7 @@ export interface SettingsComponentState {
conflictResolutionPolicyProcedureBaseline: string;
isConflictResolutionDirty: boolean;
initialNotification: DataModels.Notification;
selectedTab: SettingsV2TabTypes;
}
@@ -167,11 +149,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private shouldShowComputedPropertiesEditor: boolean;
private shouldShowIndexingPolicyEditor: boolean;
private shouldShowPartitionKeyEditor: boolean;
private isGlobalSecondaryIndex: boolean;
private isVectorSearchEnabled: boolean;
private isFullTextSearchEnabled: boolean;
private totalThroughputUsed: number;
private throughputBucketsEnabled: boolean;
public mongoDBCollectionResource: MongoDBCollectionResource;
constructor(props: SettingsComponentProps) {
@@ -185,16 +164,9 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.shouldShowComputedPropertiesEditor = userContext.apiType === "SQL";
this.shouldShowIndexingPolicyEditor = userContext.apiType !== "Cassandra" && userContext.apiType !== "Mongo";
this.shouldShowPartitionKeyEditor = userContext.apiType === "SQL" && isRunningOnPublicCloud();
this.isGlobalSecondaryIndex =
!!this.collection?.materializedViewDefinition() || !!this.collection?.materializedViews();
this.isVectorSearchEnabled = isVectorSearchEnabled() && !hasDatabaseSharedThroughput(this.collection);
this.isFullTextSearchEnabled = isFullTextSearchEnabled() && !hasDatabaseSharedThroughput(this.collection);
this.changeFeedPolicyVisible = userContext.features.enableChangeFeedPolicy;
this.throughputBucketsEnabled =
userContext.apiType === "SQL" &&
userContext.features.enableThroughputBuckets &&
userContext.authType === AuthType.AAD;
// Mongo container with system partition key still treat as "Fixed"
this.isFixedContainer =
@@ -213,8 +185,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
wasAutopilotOriginallySet: false,
isScaleSaveable: false,
isScaleDiscardable: false,
throughputBuckets: undefined,
throughputBucketsBaseline: undefined,
throughputError: undefined,
timeToLive: undefined,
@@ -233,14 +203,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
changeFeedPolicyBaseline: undefined,
isSubSettingsSaveable: false,
isSubSettingsDiscardable: false,
isThroughputBucketsSaveable: false,
vectorEmbeddingPolicy: undefined,
vectorEmbeddingPolicyBaseline: undefined,
fullTextPolicy: undefined,
fullTextPolicyBaseline: undefined,
shouldDiscardContainerPolicies: false,
isContainerPolicyDirty: false,
indexingPolicyContent: undefined,
indexingPolicyContentBaseline: undefined,
@@ -267,6 +229,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
conflictResolutionPolicyProcedureBaseline: undefined,
isConflictResolutionDirty: false,
initialNotification: undefined,
selectedTab: SettingsV2TabTypes.ScaleTab,
};
@@ -346,12 +309,10 @@ 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 ||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicySaveable) ||
this.state.isThroughputBucketsSaveable
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicySaveable)
);
};
@@ -359,12 +320,10 @@ 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 ||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicyDiscardable) ||
this.state.isThroughputBucketsSaveable
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicyDiscardable)
);
};
@@ -444,14 +403,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.setState({
throughput: this.state.throughputBaseline,
throughputBuckets: this.state.throughputBucketsBaseline,
throughputBucketsBaseline: this.state.throughputBucketsBaseline,
timeToLive: this.state.timeToLiveBaseline,
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: [],
@@ -463,14 +418,11 @@ 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,
isThroughputBucketsSaveable: false,
isSubSettingsDiscardable: false,
isContainerPolicyDirty: false,
isIndexingPolicyDirty: false,
isMongoIndexingPolicySaveable: false,
isMongoIndexingPolicyDiscardable: false,
@@ -498,21 +450,9 @@ 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 onThroughputBucketsSaveableChange = (isSaveable: boolean): void => {
this.setState({ isThroughputBucketsSaveable: isSaveable });
};
private resetShouldDiscardContainerPolicies = (): void => this.setState({ shouldDiscardContainerPolicies: false });
private resetShouldDiscardIndexingPolicy = (): void => this.setState({ shouldDiscardIndexingPolicy: false });
private logIndexingPolicySuccessMessage = (): void => {
@@ -600,12 +540,6 @@ 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 });
@@ -759,10 +693,6 @@ 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();
@@ -781,13 +711,9 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
] as DataModels.ComputedProperties;
}
const throughputBuckets = this.offer?.throughputBuckets;
return {
throughput: offerThroughput,
throughputBaseline: offerThroughput,
throughputBuckets,
throughputBucketsBaseline: throughputBuckets,
changeFeedPolicy: changeFeedPolicy,
changeFeedPolicyBaseline: changeFeedPolicy,
timeToLive: timeToLive,
@@ -800,10 +726,6 @@ 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,
@@ -875,10 +797,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.setState({ throughput: newThroughput, throughputError });
};
private onThroughputBucketChange = (throughputBuckets: DataModels.ThroughputBucket[]): void => {
this.setState({ throughputBuckets });
};
private onAutoPilotSelected = (isAutoPilotSelected: boolean): void =>
this.setState({ isAutoPilotSelected: isAutoPilotSelected });
@@ -938,7 +856,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
if (
this.state.isSubSettingsSaveable ||
this.state.isContainerPolicyDirty ||
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty
@@ -960,10 +877,6 @@ 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 =
@@ -1002,8 +915,6 @@ 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();
@@ -1012,7 +923,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.setState({
isSubSettingsSaveable: false,
isSubSettingsDiscardable: false,
isContainerPolicyDirty: false,
isIndexingPolicyDirty: false,
isConflictResolutionDirty: false,
isComputedPropertiesDirty: false,
@@ -1069,24 +979,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
}
}
if (this.throughputBucketsEnabled && this.state.isThroughputBucketsSaveable) {
const updatedOffer: DataModels.Offer = await updateOffer({
databaseId: this.collection.databaseId,
collectionId: this.collection.id(),
currentOffer: this.collection.offer(),
autopilotThroughput: this.collection.offer().autoscaleMaxThroughput
? this.collection.offer().autoscaleMaxThroughput
: undefined,
manualThroughput: this.collection.offer().manualThroughput
? this.collection.offer().manualThroughput
: undefined,
throughputBuckets: this.state.throughputBuckets,
});
this.collection.offer(updatedOffer);
this.offer = updatedOffer;
this.setState({ isThroughputBucketsSaveable: false });
}
if (this.state.isScaleSaveable) {
const updateOfferParams: DataModels.UpdateOfferParams = {
databaseId: this.collection.databaseId,
@@ -1160,6 +1052,7 @@ 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,
};
@@ -1201,21 +1094,6 @@ 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,
@@ -1267,23 +1145,14 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onConflictResolutionDirtyChange: this.onConflictResolutionDirtyChange,
};
const throughputBucketsComponentProps: ThroughputBucketsComponentProps = {
currentBuckets: this.state.throughputBuckets,
throughputBucketsBaseline: this.state.throughputBucketsBaseline,
onBucketsChange: this.onThroughputBucketChange,
onSaveableChange: this.onThroughputBucketsSaveableChange,
};
const partitionKeyComponentProps: PartitionKeyComponentProps = {
database: useDatabases.getState().findDatabaseWithId(this.collection.databaseId),
collection: this.collection,
explorer: this.props.settingsTab.getContainer(),
isReadOnly: isFabricNative(),
};
const globalSecondaryIndexComponentProps: GlobalSecondaryIndexComponentProps = {
collection: this.collection,
explorer: this.props.settingsTab.getContainer(),
const containerVectorPolicyProps: ContainerVectorPolicyComponentProps = {
vectorEmbeddingPolicy: this.collection.rawDataModel?.vectorEmbeddingPolicy,
};
const tabs: SettingsV2TabInfo[] = [];
@@ -1299,10 +1168,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
content: <SubSettingsComponent {...subSettingsComponentProps} />,
});
if (this.isVectorSearchEnabled || this.isFullTextSearchEnabled) {
if (this.isVectorSearchEnabled) {
tabs.push({
tab: SettingsV2TabTypes.ContainerVectorPolicyTab,
content: <ContainerPolicyComponent {...containerPolicyComponentProps} />,
content: <ContainerVectorPolicyComponent {...containerVectorPolicyProps} />,
});
}
@@ -1342,20 +1211,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
});
}
if (this.throughputBucketsEnabled) {
tabs.push({
tab: SettingsV2TabTypes.ThroughputBucketsTab,
content: <ThroughputBucketsComponent {...throughputBucketsComponentProps} />,
});
}
if (this.isGlobalSecondaryIndex) {
tabs.push({
tab: SettingsV2TabTypes.GlobalSecondaryIndexTab,
content: <GlobalSecondaryIndexComponent {...globalSecondaryIndexComponentProps} />,
});
}
const pivotProps: IPivotProps = {
onLinkClick: this.onPivotChange,
selectedKey: SettingsV2TabTypes[this.state.selectedTab],

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -120,6 +120,11 @@ 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

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

View File

@@ -1,10 +1,18 @@
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,
@@ -28,8 +36,39 @@ 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,6 +10,7 @@ import * as AutoPilotUtils from "../../../../Utils/AutoPilotUtils";
import { isRunningOnNationalCloud } from "../../../../Utils/CloudUtils";
import {
getTextFieldStyles,
getThroughputApplyLongDelayMessage,
getThroughputApplyShortDelayMessage,
subComponentStackProps,
throughputUnit,
@@ -33,6 +34,7 @@ export interface ScaleComponentProps {
onMaxAutoPilotThroughputChange: (newThroughput: number) => void;
onScaleSaveableChange: (isScaleSaveable: boolean) => void;
onScaleDiscardableChange: (isScaleDiscardable: boolean) => void;
initialNotification: DataModels.Notification;
throughputError?: string;
}
@@ -100,6 +102,10 @@ 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(
@@ -114,6 +120,26 @@ 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,177 +0,0 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { ThroughputBucketsComponent } from "./ThroughputBucketsComponent";
describe("ThroughputBucketsComponent", () => {
const mockOnBucketsChange = jest.fn();
const mockOnSaveableChange = jest.fn();
const defaultProps = {
currentBuckets: [
{ id: 1, maxThroughputPercentage: 50 },
{ id: 2, maxThroughputPercentage: 60 },
],
throughputBucketsBaseline: [
{ id: 1, maxThroughputPercentage: 40 },
{ id: 2, maxThroughputPercentage: 50 },
],
onBucketsChange: mockOnBucketsChange,
onSaveableChange: mockOnSaveableChange,
};
beforeEach(() => {
jest.clearAllMocks();
});
it("renders the correct number of buckets", () => {
render(<ThroughputBucketsComponent {...defaultProps} />);
expect(screen.getAllByText(/Group \d+/)).toHaveLength(5);
});
it("renders buckets in the correct order even if input is unordered", () => {
const unorderedBuckets = [
{ id: 2, maxThroughputPercentage: 60 },
{ id: 1, maxThroughputPercentage: 50 },
];
render(<ThroughputBucketsComponent {...defaultProps} currentBuckets={unorderedBuckets} />);
const bucketLabels = screen.getAllByText(/Group \d+/).map((el) => el.textContent);
expect(bucketLabels).toEqual(["Group 1 (Data Explorer Query Bucket)", "Group 2", "Group 3", "Group 4", "Group 5"]);
});
it("renders all provided buckets even if they exceed the max default bucket count", () => {
const oversizedBuckets = [
{ id: 1, maxThroughputPercentage: 50 },
{ id: 2, maxThroughputPercentage: 60 },
{ id: 3, maxThroughputPercentage: 70 },
{ id: 4, maxThroughputPercentage: 80 },
{ id: 5, maxThroughputPercentage: 90 },
{ id: 6, maxThroughputPercentage: 100 },
{ id: 7, maxThroughputPercentage: 40 },
];
render(<ThroughputBucketsComponent {...defaultProps} currentBuckets={oversizedBuckets} />);
expect(screen.getAllByText(/Group \d+/)).toHaveLength(7);
expect(screen.getByDisplayValue("50")).toBeInTheDocument();
expect(screen.getByDisplayValue("60")).toBeInTheDocument();
expect(screen.getByDisplayValue("70")).toBeInTheDocument();
expect(screen.getByDisplayValue("80")).toBeInTheDocument();
expect(screen.getByDisplayValue("90")).toBeInTheDocument();
expect(screen.getByDisplayValue("100")).toBeInTheDocument();
expect(screen.getByDisplayValue("40")).toBeInTheDocument();
});
it("calls onBucketsChange when a bucket value changes", () => {
render(<ThroughputBucketsComponent {...defaultProps} />);
const input = screen.getByDisplayValue("50");
fireEvent.change(input, { target: { value: "70" } });
expect(mockOnBucketsChange).toHaveBeenCalledWith([
{ id: 1, maxThroughputPercentage: 70 },
{ id: 2, maxThroughputPercentage: 60 },
{ id: 3, maxThroughputPercentage: 100 },
{ id: 4, maxThroughputPercentage: 100 },
{ id: 5, maxThroughputPercentage: 100 },
]);
});
it("triggers onSaveableChange when values change", () => {
render(<ThroughputBucketsComponent {...defaultProps} />);
const input = screen.getByDisplayValue("50");
fireEvent.change(input, { target: { value: "80" } });
expect(mockOnSaveableChange).toHaveBeenCalledWith(true);
});
it("updates state consistently after multiple changes to different buckets", () => {
render(<ThroughputBucketsComponent {...defaultProps} />);
const input1 = screen.getByDisplayValue("50");
fireEvent.change(input1, { target: { value: "70" } });
const input2 = screen.getByDisplayValue("60");
fireEvent.change(input2, { target: { value: "80" } });
expect(mockOnBucketsChange).toHaveBeenCalledWith([
{ id: 1, maxThroughputPercentage: 70 },
{ id: 2, maxThroughputPercentage: 80 },
{ id: 3, maxThroughputPercentage: 100 },
{ id: 4, maxThroughputPercentage: 100 },
{ id: 5, maxThroughputPercentage: 100 },
]);
});
it("resets to baseline when currentBuckets are reset", () => {
const { rerender } = render(<ThroughputBucketsComponent {...defaultProps} />);
const input1 = screen.getByDisplayValue("50");
fireEvent.change(input1, { target: { value: "70" } });
rerender(<ThroughputBucketsComponent {...defaultProps} currentBuckets={defaultProps.throughputBucketsBaseline} />);
expect(screen.getByDisplayValue("40")).toBeInTheDocument();
expect(screen.getByDisplayValue("50")).toBeInTheDocument();
});
it("does not call onBucketsChange when value remains unchanged", () => {
render(<ThroughputBucketsComponent {...defaultProps} />);
const input = screen.getByDisplayValue("50");
fireEvent.change(input, { target: { value: "50" } });
expect(mockOnBucketsChange).not.toHaveBeenCalled();
});
it("disables input and slider when maxThroughputPercentage is 100", () => {
render(
<ThroughputBucketsComponent
{...defaultProps}
currentBuckets={[
{ id: 1, maxThroughputPercentage: 100 },
{ id: 2, maxThroughputPercentage: 50 },
]}
/>,
);
const disabledInputs = screen.getAllByDisplayValue("100");
expect(disabledInputs.length).toBeGreaterThan(0);
expect(disabledInputs[0]).toBeDisabled();
const sliders = screen.getAllByRole("slider");
expect(sliders.length).toBeGreaterThan(0);
expect(sliders[0]).toHaveAttribute("aria-disabled", "true");
expect(sliders[1]).toHaveAttribute("aria-disabled", "false");
});
it("toggles bucket value between 50 and 100 with switch", () => {
render(<ThroughputBucketsComponent {...defaultProps} />);
const toggles = screen.getAllByRole("switch");
fireEvent.click(toggles[0]);
expect(mockOnBucketsChange).toHaveBeenCalledWith([
{ id: 1, maxThroughputPercentage: 100 },
{ id: 2, maxThroughputPercentage: 60 },
{ id: 3, maxThroughputPercentage: 100 },
{ id: 4, maxThroughputPercentage: 100 },
{ id: 5, maxThroughputPercentage: 100 },
]);
fireEvent.click(toggles[0]);
expect(mockOnBucketsChange).toHaveBeenCalledWith([
{ id: 1, maxThroughputPercentage: 50 },
{ id: 2, maxThroughputPercentage: 60 },
{ id: 3, maxThroughputPercentage: 100 },
{ id: 4, maxThroughputPercentage: 100 },
{ id: 5, maxThroughputPercentage: 100 },
]);
});
it("ensures default buckets are used when no buckets are provided", () => {
render(<ThroughputBucketsComponent {...defaultProps} currentBuckets={[]} />);
expect(screen.getAllByText(/Group \d+/)).toHaveLength(5);
expect(screen.getAllByDisplayValue("100")).toHaveLength(5);
});
});

View File

@@ -1,105 +0,0 @@
import { Label, Slider, Stack, TextField, Toggle } from "@fluentui/react";
import { ThroughputBucket } from "Contracts/DataModels";
import React, { FC, useEffect, useState } from "react";
import { isDirty } from "../../SettingsUtils";
const MAX_BUCKET_SIZES = 5;
const DEFAULT_BUCKETS = Array.from({ length: MAX_BUCKET_SIZES }, (_, i) => ({
id: i + 1,
maxThroughputPercentage: 100,
}));
export interface ThroughputBucketsComponentProps {
currentBuckets: ThroughputBucket[];
throughputBucketsBaseline: ThroughputBucket[];
onBucketsChange: (updatedBuckets: ThroughputBucket[]) => void;
onSaveableChange: (isSaveable: boolean) => void;
}
export const ThroughputBucketsComponent: FC<ThroughputBucketsComponentProps> = ({
currentBuckets,
throughputBucketsBaseline,
onBucketsChange,
onSaveableChange,
}) => {
const getThroughputBuckets = (buckets: ThroughputBucket[]): ThroughputBucket[] => {
if (!buckets || buckets.length === 0) {
return DEFAULT_BUCKETS;
}
const maxBuckets = Math.max(DEFAULT_BUCKETS.length, buckets.length);
const adjustedDefaultBuckets = Array.from({ length: maxBuckets }, (_, i) => ({
id: i + 1,
maxThroughputPercentage: 100,
}));
return adjustedDefaultBuckets.map(
(defaultBucket) => buckets?.find((bucket) => bucket.id === defaultBucket.id) || defaultBucket,
);
};
const [throughputBuckets, setThroughputBuckets] = useState<ThroughputBucket[]>(getThroughputBuckets(currentBuckets));
useEffect(() => {
setThroughputBuckets(getThroughputBuckets(currentBuckets));
onSaveableChange(false);
}, [currentBuckets]);
useEffect(() => {
const isChanged = isDirty(throughputBuckets, getThroughputBuckets(throughputBucketsBaseline));
onSaveableChange(isChanged);
}, [throughputBuckets]);
const handleBucketChange = (id: number, newValue: number) => {
const updatedBuckets = throughputBuckets.map((bucket) =>
bucket.id === id ? { ...bucket, maxThroughputPercentage: newValue } : bucket,
);
setThroughputBuckets(updatedBuckets);
const settingsChanged = isDirty(updatedBuckets, throughputBuckets);
settingsChanged && onBucketsChange(updatedBuckets);
};
const onToggle = (id: number, checked: boolean) => {
handleBucketChange(id, checked ? 50 : 100);
};
return (
<Stack tokens={{ childrenGap: "m" }} styles={{ root: { width: "70%", maxWidth: 700 } }}>
<Label>Throughput Buckets</Label>
<Stack>
{throughputBuckets?.map((bucket) => (
<Stack key={bucket.id} horizontal tokens={{ childrenGap: 8 }} verticalAlign="center">
<Slider
min={1}
max={100}
step={1}
value={bucket.maxThroughputPercentage}
onChange={(newValue) => handleBucketChange(bucket.id, newValue)}
showValue={false}
label={`Group ${bucket.id}${bucket.id === 1 ? " (Data Explorer Query Bucket)" : ""}`}
styles={{ root: { flex: 2, maxWidth: 400 } }}
disabled={bucket.maxThroughputPercentage === 100}
/>
<TextField
value={bucket.maxThroughputPercentage.toString()}
onChange={(event, newValue) => handleBucketChange(bucket.id, parseInt(newValue || "0", 10))}
type="number"
suffix="%"
styles={{
fieldGroup: { width: 80 },
}}
disabled={bucket.maxThroughputPercentage === 100}
/>
<Toggle
onText="Active"
offText="Inactive"
checked={bucket.maxThroughputPercentage !== 100}
onChange={(event, checked) => onToggle(bucket.id, checked)}
styles={{ root: { marginBottom: 0 }, text: { fontSize: 12 } }}
></Toggle>
</Stack>
))}
</Stack>
</Stack>
);
};

View File

@@ -17,13 +17,14 @@ import {
} from "@fluentui/react";
import React from "react";
import * as DataModels from "../../../../../Contracts/DataModels";
import { SubscriptionType } from "../../../../../Contracts/SubscriptionType";
import * as SharedConstants from "../../../../../Shared/Constants";
import { Action, ActionModifiers } from "../../../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../../../../UserContext";
import * as AutoPilotUtils from "../../../../../Utils/AutoPilotUtils";
import { autoPilotThroughput1K } from "../../../../../Utils/AutoPilotUtils";
import { calculateEstimateNumber } from "../../../../../Utils/PricingUtils";
import { calculateEstimateNumber, usageInGB } from "../../../../../Utils/PricingUtils";
import { Int32 } from "../../../../Panes/Tables/Validators/EntityPropertyValidationCommon";
import {
PriceBreakdown,
@@ -365,6 +366,29 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
});
};
private minRUperGBSurvey = (): JSX.Element => {
const href = `https://ncv.microsoft.com/vRBTO37jmO?ctx={"AzureSubscriptionId":"${userContext.subscriptionId}","CosmosDBAccountName":"${userContext.databaseAccount?.name}"}`;
const oneTBinKB = 1000000000;
const minRUperGB = 10;
const featureFlagEnabled = userContext.features.showMinRUSurvey;
const collectionIsEligible =
userContext.subscriptionType !== SubscriptionType.Internal &&
this.props.usageSizeInKB > oneTBinKB &&
this.props.minimum >= usageInGB(this.props.usageSizeInKB) * minRUperGB;
if (featureFlagEnabled || collectionIsEligible) {
return (
<Text>
Need to scale below {this.props.minimum} RU/s? Reach out by filling{" "}
<a target="_blank" rel="noreferrer" href={href}>
this questionnaire
</a>
.
</Text>
);
}
return undefined;
};
private renderThroughputModeChoices = (): JSX.Element => {
const labelId = "settingsV2RadioButtonLabelId";
return (
@@ -637,6 +661,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
</Link>
</Text>
)}
{this.minRUperGBSurvey()}
{this.props.spendAckVisible && (
<Checkbox
id="spendAckCheckBox"

View File

@@ -0,0 +1,64 @@
// 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,6 +44,7 @@ describe("SettingsUtils", () => {
readSettings: undefined,
onSettingsClick: undefined,
loadOffer: undefined,
getPendingThroughputSplitNotification: undefined,
} as ViewModels.Database;
};
newCollection.offer(undefined);

View File

@@ -4,15 +4,7 @@ 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
| DataModels.VectorEmbedding[]
| DataModels.FullTextPolicy
| DataModels.ThroughputBucket[];
export type isDirtyTypes = boolean | string | number | DataModels.IndexingPolicy | DataModels.ComputedProperties;
export const TtlOff = "off";
export const TtlOn = "on";
export const TtlOnNoDefault = "on-nodefault";
@@ -56,13 +48,6 @@ export enum SettingsV2TabTypes {
PartitionKeyTab,
ComputedPropertiesTab,
ContainerVectorPolicyTab,
ThroughputBucketsTab,
GlobalSecondaryIndexTab,
}
export enum ContainerPolicyTabTypes {
VectorPolicyTab,
FullTextPolicyTab,
}
export interface IsComponentDirtyResult {
@@ -169,11 +154,7 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => {
case SettingsV2TabTypes.ComputedPropertiesTab:
return "Computed Properties";
case SettingsV2TabTypes.ContainerVectorPolicyTab:
return "Container Policies";
case SettingsV2TabTypes.ThroughputBucketsTab:
return "Throughput Buckets";
case SettingsV2TabTypes.GlobalSecondaryIndexTab:
return "Global Secondary Index (Preview)";
return "Container Vector Policy (preview)";
default:
throw new Error(`Unknown tab ${tab}`);
}

View File

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

View File

@@ -55,13 +55,10 @@ exports[`SettingsComponent renders 1`] = `
},
"databaseId": "test",
"defaultTtl": [Function],
"fullTextPolicy": [Function],
"geospatialConfig": [Function],
"getDatabase": [Function],
"id": [Function],
"indexingPolicy": [Function],
"materializedViewDefinition": [Function],
"materializedViews": [Function],
"offer": [Function],
"partitionKey": {
"kind": "hash",
@@ -74,7 +71,6 @@ exports[`SettingsComponent renders 1`] = `
"readSettings": [Function],
"uniqueKeyPolicy": {},
"usageSizeInKB": [Function],
"vectorEmbeddingPolicy": [Function],
}
}
isAutoPilotSelected={false}
@@ -136,13 +132,10 @@ exports[`SettingsComponent renders 1`] = `
},
"databaseId": "test",
"defaultTtl": [Function],
"fullTextPolicy": [Function],
"geospatialConfig": [Function],
"getDatabase": [Function],
"id": [Function],
"indexingPolicy": [Function],
"materializedViewDefinition": [Function],
"materializedViews": [Function],
"offer": [Function],
"partitionKey": {
"kind": "hash",
@@ -155,7 +148,6 @@ exports[`SettingsComponent renders 1`] = `
"readSettings": [Function],
"uniqueKeyPolicy": {},
"usageSizeInKB": [Function],
"vectorEmbeddingPolicy": [Function],
}
}
displayedTtlSeconds="5"
@@ -257,13 +249,10 @@ exports[`SettingsComponent renders 1`] = `
},
"databaseId": "test",
"defaultTtl": [Function],
"fullTextPolicy": [Function],
"geospatialConfig": [Function],
"getDatabase": [Function],
"id": [Function],
"indexingPolicy": [Function],
"materializedViewDefinition": [Function],
"materializedViews": [Function],
"offer": [Function],
"partitionKey": {
"kind": "hash",
@@ -276,7 +265,6 @@ exports[`SettingsComponent renders 1`] = `
"readSettings": [Function],
"uniqueKeyPolicy": {},
"usageSizeInKB": [Function],
"vectorEmbeddingPolicy": [Function],
}
}
explorer={
@@ -306,7 +294,6 @@ exports[`SettingsComponent renders 1`] = `
},
}
}
isReadOnly={false}
/>
</PivotItem>
<PivotItem
@@ -343,101 +330,6 @@ exports[`SettingsComponent renders 1`] = `
shouldDiscardComputedProperties={false}
/>
</PivotItem>
<PivotItem
headerText="Global Secondary Index (Preview)"
itemKey="GlobalSecondaryIndexTab"
key="GlobalSecondaryIndexTab"
style={
{
"marginTop": 20,
}
}
>
<GlobalSecondaryIndexComponent
collection={
{
"analyticalStorageTtl": [Function],
"changeFeedPolicy": [Function],
"computedProperties": [Function],
"conflictResolutionPolicy": [Function],
"container": Explorer {
"_isInitializingNotebooks": false,
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"copyNotebook": [Function],
"parameters": [Function],
},
},
"databaseId": "test",
"defaultTtl": [Function],
"fullTextPolicy": [Function],
"geospatialConfig": [Function],
"getDatabase": [Function],
"id": [Function],
"indexingPolicy": [Function],
"materializedViewDefinition": [Function],
"materializedViews": [Function],
"offer": [Function],
"partitionKey": {
"kind": "hash",
"paths": [],
"version": 2,
},
"partitionKeyProperties": [
"partitionKey",
],
"readSettings": [Function],
"uniqueKeyPolicy": {},
"usageSizeInKB": [Function],
"vectorEmbeddingPolicy": [Function],
}
}
explorer={
Explorer {
"_isInitializingNotebooks": false,
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"copyNotebook": [Function],
"parameters": [Function],
},
}
}
/>
</PivotItem>
</StyledPivot>
</div>
</div>

View File

@@ -1,5 +1,4 @@
import { Checkbox, DirectionalHint, Link, Stack, Text, TextField, TooltipHost } from "@fluentui/react";
import { getWorkloadType } from "Common/DatabaseAccountUtility";
import { useDatabases } from "Explorer/useDatabases";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as Constants from "../../../Common/Constants";
@@ -35,23 +34,10 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
setIsThroughputCapExceeded,
onCostAcknowledgeChange,
}: ThroughputInputProps) => {
let defaultThroughput: number;
const workloadType: Constants.WorkloadType = getWorkloadType();
if (
isFreeTier ||
isQuickstart ||
[Constants.WorkloadType.Learning, Constants.WorkloadType.DevelopmentTesting].includes(workloadType)
) {
defaultThroughput = AutoPilotUtils.autoPilotThroughput1K;
} else if (workloadType === Constants.WorkloadType.Production) {
defaultThroughput = AutoPilotUtils.autoPilotThroughput10K;
} else {
defaultThroughput = AutoPilotUtils.autoPilotThroughput4K;
}
const [isAutoscaleSelected, setIsAutoScaleSelected] = useState<boolean>(true);
const [throughput, setThroughput] = useState<number>(defaultThroughput);
const [throughput, setThroughput] = useState<number>(
isFreeTier || isQuickstart ? AutoPilotUtils.autoPilotThroughput1K : AutoPilotUtils.autoPilotThroughput4K,
);
const [isCostAcknowledged, setIsCostAcknowledged] = useState<boolean>(false);
const [throughputError, setThroughputError] = useState<string>("");
const [totalThroughputUsed, setTotalThroughputUsed] = useState<number>(0);
@@ -61,6 +47,7 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit;
const numberOfRegions = userContext.databaseAccount?.properties.locations?.length || 1;
useEffect(() => {
// throughput cap check for the initial state
let totalThroughput = 0;
@@ -170,6 +157,9 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
const handleOnChangeMode = (event: React.ChangeEvent<HTMLInputElement>, mode: string): void => {
if (mode === "Autoscale") {
const defaultThroughput = isFreeTier
? AutoPilotUtils.autoPilotThroughput1K
: AutoPilotUtils.autoPilotThroughput4K;
setThroughput(defaultThroughput);
setIsAutoScaleSelected(true);
setThroughputValue(defaultThroughput);

View File

@@ -23,7 +23,7 @@ import { useCallback } from "react";
export interface TreeNodeMenuItem {
label: string;
onClick: (value?: React.RefObject<HTMLElement>) => void;
onClick: () => void;
iconSrc?: string;
isDisabled?: boolean;
styleClass?: string;
@@ -74,7 +74,6 @@ 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[] => {
@@ -142,7 +141,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
data-test={`TreeNode/ContextMenuItem:${menuItem.label}`}
disabled={menuItem.isDisabled}
key={menuItem.label}
onClick={() => menuItem.onClick(contextMenuRef)}
onClick={menuItem.onClick}
>
{menuItem.label}
</MenuItem>
@@ -191,7 +190,6 @@ 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={[Function]}
onClick={[MockFunction enabledItemClick]}
>
enabledItem
</MenuItem>
<MenuItem
data-test="TreeNode/ContextMenuItem:disabledItem"
disabled={true}
onClick={[Function]}
onClick={[MockFunction disabledItemClick]}
>
disabledItem
</MenuItem>
@@ -1518,7 +1518,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
<MenuItem
data-test="TreeNode/ContextMenuItem:enabledItem"
key="enabledItem"
onClick={[Function]}
onClick={[MockFunction enabledItemClick]}
>
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={[Function]}
onClick={[MockFunction disabledItemClick]}
>
disabledItem
</MenuItem>

View File

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

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

View File

@@ -1,16 +1,15 @@
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";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import { getCopilotEnabled, isCopilotFeatureRegistered } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { IGalleryItem } from "Juno/JunoClient";
import { isFabricMirrored, isFabricMirroredKey, scheduleRefreshFabricToken } from "Platform/Fabric/FabricUtil";
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
import { acquireTokenWithMsal, getMsalInstance } from "Utils/AuthorizationUtils";
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
import { useQueryCopilot } from "hooks/useQueryCopilot";
@@ -35,7 +34,7 @@ import { PhoenixClient } from "../Phoenix/PhoenixClient";
import * as ExplorerSettings from "../Shared/ExplorerSettings";
import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
import { updateUserContext, userContext } from "../UserContext";
import { isAccountNewerThanThresholdInMs, updateUserContext, userContext } from "../UserContext";
import { getCollectionName, getUploadName } from "../Utils/APITypeUtils";
import { stringToBlob } from "../Utils/BlobUtils";
import { isCapabilityEnabled } from "../Utils/CapabilityUtils";
@@ -43,7 +42,7 @@ import { fromContentUri, toRawContentUri } from "../Utils/GitHubUtils";
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../Utils/NotificationConsoleUtils";
import { useSidePanel } from "../hooks/useSidePanel";
import { ReactTabKind, useTabs } from "../hooks/useTabs";
import { useTabs } from "../hooks/useTabs";
import "./ComponentRegisterer";
import { DialogProps, useDialog } from "./Controls/Dialog";
import { GalleryTab as GalleryTabKind } from "./Controls/NotebookGallery/GalleryViewerComponent";
@@ -55,7 +54,7 @@ import type NotebookManager from "./Notebook/NotebookManager";
import { NotebookPaneContent } from "./Notebook/NotebookManager";
import { NotebookUtil } from "./Notebook/NotebookUtil";
import { useNotebook } from "./Notebook/useNotebook";
import { AddCollectionPanel } from "./Panes/AddCollectionPanel/AddCollectionPanel";
import { AddCollectionPanel } from "./Panes/AddCollectionPanel";
import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane";
import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane";
import { StringInputPane } from "./Panes/StringInputPane/StringInputPane";
@@ -187,10 +186,6 @@ export default class Explorer {
useNotebook.getState().setNotebookBasePath(userContext.features.notebookBasePath);
}
if (isFabricMirrored()) {
useTabs.getState().closeReactTab(ReactTabKind.Home);
}
this.refreshExplorer();
}
@@ -263,8 +258,25 @@ 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 aadToken = await acquireMsalTokenForAccount(userContext.databaseAccount, false);
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")}`,
});
updateUserContext({ aadToken: aadToken });
useDataPlaneRbac.setState({ aadTokenUpdated: true });
} catch (error) {
@@ -282,6 +294,37 @@ export default class Explorer {
}
}
public openNPSSurveyDialog(): void {
if (!Platform.Portal || !["Postgres", "SQL", "Mongo"].includes(userContext.apiType)) {
return;
}
const ONE_DAY_IN_MS = 86400000;
const SEVEN_DAYS_IN_MS = 604800000;
// Try Cosmos DB subscription - survey shown to 100% of users at day 1 in Data Explorer.
if (userContext.isTryCosmosDBSubscription) {
if (isAccountNewerThanThresholdInMs(userContext.databaseAccount?.systemData?.createdAt || "", ONE_DAY_IN_MS)) {
Logger.logInfo(
`Sending message to Portal to check if NPS Survey can be displayed in Try Cosmos DB ${userContext.apiType}`,
"Explorer/openNPSSurveyDialog",
);
sendMessage({ type: MessageTypes.DisplayNPSSurvey });
}
} else {
// Show survey when an existing account is older than 7 days
if (
!isAccountNewerThanThresholdInMs(userContext.databaseAccount?.systemData?.createdAt || "", SEVEN_DAYS_IN_MS)
) {
Logger.logInfo(
`Sending message to Portal to check if NPS Survey can be displayed for existing ${userContext.apiType} account older than 7 days`,
"Explorer/openNPSSurveyDialog",
);
sendMessage({ type: MessageTypes.DisplayNPSSurvey });
}
}
}
public async openCESCVAFeedbackBlade(): Promise<void> {
sendMessage({ type: MessageTypes.OpenCESCVAFeedbackBlade });
Logger.logInfo(
@@ -351,8 +394,8 @@ export default class Explorer {
};
public onRefreshResourcesClick = async (): Promise<void> => {
if (isFabricMirroredKey()) {
scheduleRefreshFabricToken(true).then(() => this.refreshAllDatabases());
if (configContext.platform === Platform.Fabric) {
scheduleRefreshDatabaseResourceToken(true).then(() => this.refreshAllDatabases());
return;
}
@@ -1107,7 +1150,7 @@ export default class Explorer {
if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") {
userContext.authType === AuthType.ResourceToken
? this.refreshDatabaseForResourceToken()
: await this.refreshAllDatabases(); // await: we rely on the databases to be loaded before restoring the tabs further in the flow
: this.refreshAllDatabases();
}
await useNotebook.getState().refreshNotebooksEnabledStateForAccount();
@@ -1131,15 +1174,11 @@ export default class Explorer {
await this.initNotebooks(userContext.databaseAccount);
}
this.refreshSampleData();
await this.refreshSampleData();
}
public async configureCopilot(): Promise<void> {
if (
userContext.apiType !== "SQL" ||
!userContext.subscriptionId ||
![Environment.Development, Environment.Mpac, Environment.Prod].includes(getEnvironment())
) {
if (userContext.apiType !== "SQL" || !userContext.subscriptionId) {
return;
}
const copilotEnabledPromise = getCopilotEnabled();
@@ -1156,27 +1195,26 @@ export default class Explorer {
.setCopilotSampleDBEnabled(copilotEnabled && copilotUserDBEnabled && copilotSampleDBEnabled);
}
public refreshSampleData(): void {
if (!userContext.sampleDataConnectionInfo) {
public async refreshSampleData(): Promise<void> {
try {
if (!userContext.sampleDataConnectionInfo) {
return;
}
const collection: DataModels.Collection = await readSampleCollection();
if (!collection) {
return;
}
const databaseId = userContext.sampleDataConnectionInfo?.databaseId;
if (!databaseId) {
return;
}
const sampleDataResourceTokenCollection = new ResourceTokenCollection(this, databaseId, collection, true);
useDatabases.setState({ sampleDataResourceTokenCollection });
} catch (error) {
Logger.logError(getErrorMessage(error), "Explorer");
return;
}
const databaseId = userContext.sampleDataConnectionInfo?.databaseId;
if (!databaseId) {
return;
}
readSampleCollection()
.then((collection: DataModels.Collection) => {
if (!collection) {
return;
}
const sampleDataResourceTokenCollection = new ResourceTokenCollection(this, databaseId, collection, true);
useDatabases.setState({ sampleDataResourceTokenCollection });
})
.catch((error) => {
Logger.logError(getErrorMessage(error), "Explorer/refreshSampleData");
});
}
}

View File

@@ -14,6 +14,10 @@
.flex-direction(@direction: row);
padding: 4px 5px;
label {
padding: 0px;
}
.valueCol {
flex-grow: 1;
padding-right: 5px;
@@ -59,10 +63,6 @@
height: 100%;
}
.customTrashIcon {
padding-top: 33px;
}
.rightPaneTrashIconImg {
vertical-align: top;
}

View File

@@ -142,11 +142,10 @@ export const NewVertexComponent: FunctionComponent<INewVertexComponentProps> = (
<div className="labelCol">
<TextField
className="edgeInput"
label={index === 0 && "Key"}
type="text"
id="propertyKeyNewVertexPane"
componentRef={input}
required
aria-required="true"
placeholder="Key"
autoComplete="off"
aria-label={`Enter value for propery ${index + 1}`}
@@ -154,11 +153,11 @@ export const NewVertexComponent: FunctionComponent<INewVertexComponentProps> = (
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onKeyChange(event, index)}
/>
</div>
<span className="mandatoryStar">*&nbsp;</span>
<div className="valueCol">
<TextField
className="edgeInput"
label={index === 0 && "Value"}
type="text"
placeholder="Value"
autoComplete="off"
@@ -170,8 +169,6 @@ export const NewVertexComponent: FunctionComponent<INewVertexComponentProps> = (
<div>
<Dropdown
role="combobox"
label={index === 0 && "Type"}
ariaLabel="Type"
placeholder="Select an option"
defaultSelectedKey={data.values[0].type}
style={{ width: 100 }}
@@ -184,7 +181,7 @@ export const NewVertexComponent: FunctionComponent<INewVertexComponentProps> = (
</div>
<div className="actionCol">
<div
className={`rightPaneTrashIcon rightPaneBtns ${index === 0 && "customTrashIcon"}`}
className="rightPaneTrashIcon rightPaneBtns"
tabIndex={0}
role="button"
aria-label={`Delete ${data.key}`}

View File

@@ -6,12 +6,12 @@
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { userContext } from "UserContext";
import * as React from "react";
import create, { UseStore } from "zustand";
import { ConnectionStatusType, PoolIdType } from "../../../Common/Constants";
import { StyleConstants } from "../../../Common/StyleConstants";
import { Platform, configContext } from "../../../ConfigContext";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import { useSelectedNode } from "../../useSelectedNode";
@@ -93,18 +93,19 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
);
}
const rootStyle = isFabric()
? {
root: {
backgroundColor: "transparent",
padding: "2px 8px 0px 8px",
},
}
: {
root: {
backgroundColor: backgroundColor,
},
};
const rootStyle =
configContext.platform === Platform.Fabric
? {
root: {
backgroundColor: "transparent",
padding: "2px 8px 0px 8px",
},
}
: {
root: {
backgroundColor: backgroundColor,
},
};
const allButtons = staticButtons.concat(contextButtons).concat(controlButtons);
const keyboardHandlers = CommandBarUtil.createKeyboardHandlers(allButtons);

View File

@@ -37,25 +37,21 @@ describe("CommandBarComponentButtonFactory tests", () => {
expect(enableAzureSynapseLinkBtn).toBeDefined();
});
// TODO: Now that Tables API supports dataplane RBAC, calling createStaticCommandBarButtons will enable the
// Entra ID Login button, which causes this test to fail due to "Invalid hook call.". This seems to be
// unsupported in jest and needs to be tested with react-hooks-testing-library.
//
// it("Button should not be visible for Tables API", () => {
// updateUserContext({
// databaseAccount: {
// properties: {
// capabilities: [{ name: "EnableTable" }],
// },
// } as DatabaseAccount,
// });
//
// const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
// const enableAzureSynapseLinkBtn = buttons.find(
// (button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
// );
// expect(enableAzureSynapseLinkBtn).toBeUndefined();
//});
it("Button should not be visible for Tables API", () => {
updateUserContext({
databaseAccount: {
properties: {
capabilities: [{ name: "EnableTable" }],
},
} as DatabaseAccount,
});
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const enableAzureSynapseLinkBtn = buttons.find(
(button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
);
expect(enableAzureSynapseLinkBtn).toBeUndefined();
});
it("Button should not be visible for Cassandra API", () => {
updateUserContext({

View File

@@ -1,5 +1,4 @@
import { KeyboardAction } from "KeyboardShortcuts";
import { isDataplaneRbacSupported } from "Utils/APITypeUtils";
import * as React from "react";
import { useEffect, useState } from "react";
import AddSqlQueryIcon from "../../../../images/AddSqlQuery_16x16.svg";
@@ -62,7 +61,7 @@ export function createStaticCommandBarButtons(
}
}
if (isDataplaneRbacSupported(userContext.apiType)) {
if (userContext.apiType === "SQL") {
const [loginButtonProps, setLoginButtonProps] = useState<CommandButtonComponentProps | undefined>(undefined);
const dataPlaneRbacEnabled = useDataPlaneRbac((state) => state.dataPlaneRbacEnabled);
const aadTokenUpdated = useDataPlaneRbac((state) => state.aadTokenUpdated);

View File

@@ -36,10 +36,6 @@
&:active {
background-color:@NotificationHigh;
}
&:focus {
.focusedBorder();
}
.statusBar {
.dataTypeIcons {

View File

@@ -81,6 +81,10 @@ export class NotificationConsoleComponent extends React.Component<
}
}
public setElememntRef = (element: HTMLElement): void => {
this.consoleHeaderElement = element;
};
public render(): JSX.Element {
const numInProgress = this.state.allConsoleData.filter(
(data: ConsoleData) => data.type === ConsoleDataType.InProgress,
@@ -97,9 +101,7 @@ export class NotificationConsoleComponent extends React.Component<
<div
className="notificationConsoleHeader"
id="notificationConsoleHeader"
role="button"
aria-label="Console"
aria-expanded={this.props.isConsoleExpanded}
ref={this.setElememntRef}
onClick={() => this.expandCollapseConsole()}
onKeyDown={(event: React.KeyboardEvent<HTMLDivElement>) => this.onExpandCollapseKeyPress(event)}
tabIndex={0}
@@ -107,15 +109,15 @@ export class NotificationConsoleComponent extends React.Component<
<div className="statusBar">
<span className="dataTypeIcons">
<span className="notificationConsoleHeaderIconWithData">
<img src={LoadingIcon} alt="In progress items" />
<img src={LoadingIcon} alt="in progress items" />
<span className="numInProgress">{numInProgress}</span>
</span>
<span className="notificationConsoleHeaderIconWithData">
<img src={ErrorBlackIcon} alt="Error items" />
<img src={ErrorBlackIcon} alt="error items" />
<span className="numErroredItems">{numErroredItems}</span>
</span>
<span className="notificationConsoleHeaderIconWithData">
<img src={infoBubbleIcon} alt="Info items" />
<img src={infoBubbleIcon} alt="info items" />
<span className="numInfoItems">{numInfoItems}</span>
</span>
</span>
@@ -127,10 +129,17 @@ export class NotificationConsoleComponent extends React.Component<
</span>
</span>
</div>
<div className="expandCollapseButton" data-test="NotificationConsole/ExpandCollapseButton">
<div
className="expandCollapseButton"
data-test="NotificationConsole/ExpandCollapseButton"
role="button"
tabIndex={0}
aria-label={"console button" + (this.props.isConsoleExpanded ? " expanded" : " collapsed")}
aria-expanded={!this.props.isConsoleExpanded}
>
<img
src={this.props.isConsoleExpanded ? ChevronDownIcon : ChevronUpIcon}
alt={this.props.isConsoleExpanded ? "Collapse icon" : "Expand icon"}
alt={this.props.isConsoleExpanded ? "ChevronDownIcon" : "ChevronUpIcon"}
/>
</div>
</div>
@@ -250,6 +259,9 @@ export class NotificationConsoleComponent extends React.Component<
}
private onConsoleWasExpanded = (): void => {
if (this.props.isConsoleExpanded && this.consoleHeaderElement) {
this.consoleHeaderElement.focus();
}
useNotificationConsole.getState().setConsoleAnimationFinished(true);
};

View File

@@ -5,13 +5,10 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
className="notificationConsoleContainer"
>
<div
aria-expanded={false}
aria-label="Console"
className="notificationConsoleHeader"
id="notificationConsoleHeader"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
<div
@@ -24,7 +21,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
className="notificationConsoleHeaderIconWithData"
>
<img
alt="In progress items"
alt="in progress items"
src={{}}
/>
<span
@@ -37,7 +34,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
className="notificationConsoleHeaderIconWithData"
>
<img
alt="Error items"
alt="error items"
src={{}}
/>
<span
@@ -50,7 +47,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
className="notificationConsoleHeaderIconWithData"
>
<img
alt="Info items"
alt="info items"
src={{}}
/>
<span
@@ -74,11 +71,15 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
</span>
</div>
<div
aria-expanded={true}
aria-label="console button collapsed"
className="expandCollapseButton"
data-test="NotificationConsole/ExpandCollapseButton"
role="button"
tabIndex={0}
>
<img
alt="Expand icon"
alt="ChevronUpIcon"
src=""
/>
</div>
@@ -175,13 +176,10 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
className="notificationConsoleContainer"
>
<div
aria-expanded={false}
aria-label="Console"
className="notificationConsoleHeader"
id="notificationConsoleHeader"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
<div
@@ -194,7 +192,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
className="notificationConsoleHeaderIconWithData"
>
<img
alt="In progress items"
alt="in progress items"
src={{}}
/>
<span
@@ -207,7 +205,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
className="notificationConsoleHeaderIconWithData"
>
<img
alt="Error items"
alt="error items"
src={{}}
/>
<span
@@ -220,7 +218,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
className="notificationConsoleHeaderIconWithData"
>
<img
alt="Info items"
alt="info items"
src={{}}
/>
<span
@@ -246,11 +244,15 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
</span>
</div>
<div
aria-expanded={true}
aria-label="console button collapsed"
className="expandCollapseButton"
data-test="NotificationConsole/ExpandCollapseButton"
role="button"
tabIndex={0}
>
<img
alt="Expand icon"
alt="ChevronUpIcon"
src=""
/>
</div>

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