Merge branch 'master' of https://github.com/Azure/cosmos-explorer into 2819223

This commit is contained in:
Satyapriya Bai 2024-07-01 13:18:45 +05:30
commit 199ce64bdb
273 changed files with 20249 additions and 8448 deletions

10
.editorconfig Normal file
View File

@ -0,0 +1,10 @@
# NOTE: Prettier reads EditorConfig settings, so be careful adjusting settings here and assuming they'll only affect your editor ;).
# top-most EditorConfig file
root = true
[*.yml]
indent_size = 2
[*.{js,jsx,ts,tsx}]
indent_size = 2

View File

@ -1,3 +1,5 @@
playwright.config.ts
**/node_modules/
src/**/__mocks__/**/*
dist/
@ -89,10 +91,7 @@ src/Explorer/Tables/TableEntityProcessor.ts
src/Explorer/Tables/Utilities.ts
src/Explorer/Tabs/ConflictsTab.ts
src/Explorer/Tabs/DatabaseSettingsTab.ts
src/Explorer/Tabs/DocumentsTab.test.ts
src/Explorer/Tabs/DocumentsTab.ts
src/Explorer/Tabs/GraphTab.ts
src/Explorer/Tabs/MongoDocumentsTab.ts
src/Explorer/Tabs/NotebookV2Tab.ts
src/Explorer/Tabs/ScriptTabBase.ts
src/Explorer/Tabs/TabComponents.ts
@ -128,7 +127,7 @@ src/Explorer/Controls/InputTypeahead/InputTypeaheadComponent.tsx
src/Explorer/Controls/Notebook/NotebookTerminalComponent.test.tsx
src/Explorer/Controls/Notebook/NotebookTerminalComponent.tsx
src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx
src/Explorer/Controls/TreeComponent/TreeComponent.tsx
src/Explorer/Controls/TreeComponent/LegacyTreeComponent.tsx
src/Explorer/Graph/GraphExplorerComponent/GraphExplorer.test.tsx
src/Explorer/Graph/GraphExplorerComponent/NodePropertiesComponent.tsx
src/Explorer/Graph/GraphExplorerComponent/ReadOnlyNodePropertiesComponent.test.tsx
@ -145,4 +144,5 @@ src/Explorer/Notebook/temp/inputs/connected-editors/codemirror.tsx
src/Explorer/Tree/ResourceTreeAdapter.tsx
__mocks__/monaco-editor.ts
src/Explorer/Tree/ResourceTree.tsx
src/Utils/EndpointUtils.ts
src/Utils/PriorityBasedExecutionUtils.ts

View File

@ -8,6 +8,9 @@ on:
pull_request:
branches:
- master
permissions:
id-token: write
contents: read
jobs:
codemetrics:
runs-on: ubuntu-latest
@ -101,72 +104,6 @@ jobs:
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 }}
endtoendemulator:
name: "End To End Emulator Tests"
# Temporarily disabled. This test needs to be rewritten in playwright
if: false
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18.x
uses: actions/setup-node@v4
with:
node-version: 18.x
- uses: southpolesteve/cosmos-emulator-github-action@v1
- name: End to End Tests
run: |
npm ci
npm start &
npm run wait-for-server
npx jest -c ./jest.config.e2e.js --detectOpenHandles test/sql/container.spec.ts
shell: bash
env:
DATA_EXPLORER_ENDPOINT: "https://localhost:1234/explorer.html?platform=Emulator"
PLATFORM: "Emulator"
NODE_TLS_REJECT_UNAUTHORIZED: 0
- uses: actions/upload-artifact@v3
if: failure()
with:
name: screenshots
path: failed-*
endtoend:
name: "E2E"
runs-on: ubuntu-latest
env:
NODE_TLS_REJECT_UNAUTHORIZED: 0
NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET }}
strategy:
fail-fast: false
matrix:
test-file:
- ./test/cassandra/container.spec.ts
- ./test/graph/container.spec.ts
- ./test/sql/container.spec.ts
- ./test/mongo/container.spec.ts
- ./test/mongo/container32.spec.ts
- ./test/selfServe/selfServeExample.spec.ts
# - ./test/notebooks/upload.spec.ts // TEMP disabled since notebooks service is off
- ./test/sql/resourceToken.spec.ts
- ./test/tables/container.spec.ts
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18.x
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: npm ci
- run: npm start &
- run: npm run wait-for-server
- name: ${{ matrix['test-file'] }}
run: |
# Run tests up to three times
for i in $(seq 1 3); do npx jest -c ./jest.config.playwright.js ${{ matrix['test-file'] }} && s=0 && break || s=$? && sleep 1; done; (exit $s)
shell: bash
- uses: actions/upload-artifact@v3
if: failure()
with:
name: screenshots
path: screenshots/
nuget:
name: Publish Nuget
if: github.ref == 'refs/heads/master' || contains(github.ref, 'hotfix/') || contains(github.ref, 'release/')
@ -216,3 +153,70 @@ jobs:
name: packages
with:
path: "*.nupkg"
playwright-tests:
name: "Run Playwright Tests (Shard ${{ matrix.shardIndex }} of ${{ matrix.shardTotal }})"
runs-on: ubuntu-latest
env:
NODE_TLS_REJECT_UNAUTHORIZED: 0
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- uses: actions/checkout@v4
- name: "Az CLI login"
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Use Node.js 18.x
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: npm ci
- run: npx playwright install --with-deps
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- name: Upload blob report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shardIndex }}
path: blob-report
retention-days: 1
merge-playwright-reports:
name: "Merge Playwright Reports"
# Merge reports after playwright-tests, even if some shards have failed
if: ${{ !cancelled() }}
needs: [playwright-tests]
runs-on: ubuntu-latest
steps:
- 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: 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

View File

@ -9,6 +9,10 @@ on:
# Once every hour
- cron: "0 15 * * *"
permissions:
id-token: write
contents: read
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
@ -16,10 +20,17 @@ jobs:
name: "Cleanup Test Database Accounts"
runs-on: ubuntu-latest
env:
NOTEBOOKS_TEST_RUNNER_CLIENT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_ID }}
NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
steps:
- uses: actions/checkout@v2
- name: "Az CLI login"
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Use Node.js 18.x
uses: actions/setup-node@v1
with:

4
.gitignore vendored
View File

@ -17,3 +17,7 @@ Contracts/*
failure.png
screenshots/*
GettingStarted-ignore*.ipynb
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@ -20,8 +20,8 @@
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.organizeImports": true
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"editor.defaultFormatter": "esbenp.prettier-vscode"

View File

@ -1,5 +1,5 @@
{
"JUNO_ENDPOINT": "https://tools-staging.cosmos.azure.com",
"isTerminalEnabled" : true,
"isPhoenixEnabled" : true
"JUNO_ENDPOINT": "https://tools.cosmos.azure.com",
"isTerminalEnabled": true,
"isPhoenixEnabled": true
}

3
images/Home_16.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.31299 1.26164C7.69849 0.897163 8.30151 0.897163 8.68701 1.26164L13.5305 5.84098C13.8302 6.12431 14 6.51853 14 6.93094V12.5002C14 13.3286 13.3284 14.0002 12.5 14.0002H10.5C9.67157 14.0002 9 13.3286 9 12.5002V10.0002C9 9.72407 8.77614 9.50021 8.5 9.50021H7.5C7.22386 9.50021 7 9.72407 7 10.0002V12.5002C7 13.3286 6.32843 14.0002 5.5 14.0002H3.5C2.67157 14.0002 2 13.3286 2 12.5002V6.93094C2 6.51853 2.1698 6.12431 2.46948 5.84098L7.31299 1.26164ZM8 1.98828L3.15649 6.56762C3.0566 6.66207 3 6.79347 3 6.93094V12.5002C3 12.7763 3.22386 13.0002 3.5 13.0002H5.5C5.77614 13.0002 6 12.7763 6 12.5002V10.0002C6 9.17179 6.67157 8.50022 7.5 8.50022H8.5C9.32843 8.50022 10 9.17179 10 10.0002V12.5002C10 12.7763 10.2239 13.0002 10.5 13.0002H12.5C12.7761 13.0002 13 12.7763 13 12.5002V6.93094C13 6.79347 12.9434 6.66207 12.8435 6.56762L8 1.98828Z" fill="#0078D4" />
</svg>

After

Width:  |  Height:  |  Size: 968 B

View File

@ -1,13 +0,0 @@
const isCI = require("is-ci");
module.exports = {
exitOnPageError: false,
launchOptions: {
headless: isCI,
slowMo: 10,
timeout: 60000,
},
contextOptions: {
ignoreHTTPSErrors: true,
},
};

View File

@ -31,7 +31,7 @@ module.exports = {
coveragePathIgnorePatterns: ["/node_modules/"],
// A list of reporter names that Jest uses when writing coverage reports
coverageReporters: ["json", "text", "cobertura"],
coverageReporters: ["json", "text", "cobertura", "lcov"],
// An object that configures minimum threshold enforcement for coverage results
coverageThreshold: {
@ -76,6 +76,10 @@ module.exports = {
"^dnd-core$": "dnd-core/dist/cjs",
"^react-dnd$": "react-dnd/dist/cjs",
"^react-dnd-html5-backend$": "react-dnd-html5-backend/dist/cjs",
"d3-force": "<rootDir>/node_modules/d3-force/dist/d3-force.min.js",
"d3-quadtree": "<rootDir>/node_modules/d3-quadtree/dist/d3-quadtree.min.js",
"d3-scale-chromatic": "<rootDir>/node_modules/d3-scale-chromatic/dist/d3-scale-chromatic.min.js",
"d3-zoom": "<rootDir>/node_modules/d3-zoom/dist/d3-zoom.min.js",
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
@ -130,7 +134,6 @@ module.exports = {
// The test environment that will be used for testing
// testEnvironment: "jest-environment-jsdom",
modulePaths: ["node_modules", "<rootDir>/src"],
// Options that will be passed to the testEnvironment

View File

@ -1,7 +0,0 @@
module.exports = {
preset: "jest-playwright-preset",
testMatch: ["<rootDir>/test/**/*.spec.[jt]s?(x)"],
setupFiles: ["dotenv/config"],
testEnvironment: "./test/playwrightEnv.js",
setupFilesAfterEnv: ["expect-playwright"],
};

View File

@ -130,6 +130,7 @@
@ActiveTabWidth: 141px;
@TabsHeight: 30px;
@TabsWidth: 140px;
@ContentWrapper: 111px;
@StatusIconContainerSize: 18px;
@LoadingErrorIconSize: 14px;
@ErrorIconContainer: 16px;
@ -147,6 +148,7 @@
// CommandBar
@CommandBarButtonHeight: 40px;
@FabricCommandBarButtonHeight: 34px;
/**********************************************************************************
Portal Consts
@ -162,9 +164,10 @@
/**********************************************************************************/
@FabricFont: "Segoe UI", "Segoe UI Web (West European)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;
@FabricToolbarIconColor: "brightness(0) saturate(100%) invert(50%) sepia(17%) saturate(1459%) hue-rotate(81deg) brightness(99%) contrast(94%)";
@FabricBoxBorderRadius: 8px;
@FabricBoxBorderShadow: 0 0 2px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.14);
@FabricBoxBorderShadow: rgba(0, 0, 0, 0.133) 0px 1.6px 3.6px 0px, rgba(0, 0, 0, 0.11) 0px 0.3px 0.9px 0px;
@FabricBoxMargin: 4px 3px 4px 3px;
@FabricAccentMediumHigh: #0c695a;
@ -334,3 +337,10 @@
height: 0;
border-color: @InfoPointerColor transparent;
}
/*********************************************************************************************************
Screen Reader Only
**********************************************************************************************************/
.screenReaderOnly {
position: absolute;
left: -9999px;
}

View File

@ -2264,38 +2264,49 @@ a:link {
width: 82px;
}
.tabdocuments .scrollable {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding-left: 5px;
padding-right: 5px;
width: 100%;
}
// .tabdocuments .scrollable {
// height: 100%;
// overflow-y: auto;
// overflow-x: hidden;
// padding-left: 5px;
// padding-right: 5px;
// width: 100%;
// }
.tabdocuments > .tabdocumentsGridElement {
width: 50%;
}
// .tabdocuments > .tabdocumentsGridElement {
// width: 50%;
// }
.tabdocuments > .evenlySpacedHeader {
width: 30%;
}
// .tabdocuments > .evenlySpacedHeader {
// width: 30%;
// }
.tabdocuments.scrollable:focus,
.tabdocuments.scrollable:active {
outline: 1px dotted;
}
// .tabdocuments.scrollable:focus,
// .tabdocuments.scrollable:active {
// outline: 1px dotted;
// }
.tabdocuments .scrollable table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// .tabdocuments .scrollable table td {
// white-space: nowrap;
// overflow: hidden;
// text-overflow: ellipsis;
// }
.mongoDocumentEditor .monaco-editor.vs .redsquiggly {
display: none !important;
}
.monaco-editor .quick-input-list-label {
/* Restore some of Monaco's default styles that are clobbered by our global styles */
padding: 0;
line-height: 22px;
}
.monaco-editor .quick-input-list .highlight {
/* Padding in highlighted text within the quick input list breaks the flow of the text */
padding: 0;
}
td a {
color: #393939;
}
@ -2305,10 +2316,9 @@ td a:hover {
}
.loadMore {
display: block;
width: 100%;
padding-left: 30%;
padding-top: 2px;
cursor: pointer;
text-align: center;
}
.loadMore > a:focus {
@ -2356,9 +2366,9 @@ a:link {
.tabsManagerContainer {
height: 100%;
flex-grow: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 300px;
overflow-y: scroll;
}
.tabs {
@ -2547,10 +2557,12 @@ a:link {
}
.filterdivs {
padding-top: 15px;
height: 45px;
margin-bottom: 8px;
margin: 10px 0px;
white-space: nowrap;
input {
line-height: 14px; // To correct vertical position of the down arrow of the input
outline: none; // Remove the dotted border on focus, because fluent has its own focus style (underlined)
}
}
.editFilterContainer {
@ -2567,6 +2579,18 @@ a:link {
cursor: pointer;
}
.documentsTab {
.documentsTable {
.documentsTableCell {
border-left: 1px solid @BaseMedium;
height: 100%;
}
.documentsTableHeader {
border-bottom: 1px solid @BaseMedium;
}
}
}
.querydropdown {
border: 1px solid @BaseMedium;
font-style: normal;
@ -2647,7 +2671,7 @@ a:link {
width: @ActiveTabWidth;
}
.nav-tabs > li.active > .tabNavContentContainer > .tab_Content > .tabNavText {
.nav-tabs > li.active > .tabNavContentContainer > .tab_Content > .contentWrapper > .tabNavText {
font-weight: bolder;
border-bottom: 2px solid rgba(0, 120, 212, 1);
}
@ -2683,6 +2707,9 @@ a:link {
width: @TabsWidth;
border-right: @ButtonBorderWidth solid @BaseMedium;
white-space: nowrap;
.contentWrapper {
.flex-display();
width: @ContentWrapper;
.statusIconContainer {
width: @StatusIconContainerSize;
@ -2741,9 +2768,10 @@ a:link {
white-space: nowrap;
flex-grow: 1;
}
}
.tabIconSection {
width: 30px;
width: 29px;
position: relative;
padding-top: 2px;
@ -2897,9 +2925,21 @@ a:link {
padding-left: 8px;
}
.settingsSectionInlineCheckbox {
display: flex;
flex-direction: row-reverse;
justify-content: flex-end;
align-items: center;
gap: 5px;
}
.settingsSectionLabel {
margin-bottom: @DefaultSpace;
margin-right: 5px;
.panelInfoIcon {
margin-left: 5px;
}
}
.pageOptionsPart {

View File

@ -25,33 +25,38 @@ a:focus {
}
.resourceTreeAndTabs {
border-radius: @FabricBoxBorderRadius;
border-radius: 0px;
box-shadow: @FabricBoxBorderShadow;
margin: @FabricBoxMargin;
margin-top: 4px;
margin-top: 0px;
margin-bottom: 0px;
background-color: #ffffff;
}
.tabsManagerContainer {
background-color: #fafafa
background-color: #ffffff
}
.nav-tabs-margin {
padding-top: 8px;
background-color: #fafafa
background-color: #ffffff
}
.commandBarContainer {
background-color: #ffffff;
border-bottom: none;
border-radius: @FabricBoxBorderRadius;
border-radius: @FabricBoxBorderRadius @FabricBoxBorderRadius 0px 0px;
box-shadow: @FabricBoxBorderShadow;
margin: @FabricBoxMargin;
margin-top: 0px;
margin-bottom: 0px;
padding-top: 2px;
padding: 0px;
height: 40px;
}
.dividerContainer {
padding: @SmallSpace 0px @SmallSpace 0px;
height: @FabricCommandBarButtonHeight;
.flex-display();
span {
@ -70,7 +75,7 @@ a:focus {
border-bottom: 2px solid @FabricAccentMedium;
}
.nav-tabs>li.active>.tabNavContentContainer>.tab_Content>.tabNavText {
.nav-tabs>li.active>.tabNavContentContainer>.tab_Content>.contentWrapper>.tabNavText {
border-bottom: 0px none transparent;
}
@ -88,9 +93,11 @@ a:focus {
width: calc(@TabsWidth - (@SmallSpace * 2));
padding-bottom: @SmallSpace;
.contentWrapper {
.statusIconContainer {
margin-left: 0px;
}
}
.tabIconSection {
.cancelButton {
@ -158,9 +165,10 @@ a:focus {
.dataExplorerErrorConsoleContainer {
border-radius: @FabricBoxBorderRadius;
border-radius: 0px 0px @FabricBoxBorderRadius @FabricBoxBorderRadius;
box-shadow: @FabricBoxBorderShadow;
margin: @FabricBoxMargin;
margin-top: 0px;
width: auto;
align-self: auto;
}

3995
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,10 +5,10 @@
"main": "index.js",
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.0.0",
"@azure/cosmos": "4.0.1-beta.3",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "1.2.1",
"@azure/ms-rest-nodeauth": "3.0.7",
"@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",
@ -46,6 +46,8 @@
"@types/lodash": "4.14.171",
"@types/mkdirp": "1.0.1",
"@types/node-fetch": "2.5.7",
"@uiw/react-split": "5.9.3",
"@xmldom/xmldom": "0.7.13",
"applicationinsights": "1.8.0",
"bootstrap": "3.4.1",
"canvas": "file:./canvas",
@ -54,7 +56,7 @@
"copy-webpack-plugin": "11.0.0",
"crossroads": "0.12.2",
"css-element-queries": "1.1.1",
"d3": "6.1.1",
"d3": "7.8.5",
"datatables.net-colreorder-dt": "1.7.0",
"datatables.net-dt": "1.13.8",
"date-fns": "1.29.0",
@ -69,17 +71,19 @@
"i18next-browser-languagedetector": "6.0.1",
"i18next-http-backend": "1.0.23",
"iframe-resizer-react": "1.1.0",
"immer": "9.0.6",
"immutable": "4.0.0-rc.12",
"is-ci": "2.0.0",
"jquery": "3.7.1",
"jquery-typeahead": "2.11.1",
"jquery-ui-dist": "1.13.2",
"knockout": "3.5.1",
"loader-utils": "2.0.3",
"mkdirp": "1.0.4",
"monaco-editor": "0.44.0",
"ms": "2.1.3",
"patch-package": "8.0.0",
"p-retry": "4.6.2",
"patch-package": "8.0.0",
"plotly.js-cartesian-dist-min": "1.52.3",
"post-robot": "10.0.42",
"q": "1.5.1",
@ -95,13 +99,16 @@
"react-splitter-layout": "4.0.0",
"react-string-format": "1.0.1",
"react-youtube": "9.0.1",
"react-window": "1.8.10",
"reflect-metadata": "0.1.13",
"rx-jupyter": "5.5.12",
"sanitize-html": "2.3.3",
"shell-quote": "1.7.3",
"styled-components": "5.0.1",
"swr": "0.4.0",
"terser-webpack-plugin": "5.3.9",
"underscore": "1.9.1",
"tinykeys": "2.1.0",
"underscore": "1.12.1",
"utility-types": "3.10.0",
"zustand": "3.5.0"
},
@ -110,6 +117,7 @@
"@babel/preset-env": "7.9.0",
"@babel/preset-react": "7.9.4",
"@babel/preset-typescript": "7.9.0",
"@playwright/test": "1.44.0",
"@testing-library/react": "11.2.3",
"@types/applicationinsights-js": "1.0.7",
"@types/codemirror": "0.0.56",
@ -118,8 +126,8 @@
"@types/datatables.net": "1.10.28",
"@types/datatables.net-colreorder": "1.4.5",
"@types/dom-to-image": "2.6.2",
"@types/enzyme": "3.10.7",
"@types/enzyme-adapter-react-16": "1.0.6",
"@types/enzyme": "3.10.12",
"@types/enzyme-adapter-react-16": "1.0.9",
"@types/hasher": "0.0.31",
"@types/jest": "26.0.20",
"@types/jquery": "3.5.29",
@ -131,6 +139,7 @@
"@types/react-notification-system": "0.2.39",
"@types/react-redux": "7.1.7",
"@types/react-splitter-layout": "3.0.1",
"@types/react-window": "1.8.8",
"@types/sanitize-html": "1.27.2",
"@types/sinon": "2.3.3",
"@types/styled-components": "5.1.1",
@ -146,14 +155,13 @@
"create-file-webpack": "1.0.2",
"css-loader": "6.8.1",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.5",
"enzyme-to-json": "3.6.1",
"enzyme-adapter-react-16": "1.15.8",
"enzyme-to-json": "3.6.2",
"eslint": "8.50.0",
"eslint-cli": "1.1.1",
"eslint-plugin-no-null": "1.0.2",
"eslint-plugin-prefer-arrow": "1.2.3",
"eslint-plugin-react-hooks": "4.6.0",
"expect-playwright": "0.3.3",
"fast-glob": "3.2.5",
"fs-extra": "7.0.0",
"html-inline-css-webpack-plugin": "1.11.2",
@ -162,7 +170,6 @@
"html-webpack-plugin": "5.5.3",
"jest": "26.6.3",
"jest-canvas-mock": "2.3.1",
"jest-playwright-preset": "1.5.1",
"jest-react-hooks-shallow": "1.5.1",
"jest-trx-results-processor": "0.0.7",
"less": "3.8.1",
@ -170,25 +177,24 @@
"less-vars-loader": "1.1.0",
"mini-css-extract-plugin": "2.1.0",
"monaco-editor-webpack-plugin": "7.1.0",
"node-fetch": "2.6.1",
"playwright": "1.13.0",
"node-fetch": "2.6.7",
"prettier": "3.0.3",
"process": "0.11.10",
"querystring-es3": "0.2.1",
"raw-loader": "0.5.1",
"react-dev-utils": "11.0.4",
"react-dev-utils": "12.0.1",
"rimraf": "3.0.0",
"sinon": "3.2.1",
"style-loader": "0.23.0",
"ts-loader": "9.2.4",
"typedoc": "0.21.5",
"typedoc": "0.22.15",
"typescript": "4.3.5",
"url-loader": "4.1.1",
"wait-on": "4.0.2",
"webpack": "5.88.2",
"webpack-bundle-analyzer": "4.9.1",
"webpack-cli": "5.1.4",
"webpack-dev-server": "4.15.1"
"webpack-dev-server": "4.15.2"
},
"scripts": {
"postinstall": "patch-package",
@ -203,6 +209,7 @@
"test": "rimraf coverage && jest",
"test:debug": "jest --runInBand",
"test:e2e": "jest -c ./jest.config.playwright.js --detectOpenHandles",
"test:file": "jest --coverage=false",
"watch": "npm run start",
"wait-for-server": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/",
"build:ase": "gulp build:ase",

View File

@ -0,0 +1,11 @@
diff --git a/node_modules/@uiw/react-split/cjs/index.d.ts b/node_modules/@uiw/react-split/cjs/index.d.ts
index 644bcc3..f794760 100644
--- a/node_modules/@uiw/react-split/cjs/index.d.ts
+++ b/node_modules/@uiw/react-split/cjs/index.d.ts
@@ -56,5 +56,5 @@ export default class Split extends React.Component<SplitProps, SplitState> {
onMouseDown(paneNumber: number, env: React.MouseEvent<HTMLDivElement, MouseEvent>): void;
onDragging(env: Event): void;
onDragEnd(): void;
- render(): import("react/jsx-runtime").JSX.Element;
+ render(): JSX.Element;
}

53
playwright.config.ts Normal file
View File

@ -0,0 +1,53 @@
import { defineConfig, devices } from '@playwright/test';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: 'test',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 3 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'blob' : 'html',
timeout: 10 * 60 * 1000,
use: {
actionTimeout: 5 * 60 * 1000,
trace: 'off',
video: 'off',
screenshot: 'on',
testIdAttribute: 'data-test',
contextOptions: {
ignoreHTTPSErrors: true,
},
},
expect: {
timeout: 5 * 60 * 1000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run start',
url: 'https://127.0.0.1:1234/_ready',
timeout: 120 * 1000,
ignoreHTTPSErrors: true,
reuseExistingServer: !process.env.CI,
},
});

View File

@ -88,6 +88,12 @@ export class CapabilityNames {
public static readonly EnableStorageAnalytics: string = "EnableStorageAnalytics";
public static readonly EnableMongo: string = "EnableMongo";
public static readonly EnableServerless: string = "EnableServerless";
public static readonly EnableNoSQLVectorSearch: string = "EnableNoSQLVectorSearch";
}
export enum CapacityMode {
Provisioned = "Provisioned",
Serverless = "Serverless",
}
// flight names returned from the portal are always lowercase
@ -124,7 +130,37 @@ export enum MongoBackendEndpointType {
remote,
}
// TODO: 435619 Add default endpoints per cloud and use regional only when available
export class BackendApi {
public static readonly GenerateToken: string = "GenerateToken";
public static readonly PortalSettings: string = "PortalSettings";
public static readonly AccountRestrictions: string = "AccountRestrictions";
}
export class PortalBackendEndpoints {
public static readonly Development: string = "https://localhost:7235";
public static readonly Mpac: string = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
public static readonly Prod: string = "https://cdb-ms-prod-pbe.cosmos.azure.com";
public static readonly Fairfax: string = "https://cdb-ff-prod-pbe.cosmos.azure.us";
public static readonly Mooncake: string = "https://cdb-mc-prod-pbe.cosmos.azure.cn";
}
export class MongoProxyEndpoints {
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 CassandraProxyEndpoints {
public static readonly Development: string = "https://localhost:7240";
public static readonly Mpac: string = "https://cdb-ms-mpac-cp.cosmos.azure.com";
public static readonly Prod: string = "https://cdb-ms-prod-cp.cosmos.azure.com";
public static readonly Fairfax: string = "https://cdb-ff-prod-cp.cosmos.azure.us";
public static readonly Mooncake: string = "https://cdb-mc-prod-cp.cosmos.azure.cn";
}
//TODO: Remove this when new backend is migrated over
export class CassandraBackend {
public static readonly createOrDeleteApi: string = "api/cassandra/createordelete";
public static readonly guestCreateOrDeleteApi: string = "api/guest/cassandra/createordelete";
@ -136,6 +172,17 @@ export class CassandraBackend {
public static readonly guestSchemaApi: string = "api/guest/cassandra/schema";
}
export class CassandraProxyAPIs {
public static readonly createOrDeleteApi: string = "api/cassandra/createordelete";
public static readonly connectionStringCreateOrDeleteApi: string = "api/connectionstring/cassandra/createordelete";
public static readonly queryApi: string = "api/cassandra";
public static readonly connectionStringQueryApi: string = "api/connectionstring/cassandra";
public static readonly keysApi: string = "api/cassandra/keys";
public static readonly connectionStringKeysApi: string = "api/connectionstring/cassandra/keys";
public static readonly schemaApi: string = "api/cassandra/schema";
public static readonly connectionStringSchemaApi: string = "api/connectionstring/cassandra/schema";
}
export class Queries {
public static CustomPageOption: string = "custom";
public static UnlimitedPageOption: string = "unlimited";
@ -209,6 +256,11 @@ export class HttpHeaders {
public static partitionKey: string = "x-ms-documentdb-partitionkey";
public static migrateOfferToManualThroughput: string = "x-ms-cosmos-migrate-offer-to-manual-throughput";
public static migrateOfferToAutopilot: string = "x-ms-cosmos-migrate-offer-to-autopilot";
public static xAPIKey: string = "X-API-Key";
}
export class ContentType {
public static applicationJson: string = "application/json";
}
export class ApiType {

View File

@ -1,7 +1,6 @@
import * as Cosmos from "@azure/cosmos";
import { sendCachedDataMessage } from "Common/MessageHandler";
import { getAuthorizationTokenUsingResourceTokens } from "Common/getAuthorizationTokenUsingResourceTokens";
import { AuthorizationToken, MessageTypes } from "Contracts/MessageTypes";
import { AuthorizationToken } from "Contracts/FabricMessageTypes";
import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { AuthType } from "../AuthType";
@ -40,9 +39,10 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
case Cosmos.ResourceType.item:
case Cosmos.ResourceType.pkranges:
// User resource tokens
// TODO userContext.fabricContext.databaseConnectionInfo can be undefined
headers[HttpHeaders.msDate] = new Date().toUTCString();
const resourceTokens = userContext.fabricDatabaseConnectionInfo.resourceTokens;
checkDatabaseResourceTokensValidity(userContext.fabricDatabaseConnectionInfo.resourceTokensTimestamp);
const resourceTokens = userContext.fabricContext.databaseConnectionInfo.resourceTokens;
checkDatabaseResourceTokensValidity(userContext.fabricContext.databaseConnectionInfo.resourceTokensTimestamp);
return getAuthorizationTokenUsingResourceTokens(resourceTokens, requestInfo.path, requestInfo.resourceId);
case Cosmos.ResourceType.none:
@ -50,13 +50,23 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
case Cosmos.ResourceType.offer:
case Cosmos.ResourceType.user:
case Cosmos.ResourceType.permission:
// 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.databaseConnectionInfo.resourceTokens;
return getAuthorizationTokenUsingResourceTokens(resourceTokens2, requestInfo.path, requestInfo.resourceId);
/* ************** TODO: Uncomment this code if we need to support these operations **************
// User master tokens
const authorizationToken = await sendCachedDataMessage<AuthorizationToken>(MessageTypes.GetAuthorizationToken, [
requestInfo,
]);
const authorizationToken = await sendCachedDataMessage<AuthorizationToken>(
FabricMessageTypes.GetAuthorizationToken,
[requestInfo],
userContext.fabricContext.connectionId,
);
console.log("Response from Fabric: ", authorizationToken);
headers[HttpHeaders.msDate] = authorizationToken.XDate;
return decodeURIComponent(authorizationToken.PrimaryReadWriteToken);
***********************************************************************************************/
}
}

View File

@ -1,9 +1,9 @@
import { userContext } from "../UserContext";
export const getEntityName = (): string => {
export const getEntityName = (multiple?: boolean): string => {
if (userContext.apiType === "Mongo") {
return "document";
return multiple ? "documents" : "document";
}
return "item";
return multiple ? "items" : "item";
};

View File

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

View File

@ -1,5 +1,7 @@
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
import Q from "q";
import * as _ from "underscore";
import * as Logger from "../Common/Logger";
import { MessageTypes } from "../Contracts/ExplorerContracts";
import { getDataExplorerWindow } from "../Utils/WindowUtils";
import * as Constants from "./Constants";
@ -27,15 +29,24 @@ export function handleCachedDataMessage(message: any): void {
runGarbageCollector();
}
/**
*
* @param messageType
* @param params
* @param scope Use this string to identify request Useful to distinguish response from different senders
* @param timeoutInMs
* @returns
*/
export function sendCachedDataMessage<TResponseDataModel>(
messageType: MessageTypes,
messageType: MessageTypes | FabricMessageTypes,
params: Object[],
scope?: string,
timeoutInMs?: number,
): Q.Promise<TResponseDataModel> {
let cachedDataPromise: CachedDataPromise<TResponseDataModel> = {
deferred: Q.defer<TResponseDataModel>(),
startTime: new Date(),
id: _.uniqueId(),
id: _.uniqueId(scope),
};
RequestMap[cachedDataPromise.id] = cachedDataPromise;
sendMessage({ type: messageType, params: params, id: cachedDataPromise.id });
@ -47,6 +58,10 @@ export function sendCachedDataMessage<TResponseDataModel>(
);
}
/**
*
* @param data Overwrite the data property of the message
*/
export function sendMessage(data: any): void {
_sendMessage({
signature: "pcIframe",
@ -82,10 +97,18 @@ const _sendMessage = (message: any): void => {
const portalChildWindow = getDataExplorerWindow(window) || window;
if (portalChildWindow === window) {
// Current window is a child of portal, send message to portal window
portalChildWindow.parent.postMessage(message, portalChildWindow.document.referrer || "*");
if (portalChildWindow.document.referrer) {
portalChildWindow.parent.postMessage(message, portalChildWindow.document.referrer);
} else {
Logger.logError("Iframe failed to send message to portal", "MessageHandler");
}
} else {
// Current window is not a child of portal, send message to the child window instead (which is data explorer)
portalChildWindow.postMessage(message, portalChildWindow.location.origin || "*");
if (portalChildWindow.location.origin) {
portalChildWindow.postMessage(message, portalChildWindow.location.origin);
} else {
Logger.logError("Iframe failed to send message to data explorer", "MessageHandler");
}
}
}
};

View File

@ -1,6 +1,10 @@
import { Constants as CosmosSDKConstants } from "@azure/cosmos";
import {
allowedMongoProxyEndpoints,
allowedMongoProxyEndpoints_ToBeDeprecated,
validateEndpoint,
} from "Utils/EndpointUtils";
import queryString from "querystring";
import { allowedMongoProxyEndpoints, validateEndpoint } from "Utils/EndpointValidation";
import { AuthType } from "../AuthType";
import { configContext } from "../ConfigContext";
import * as DataModels from "../Contracts/DataModels";
@ -10,7 +14,7 @@ import DocumentId from "../Explorer/Tree/DocumentId";
import { hasFlag } from "../Platform/Hosted/extractFeatures";
import { userContext } from "../UserContext";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import { ApiType, HttpHeaders, HttpStatusCodes } from "./Constants";
import { ApiType, ContentType, HttpHeaders, HttpStatusCodes, MongoProxyEndpoints } from "./Constants";
import { MinimalQueryIterator } from "./IteratorUtilities";
import { sendMessage } from "./MessageHandler";
@ -62,6 +66,73 @@ export function queryDocuments(
isResourceList: boolean,
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 = {
databaseID: databaseId,
collectionID: collection.id(),
resourceUrl: `${resourceEndpoint}dbs/${databaseId}/colls/${collection.id()}/docs/`,
resourceID: collection.rid,
resourceType: "docs",
subscriptionID: userContext.subscriptionId,
resourceGroup: userContext.resourceGroup,
databaseAccountName: databaseAccount.name,
partitionKey:
collection && collection.partitionKey && !collection.partitionKey.systemKey
? collection.partitionKeyProperties?.[0]
: "",
query,
};
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" : "/queryDocuments";
return window
.fetch(`${endpoint}${path}`, {
method: "POST",
body: JSON.stringify(params),
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;
});
}
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;
@ -122,6 +193,54 @@ 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("/");
const path = idComponents.slice(0, 4).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("readDocument");
return window
.fetch(endpoint, {
method: "POST",
body: JSON.stringify(params),
headers: {
...defaultHeaders,
...authHeaders(),
[HttpHeaders.contentType]: ContentType.applicationJson,
},
})
.then(async (response) => {
if (response.ok) {
return response.json();
}
return await errorHandling(response, "reading document", params);
});
}
export function readDocument_ToBeDeprecated(
databaseId: string,
collection: Collection,
documentId: DocumentId,
): Promise<DataModels.DocumentId> {
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
@ -169,6 +288,51 @@ export function createDocument(
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 = {
databaseID: databaseId,
collectionID: collection.id(),
resourceUrl: `${resourceEndpoint}dbs/${databaseId}/colls/${collection.id()}/docs/`,
resourceID: collection.rid,
resourceType: "docs",
subscriptionID: userContext.subscriptionId,
resourceGroup: userContext.resourceGroup,
databaseAccountName: databaseAccount.name,
partitionKey:
collection && collection.partitionKey && !collection.partitionKey.systemKey ? partitionKeyProperty : "",
documentContent: JSON.stringify(documentContent),
};
const endpoint = getFeatureEndpointOrDefault("createDocument");
return window
.fetch(`${endpoint}/createDocument`, {
method: "POST",
body: JSON.stringify(params),
headers: {
...defaultHeaders,
...authHeaders(),
[HttpHeaders.contentType]: ContentType.applicationJson,
},
})
.then(async (response) => {
if (response.ok) {
return response.json();
}
return await errorHandling(response, "creating document", params);
});
}
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;
@ -208,6 +372,56 @@ export function updateDocument(
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("/");
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]
: "",
documentContent,
};
const endpoint = getFeatureEndpointOrDefault("updateDocument");
return window
.fetch(endpoint, {
method: "PUT",
body: JSON.stringify(params),
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 updateDocument_ToBeDeprecated(
databaseId: string,
collection: Collection,
documentId: DocumentId,
documentContent: string,
): Promise<DataModels.DocumentId> {
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
@ -237,7 +451,7 @@ export function updateDocument(
headers: {
...defaultHeaders,
...authHeaders(),
[HttpHeaders.contentType]: "application/json",
[HttpHeaders.contentType]: ContentType.applicationJson,
[CosmosSDKConstants.HttpHeaders.PartitionKey]: JSON.stringify(documentId.partitionKeyHeader()),
},
})
@ -250,6 +464,53 @@ export function updateDocument(
}
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("/");
@ -277,7 +538,7 @@ export function deleteDocument(databaseId: string, collection: Collection, docum
headers: {
...defaultHeaders,
...authHeaders(),
[HttpHeaders.contentType]: "application/json",
[HttpHeaders.contentType]: ContentType.applicationJson,
[CosmosSDKConstants.HttpHeaders.PartitionKey]: JSON.stringify(documentId.partitionKeyHeader()),
},
})
@ -291,6 +552,52 @@ export function deleteDocument(databaseId: string, collection: Collection, docum
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];
const createCollectionParams = {
databaseID: params.databaseId,
collectionID: params.collectionId,
resourceUrl: databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint,
resourceID: "",
resourceType: "colls",
subscriptionID: userContext.subscriptionId,
resourceGroup: userContext.resourceGroup,
databaseAccountName: databaseAccount.name,
partitionKey: shardKey,
isAutoscale: !!params.autoPilotMaxThroughput,
hasSharedThroughput: params.databaseLevelThroughput,
offerThroughput: params.autoPilotMaxThroughput || params.offerThroughput,
createDatabase: params.createNewDatabase,
isSharded: !!shardKey,
};
const endpoint = getFeatureEndpointOrDefault("createCollectionWithProxy");
return window
.fetch(`${endpoint}/createCollection`, {
method: "POST",
body: JSON.stringify(createCollectionParams),
headers: {
...defaultHeaders,
...authHeaders(),
[HttpHeaders.contentType]: ContentType.applicationJson,
},
})
.then(async (response) => {
if (response.ok) {
return response.json();
}
return await errorHandling(response, "creating collection", createCollectionParams);
});
}
export function createMongoCollectionWithProxy_ToBeDeprecated(
params: DataModels.CreateCollectionParams,
): Promise<DataModels.Collection> {
const { databaseAccount } = userContext;
const shardKey: string = params.partitionKey?.paths[0];
@ -334,13 +641,20 @@ export function createMongoCollectionWithProxy(
return await errorHandling(response, "creating collection", mongoParams);
});
}
export function getFeatureEndpointOrDefault(feature: string): string {
const endpoint =
let endpoint;
if (useMongoProxyEndpoint(feature)) {
endpoint = configContext.MONGO_PROXY_ENDPOINT;
} else {
endpoint =
hasFlag(userContext.features.mongoProxyAPIs, feature) &&
validateEndpoint(userContext.features.mongoProxyEndpoint, allowedMongoProxyEndpoints)
validateEndpoint(userContext.features.mongoProxyEndpoint, [
...allowedMongoProxyEndpoints,
...allowedMongoProxyEndpoints_ToBeDeprecated,
])
? userContext.features.mongoProxyEndpoint
: configContext.MONGO_BACKEND_ENDPOINT || configContext.BACKEND_ENDPOINT;
}
return getEndpoint(endpoint);
}
@ -349,11 +663,37 @@ export function getEndpoint(endpoint: string): string {
let url = endpoint + "/api/mongo/explorer";
if (userContext.authType === AuthType.EncryptedToken) {
if (endpoint === configContext.MONGO_PROXY_ENDPOINT) {
url = url.replace("api/mongo", "api/connectionstring/mongo");
} else {
url = url.replace("api/mongo", "api/guest/mongo");
}
}
return url;
}
export function useMongoProxyEndpoint(api: string): boolean {
const activeMongoProxyEndpoints: string[] = [
MongoProxyEndpoints.Local,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
MongoProxyEndpoints.Fairfax,
];
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
// It causes problems for TypeScript understanding the types
async function errorHandling(response: Response, action: string, params: unknown): Promise<void> {

View File

@ -3,8 +3,7 @@ import * as _ from "underscore";
import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
import Explorer from "../Explorer/Explorer";
import DocumentsTab from "../Explorer/Tabs/DocumentsTab";
import DocumentId from "../Explorer/Tree/DocumentId";
import DocumentId, { IDocumentIdContainer } from "../Explorer/Tree/DocumentId";
import { useDatabases } from "../Explorer/useDatabases";
import { userContext } from "../UserContext";
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
@ -162,10 +161,10 @@ export class QueriesClient {
{
partitionKey: QueriesClient.PartitionKey,
partitionKeyProperties: ["id"],
} as DocumentsTab,
} as IDocumentIdContainer,
query,
[query.queryName],
); // TODO: Remove DocumentId's dependency on DocumentsTab
);
const options: any = { partitionKey: query.resourceId };
return deleteDocument(queriesCollection, documentId)
.then(

View File

@ -1,14 +1,10 @@
import { ResourceTree } from "Explorer/Tree/ResourceTree";
import React, { FunctionComponent, MutableRefObject, useEffect, useRef } from "react";
import arrowLeftImg from "../../images/imgarrowlefticon.svg";
import refreshImg from "../../images/refresh-cosmos.svg";
import { AuthType } from "../AuthType";
import Explorer from "../Explorer/Explorer";
import { ResourceTokenTree } from "../Explorer/Tree/ResourceTokenTree";
import { ResourceTree2 } from "../Explorer/Tree2/ResourceTree";
import { ResourceTree } from "../Explorer/Tree/ResourceTree";
import { userContext } from "../UserContext";
import { getApiShortDisplayName } from "../Utils/APITypeUtils";
import { Platform, configContext } from "./../ConfigContext";
import { NormalizedEventKey } from "./Constants";
export interface ResourceTreeContainerProps {
@ -74,12 +70,8 @@ export const ResourceTreeContainer: FunctionComponent<ResourceTreeContainerProps
</div>
</div>
</div>
{userContext.authType === AuthType.ResourceToken ? (
<ResourceTokenTree />
) : userContext.features.enableKoResourceTree ? (
{userContext.features.enableKoResourceTree ? (
<div style={{ overflowY: "auto" }} data-bind="react:resourceTree" />
) : configContext.platform === Platform.Fabric ? (
<ResourceTree2 container={container} />
) : (
<ResourceTree container={container} />
)}

View File

@ -142,7 +142,7 @@ export const TableEntity: FunctionComponent<TableEntityProps> = ({
<Image
{...imageProps}
src={EditIcon}
alt="editEntity"
alt={`Edit ${entityProperty} entity`}
onClick={onEditEntity}
tabIndex={0}
onKeyPress={handleKeyPress}
@ -156,7 +156,7 @@ export const TableEntity: FunctionComponent<TableEntityProps> = ({
<Image
{...imageProps}
src={DeleteIcon}
alt="delete entity"
alt={`Delete ${entityProperty} entity`}
id="deleteEntity"
onClick={onDeleteEntity}
tabIndex={0}

View File

@ -2,6 +2,7 @@
exports[`getCommonQueryOptions builds the correct default options objects 1`] = `
Object {
"disableNonStreamingOrderByQuery": true,
"enableScanInQuery": true,
"forceQueryPlan": true,
"maxDegreeOfParallelism": 0,
@ -12,6 +13,7 @@ Object {
exports[`getCommonQueryOptions reads from localStorage 1`] = `
Object {
"disableNonStreamingOrderByQuery": true,
"enableScanInQuery": true,
"forceQueryPlan": true,
"maxDegreeOfParallelism": 17,

View File

@ -6,13 +6,13 @@ import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstan
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext";
import { getCollectionName } from "../../Utils/APITypeUtils";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { createUpdateCassandraTable } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
import { createUpdateGremlinGraph } from "../../Utils/arm/generatedClients/cosmos/gremlinResources";
import { createUpdateMongoDBCollection } from "../../Utils/arm/generatedClients/cosmos/mongoDBResources";
import { createUpdateSqlContainer } from "../../Utils/arm/generatedClients/cosmos/sqlResources";
import { createUpdateTable } from "../../Utils/arm/generatedClients/cosmos/tableResources";
import * as ARMTypes from "../../Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
import { createMongoCollectionWithProxy } from "../MongoProxyClient";
@ -96,6 +96,9 @@ const createSqlContainer = async (params: DataModels.CreateCollectionParams): Pr
if (params.uniqueKeyPolicy) {
resource.uniqueKeyPolicy = params.uniqueKeyPolicy;
}
if (params.vectorEmbeddingPolicy) {
resource.vectorEmbeddingPolicy = params.vectorEmbeddingPolicy;
}
const rpPayload: ARMTypes.SqlDatabaseCreateUpdateParameters = {
properties: {

View File

@ -0,0 +1,195 @@
import { ApiType, userContext } from "UserContext";
import * as NotificationConsoleUtils from "Utils/NotificationConsoleUtils";
import {
cancel,
create,
get,
listByDatabaseAccount,
} from "Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
import {
CosmosCassandraDataTransferDataSourceSink,
CosmosMongoDataTransferDataSourceSink,
CosmosSqlDataTransferDataSourceSink,
CreateJobRequest,
DataTransferJobFeedResults,
DataTransferJobGetResults,
} from "Utils/arm/generatedClients/dataTransferService/types";
import { addToPolling, removeFromPolling, updateDataTransferJob, useDataTransferJobs } from "hooks/useDataTransferJobs";
import promiseRetry, { AbortError, FailedAttemptError } from "p-retry";
export interface DataTransferParams {
jobName: string;
apiType: ApiType;
subscriptionId: string;
resourceGroupName: string;
accountName: string;
sourceDatabaseName: string;
sourceCollectionName: string;
targetDatabaseName: string;
targetCollectionName: string;
}
export const getDataTransferJobs = async (
subscriptionId: string,
resourceGroup: string,
accountName: string,
): Promise<DataTransferJobGetResults[]> => {
let dataTransferJobs: DataTransferJobGetResults[] = [];
let dataTransferFeeds: DataTransferJobFeedResults = await listByDatabaseAccount(
subscriptionId,
resourceGroup,
accountName,
);
dataTransferJobs = [...dataTransferJobs, ...(dataTransferFeeds?.value || [])];
while (dataTransferFeeds?.nextLink) {
const nextResponse = await window.fetch(dataTransferFeeds.nextLink, {
headers: {
Authorization: userContext.authorizationToken,
},
});
if (nextResponse.ok) {
dataTransferFeeds = await nextResponse.json();
dataTransferJobs = [...dataTransferJobs, ...(dataTransferFeeds?.value || [])];
} else {
break;
}
}
return dataTransferJobs;
};
export const initiateDataTransfer = async (params: DataTransferParams): Promise<DataTransferJobGetResults> => {
const {
jobName,
apiType,
subscriptionId,
resourceGroupName,
accountName,
sourceDatabaseName,
sourceCollectionName,
targetDatabaseName,
targetCollectionName,
} = params;
const sourcePayload = createPayload(apiType, sourceDatabaseName, sourceCollectionName);
const targetPayload = createPayload(apiType, targetDatabaseName, targetCollectionName);
const body: CreateJobRequest = {
properties: {
source: sourcePayload,
destination: targetPayload,
},
};
return create(subscriptionId, resourceGroupName, accountName, jobName, body);
};
export const pollDataTransferJob = async (
jobName: string,
subscriptionId: string,
resourceGroupName: string,
accountName: string,
): Promise<unknown> => {
const currentPollingJobs = useDataTransferJobs.getState().pollingDataTransferJobs;
if (currentPollingJobs.has(jobName)) {
return;
}
let clearMessage = NotificationConsoleUtils.logConsoleProgress(`Data transfer job ${jobName} in progress`);
return await promiseRetry(
() => pollDataTransferJobOperation(jobName, subscriptionId, resourceGroupName, accountName, clearMessage),
{
retries: 500,
maxTimeout: 5000,
onFailedAttempt: (error: FailedAttemptError) => {
clearMessage();
clearMessage = NotificationConsoleUtils.logConsoleProgress(error.message);
},
},
);
};
const pollDataTransferJobOperation = async (
jobName: string,
subscriptionId: string,
resourceGroupName: string,
accountName: string,
clearMessage?: () => void,
): Promise<DataTransferJobGetResults> => {
if (!userContext.authorizationToken) {
throw new Error("No authority token provided");
}
addToPolling(jobName);
const body: DataTransferJobGetResults = await get(subscriptionId, resourceGroupName, accountName, jobName);
const status = body?.properties?.status;
updateDataTransferJob(body);
if (status === "Cancelled") {
removeFromPolling(jobName);
clearMessage && clearMessage();
const cancelMessage = `Data transfer job ${jobName} cancelled`;
NotificationConsoleUtils.logConsoleError(cancelMessage);
throw new AbortError(cancelMessage);
}
if (status === "Failed" || status === "Faulted") {
removeFromPolling(jobName);
const errorMessage = body?.properties?.error
? JSON.stringify(body?.properties?.error)
: "Operation could not be completed";
const error = new Error(errorMessage);
clearMessage && clearMessage();
NotificationConsoleUtils.logConsoleError(`Data transfer job ${jobName} failed: ${errorMessage}`);
throw new AbortError(error);
}
if (status === "Completed") {
removeFromPolling(jobName);
clearMessage && clearMessage();
NotificationConsoleUtils.logConsoleInfo(`Data transfer job ${jobName} completed`);
return body;
}
const processedCount = body.properties.processedCount;
const totalCount = body.properties.totalCount;
const retryMessage = `Data transfer job ${jobName} in progress, total count: ${totalCount}, processed count: ${processedCount}`;
throw new Error(retryMessage);
};
export const cancelDataTransferJob = async (
subscriptionId: string,
resourceGroupName: string,
accountName: string,
jobName: string,
): Promise<void> => {
const cancelResult: DataTransferJobGetResults = await cancel(subscriptionId, resourceGroupName, accountName, jobName);
updateDataTransferJob(cancelResult);
removeFromPolling(cancelResult?.properties?.jobName);
};
const createPayload = (
apiType: ApiType,
databaseName: string,
containerName: string,
):
| CosmosSqlDataTransferDataSourceSink
| CosmosMongoDataTransferDataSourceSink
| CosmosCassandraDataTransferDataSourceSink => {
switch (apiType) {
case "SQL":
return {
component: "CosmosDBSql",
databaseName: databaseName,
containerName: containerName,
} as CosmosSqlDataTransferDataSourceSink;
case "Mongo":
return {
component: "CosmosDBMongo",
databaseName: databaseName,
collectionName: containerName,
} as CosmosMongoDataTransferDataSourceSink;
case "Cassandra":
return {
component: "CosmosDBCassandra",
keyspaceName: databaseName,
tableName: containerName,
};
default:
throw new Error(`Unsupported API type for data transfer: ${apiType}`);
}
};

View File

@ -1,3 +1,4 @@
import { BulkOperationType, OperationInput } from "@azure/cosmos";
import { CollectionBase } from "../../Contracts/ViewModels";
import DocumentId from "../../Explorer/Tree/DocumentId";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
@ -24,3 +25,58 @@ export const deleteDocument = async (collection: CollectionBase, documentId: Doc
clearMessage();
}
};
/**
* Bulk delete documents
* @param collection
* @param documentId
* @returns array of ids that were successfully deleted
*/
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());
// Bulk can only delete 100 documents at a time
const BULK_DELETE_LIMIT = 100;
const promiseArray = [];
while (documentIds.length > 0) {
const documentIdsChunk = documentIds.splice(0, BULK_DELETE_LIMIT);
const operations: OperationInput[] = documentIdsChunk.map((documentId) => ({
id: documentId.id(),
// bulk delete: if not partition key is specified, do not pass empty array, but undefined
partitionKey:
documentId.partitionKeyValue &&
Array.isArray(documentId.partitionKeyValue) &&
documentId.partitionKeyValue.length === 0
? undefined
: documentId.partitionKeyValue,
operationType: BulkOperationType.Delete,
}));
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(
error,
"DeleteDocuments",
`Error while deleting ${documentIds.length} ${getEntityName(documentIds.length > 1)}`,
);
throw error;
} finally {
clearMessage();
}
};

View File

@ -1,4 +1,5 @@
import { FeedOptions, ItemDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
import { Queries } from "../Constants";
import { client } from "../CosmosClient";
@ -26,5 +27,6 @@ export const getCommonQueryOptions = (options: FeedOptions): FeedOptions => {
(storedItemPerPageSetting !== undefined && storedItemPerPageSetting) ||
Queries.itemsPerPage;
options.maxDegreeOfParallelism = LocalStorageUtility.getEntryNumber(StorageKey.MaxDegreeOfParellism);
options.disableNonStreamingOrderByQuery = !isVectorSearchEnabled();
return options;
};

View File

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

View File

@ -2,7 +2,6 @@ import { CosmosClient } from "@azure/cosmos";
import { sampleDataClient } from "Common/SampleDataClient";
import { userContext } from "UserContext";
import * as DataModels from "../../Contracts/DataModels";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@ -31,7 +30,6 @@ export async function readCollectionInternal(
collectionId: string,
): Promise<DataModels.Collection> {
let collection: DataModels.Collection;
const clearMessage = logConsoleProgress(`Querying container ${collectionId}`);
try {
const response = await cosmosClient.database(databaseId).container(collectionId).read();
collection = response.resource as DataModels.Collection;
@ -39,6 +37,5 @@ export async function readCollectionInternal(
handleError(error, "ReadCollection", `Error while querying container ${collectionId}`);
throw error;
}
clearMessage();
return collection;
}

View File

@ -18,13 +18,13 @@ export async function readCollections(databaseId: string): Promise<DataModels.Co
if (
configContext.platform === Platform.Fabric &&
userContext.fabricDatabaseConnectionInfo &&
userContext.fabricDatabaseConnectionInfo.databaseId === databaseId
userContext.fabricContext &&
userContext.fabricContext.databaseConnectionInfo.databaseId === databaseId
) {
const collections: DataModels.Collection[] = [];
const promises: Promise<ContainerResponse>[] = [];
for (const collectionResourceId in userContext.fabricDatabaseConnectionInfo.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];

View File

@ -14,8 +14,8 @@ export async function readDatabases(): Promise<DataModels.Database[]> {
let databases: DataModels.Database[];
const clearMessage = logConsoleProgress(`Querying databases`);
if (configContext.platform === Platform.Fabric && userContext.fabricDatabaseConnectionInfo?.resourceTokens) {
const tokensData = userContext.fabricDatabaseConnectionInfo;
if (configContext.platform === Platform.Fabric && userContext.fabricContext?.databaseConnectionInfo.resourceTokens) {
const tokensData = userContext.fabricContext.databaseConnectionInfo;
const databaseIdsSet = new Set<string>(); // databaseId

View File

@ -1,17 +1,25 @@
import { JunoEndpoints } from "Common/Constants";
import {
BackendApi,
CassandraProxyEndpoints,
JunoEndpoints,
MongoProxyEndpoints,
PortalBackendEndpoints,
} from "Common/Constants";
import {
allowedAadEndpoints,
allowedArcadiaEndpoints,
allowedCassandraProxyEndpoints,
allowedEmulatorEndpoints,
allowedGraphEndpoints,
allowedHostedExplorerEndpoints,
allowedJunoOrigins,
allowedMongoBackendEndpoints,
allowedMongoProxyEndpoints,
allowedMsalRedirectEndpoints,
defaultAllowedArmEndpoints,
defaultAllowedBackendEndpoints,
validateEndpoint,
} from "Utils/EndpointValidation";
} from "Utils/EndpointUtils";
export enum Platform {
Portal = "Portal",
@ -34,10 +42,22 @@ export interface ConfigContext {
ARM_API_VERSION: string;
GRAPH_ENDPOINT: string;
GRAPH_API_VERSION: string;
// This is the endpoint to get offering Ids to be used to fetch prices. Refer to this doc: https://learn.microsoft.com/en-us/rest/api/marketplacecatalog/dataplane/skus/list?view=rest-marketplacecatalog-dataplane-2023-05-01-preview&tabs=HTTP
CATALOG_ENDPOINT: string;
CATALOG_API_VERSION: string;
CATALOG_API_KEY: string;
ARCADIA_ENDPOINT: string;
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
BACKEND_ENDPOINT?: string;
PORTAL_BACKEND_ENDPOINT?: string;
NEW_BACKEND_APIS?: BackendApi[];
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;
GITHUB_CLIENT_ID: string;
@ -67,6 +87,7 @@ let configContext: Readonly<ConfigContext> = {
`^https:\\/\\/.*\\.analysis-df\\.net$`,
`^https:\\/\\/.*\\.analysis-df\\.windows\\.net$`,
`^https:\\/\\/.*\\.azure-test\\.net$`,
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net`,
], // Webpack injects this at build time
gitSha: process.env.GIT_SHA,
hostedExplorerURL: "https://cosmos.azure.com/",
@ -76,12 +97,31 @@ let configContext: Readonly<ConfigContext> = {
ARM_API_VERSION: "2016-06-01",
GRAPH_ENDPOINT: "https://graph.microsoft.com",
GRAPH_API_VERSION: "1.6",
CATALOG_ENDPOINT: "https://catalogapi.azure.com/",
CATALOG_API_VERSION: "2023-05-01-preview",
CATALOG_API_KEY: "",
ARCADIA_ENDPOINT: "https://workspaceartifacts.projectarcadia.net",
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: "dev.azuresynapse.net",
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",
],
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,
};
@ -127,10 +167,18 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
delete newContext.BACKEND_ENDPOINT;
}
if (!validateEndpoint(newContext.MONGO_PROXY_ENDPOINT, allowedMongoProxyEndpoints)) {
delete newContext.MONGO_PROXY_ENDPOINT;
}
if (!validateEndpoint(newContext.MONGO_BACKEND_ENDPOINT, allowedMongoBackendEndpoints)) {
delete newContext.MONGO_BACKEND_ENDPOINT;
}
if (!validateEndpoint(newContext.CASSANDRA_PROXY_ENDPOINT, allowedCassandraProxyEndpoints)) {
delete newContext.CASSANDRA_PROXY_ENDPOINT;
}
if (!validateEndpoint(newContext.JUNO_ENDPOINT, allowedJunoOrigins)) {
delete newContext.JUNO_ENDPOINT;
}
@ -148,12 +196,12 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
// Injected for local development. These will be removed in the production bundle by webpack
if (process.env.NODE_ENV === "development") {
const port: string = process.env.PORT || "1234";
updateConfigContext({
BACKEND_ENDPOINT: "https://localhost:" + port,
MONGO_BACKEND_ENDPOINT: "https://localhost:" + port,
PROXY_PATH: "/proxy",
EMULATOR_ENDPOINT: "https://localhost:8081",
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac,
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Mpac,
});
}

View File

@ -0,0 +1,27 @@
import { FabricMessageTypes } from "./FabricMessageTypes";
// This is the current version of these messages
export const DATA_EXPLORER_RPC_VERSION = "3";
// Data Explorer to Fabric
export type DataExploreMessageV3 =
| {
type: FabricMessageTypes.Ready;
id: string;
params: [string]; // version
}
| {
type: FabricMessageTypes.GetAuthorizationToken;
id: string;
params: GetCosmosTokenMessageOptions[];
}
| {
type: FabricMessageTypes.GetAllResourceTokens;
id: string;
};
export type GetCosmosTokenMessageOptions = {
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
resourceType: "" | "dbs" | "colls" | "docs" | "sprocs" | "pkranges";
resourceId: string;
};

View File

@ -1,4 +1,4 @@
import { ConnectionStatusType, ContainerStatusType } from "../Common/Constants";
import { CapacityMode, ConnectionStatusType, ContainerStatusType } from "../Common/Constants";
export interface ArmEntity {
id: string;
@ -35,6 +35,7 @@ export interface DatabaseAccountExtendedProperties {
ipRules?: IpRule[];
privateEndpointConnections?: unknown[];
capacity?: { totalThroughputLimit: number };
capacityMode?: CapacityMode;
locations?: DatabaseAccountResponseLocation[];
postgresqlEndpoint?: string;
publicNetworkAccess?: string;
@ -157,8 +158,10 @@ export interface Collection extends Resource {
changeFeedPolicy?: ChangeFeedPolicy;
analyticalStorageTtl?: number;
geospatialConfig?: GeospatialConfig;
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
schema?: ISchema;
requestSchema?: () => void;
computedProperties?: ComputedProperties;
}
export interface CollectionsWithPagination {
@ -193,10 +196,23 @@ export interface IndexingPolicy {
indexingMode: "consistent" | "lazy" | "none";
includedPaths: any;
excludedPaths: any;
compositeIndexes?: any;
spatialIndexes?: any;
compositeIndexes?: any[];
spatialIndexes?: any[];
vectorIndexes?: VectorIndex[];
}
export interface VectorIndex {
path: string;
type: "flat" | "diskANN" | "quantizedFlat";
}
export interface ComputedProperty {
name: string;
query: string;
}
export type ComputedProperties = ComputedProperty[];
export interface PartitionKey {
paths: string[];
kind: "Hash" | "Range" | "MultiHash";
@ -325,6 +341,18 @@ export interface CreateCollectionParams {
partitionKey?: PartitionKey;
uniqueKeyPolicy?: UniqueKeyPolicy;
createMongoWildcardIndex?: boolean;
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
}
export interface VectorEmbeddingPolicy {
vectorEmbeddings: VectorEmbedding[];
}
export interface VectorEmbedding {
dataType: "float16" | "float32" | "uint8" | "int8";
dimensions: number;
distanceFunction: "euclidean" | "cosine" | "dotproduct";
path: string;
}
export interface ReadDatabaseOfferParams {

View File

@ -1,6 +1,6 @@
import { MessageTypes } from "Contracts/MessageTypes";
import * as ActionContracts from "./ActionContracts";
import * as Diagnostics from "./Diagnostics";
import { MessageTypes } from "./MessageTypes";
import * as Versions from "./Versions";
export { ActionContracts, Diagnostics, MessageTypes, Versions };

View File

@ -0,0 +1,13 @@
/**
* Data Explorer -> Fabric communication.
*/
export enum FabricMessageTypes {
GetAuthorizationToken = "GetAuthorizationToken",
GetAllResourceTokens = "GetAllResourceTokens",
Ready = "Ready",
}
export interface AuthorizationToken {
XDate: string;
PrimaryReadWriteToken: string;
}

View File

@ -1,6 +1,12 @@
import { AuthorizationToken, MessageTypes } from "./MessageTypes";
import { AuthorizationToken } from "Contracts/FabricMessageTypes";
export type FabricMessage =
// This is the version of these messages
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;
@ -26,37 +32,52 @@ export type FabricMessage =
| {
type: "allResourceTokens";
message: {
id: string;
error: string | undefined;
endpoint: string | undefined;
databaseId: string | undefined;
resourceTokens: unknown | undefined;
resourceTokensTimestamp: number | undefined;
};
};
// -----------------------------
export type DataExploreMessage =
| "ready"
export type FabricMessageV2 =
| {
type: MessageTypes.TelemetryInfo;
data: {
action: "LoadDatabases";
actionModifier: "success" | "start";
defaultExperience: "SQL";
};
type: "newContainer";
databaseName: string;
}
| {
type: MessageTypes.GetAuthorizationToken;
type: "initialize";
version: string;
id: string;
params: GetCosmosTokenMessageOptions[];
message: {
connectionId: string;
isVisible: boolean;
};
}
| {
type: MessageTypes.GetAllResourceTokens;
type: "authorizationToken";
message: {
id: string;
error: string | undefined;
data: AuthorizationToken | undefined;
};
}
| {
type: "allResourceTokens_v2";
message: {
id: string;
error: string | undefined;
data: FabricDatabaseConnectionInfo | undefined;
};
}
| {
type: "explorerVisible";
message: {
visible: boolean;
};
};
export type GetCosmosTokenMessageOptions = {
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
resourceType: "" | "dbs" | "colls" | "docs" | "sprocs" | "pkranges";
resourceId: string;
};
export type CosmosDBTokenResponse = {
token: string;
@ -66,12 +87,9 @@ export type CosmosDBTokenResponse = {
export type CosmosDBConnectionInfoResponse = {
endpoint: string;
databaseId: string;
resourceTokens: unknown;
resourceTokens: { [resourceId: string]: string };
};
export interface FabricDatabaseConnectionInfo {
endpoint: string;
databaseId: string;
resourceTokens: { [resourceId: string]: string };
export interface FabricDatabaseConnectionInfo extends CosmosDBConnectionInfoResponse {
resourceTokensTimestamp: number;
}

View File

@ -1,6 +1,13 @@
/**
* Messaging types used with Data Explorer <-> Portal communication,
* Hosted <-> Explorer communication and Data Explorer -> Fabric communication.
* Hosted <-> Explorer communication
*
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
* WARNING: !!!!!!! YOU CAN ONLY ADD NEW TYPES TO THE END OF THIS ENUM !!!!!!!
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
*
* Enum are integers, so inserting or deleting a type will break the communication.
*
*/
export enum MessageTypes {
TelemetryInfo,
@ -37,13 +44,9 @@ export enum MessageTypes {
DisplayNPSSurvey,
OpenVCoreMongoNetworkingBlade,
OpenVCoreMongoConnectionStringsBlade,
// Data Explorer -> Fabric communication
GetAuthorizationToken,
GetAllResourceTokens,
}
export interface AuthorizationToken {
XDate: string;
PrimaryReadWriteToken: string;
GetAuthorizationToken, // unused. Can be removed if the portal uses the same list of enums.
GetAllResourceTokens, // unused. Can be removed if the portal uses the same list of enums.
Ready, // unused. Can be removed if the portal uses the same list of enums.
OpenCESCVAFeedbackBlade,
ActivateTab,
}

View File

@ -135,6 +135,7 @@ export interface Collection extends CollectionBase {
changeFeedPolicy: ko.Observable<DataModels.ChangeFeedPolicy>;
geospatialConfig: ko.Observable<DataModels.GeospatialConfig>;
documentIds: ko.ObservableArray<DocumentId>;
computedProperties: ko.Observable<DataModels.ComputedProperties>;
cassandraKeys: CassandraTableKeys;
cassandraSchema: CassandraTableKey[];
@ -175,6 +176,11 @@ export interface Collection extends CollectionBase {
loadTriggers(): Promise<any>;
loadOffer(): Promise<void>;
showStoredProcedures: ko.Observable<boolean>;
showTriggers: ko.Observable<boolean>;
showUserDefinedFunctions: ko.Observable<boolean>;
showConflicts: ko.Observable<boolean>;
createStoredProcedureNode(data: StoredProcedureDefinition & Resource): StoredProcedure;
createUserDefinedFunctionNode(data: UserDefinedFunctionDefinition & Resource): UserDefinedFunction;
createTriggerNode(data: TriggerDefinition | SqlTriggerResource): Trigger;
@ -323,9 +329,9 @@ export enum DocumentExplorerState {
noDocumentSelected,
newDocumentValid,
newDocumentInvalid,
exisitingDocumentNoEdits,
exisitingDocumentDirtyValid,
exisitingDocumentDirtyInvalid,
existingDocumentNoEdits,
existingDocumentDirtyValid,
existingDocumentDirtyInvalid,
}
export enum IndexingPolicyEditorState {
@ -338,9 +344,9 @@ export enum IndexingPolicyEditorState {
export enum ScriptEditorState {
newInvalid,
newValid,
exisitingNoEdits,
exisitingDirtyValid,
exisitingDirtyInvalid,
existingNoEdits,
existingDirtyValid,
existingDirtyInvalid,
}
export enum CollectionTabKind {
@ -386,9 +392,11 @@ export interface DataExplorerInputsFrame {
dnsSuffix?: string;
serverId?: string;
extensionEndpoint?: string;
portalBackendEndpoint?: string;
mongoProxyEndpoint?: string;
cassandraProxyEndpoint?: string;
subscriptionType?: SubscriptionType;
quotaId?: string;
addCollectionDefaultFlight?: string;
isTryCosmosDBSubscription?: boolean;
loadDatabaseAccountTimestamp?: number;
sharedThroughputMinimum?: number;
@ -406,6 +414,7 @@ export interface DataExplorerInputsFrame {
features?: {
[key: string]: string;
};
feedbackPolicies?: any;
}
export interface SelfServeFrameInputs {
@ -416,6 +425,7 @@ export interface SelfServeFrameInputs {
authorizationToken: string;
csmEndpoint: string;
flights?: readonly string[];
catalogAPIKey: string;
}
export class MonacoEditorSettings {

View File

@ -1,3 +1,4 @@
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import { useDatabases } from "Explorer/useDatabases";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
@ -19,7 +20,6 @@ import { userContext } from "../UserContext";
import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils";
import { useSidePanel } from "../hooks/useSidePanel";
import { Platform, configContext } from "./../ConfigContext";
import { TreeNodeMenuItem } from "./Controls/TreeComponent/TreeComponent";
import Explorer from "./Explorer";
import { useNotebook } from "./Notebook/useNotebook";
import { DeleteCollectionConfirmationPane } from "./Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane";

View File

@ -1,4 +1,4 @@
import { Icon, Label, Stack } 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";
@ -8,6 +8,7 @@ export interface CollapsibleSectionProps {
isExpandedByDefault: boolean;
onExpand?: () => void;
children: JSX.Element;
tooltipContent?: string | JSX.Element | JSX.Element[];
}
export interface CollapsibleSectionState {
@ -26,8 +27,8 @@ export class CollapsibleSectionComponent extends React.Component<CollapsibleSect
this.setState({ isExpanded: !this.state.isExpanded });
};
public componentDidUpdate(): void {
if (this.state.isExpanded && this.props.onExpand) {
public componentDidUpdate(_prevProps: CollapsibleSectionProps, prevState: CollapsibleSectionState): void {
if (!prevState.isExpanded && this.state.isExpanded && this.props.onExpand) {
this.props.onExpand();
}
}
@ -43,7 +44,7 @@ export class CollapsibleSectionComponent extends React.Component<CollapsibleSect
return (
<>
<Stack
className="collapsibleSection"
className={"collapsibleSection"}
horizontal
verticalAlign="center"
tokens={accordionStackTokens}
@ -55,6 +56,19 @@ export class CollapsibleSectionComponent extends React.Component<CollapsibleSect
>
<Icon iconName={this.state.isExpanded ? "ChevronDown" : "ChevronRight"} />
<Label>{this.props.title}</Label>
{this.props.tooltipContent && (
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={this.props.tooltipContent}
styles={{
root: {
marginLeft: "0 !important",
},
}}
>
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} />
</TooltipHost>
)}
</Stack>
{this.state.isExpanded && this.props.children}
</>

View File

@ -1,6 +1,7 @@
/**
* React component for Command button component.
*/
import { KeyboardAction } from "KeyboardShortcuts";
import * as React from "react";
import CollapseChevronDownIcon from "../../../../images/QueryBuilder/CollapseChevronDown_16x.png";
import { KeyCodes } from "../../../Common/Constants";
@ -30,7 +31,7 @@ export interface CommandButtonComponentProps {
/**
* Click handler for command button click
*/
onCommandClick: (e: React.SyntheticEvent) => void;
onCommandClick?: (e: React.SyntheticEvent | KeyboardEvent) => void;
/**
* Label for the button
@ -107,10 +108,17 @@ export interface CommandButtonComponentProps {
* Vertical bar to divide buttons
*/
isDivider?: boolean;
/**
* Aria-label for the button
*/
ariaLabel: string;
/**
* If specified, a keyboard action that should trigger this button's onCommandClick handler when activated.
* If not specified, the button will not be triggerable by keyboard shortcuts.
*/
keyboardAction?: KeyboardAction;
}
export class CommandButtonComponent extends React.Component<CommandButtonComponentProps> {

View File

@ -20,7 +20,10 @@ export interface EditorReactProps {
lineDecorationsWidth?: monaco.editor.IEditorOptions["lineDecorationsWidth"];
minimap?: monaco.editor.IEditorOptions["minimap"];
scrollBeyondLastLine?: monaco.editor.IEditorOptions["scrollBeyondLastLine"];
fontSize?: monaco.editor.IEditorOptions["fontSize"];
monacoContainerStyles?: React.CSSProperties;
className?: string;
spinnerClassName?: string;
}
export class EditorReact extends React.Component<EditorReactProps, EditorReactStates> {
@ -46,9 +49,25 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
}, 100);
}
public componentDidUpdate(previous: EditorReactProps) {
if (this.props.content !== previous.content) {
this.editor?.setValue(this.props.content);
public componentDidUpdate() {
if (!this.editor) {
return;
}
const existingContent = this.editor.getModel().getValue();
if (this.props.content !== existingContent) {
if (this.props.isReadOnly) {
this.editor.setValue(this.props.content);
} else {
this.editor.pushUndoStop();
this.editor.executeEdits("", [
{
range: this.editor.getModel().getFullModelRange(),
text: this.props.content,
},
]);
}
}
}
@ -59,9 +78,11 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
public render(): JSX.Element {
return (
<React.Fragment>
{!this.state.showEditor && <Spinner size={SpinnerSize.large} className="spinner" />}
{!this.state.showEditor && (
<Spinner size={SpinnerSize.large} className={this.props.spinnerClassName || "spinner"} />
)}
<div
className="jsonEditor"
className={this.props.className || "jsonEditor"}
style={this.props.monacoContainerStyles}
ref={(elt: HTMLElement) => this.setRef(elt)}
/>
@ -71,9 +92,14 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
protected configureEditor(editor: monaco.editor.IStandaloneCodeEditor) {
this.editor = editor;
const queryEditorModel = this.editor.getModel();
if (!this.props.isReadOnly && this.props.onContentChanged) {
queryEditorModel.onDidChangeContent(() => {
// Hooking the model's onDidChangeContent event because of some event ordering issues.
// If a single user input causes BOTH the editor content to change AND the cursor selection to change (which is likely),
// then there are some inconsistencies as to which event fires first.
// But the editor.onDidChangeModelContent event seems to always fire before the cursor selection event.
// (This is NOT true for the model's onDidChangeContent event, which sometimes fires after the cursor selection event.)
// If the cursor selection event fires first, then the calling component may re-render the component with old content, so we want to ensure the model content changed event always fires first.
this.editor.onDidChangeModelContent(() => {
const queryEditorModel = this.editor.getModel();
this.props.onContentChanged(queryEditorModel.getValue());
});
@ -98,7 +124,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
value: this.props.content,
readOnly: this.props.isReadOnly,
ariaLabel: this.props.ariaLabel,
fontSize: 12,
fontSize: this.props.fontSize || 12,
automaticLayout: true,
theme: this.props.theme,
wordWrap: this.props.wordWrap || "off",
@ -111,7 +137,13 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
this.rootNode.innerHTML = "";
const monaco = await loadMonaco();
try {
createCallback(monaco?.editor?.create(this.rootNode, options));
} catch (error) {
// This could happen if the parent node suddenly disappears during create()
console.error("Unable to create EditorReact", error);
return;
}
if (this.rootNode.innerHTML) {
this.setState({

View File

@ -25,7 +25,14 @@
font-family: @DataExplorerFont;
}
.settingsV2IndexingPolicyEditor {
.settingsV2Editor {
width: 100%;
height: 60vh;
}
.settingsV2EditorSpinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

View File

@ -1,5 +1,15 @@
import { IPivotItemProps, IPivotProps, Pivot, PivotItem } from "@fluentui/react";
import {
ComputedPropertiesComponent,
ComputedPropertiesComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent";
import {
ContainerVectorPolicyComponent,
ContainerVectorPolicyComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ContainerVectorPolicyComponent";
import { useDatabases } from "Explorer/useDatabases";
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
import * as React from "react";
import DiscardIcon from "../../../../images/discard.svg";
import SaveIcon from "../../../../images/save-cosmos.svg";
@ -18,6 +28,10 @@ import { userContext } from "../../../UserContext";
import * as AutoPilotUtils from "../../../Utils/AutoPilotUtils";
import { MongoDBCollectionResource, MongoIndex } from "../../../Utils/arm/generatedClients/cosmos/types";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import {
PartitionKeyComponent,
PartitionKeyComponentProps,
} from "../../Controls/Settings/SettingsSubComponents/PartitionKeyComponent";
import { useCommandBar } from "../../Menus/CommandBar/CommandBarComponentAdapter";
import { SettingsTabV2 } from "../../Tabs/SettingsTabV2";
import "./SettingsComponent.less";
@ -103,6 +117,11 @@ export interface SettingsComponentState {
indexesToAdd: AddMongoIndexProps[];
indexTransformationProgress: number;
computedPropertiesContent: DataModels.ComputedProperties;
computedPropertiesContentBaseline: DataModels.ComputedProperties;
shouldDiscardComputedProperties: boolean;
isComputedPropertiesDirty: boolean;
conflictResolutionPolicyMode: DataModels.ConflictResolutionMode;
conflictResolutionPolicyModeBaseline: DataModels.ConflictResolutionMode;
conflictResolutionPolicyPath: string;
@ -127,7 +146,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private offer: DataModels.Offer;
private changeFeedPolicyVisible: boolean;
private isFixedContainer: boolean;
private shouldShowComputedPropertiesEditor: boolean;
private shouldShowIndexingPolicyEditor: boolean;
private shouldShowPartitionKeyEditor: boolean;
private isVectorSearchEnabled: boolean;
private totalThroughputUsed: number;
public mongoDBCollectionResource: MongoDBCollectionResource;
@ -139,7 +161,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.collection = this.props.settingsTab.collection as ViewModels.Collection;
this.offer = this.collection?.offer();
this.isAnalyticalStorageEnabled = !!this.collection?.analyticalStorageTtl();
this.shouldShowComputedPropertiesEditor = userContext.apiType === "SQL";
this.shouldShowIndexingPolicyEditor = userContext.apiType !== "Cassandra" && userContext.apiType !== "Mongo";
this.shouldShowPartitionKeyEditor = userContext.apiType === "SQL" && isRunningOnPublicCloud();
this.isVectorSearchEnabled = isVectorSearchEnabled() && !hasDatabaseSharedThroughput(this.collection);
this.changeFeedPolicyVisible = userContext.features.enableChangeFeedPolicy;
@ -191,6 +216,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
isMongoIndexingPolicyDiscardable: false,
indexTransformationProgress: undefined,
computedPropertiesContent: undefined,
computedPropertiesContentBaseline: undefined,
shouldDiscardComputedProperties: false,
isComputedPropertiesDirty: false,
conflictResolutionPolicyMode: undefined,
conflictResolutionPolicyModeBaseline: undefined,
conflictResolutionPolicyPath: undefined,
@ -281,6 +311,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.state.isSubSettingsSaveable ||
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty ||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicySaveable)
);
};
@ -291,6 +322,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.state.isSubSettingsDiscardable ||
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty ||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicyDiscardable)
);
};
@ -395,6 +427,9 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
isMongoIndexingPolicySaveable: false,
isMongoIndexingPolicyDiscardable: false,
isConflictResolutionDirty: false,
computedPropertiesContent: this.state.computedPropertiesContentBaseline,
shouldDiscardComputedProperties: true,
isComputedPropertiesDirty: false,
});
};
@ -514,6 +549,31 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private onMongoIndexingPolicyDiscardableChange = (isMongoIndexingPolicyDiscardable: boolean): void =>
this.setState({ isMongoIndexingPolicyDiscardable });
private onComputedPropertiesContentChange = (newComputedProperties: DataModels.ComputedProperties): void =>
this.setState({ computedPropertiesContent: newComputedProperties });
private resetShouldDiscardComputedProperties = (): void => this.setState({ shouldDiscardComputedProperties: false });
private logComputedPropertiesSuccessMessage = (): void => {
if (this.props.settingsTab.onLoadStartKey) {
traceSuccess(
Action.Tab,
{
databaseName: this.collection.databaseId,
collectionName: this.collection.id(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.props.settingsTab.tabTitle(),
},
this.props.settingsTab.onLoadStartKey,
);
this.props.settingsTab.onLoadStartKey = undefined;
}
};
private onComputedPropertiesDirtyChange = (isComputedPropertiesDirty: boolean): void =>
this.setState({ isComputedPropertiesDirty: isComputedPropertiesDirty });
private calculateTotalThroughputUsed = (): void => {
this.totalThroughputUsed = 0;
(useDatabases.getState().databases || []).forEach(async (database) => {
@ -636,7 +696,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
const indexingPolicyContent = this.collection.indexingPolicy();
const conflictResolutionPolicy: DataModels.ConflictResolutionPolicy =
this.collection.conflictResolutionPolicy && this.collection.conflictResolutionPolicy();
const conflictResolutionPolicyMode = parseConflictResolutionMode(conflictResolutionPolicy?.mode);
const conflictResolutionPolicyPath = conflictResolutionPolicy?.conflictResolutionPath;
const conflictResolutionPolicyProcedure = parseConflictResolutionProcedure(
@ -645,6 +704,12 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
const geospatialConfigTypeString: string =
(this.collection.geospatialConfig && this.collection.geospatialConfig()?.type) || GeospatialConfigType.Geometry;
const geoSpatialConfigType = GeospatialConfigType[geospatialConfigTypeString as keyof typeof GeospatialConfigType];
let computedPropertiesContent = this.collection.computedProperties();
if (!computedPropertiesContent || computedPropertiesContent.length === 0) {
computedPropertiesContent = [
{ name: "name_of_property", query: "query_to_compute_property" },
] as DataModels.ComputedProperties;
}
return {
throughput: offerThroughput,
@ -671,6 +736,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
conflictResolutionPolicyProcedureBaseline: conflictResolutionPolicyProcedure,
geospatialConfigType: geoSpatialConfigType,
geospatialConfigTypeBaseline: geoSpatialConfigType,
computedPropertiesContent: computedPropertiesContent,
computedPropertiesContentBaseline: computedPropertiesContent,
};
};
@ -787,7 +854,12 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private saveCollectionSettings = async (startKey: number): Promise<void> => {
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
if (this.state.isSubSettingsSaveable || this.state.isIndexingPolicyDirty || this.state.isConflictResolutionDirty) {
if (
this.state.isSubSettingsSaveable ||
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty
) {
let defaultTtl: number;
switch (this.state.timeToLive) {
case TtlType.On:
@ -825,6 +897,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
newCollection.conflictResolutionPolicy = conflictResolutionChanges;
}
if (this.state.isComputedPropertiesDirty) {
newCollection.computedProperties = this.state.computedPropertiesContent;
}
const updatedCollection: DataModels.Collection = await updateCollection(
this.collection.databaseId,
this.collection.id(),
@ -838,6 +914,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.collection.conflictResolutionPolicy(updatedCollection.conflictResolutionPolicy);
this.collection.changeFeedPolicy(updatedCollection.changeFeedPolicy);
this.collection.geospatialConfig(updatedCollection.geospatialConfig);
this.collection.computedProperties(updatedCollection.computedProperties);
if (wasIndexingPolicyModified) {
await this.refreshIndexTransformationProgress();
@ -848,6 +925,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
isSubSettingsDiscardable: false,
isIndexingPolicyDirty: false,
isConflictResolutionDirty: false,
isComputedPropertiesDirty: false,
});
}
@ -1026,6 +1104,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
indexTransformationProgress: this.state.indexTransformationProgress,
refreshIndexTransformationProgress: this.refreshIndexTransformationProgress,
onIndexingPolicyDirtyChange: this.onIndexingPolicyDirtyChange,
isVectorSearchEnabled: this.isVectorSearchEnabled,
};
const mongoIndexingPolicyComponentProps: MongoIndexingPolicyComponentProps = {
@ -1042,6 +1121,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onMongoIndexingPolicyDiscardableChange: this.onMongoIndexingPolicyDiscardableChange,
};
const computedPropertiesComponentProps: ComputedPropertiesComponentProps = {
computedPropertiesContent: this.state.computedPropertiesContent,
computedPropertiesContentBaseline: this.state.computedPropertiesContentBaseline,
logComputedPropertiesSuccessMessage: this.logComputedPropertiesSuccessMessage,
onComputedPropertiesContentChange: this.onComputedPropertiesContentChange,
onComputedPropertiesDirtyChange: this.onComputedPropertiesDirtyChange,
resetShouldDiscardComputedProperties: this.resetShouldDiscardComputedProperties,
shouldDiscardComputedProperties: this.state.shouldDiscardComputedProperties,
};
const conflictResolutionPolicyComponentProps: ConflictResolutionComponentProps = {
collection: this.collection,
conflictResolutionPolicyMode: this.state.conflictResolutionPolicyMode,
@ -1056,6 +1145,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onConflictResolutionDirtyChange: this.onConflictResolutionDirtyChange,
};
const partitionKeyComponentProps: PartitionKeyComponentProps = {
database: useDatabases.getState().findDatabaseWithId(this.collection.databaseId),
collection: this.collection,
explorer: this.props.settingsTab.getContainer(),
};
const containerVectorPolicyProps: ContainerVectorPolicyComponentProps = {
vectorEmbeddingPolicy: this.collection.rawDataModel?.vectorEmbeddingPolicy,
};
const tabs: SettingsV2TabInfo[] = [];
if (!hasDatabaseSharedThroughput(this.collection) && this.offer) {
tabs.push({
@ -1069,6 +1168,13 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
content: <SubSettingsComponent {...subSettingsComponentProps} />,
});
if (this.isVectorSearchEnabled) {
tabs.push({
tab: SettingsV2TabTypes.ContainerVectorPolicyTab,
content: <ContainerVectorPolicyComponent {...containerVectorPolicyProps} />,
});
}
if (this.shouldShowIndexingPolicyEditor) {
tabs.push({
tab: SettingsV2TabTypes.IndexingPolicyTab,
@ -1091,6 +1197,20 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
});
}
if (this.shouldShowPartitionKeyEditor) {
tabs.push({
tab: SettingsV2TabTypes.PartitionKeyTab,
content: <PartitionKeyComponent {...partitionKeyComponentProps} />,
});
}
if (this.shouldShowComputedPropertiesEditor) {
tabs.push({
tab: SettingsV2TabTypes.ComputedPropertiesTab,
content: <ComputedPropertiesComponent {...computedPropertiesComponentProps} />,
});
}
const pivotProps: IPivotProps = {
onLinkClick: this.onPivotChange,
selectedKey: SettingsV2TabTypes[this.state.selectedTab],

View File

@ -11,7 +11,6 @@ import {
getThroughputApplyLongDelayMessage,
getThroughputApplyShortDelayMessage,
getToolTipContainer,
indexingPolicynUnsavedWarningMessage,
manualToAutoscaleDisclaimerElement,
mongoIndexTransformationRefreshingMessage,
mongoIndexingPolicyAADError,
@ -39,7 +38,6 @@ class SettingsRenderUtilsTestComponent extends React.Component {
{manualToAutoscaleDisclaimerElement}
{ttlWarning}
{indexingPolicynUnsavedWarningMessage}
{updateThroughputDelayedApplyWarningMessage}
{getThroughputApplyDelayedMessage(false, 1000, "RU/s", "sampleDb", "sampleCollection", 2000)}

View File

@ -61,6 +61,8 @@ export interface PriceBreakdown {
currencySign: string;
}
export type editorType = "indexPolicy" | "computedProperties";
export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14, color: "windowtext" } };
export const noLeftPaddingCheckBoxStyle: ICheckboxStyles = {
@ -254,9 +256,10 @@ export const ttlWarning: JSX.Element = (
</Text>
);
export const indexingPolicynUnsavedWarningMessage: JSX.Element = (
export const unsavedEditorWarningMessage = (editor: editorType): JSX.Element => (
<Text styles={infoAndToolTipTextStyle}>
You have not saved the latest changes made to your indexing policy. Please click save to confirm the changes.
You have not saved the latest changes made to your{" "}
{editor === "indexPolicy" ? "indexing policy" : "computed properties"}. Please click save to confirm the changes.
</Text>
);

View File

@ -0,0 +1,56 @@
import * as DataModels from "Contracts/DataModels";
import { shallow } from "enzyme";
import React from "react";
import { ComputedPropertiesComponent, ComputedPropertiesComponentProps } from "./ComputedPropertiesComponent";
describe("ComputedPropertiesComponent", () => {
const initialComputedPropertiesContent: DataModels.ComputedProperties = [
{
name: "prop1",
query: "query1",
},
];
const baseProps: ComputedPropertiesComponentProps = {
computedPropertiesContent: initialComputedPropertiesContent,
computedPropertiesContentBaseline: initialComputedPropertiesContent,
logComputedPropertiesSuccessMessage: () => {
return;
},
onComputedPropertiesContentChange: () => {
return;
},
onComputedPropertiesDirtyChange: () => {
return;
},
resetShouldDiscardComputedProperties: () => {
return;
},
shouldDiscardComputedProperties: false,
};
it("renders", () => {
const wrapper = shallow(<ComputedPropertiesComponent {...baseProps} />);
expect(wrapper).toMatchSnapshot();
});
it("computed properties are reset", () => {
const wrapper = shallow(<ComputedPropertiesComponent {...baseProps} />);
const computedPropertiesComponentInstance = wrapper.instance() as ComputedPropertiesComponent;
const resetComputedPropertiesEditorMockFn = jest.fn();
computedPropertiesComponentInstance.resetComputedPropertiesEditor = resetComputedPropertiesEditorMockFn;
wrapper.setProps({ shouldDiscardComputedProperties: true });
wrapper.update();
expect(resetComputedPropertiesEditorMockFn.mock.calls.length).toEqual(1);
});
it("dirty is set", () => {
let computedPropertiesComponent = new ComputedPropertiesComponent(baseProps);
expect(computedPropertiesComponent.IsComponentDirty()).toEqual(false);
const newProps = { ...baseProps, computedPropertiesContent: undefined as DataModels.ComputedProperties };
computedPropertiesComponent = new ComputedPropertiesComponent(newProps);
expect(computedPropertiesComponent.IsComponentDirty()).toEqual(true);
});
});

View File

@ -0,0 +1,128 @@
import { FontIcon, Link, MessageBar, MessageBarType, Stack, Text } from "@fluentui/react";
import * as DataModels from "Contracts/DataModels";
import { titleAndInputStackProps, unsavedEditorWarningMessage } from "Explorer/Controls/Settings/SettingsRenderUtils";
import { isDirty } from "Explorer/Controls/Settings/SettingsUtils";
import { loadMonaco } from "Explorer/LazyMonaco";
import * as monaco from "monaco-editor";
import * as React from "react";
export interface ComputedPropertiesComponentProps {
computedPropertiesContent: DataModels.ComputedProperties;
computedPropertiesContentBaseline: DataModels.ComputedProperties;
logComputedPropertiesSuccessMessage: () => void;
onComputedPropertiesContentChange: (newComputedProperties: DataModels.ComputedProperties) => void;
onComputedPropertiesDirtyChange: (isComputedPropertiesDirty: boolean) => void;
resetShouldDiscardComputedProperties: () => void;
shouldDiscardComputedProperties: boolean;
}
interface ComputedPropertiesComponentState {
computedPropertiesContentIsValid: boolean;
}
export class ComputedPropertiesComponent extends React.Component<
ComputedPropertiesComponentProps,
ComputedPropertiesComponentState
> {
private shouldCheckComponentIsDirty = true;
private computedPropertiesDiv = React.createRef<HTMLDivElement>();
private computedPropertiesEditor: monaco.editor.IStandaloneCodeEditor;
constructor(props: ComputedPropertiesComponentProps) {
super(props);
this.state = {
computedPropertiesContentIsValid: true,
};
}
componentDidUpdate(): void {
if (this.props.shouldDiscardComputedProperties) {
this.resetComputedPropertiesEditor();
this.props.resetShouldDiscardComputedProperties();
}
this.onComponentUpdate();
}
componentDidMount(): void {
this.resetComputedPropertiesEditor();
this.onComponentUpdate();
}
public resetComputedPropertiesEditor = (): void => {
if (!this.computedPropertiesEditor) {
this.createComputedPropertiesEditor();
} else {
const indexingPolicyEditorModel = this.computedPropertiesEditor.getModel();
const value: string = JSON.stringify(this.props.computedPropertiesContent, undefined, 4);
indexingPolicyEditorModel.setValue(value);
}
this.onComponentUpdate();
};
private onComponentUpdate = (): void => {
if (!this.shouldCheckComponentIsDirty) {
this.shouldCheckComponentIsDirty = true;
return;
}
this.props.onComputedPropertiesDirtyChange(this.IsComponentDirty());
this.shouldCheckComponentIsDirty = false;
};
public IsComponentDirty = (): boolean => {
if (
isDirty(this.props.computedPropertiesContent, this.props.computedPropertiesContentBaseline) &&
this.state.computedPropertiesContentIsValid
) {
return true;
}
return false;
};
private async createComputedPropertiesEditor(): Promise<void> {
const value: string = JSON.stringify(this.props.computedPropertiesContent, undefined, 4);
const monaco = await loadMonaco();
this.computedPropertiesEditor = monaco.editor.create(this.computedPropertiesDiv.current, {
value: value,
language: "json",
ariaLabel: "Computed properties",
});
if (this.computedPropertiesEditor) {
const computedPropertiesEditorModel = this.computedPropertiesEditor.getModel();
computedPropertiesEditorModel.onDidChangeContent(this.onEditorContentChange.bind(this));
this.props.logComputedPropertiesSuccessMessage();
}
}
private onEditorContentChange = (): void => {
const computedPropertiesEditorModel = this.computedPropertiesEditor.getModel();
try {
const newComputedPropertiesContent = JSON.parse(
computedPropertiesEditorModel.getValue(),
) as DataModels.ComputedProperties;
this.props.onComputedPropertiesContentChange(newComputedPropertiesContent);
this.setState({ computedPropertiesContentIsValid: true });
} catch (e) {
this.setState({ computedPropertiesContentIsValid: false });
}
};
public render(): JSX.Element {
return (
<Stack {...titleAndInputStackProps}>
{isDirty(this.props.computedPropertiesContent, this.props.computedPropertiesContentBaseline) && (
<MessageBar messageBarType={MessageBarType.warning}>
{unsavedEditorWarningMessage("computedProperties")}
</MessageBar>
)}
<Text style={{ marginLeft: "30px", marginBottom: "10px" }}>
<Link target="_blank" href="https://aka.ms/computed-properties-preview/">
{"Learn more"} <FontIcon iconName="NavigateExternalInline" />
</Link>
&#160; about how to define computed properties and how to use them.
</Text>
<div className="settingsV2Editor" tabIndex={0} ref={this.computedPropertiesDiv}></div>
</Stack>
);
}
}

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

@ -3,7 +3,7 @@ import * as monaco from "monaco-editor";
import * as React from "react";
import * as DataModels from "../../../../Contracts/DataModels";
import { loadMonaco } from "../../../LazyMonaco";
import { indexingPolicynUnsavedWarningMessage, titleAndInputStackProps } from "../SettingsRenderUtils";
import { titleAndInputStackProps, unsavedEditorWarningMessage } from "../SettingsRenderUtils";
import { isDirty, isIndexTransforming } from "../SettingsUtils";
import { IndexingPolicyRefreshComponent } from "./IndexingPolicyRefresh/IndexingPolicyRefreshComponent";
@ -16,6 +16,7 @@ export interface IndexingPolicyComponentProps {
logIndexingPolicySuccessMessage: () => void;
indexTransformationProgress: number;
refreshIndexTransformationProgress: () => Promise<void>;
isVectorSearchEnabled?: boolean;
onIndexingPolicyDirtyChange: (isIndexingPolicyDirty: boolean) => void;
}
@ -119,10 +120,15 @@ export class IndexingPolicyComponent extends React.Component<
indexTransformationProgress={this.props.indexTransformationProgress}
refreshIndexTransformationProgress={this.props.refreshIndexTransformationProgress}
/>
{isDirty(this.props.indexingPolicyContent, this.props.indexingPolicyContentBaseline) && (
<MessageBar messageBarType={MessageBarType.warning}>{indexingPolicynUnsavedWarningMessage}</MessageBar>
{this.props.isVectorSearchEnabled && (
<MessageBar messageBarType={MessageBarType.severeWarning}>
Container vector policies and vector indexes are not modifiable after container creation
</MessageBar>
)}
<div className="settingsV2IndexingPolicyEditor" tabIndex={0} ref={this.indexingPolicyDiv}></div>
{isDirty(this.props.indexingPolicyContent, this.props.indexingPolicyContentBaseline) && (
<MessageBar messageBarType={MessageBarType.warning}>{unsavedEditorWarningMessage("indexPolicy")}</MessageBar>
)}
<div className="settingsV2Editor" tabIndex={0} ref={this.indexingPolicyDiv}></div>
</Stack>
);
}

View File

@ -19,7 +19,6 @@ import {
addMongoIndexStackProps,
createAndAddMongoIndexStackProps,
customDetailsListStyles,
indexingPolicynUnsavedWarningMessage,
infoAndToolTipTextStyle,
mediumWidthStackStyles,
mongoCompoundIndexNotSupportedMessage,
@ -27,15 +26,16 @@ import {
onRenderRow,
separatorStyles,
subComponentStackProps,
unsavedEditorWarningMessage,
} from "../../SettingsRenderUtils";
import {
AddMongoIndexProps,
getMongoIndexType,
getMongoIndexTypeText,
isIndexTransforming,
MongoIndexIdField,
MongoIndexTypes,
MongoNotificationType,
getMongoIndexType,
getMongoIndexTypeText,
isIndexTransforming,
} from "../../SettingsUtils";
import { IndexingPolicyRefreshComponent } from "../IndexingPolicyRefresh/IndexingPolicyRefreshComponent";
import { AddMongoIndexComponent } from "./AddMongoIndexComponent";
@ -297,7 +297,7 @@ export class MongoIndexingPolicyComponent extends React.Component<MongoIndexingP
if (this.getMongoWarningNotificationMessage()) {
warningMessage = this.getMongoWarningNotificationMessage();
} else if (this.isMongoIndexingPolicySaveable()) {
warningMessage = indexingPolicynUnsavedWarningMessage;
warningMessage = unsavedEditorWarningMessage("indexPolicy");
}
return (

View File

@ -0,0 +1,216 @@
import {
DefaultButton,
FontWeights,
Link,
MessageBar,
MessageBarType,
PrimaryButton,
ProgressIndicator,
Stack,
Text,
} from "@fluentui/react";
import * as React from "react";
import * as ViewModels from "../../../../Contracts/ViewModels";
import { handleError } from "Common/ErrorHandlingUtils";
import { cancelDataTransferJob, pollDataTransferJob } from "Common/dataAccess/dataTransfers";
import Explorer from "Explorer/Explorer";
import { ChangePartitionKeyPane } from "Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane";
import {
CosmosSqlDataTransferDataSourceSink,
DataTransferJobGetResults,
} from "Utils/arm/generatedClients/dataTransferService/types";
import { refreshDataTransferJobs, useDataTransferJobs } from "hooks/useDataTransferJobs";
import { useSidePanel } from "hooks/useSidePanel";
import { userContext } from "../../../../UserContext";
export interface PartitionKeyComponentProps {
database: ViewModels.Database;
collection: ViewModels.Collection;
explorer: Explorer;
}
export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({ database, collection, explorer }) => {
const { dataTransferJobs } = useDataTransferJobs();
const [portalDataTransferJob, setPortalDataTransferJob] = React.useState<DataTransferJobGetResults>(null);
React.useEffect(() => {
const loadDataTransferJobs = refreshDataTransferOperations;
loadDataTransferJobs();
}, []);
React.useEffect(() => {
const currentJob = findPortalDataTransferJob();
setPortalDataTransferJob(currentJob);
startPollingforUpdate(currentJob);
}, [dataTransferJobs]);
const isHierarchicalPartitionedContainer = (): boolean => collection.partitionKey?.kind === "MultiHash";
const getPartitionKeyValue = (): string => {
return (collection.partitionKeyProperties || []).map((property) => "/" + property).join(", ");
};
const partitionKeyName = "Partition key";
const partitionKeyValue = getPartitionKeyValue();
const textHeadingStyle = {
root: { fontWeight: FontWeights.semibold, fontSize: 16 },
};
const textSubHeadingStyle = {
root: { fontWeight: FontWeights.semibold },
};
const startPollingforUpdate = (currentJob: DataTransferJobGetResults) => {
if (isCurrentJobInProgress(currentJob)) {
const jobName = currentJob?.properties?.jobName;
try {
pollDataTransferJob(
jobName,
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
);
} catch (error) {
handleError(error, "ChangePartitionKey", `Failed to complete data transfer job ${jobName}`);
}
}
};
const cancelRunningDataTransferJob = async (currentJob: DataTransferJobGetResults) => {
await cancelDataTransferJob(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
currentJob?.properties?.jobName,
);
};
const isCurrentJobInProgress = (currentJob: DataTransferJobGetResults) => {
const jobStatus = currentJob?.properties?.status;
return (
jobStatus &&
jobStatus !== "Completed" &&
jobStatus !== "Cancelled" &&
jobStatus !== "Failed" &&
jobStatus !== "Faulted"
);
};
const refreshDataTransferOperations = async () => {
await refreshDataTransferJobs(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
);
};
const findPortalDataTransferJob = (): DataTransferJobGetResults => {
return dataTransferJobs.find((feed: DataTransferJobGetResults) => {
const sourceSink: CosmosSqlDataTransferDataSourceSink = feed?.properties
?.source as CosmosSqlDataTransferDataSourceSink;
return sourceSink.databaseName === collection.databaseId && sourceSink.containerName === collection.id();
});
};
const getProgressDescription = (): string => {
const processedCount = portalDataTransferJob?.properties?.processedCount;
const totalCount = portalDataTransferJob?.properties?.totalCount;
const processedCountString = totalCount > 0 ? `(${processedCount} of ${totalCount} documents processed)` : "";
return `${portalDataTransferJob?.properties?.status} ${processedCountString}`;
};
const startPartitionkeyChangeWorkflow = () => {
useSidePanel
.getState()
.openSidePanel(
"Change partition key",
<ChangePartitionKeyPane
sourceDatabase={database}
sourceCollection={collection}
explorer={explorer}
onClose={refreshDataTransferOperations}
/>,
);
};
const getPercentageComplete = () => {
const jobStatus = portalDataTransferJob?.properties?.status;
const isCompleted = jobStatus === "Completed";
if (isCompleted) {
return 1;
}
const processedCount = portalDataTransferJob?.properties?.processedCount;
const totalCount = portalDataTransferJob?.properties?.totalCount;
const isJobInProgress = isCurrentJobInProgress(portalDataTransferJob);
return isJobInProgress ? (totalCount > 0 ? processedCount / totalCount : null) : 0;
};
return (
<Stack tokens={{ childrenGap: 20 }} styles={{ root: { maxWidth: 600 } }}>
<Stack tokens={{ childrenGap: 10 }}>
<Text styles={textHeadingStyle}>Change {partitionKeyName.toLowerCase()}</Text>
<Stack horizontal tokens={{ childrenGap: 20 }}>
<Stack tokens={{ childrenGap: 5 }}>
<Text styles={textSubHeadingStyle}>Current {partitionKeyName.toLowerCase()}</Text>
<Text styles={textSubHeadingStyle}>Partitioning</Text>
</Stack>
<Stack tokens={{ childrenGap: 5 }}>
<Text>{partitionKeyValue}</Text>
<Text>{isHierarchicalPartitionedContainer() ? "Hierarchical" : "Non-hierarchical"}</Text>
</Stack>
</Stack>
</Stack>
<MessageBar messageBarType={MessageBarType.warning}>
To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the
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

@ -306,7 +306,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
};
const costElement = (): JSX.Element => {
const prices: PriceBreakdown = getRuPriceBreakdown(throughput, serverId, numberOfRegions, isMultimaster, true);
const prices: PriceBreakdown = getRuPriceBreakdown(throughput, serverId, numberOfRegions, isMultimaster, false);
return (
<Stack {...checkBoxAndInputStackProps} style={{ marginTop: 15 }}>
{newThroughput && newThroughputCostElement()}

View File

@ -917,7 +917,7 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
>
$
0.012
0.0080
/hr
</Text>
<Text
@ -929,7 +929,7 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
>
$
0.29
0.19
/day
</Text>
<Text
@ -941,7 +941,7 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
>
$
8.76
5.84
/mo
</Text>
</Stack>
@ -1354,7 +1354,7 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
>
$
0.012
0.0080
/hr
</Text>
<Text
@ -1366,7 +1366,7 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
>
$
0.29
0.19
/day
</Text>
<Text
@ -1378,7 +1378,7 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
>
$
8.76
5.84
/mo
</Text>
</Stack>

View File

@ -0,0 +1,36 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ComputedPropertiesComponent renders 1`] = `
<Stack
tokens={
Object {
"childrenGap": 5,
}
}
>
<Text
style={
Object {
"marginBottom": "10px",
"marginLeft": "30px",
}
}
>
<StyledLinkBase
href="https://aka.ms/computed-properties-preview/"
target="_blank"
>
Learn more
<FontIcon
iconName="NavigateExternalInline"
/>
</StyledLinkBase>
  about how to define computed properties and how to use them.
</Text>
<div
className="settingsV2Editor"
tabIndex={0}
/>
</Stack>
`;

View File

@ -12,7 +12,7 @@ exports[`IndexingPolicyComponent renders 1`] = `
refreshIndexTransformationProgress={[Function]}
/>
<div
className="settingsV2IndexingPolicyEditor"
className="settingsV2Editor"
tabIndex={0}
/>
</Stack>

View File

@ -4,7 +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;
export type isDirtyTypes = boolean | string | number | DataModels.IndexingPolicy | DataModels.ComputedProperties;
export const TtlOff = "off";
export const TtlOn = "on";
export const TtlOnNoDefault = "on-nodefault";
@ -45,6 +45,9 @@ export enum SettingsV2TabTypes {
ConflictResolutionTab,
SubSettingsTab,
IndexingPolicyTab,
PartitionKeyTab,
ComputedPropertiesTab,
ContainerVectorPolicyTab,
}
export interface IsComponentDirtyResult {
@ -146,6 +149,12 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => {
return "Settings";
case SettingsV2TabTypes.IndexingPolicyTab:
return "Indexing Policy";
case SettingsV2TabTypes.PartitionKeyTab:
return "Partition Keys (preview)";
case SettingsV2TabTypes.ComputedPropertiesTab:
return "Computed Properties";
case SettingsV2TabTypes.ContainerVectorPolicyTab:
return "Container Vector Policy (preview)";
default:
throw new Error(`Unknown tab ${tab}`);
}
@ -199,3 +208,49 @@ export const getMongoIndexTypeText = (index: MongoIndexTypes): string => {
export const isIndexTransforming = (indexTransformationProgress: number): boolean =>
// index transformation progress can be 0
indexTransformationProgress !== undefined && indexTransformationProgress !== 100;
export const getPartitionKeyName = (apiType: string, isLowerCase?: boolean): string => {
const partitionKeyName = apiType === "Mongo" ? "Shard key" : "Partition key";
return isLowerCase ? partitionKeyName.toLocaleLowerCase() : partitionKeyName;
};
export const getPartitionKeyTooltipText = (apiType: string): string => {
if (apiType === "Mongo") {
return "The shard key (field) is used to split your data across many replica sets (shards) to achieve unlimited scalability. Its critical to choose a field that will evenly distribute your data.";
}
let tooltipText = `The ${getPartitionKeyName(
apiType,
true,
)} is used to automatically distribute data across partitions for scalability. Choose a property in your JSON document that has a wide range of values and evenly distributes request volume.`;
if (apiType === "SQL") {
tooltipText += " For small read-heavy workloads or write-heavy workloads of any size, id is often a good choice.";
}
return tooltipText;
};
export const getPartitionKeySubtext = (partitionKeyDefault: boolean, apiType: string): string => {
if (partitionKeyDefault && (apiType === "SQL" || apiType === "Mongo")) {
const subtext = "For small workloads, the item ID is a suitable choice for the partition key.";
return subtext;
}
return "";
};
export const getPartitionKeyPlaceHolder = (apiType: string, index?: number): string => {
switch (apiType) {
case "Mongo":
return "e.g., categoryId";
case "Gremlin":
return "e.g., /address";
case "SQL":
return `${
index === undefined
? "Required - first partition key e.g., /TenantId"
: index === 0
? "second partition key e.g., /UserId"
: "third partition key e.g., /SessionId"
}`;
default:
return "e.g., /address/zipCode";
}
};

View File

@ -40,6 +40,12 @@ export const collection = {
version: 2,
},
partitionKeyProperties: ["partitionKey"],
computedProperties: ko.observable<DataModels.ComputedProperties>([
{
name: "queryName",
query: "query",
},
]),
readSettings: () => {
return;
},

View File

@ -26,10 +26,10 @@ exports[`SettingsComponent renders 1`] = `
Object {
"analyticalStorageTtl": [Function],
"changeFeedPolicy": [Function],
"computedProperties": [Function],
"conflictResolutionPolicy": [Function],
"container": Explorer {
"_isInitializingNotebooks": false,
"_resetNotebookWorkspace": [Function],
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
@ -103,10 +103,10 @@ exports[`SettingsComponent renders 1`] = `
Object {
"analyticalStorageTtl": [Function],
"changeFeedPolicy": [Function],
"computedProperties": [Function],
"conflictResolutionPolicy": [Function],
"container": Explorer {
"_isInitializingNotebooks": false,
"_resetNotebookWorkspace": [Function],
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
@ -196,6 +196,7 @@ exports[`SettingsComponent renders 1`] = `
"indexingMode": "consistent",
}
}
isVectorSearchEnabled={false}
logIndexingPolicySuccessMessage={[Function]}
onIndexingPolicyContentChange={[Function]}
onIndexingPolicyDirtyChange={[Function]}
@ -204,6 +205,131 @@ exports[`SettingsComponent renders 1`] = `
shouldDiscardIndexingPolicy={false}
/>
</PivotItem>
<PivotItem
headerText="Partition Keys (preview)"
itemKey="PartitionKeyTab"
key="PartitionKeyTab"
style={
Object {
"marginTop": 20,
}
}
>
<PartitionKeyComponent
collection={
Object {
"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": Object {
"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],
"geospatialConfig": [Function],
"getDatabase": [Function],
"id": [Function],
"indexingPolicy": [Function],
"offer": [Function],
"partitionKey": Object {
"kind": "hash",
"paths": Array [],
"version": 2,
},
"partitionKeyProperties": Array [
"partitionKey",
],
"readSettings": [Function],
"uniqueKeyPolicy": Object {},
"usageSizeInKB": [Function],
}
}
explorer={
Explorer {
"_isInitializingNotebooks": false,
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": Object {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"copyNotebook": [Function],
"parameters": [Function],
},
}
}
/>
</PivotItem>
<PivotItem
headerText="Computed Properties"
itemKey="ComputedPropertiesTab"
key="ComputedPropertiesTab"
style={
Object {
"marginTop": 20,
}
}
>
<ComputedPropertiesComponent
computedPropertiesContent={
Array [
Object {
"name": "queryName",
"query": "query",
},
]
}
computedPropertiesContentBaseline={
Array [
Object {
"name": "queryName",
"query": "query",
},
]
}
logComputedPropertiesSuccessMessage={[Function]}
onComputedPropertiesContentChange={[Function]}
onComputedPropertiesDirtyChange={[Function]}
resetShouldDiscardComputedProperties={[Function]}
shouldDiscardComputedProperties={false}
/>
</PivotItem>
</StyledPivot>
</div>
</div>

View File

@ -99,18 +99,6 @@ exports[`SettingsUtils functions render 1`] = `
</StyledLinkBase>
.
</Text>
<Text
styles={
Object {
"root": Object {
"color": "windowtext",
"fontSize": 14,
},
}
}
>
You have not saved the latest changes made to your indexing policy. Please click save to confirm the changes.
</Text>
<Text
id="updateThroughputDelayedApplyWarningMessage"
styles={

View File

@ -223,6 +223,7 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
<Text variant="small" aria-label="capacity calculator of azure cosmos db">
Estimate your required RU/s with{" "}
<Link
className="underlinedLink outlineNone"
target="_blank"
href="https://cosmos.azure.com/capacitycalculator/"
aria-label="capacity calculator of azure cosmos db"
@ -271,7 +272,12 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
<Stack className="throughputInputSpacing">
<Text variant="small" aria-label="ruDescription">
Estimate your required RU/s with&nbsp;
<Link target="_blank" href="https://cosmos.azure.com/capacitycalculator/" aria-label="Capacity calculator">
<Link
className="underlinedLink"
target="_blank"
href="https://cosmos.azure.com/capacitycalculator/"
aria-label="Capacity calculator"
>
capacity calculator
</Link>
.

View File

@ -733,11 +733,13 @@ exports[`ThroughputInput Pane should render Default properly 1`] = `
<StyledLinkBase
aria-label="capacity calculator of azure cosmos db"
className="underlinedLink outlineNone"
href="https://cosmos.azure.com/capacitycalculator/"
target="_blank"
>
<LinkBase
aria-label="capacity calculator of azure cosmos db"
className="underlinedLink outlineNone"
href="https://cosmos.azure.com/capacitycalculator/"
styles={[Function]}
target="_blank"
@ -1017,7 +1019,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = `
>
<a
aria-label="capacity calculator of azure cosmos db"
className="ms-Link root-117"
className="ms-Link underlinedLink outlineNone root-117"
href="https://cosmos.azure.com/capacitycalculator/"
onClick={[Function]}
target="_blank"

View File

@ -1,48 +1,48 @@
import React from "react";
import { shallow } from "enzyme";
import { TreeComponent, TreeNode, TreeNodeComponent } from "./TreeComponent";
import React from "react";
import { LegacyTreeComponent, LegacyTreeNode, LegacyTreeNodeComponent } from "./LegacyTreeComponent";
const buildChildren = (): TreeNode[] => {
const grandChild11: TreeNode = {
const buildChildren = (): LegacyTreeNode[] => {
const grandChild11: LegacyTreeNode = {
label: "ZgrandChild11",
};
const grandChild12: TreeNode = {
const grandChild12: LegacyTreeNode = {
label: "AgrandChild12",
};
const child1: TreeNode = {
const child1: LegacyTreeNode = {
label: "Bchild1",
children: [grandChild11, grandChild12],
};
const child2: TreeNode = {
const child2: LegacyTreeNode = {
label: "2child2",
};
return [child1, child2];
};
const buildChildren2 = (): TreeNode[] => {
const grandChild11: TreeNode = {
const buildChildren2 = (): LegacyTreeNode[] => {
const grandChild11: LegacyTreeNode = {
label: "ZgrandChild11",
};
const grandChild12: TreeNode = {
const grandChild12: LegacyTreeNode = {
label: "AgrandChild12",
};
const child1: TreeNode = {
const child1: LegacyTreeNode = {
label: "aChild",
};
const child2: TreeNode = {
const child2: LegacyTreeNode = {
label: "bchild",
children: [grandChild11, grandChild12],
};
const child3: TreeNode = {
const child3: LegacyTreeNode = {
label: "cchild",
};
const child4: TreeNode = {
const child4: LegacyTreeNode = {
label: "dchild",
children: [grandChild11, grandChild12],
};
@ -50,7 +50,7 @@ const buildChildren2 = (): TreeNode[] => {
return [child1, child2, child3, child4];
};
describe("TreeComponent", () => {
describe("LegacyTreeComponent", () => {
it("renders a simple tree", () => {
const root = {
label: "root",
@ -62,14 +62,14 @@ describe("TreeComponent", () => {
className: "tree",
};
const wrapper = shallow(<TreeComponent {...props} />);
const wrapper = shallow(<LegacyTreeComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
describe("TreeNodeComponent", () => {
describe("LegacyTreeNodeComponent", () => {
it("renders a simple node (sorted children, expanded)", () => {
const node: TreeNode = {
const node: LegacyTreeNode = {
label: "label",
id: "id",
children: buildChildren(),
@ -98,12 +98,12 @@ describe("TreeNodeComponent", () => {
generation: 12,
paddingLeft: 23,
};
const wrapper = shallow(<TreeNodeComponent {...props} />);
const wrapper = shallow(<LegacyTreeNodeComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders unsorted children by default", () => {
const node: TreeNode = {
const node: LegacyTreeNode = {
label: "label",
children: buildChildren(),
isExpanded: true,
@ -113,12 +113,12 @@ describe("TreeNodeComponent", () => {
generation: 2,
paddingLeft: 9,
};
const wrapper = shallow(<TreeNodeComponent {...props} />);
const wrapper = shallow(<LegacyTreeNodeComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("does not render children by default", () => {
const node: TreeNode = {
const node: LegacyTreeNode = {
label: "label",
children: buildChildren(),
isAlphaSorted: false,
@ -128,12 +128,12 @@ describe("TreeNodeComponent", () => {
generation: 2,
paddingLeft: 9,
};
const wrapper = shallow(<TreeNodeComponent {...props} />);
const wrapper = shallow(<LegacyTreeNodeComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders sorted children, expanded, leaves and parents separated", () => {
const node: TreeNode = {
const node: LegacyTreeNode = {
label: "label",
id: "id",
children: buildChildren2(),
@ -156,12 +156,12 @@ describe("TreeNodeComponent", () => {
generation: 12,
paddingLeft: 23,
};
const wrapper = shallow(<TreeNodeComponent {...props} />);
const wrapper = shallow(<LegacyTreeNodeComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders loading icon", () => {
const node: TreeNode = {
const node: LegacyTreeNode = {
label: "label",
children: [],
isExpanded: true,
@ -172,7 +172,7 @@ describe("TreeNodeComponent", () => {
generation: 2,
paddingLeft: 9,
};
const wrapper = shallow(<TreeNodeComponent {...props} />);
const wrapper = shallow(<LegacyTreeNodeComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -12,6 +12,7 @@ import {
IContextualMenuItemProps,
IContextualMenuProps,
} from "@fluentui/react";
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import * as React from "react";
import AnimateHeight from "react-animate-height";
import LoadingIndicator_3Squares from "../../../../images/LoadingIndicator_3Squares.gif";
@ -22,18 +23,10 @@ import { StyleConstants } from "../../../Common/StyleConstants";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
export interface TreeNodeMenuItem {
label: string;
onClick: () => void;
iconSrc?: string;
isDisabled?: boolean;
styleClass?: string;
}
export interface TreeNode {
export interface LegacyTreeNode {
label: string;
id?: string;
children?: TreeNode[];
children?: LegacyTreeNode[];
contextMenu?: TreeNodeMenuItem[];
iconSrc?: string;
isExpanded?: boolean;
@ -50,34 +43,37 @@ export interface TreeNode {
onContextMenuOpen?: () => void;
}
export interface TreeComponentProps {
rootNode: TreeNode;
export interface LegacyTreeComponentProps {
rootNode: LegacyTreeNode;
style?: any;
className?: string;
}
export class TreeComponent extends React.Component<TreeComponentProps> {
export class LegacyTreeComponent extends React.Component<LegacyTreeComponentProps> {
public render(): JSX.Element {
return (
<div style={this.props.style} className={`treeComponent ${this.props.className}`} role="tree">
<TreeNodeComponent paddingLeft={0} node={this.props.rootNode} generation={0} />
<LegacyTreeNodeComponent paddingLeft={0} node={this.props.rootNode} generation={0} />
</div>
);
}
}
/* Tree node is a react component */
interface TreeNodeComponentProps {
node: TreeNode;
interface LegacyTreeNodeComponentProps {
node: LegacyTreeNode;
generation: number;
paddingLeft: number;
}
interface TreeNodeComponentState {
interface LegacyTreeNodeComponentState {
isExpanded: boolean;
isMenuShowing: boolean;
}
export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, TreeNodeComponentState> {
export class LegacyTreeNodeComponent extends React.Component<
LegacyTreeNodeComponentProps,
LegacyTreeNodeComponentState
> {
private static readonly paddingPerGenerationPx = 16;
private static readonly iconOffset = 22;
private static readonly transitionDurationMS = 200;
@ -85,7 +81,7 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
private contextMenuRef = React.createRef<HTMLDivElement>();
private isExpanded: boolean;
constructor(props: TreeNodeComponentProps) {
constructor(props: LegacyTreeNodeComponentProps) {
super(props);
this.isExpanded = props.node.isExpanded;
this.state = {
@ -94,13 +90,13 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
};
}
componentDidUpdate(prevProps: TreeNodeComponentProps, prevState: TreeNodeComponentState) {
componentDidUpdate(prevProps: LegacyTreeNodeComponentProps, prevState: LegacyTreeNodeComponentState) {
// Only call when expand has actually changed
if (this.state.isExpanded !== prevState.isExpanded) {
if (this.state.isExpanded) {
this.props.node.onExpanded && setTimeout(this.props.node.onExpanded, TreeNodeComponent.callbackDelayMS);
this.props.node.onExpanded && setTimeout(this.props.node.onExpanded, LegacyTreeNodeComponent.callbackDelayMS);
} else {
this.props.node.onCollapsed && setTimeout(this.props.node.onCollapsed, TreeNodeComponent.callbackDelayMS);
this.props.node.onCollapsed && setTimeout(this.props.node.onCollapsed, LegacyTreeNodeComponent.callbackDelayMS);
}
}
if (this.props.node.isExpanded !== this.isExpanded) {
@ -115,18 +111,18 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
return this.renderNode(this.props.node, this.props.generation);
}
private static getSortedChildren(treeNode: TreeNode): TreeNode[] {
private static getSortedChildren(treeNode: LegacyTreeNode): LegacyTreeNode[] {
if (!treeNode || !treeNode.children) {
return undefined;
}
const compareFct = (a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label);
const compareFct = (a: LegacyTreeNode, b: LegacyTreeNode) => a.label.localeCompare(b.label);
let unsortedChildren;
if (treeNode.isLeavesParentsSeparate) {
// Separate parents and leave
const parents: TreeNode[] = treeNode.children.filter((node) => node.children);
const leaves: TreeNode[] = treeNode.children.filter((node) => !node.children);
const parents: LegacyTreeNode[] = treeNode.children.filter((node) => node.children);
const leaves: LegacyTreeNode[] = treeNode.children.filter((node) => !node.children);
if (treeNode.isAlphaSorted) {
parents.sort(compareFct);
@ -141,18 +137,18 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
return unsortedChildren;
}
private static isNodeHeaderBlank(node: TreeNode): boolean {
private static isNodeHeaderBlank(node: LegacyTreeNode): boolean {
return (node.label === undefined || node.label === null) && !node.contextMenu;
}
private renderNode(node: TreeNode, generation: number): JSX.Element {
let paddingLeft = generation * TreeNodeComponent.paddingPerGenerationPx;
private renderNode(node: LegacyTreeNode, generation: number): JSX.Element {
const paddingLeft = generation * LegacyTreeNodeComponent.paddingPerGenerationPx;
let additionalOffsetPx = 15;
if (node.children) {
const childrenWithSubChildren = node.children.filter((child: TreeNode) => !!child.children);
const childrenWithSubChildren = node.children.filter((child: LegacyTreeNode) => !!child.children);
if (childrenWithSubChildren.length > 0) {
additionalOffsetPx = TreeNodeComponent.iconOffset;
additionalOffsetPx = LegacyTreeNodeComponent.iconOffset;
}
}
@ -160,16 +156,17 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
const showSelected =
this.props.node.isSelected &&
this.props.node.isSelected() &&
!TreeNodeComponent.isAnyDescendantSelected(this.props.node);
!LegacyTreeNodeComponent.isAnyDescendantSelected(this.props.node);
const headerStyle: React.CSSProperties = { paddingLeft: this.props.paddingLeft };
if (TreeNodeComponent.isNodeHeaderBlank(node)) {
if (LegacyTreeNodeComponent.isNodeHeaderBlank(node)) {
headerStyle.height = 0;
headerStyle.padding = 0;
}
return (
<div
data-test={`Tree/TreeNode:${node.label}`}
className={`${this.props.node.className || ""} main${generation} nodeItem ${showSelected ? "selected" : ""}`}
onClick={(event: React.MouseEvent<HTMLDivElement>) => this.onNodeClick(event, node)}
onKeyPress={(event: React.KeyboardEvent<HTMLDivElement>) => this.onNodeKeyPress(event, node)}
@ -178,9 +175,9 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
>
<div
className={`treeNodeHeader ${this.state.isMenuShowing ? "showingMenu" : ""}`}
data-test={`Tree/TreeNode/Header:${node.label}`}
style={headerStyle}
tabIndex={node.children ? -1 : 0}
data-test={node.label}
>
{this.renderCollapseExpandIcon(node)}
{node.iconSrc && <img className="nodeIcon" src={node.iconSrc} alt="" />}
@ -195,10 +192,13 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
<img className="loadingIcon" src={LoadingIndicator_3Squares} hidden={!this.props.node.isLoading} />
</div>
{node.children && (
<AnimateHeight duration={TreeNodeComponent.transitionDurationMS} height={this.state.isExpanded ? "auto" : 0}>
<AnimateHeight
duration={LegacyTreeNodeComponent.transitionDurationMS}
height={this.state.isExpanded ? "auto" : 0}
>
<div className="nodeChildren" data-test={node.label} role="group">
{TreeNodeComponent.getSortedChildren(node).map((childNode: TreeNode) => (
<TreeNodeComponent
{LegacyTreeNodeComponent.getSortedChildren(node).map((childNode: LegacyTreeNode) => (
<LegacyTreeNodeComponent
key={`${childNode.label}-${generation + 1}-${childNode.timestamp}`}
node={childNode}
generation={generation + 1}
@ -216,12 +216,14 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
* Recursive: is the node or any descendant selected
* @param node
*/
private static isAnyDescendantSelected(node: TreeNode): boolean {
private static isAnyDescendantSelected(node: LegacyTreeNode): boolean {
return (
node.children &&
node.children.reduce(
(previous: boolean, child: TreeNode) =>
previous || (child.isSelected && child.isSelected()) || TreeNodeComponent.isAnyDescendantSelected(child),
(previous: boolean, child: LegacyTreeNode) =>
previous ||
(child.isSelected && child.isSelected()) ||
LegacyTreeNodeComponent.isAnyDescendantSelected(child),
false,
)
);
@ -232,10 +234,10 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
}
private onRightClick = (): void => {
this.contextMenuRef.current.firstChild.dispatchEvent(TreeNodeComponent.createClickEvent());
this.contextMenuRef.current.firstChild.dispatchEvent(LegacyTreeNodeComponent.createClickEvent());
};
private renderContextMenuButton(node: TreeNode): JSX.Element {
private renderContextMenuButton(node: LegacyTreeNode): JSX.Element {
const menuItemLabel = "More";
const buttonStyles: Partial<IButtonStyles> = {
rootFocused: { outline: `1px dashed ${StyleConstants.FocusColor}` },
@ -247,7 +249,7 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
name="More"
title="More"
className="treeMenuEllipsis"
ariaLabel={menuItemLabel}
ariaLabel={`${menuItemLabel} options`}
menuIconProps={{
iconName: menuItemLabel,
styles: { root: { fontSize: "18px", fontWeight: "bold" } },
@ -263,9 +265,9 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
onMenuDismissed: (contextualMenu?: IContextualMenuProps) => this.setState({ isMenuShowing: false }),
contextualMenuItemAs: (props: IContextualMenuItemProps) => (
<div
data-test={`treeComponentMenuItemContainer`}
data-test={`Tree/TreeNode/MenuItem:${props.item.text}`}
className="treeComponentMenuItemContainer"
onContextMenu={(e) => e.target.dispatchEvent(TreeNodeComponent.createClickEvent())}
onContextMenu={(e) => e.target.dispatchEvent(LegacyTreeNodeComponent.createClickEvent())}
>
{props.item.onRenderIcon()}
<span
@ -297,7 +299,7 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
);
}
private renderCollapseExpandIcon(node: TreeNode): JSX.Element {
private renderCollapseExpandIcon(node: LegacyTreeNode): JSX.Element {
if (!node.children || !node.label) {
return <></>;
}
@ -314,12 +316,12 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
);
}
private onNodeClick = (event: React.MouseEvent<HTMLDivElement>, node: TreeNode): void => {
private onNodeClick = (event: React.MouseEvent<HTMLDivElement>, node: LegacyTreeNode): void => {
event.stopPropagation();
if (node.children) {
const isExpanded = !this.state.isExpanded;
// Prevent collapsing if node header is blank
if (!(TreeNodeComponent.isNodeHeaderBlank(node) && !isExpanded)) {
if (!(LegacyTreeNodeComponent.isNodeHeaderBlank(node) && !isExpanded)) {
this.setState({ isExpanded });
}
}
@ -327,14 +329,14 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
this.props.node.onClick && this.props.node.onClick(this.state.isExpanded);
};
private onNodeKeyPress = (event: React.KeyboardEvent<HTMLDivElement>, node: TreeNode): void => {
private onNodeKeyPress = (event: React.KeyboardEvent<HTMLDivElement>, node: LegacyTreeNode): void => {
if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) {
event.stopPropagation();
this.props.node.onClick && this.props.node.onClick(this.state.isExpanded);
}
};
private onCollapseExpandIconKeyPress = (event: React.KeyboardEvent<HTMLDivElement>, node: TreeNode): void => {
private onCollapseExpandIconKeyPress = (event: React.KeyboardEvent<HTMLDivElement>, node: LegacyTreeNode): void => {
if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) {
event.stopPropagation();
if (node.children) {

View File

@ -0,0 +1,194 @@
import { TreeItem, TreeItemLayout, tokens } from "@fluentui/react-components";
import PromiseSource from "Utils/PromiseSource";
import { mount, shallow } from "enzyme";
import React from "react";
import { act } from "react-dom/test-utils";
import { TreeNode, TreeNodeComponent } from "./TreeNodeComponent";
function generateTestNode(id: string, additionalProps?: Partial<TreeNode>): TreeNode {
const node: TreeNode = {
id,
label: `${id}Label`,
className: `${id}Class`,
iconSrc: `${id}Icon`,
onClick: jest.fn().mockName(`${id}Click`),
...additionalProps,
};
return node;
}
describe("TreeNodeComponent", () => {
it("renders a single node", () => {
const node = generateTestNode("root");
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component).toMatchSnapshot();
// The "click" handler is actually attached to onOpenChange, with a type of "Click".
component
.find(TreeItem)
.props()
.onOpenChange(null!, { open: true, value: "borp", target: null!, event: null!, type: "Click" });
expect(node.onClick).toHaveBeenCalled();
});
it("renders a node with a menu", () => {
const node = generateTestNode("root", {
contextMenu: [
{
label: "enabledItem",
onClick: jest.fn().mockName("enabledItemClick"),
},
{
label: "disabledItem",
onClick: jest.fn().mockName("disabledItemClick"),
isDisabled: true,
},
],
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component).toMatchSnapshot();
});
it("renders a loading spinner if the node is loading", async () => {
const loading = new PromiseSource();
const node = generateTestNode("root", {
onExpanded: () => loading.promise,
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
act(() => {
component
.find(TreeItem)
.props()
.onOpenChange(null!, { open: true, value: "borp", target: null!, event: null!, type: "ExpandIconClick" });
});
expect(component).toMatchSnapshot("loading");
await loading.resolveAndWait();
expect(component).toMatchSnapshot("loaded");
});
it("renders single selected leaf node as selected", () => {
const node = generateTestNode("root", {
isSelected: () => true,
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component.find(TreeItemLayout).props().style?.backgroundColor).toStrictEqual(
tokens.colorNeutralBackground1Selected,
);
expect(component).toMatchSnapshot();
});
it("renders selected parent node as selected if no descendant nodes are selected", () => {
const node = generateTestNode("root", {
isSelected: () => true,
children: [
generateTestNode("child1", {
children: [generateTestNode("grandchild1"), generateTestNode("grandchild2")],
}),
generateTestNode("child2"),
],
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component.find(TreeItemLayout).props().style?.backgroundColor).toStrictEqual(
tokens.colorNeutralBackground1Selected,
);
expect(component).toMatchSnapshot();
});
it("renders selected parent node as unselected if any descendant node is selected", () => {
const node = generateTestNode("root", {
isSelected: () => true,
children: [
generateTestNode("child1", {
children: [
generateTestNode("grandchild1", {
isSelected: () => true,
}),
generateTestNode("grandchild2"),
],
}),
generateTestNode("child2"),
],
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component.find(TreeItemLayout).props().style?.backgroundColor).toBeUndefined();
expect(component).toMatchSnapshot();
});
it("renders an icon if the node has one", () => {
const node = generateTestNode("root", {
iconSrc: "the-icon.svg",
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component).toMatchSnapshot();
});
it("renders a node as expandable if it has empty, but defined, children array", () => {
const node = generateTestNode("root", {
isLoading: true,
children: [
generateTestNode("child1", {
children: [],
}),
generateTestNode("child2"),
],
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component).toMatchSnapshot();
});
it("does not render children if the node is loading", () => {
const node = generateTestNode("root", {
isLoading: true,
children: [
generateTestNode("child1", {
children: [generateTestNode("grandchild1"), generateTestNode("grandchild2")],
}),
generateTestNode("child2"),
],
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component).toMatchSnapshot();
});
it("fully renders a tree", () => {
const child3Loading = new PromiseSource();
const node = generateTestNode("root", {
isSelected: () => true,
children: [
generateTestNode("child1", {
children: [
generateTestNode("grandchild1", {
iconSrc: "grandchild1Icon.svg",
isSelected: () => true,
}),
generateTestNode("grandchild2"),
],
}),
generateTestNode("child2Loading", {
isLoading: true,
children: [generateTestNode("grandchild3NotRendered")],
}),
generateTestNode("child3Expanding", {
onExpanded: () => child3Loading.promise,
}),
],
});
const component = mount(<TreeNodeComponent node={node} treeNodeId={node.id} />);
// Find and expand the child3Expanding node
const expandingChild = component.find(TreeItem).filterWhere((n) => n.props().value === "root/child3ExpandingLabel");
act(() => {
expandingChild.props().onOpenChange(null!, {
open: true,
value: "root/child3ExpandingLabel",
target: null!,
event: null!,
type: "Click",
});
});
expect(component).toMatchSnapshot();
});
});

View File

@ -0,0 +1,207 @@
import {
Button,
Menu,
MenuItem,
MenuList,
MenuOpenChangeData,
MenuOpenEvent,
MenuPopover,
MenuTrigger,
Spinner,
Tree,
TreeItem,
TreeItemLayout,
TreeOpenChangeData,
TreeOpenChangeEvent,
} from "@fluentui/react-components";
import { MoreHorizontal20Regular } from "@fluentui/react-icons";
import { tokens } from "@fluentui/react-theme";
import * as React from "react";
import { useCallback } from "react";
export interface TreeNodeMenuItem {
label: string;
onClick: () => void;
iconSrc?: string;
isDisabled?: boolean;
styleClass?: string;
}
export interface TreeNode {
label: string;
id?: string;
children?: TreeNode[];
contextMenu?: TreeNodeMenuItem[];
iconSrc?: string;
isExpanded?: boolean;
className?: string;
isAlphaSorted?: boolean;
// data?: any; // Piece of data corresponding to this node
timestamp?: number;
isLeavesParentsSeparate?: boolean; // Display parents together first, then leaves
isLoading?: boolean;
isScrollable?: boolean;
isSelected?: () => boolean;
onClick?: () => void; // Only if a leaf, other click will expand/collapse
onExpanded?: () => Promise<void>;
onCollapsed?: () => void;
onContextMenuOpen?: () => void;
}
export interface TreeNodeComponentProps {
node: TreeNode;
className?: string;
treeNodeId: string;
}
/** Function that returns true if any descendant (at any depth) of this node is selected. */
function isAnyDescendantSelected(node: TreeNode): boolean {
return (
node.children &&
node.children.reduce(
(previous: boolean, child: TreeNode) =>
previous || (child.isSelected && child.isSelected()) || isAnyDescendantSelected(child),
false,
)
);
}
const getTreeIcon = (iconSrc: string): JSX.Element => <img src={iconSrc} alt="" style={{ width: 16, height: 16 }} />;
export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
node,
treeNodeId,
}: TreeNodeComponentProps): JSX.Element => {
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const getSortedChildren = (treeNode: TreeNode): TreeNode[] => {
if (!treeNode || !treeNode.children) {
return undefined;
}
const compareFct = (a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label);
let unsortedChildren;
if (treeNode.isLeavesParentsSeparate) {
// Separate parents and leave
const parents: TreeNode[] = treeNode.children.filter((node) => node.children);
const leaves: TreeNode[] = treeNode.children.filter((node) => !node.children);
if (treeNode.isAlphaSorted) {
parents.sort(compareFct);
leaves.sort(compareFct);
}
unsortedChildren = parents.concat(leaves);
} else {
unsortedChildren = treeNode.isAlphaSorted ? treeNode.children.sort(compareFct) : treeNode.children;
}
return unsortedChildren;
};
// A branch node is any node with a defined children array, even if the array is empty.
const isBranch = !!node.children;
const onOpenChange = useCallback(
(_: TreeOpenChangeEvent, data: TreeOpenChangeData) => {
if (data.type === "Click" && !isBranch && node.onClick) {
node.onClick();
}
if (!node.isExpanded && data.open && node.onExpanded) {
// Catch the transition non-expanded to expanded
setIsLoading(true);
node.onExpanded?.().then(() => setIsLoading(false));
} else if (node.isExpanded && !data.open && node.onCollapsed) {
// Catch the transition expanded to non-expanded
node.onCollapsed?.();
}
},
[isBranch, node, setIsLoading],
);
const onMenuOpenChange = useCallback(
(e: MenuOpenEvent, data: MenuOpenChangeData) => {
if (data.open) {
node.onContextMenuOpen?.();
}
},
[node],
);
// We show a node as selected if it is selected AND no descendant is selected.
// We want to show only the deepest selected node as selected.
const isCurrentNodeSelected = node.isSelected && node.isSelected();
const shouldShowAsSelected = isCurrentNodeSelected && !isAnyDescendantSelected(node);
const contextMenuItems = (node.contextMenu ?? []).map((menuItem) => (
<MenuItem
data-test={`TreeNode/ContextMenuItem:${menuItem.label}`}
disabled={menuItem.isDisabled}
key={menuItem.label}
onClick={menuItem.onClick}
>
{menuItem.label}
</MenuItem>
));
const treeItem = (
<TreeItem
data-test={`TreeNodeContainer:${treeNodeId}`}
value={treeNodeId}
itemType={isBranch ? "branch" : "leaf"}
onOpenChange={onOpenChange}
>
<TreeItemLayout
className={node.className}
data-test={`TreeNode:${treeNodeId}`}
actions={
contextMenuItems.length > 0 && (
<Menu onOpenChange={onMenuOpenChange}>
<MenuTrigger disableButtonEnhancement>
<Button
aria-label="More options"
data-test="TreeNode/ContextMenuTrigger"
appearance="subtle"
icon={<MoreHorizontal20Regular />}
/>
</MenuTrigger>
<MenuPopover data-test={`TreeNode/ContextMenu:${treeNodeId}`}>
<MenuList>{contextMenuItems}</MenuList>
</MenuPopover>
</Menu>
)
}
expandIcon={isLoading ? <Spinner size="extra-tiny" /> : undefined}
iconBefore={node.iconSrc && getTreeIcon(node.iconSrc)}
style={{
backgroundColor: shouldShowAsSelected ? tokens.colorNeutralBackground1Selected : undefined,
}}
>
{node.label}
</TreeItemLayout>
{!node.isLoading && node.children?.length > 0 && (
<Tree style={{ overflow: node.isScrollable ? "auto" : undefined }}>
{getSortedChildren(node).map((childNode: TreeNode) => (
<TreeNodeComponent key={childNode.label} node={childNode} treeNodeId={`${treeNodeId}/${childNode.label}`} />
))}
</Tree>
)}
</TreeItem>
);
if (contextMenuItems.length === 0) {
return treeItem;
}
// For accessibility, it's highly recommended that any 'actions' also be available in the context menu.
// See https://react.fluentui.dev/?path=/docs/components-tree--default#actions
return (
<Menu positioning="below-end" openOnContext onOpenChange={onMenuOpenChange}>
<MenuTrigger disableButtonEnhancement>{treeItem}</MenuTrigger>
<MenuPopover>
<MenuList>{contextMenuItems}</MenuList>
</MenuPopover>
</Menu>
);
};

View File

@ -1,11 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TreeComponent renders a simple tree 1`] = `
exports[`LegacyTreeComponent renders a simple tree 1`] = `
<div
className="treeComponent tree"
role="tree"
>
<TreeNodeComponent
<LegacyTreeNodeComponent
generation={0}
node={
Object {
@ -33,16 +33,17 @@ exports[`TreeComponent renders a simple tree 1`] = `
</div>
`;
exports[`TreeNodeComponent does not render children by default 1`] = `
exports[`LegacyTreeNodeComponent does not render children by default 1`] = `
<div
className=" main2 nodeItem "
data-test="Tree/TreeNode:label"
onClick={[Function]}
onKeyPress={[Function]}
role="treeitem"
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 9,
@ -102,7 +103,7 @@ exports[`TreeNodeComponent does not render children by default 1`] = `
data-test="label"
role="group"
>
<TreeNodeComponent
<LegacyTreeNodeComponent
generation={3}
key="Bchild1-3-undefined"
node={
@ -120,7 +121,7 @@ exports[`TreeNodeComponent does not render children by default 1`] = `
}
paddingLeft={32}
/>
<TreeNodeComponent
<LegacyTreeNodeComponent
generation={3}
key="2child2-3-undefined"
node={
@ -135,9 +136,10 @@ exports[`TreeNodeComponent does not render children by default 1`] = `
</div>
`;
exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`] = `
exports[`LegacyTreeNodeComponent renders a simple node (sorted children, expanded) 1`] = `
<div
className="nodeClassname main12 nodeItem "
data-test="Tree/TreeNode:label"
id="id"
onClick={[Function]}
onKeyPress={[Function]}
@ -145,7 +147,7 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 23,
@ -172,7 +174,7 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
onKeyPress={[Function]}
>
<CustomizedIconButton
ariaLabel="More"
ariaLabel="More options"
className="treeMenuEllipsis"
menuIconProps={
Object {
@ -254,7 +256,7 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
data-test="label"
role="group"
>
<TreeNodeComponent
<LegacyTreeNodeComponent
generation={13}
key="2child2-13-undefined"
node={
@ -264,7 +266,7 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
}
paddingLeft={214}
/>
<TreeNodeComponent
<LegacyTreeNodeComponent
generation={13}
key="Bchild1-13-undefined"
node={
@ -287,16 +289,17 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
</div>
`;
exports[`TreeNodeComponent renders loading icon 1`] = `
exports[`LegacyTreeNodeComponent renders loading icon 1`] = `
<div
className=" main2 nodeItem "
data-test="Tree/TreeNode:label"
onClick={[Function]}
onKeyPress={[Function]}
role="treeitem"
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 9,
@ -360,9 +363,10 @@ exports[`TreeNodeComponent renders loading icon 1`] = `
</div>
`;
exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents separated 1`] = `
exports[`LegacyTreeNodeComponent renders sorted children, expanded, leaves and parents separated 1`] = `
<div
className="nodeClassname main12 nodeItem "
data-test="Tree/TreeNode:label"
id="id"
onClick={[Function]}
onKeyPress={[Function]}
@ -370,7 +374,7 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 23,
@ -397,7 +401,7 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
onKeyPress={[Function]}
>
<CustomizedIconButton
ariaLabel="More"
ariaLabel="More options"
className="treeMenuEllipsis"
menuIconProps={
Object {
@ -470,7 +474,7 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
data-test="label"
role="group"
>
<TreeNodeComponent
<LegacyTreeNodeComponent
generation={13}
key="bchild-13-undefined"
node={
@ -488,7 +492,7 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
}
paddingLeft={192}
/>
<TreeNodeComponent
<LegacyTreeNodeComponent
generation={13}
key="dchild-13-undefined"
node={
@ -506,7 +510,7 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
}
paddingLeft={192}
/>
<TreeNodeComponent
<LegacyTreeNodeComponent
generation={13}
key="aChild-13-undefined"
node={
@ -516,7 +520,7 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
}
paddingLeft={214}
/>
<TreeNodeComponent
<LegacyTreeNodeComponent
generation={13}
key="cchild-13-undefined"
node={
@ -531,16 +535,17 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
</div>
`;
exports[`TreeNodeComponent renders unsorted children by default 1`] = `
exports[`LegacyTreeNodeComponent renders unsorted children by default 1`] = `
<div
className=" main2 nodeItem "
data-test="Tree/TreeNode:label"
onClick={[Function]}
onKeyPress={[Function]}
role="treeitem"
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 9,
@ -600,7 +605,7 @@ exports[`TreeNodeComponent renders unsorted children by default 1`] = `
data-test="label"
role="group"
>
<TreeNodeComponent
<LegacyTreeNodeComponent
generation={3}
key="Bchild1-3-undefined"
node={
@ -618,7 +623,7 @@ exports[`TreeNodeComponent renders unsorted children by default 1`] = `
}
paddingLeft={32}
/>
<TreeNodeComponent
<LegacyTreeNodeComponent
generation={3}
key="2child2-3-undefined"
node={

File diff suppressed because it is too large Load Diff

View File

@ -1,147 +0,0 @@
import {
Button,
Menu,
MenuItem,
MenuList,
MenuPopover,
MenuTrigger,
Spinner,
Tree,
TreeItem,
TreeItemLayout,
TreeOpenChangeData,
TreeOpenChangeEvent,
} from "@fluentui/react-components";
import { MoreHorizontal20Regular } from "@fluentui/react-icons";
import { tokens } from "@fluentui/react-theme";
import * as React from "react";
export interface TreeNode2MenuItem {
label: string;
onClick: () => void;
iconSrc?: string;
isDisabled?: boolean;
styleClass?: string;
}
export interface TreeNode2 {
label: string;
id?: string;
children?: TreeNode2[];
contextMenu?: TreeNode2MenuItem[];
iconSrc?: string;
isExpanded?: boolean;
className?: string;
isAlphaSorted?: boolean;
// data?: any; // Piece of data corresponding to this node
timestamp?: number;
isLeavesParentsSeparate?: boolean; // Display parents together first, then leaves
isLoading?: boolean;
isScrollable?: boolean;
isSelected?: () => boolean;
onClick?: () => void; // Only if a leaf, other click will expand/collapse
onExpanded?: () => Promise<void>;
onCollapsed?: () => void;
onContextMenuOpen?: () => void;
}
export interface TreeNode2ComponentProps {
node: TreeNode2;
className?: string;
treeNodeId: string;
}
const getTreeIcon = (iconSrc: string): JSX.Element => <img src={iconSrc} alt="" style={{ width: 20, height: 20 }} />;
export const TreeNode2Component: React.FC<TreeNode2ComponentProps> = ({
node,
treeNodeId,
}: TreeNode2ComponentProps): JSX.Element => {
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const getSortedChildren = (treeNode: TreeNode2): TreeNode2[] => {
if (!treeNode || !treeNode.children) {
return undefined;
}
const compareFct = (a: TreeNode2, b: TreeNode2) => a.label.localeCompare(b.label);
let unsortedChildren;
if (treeNode.isLeavesParentsSeparate) {
// Separate parents and leave
const parents: TreeNode2[] = treeNode.children.filter((node) => node.children);
const leaves: TreeNode2[] = treeNode.children.filter((node) => !node.children);
if (treeNode.isAlphaSorted) {
parents.sort(compareFct);
leaves.sort(compareFct);
}
unsortedChildren = parents.concat(leaves);
} else {
unsortedChildren = treeNode.isAlphaSorted ? treeNode.children.sort(compareFct) : treeNode.children;
}
return unsortedChildren;
};
const onOpenChange = (_: TreeOpenChangeEvent, data: TreeOpenChangeData) => {
if (!node.isExpanded && data.open && node.onExpanded) {
// Catch the transition non-expanded to expanded
setIsLoading(true);
node.onExpanded?.().then(() => setIsLoading(false));
} else if (node.isExpanded && !data.open && node.onCollapsed) {
// Catch the transition expanded to non-expanded
node.onCollapsed?.();
}
};
return (
<TreeItem
value={treeNodeId}
itemType={node.children !== undefined ? "branch" : "leaf"}
style={{ height: "100%" }}
onOpenChange={onOpenChange}
>
<TreeItemLayout
className={node.className}
actions={
node.contextMenu && (
<Menu>
<MenuTrigger disableButtonEnhancement>
<Button aria-label="More options" appearance="subtle" icon={<MoreHorizontal20Regular />} />
</MenuTrigger>
<MenuPopover>
<MenuList>
{node.contextMenu.map((menuItem) => (
<MenuItem disabled={menuItem.isDisabled} key={menuItem.label} onClick={menuItem.onClick}>
{menuItem.label}
</MenuItem>
))}
</MenuList>
</MenuPopover>
</Menu>
)
}
expandIcon={isLoading ? <Spinner size="extra-tiny" /> : undefined}
iconBefore={node.iconSrc && getTreeIcon(node.iconSrc)}
style={{
backgroundColor: node.isSelected && node.isSelected() ? tokens.colorNeutralBackground1Selected : undefined,
}}
>
<span onClick={() => node.onClick?.()}>{node.label}</span>
</TreeItemLayout>
{!node.isLoading && node.children?.length > 0 && (
<Tree style={{ overflow: node.isScrollable ? "auto" : undefined }}>
{getSortedChildren(node).map((childNode: TreeNode2) => (
<TreeNode2Component
key={childNode.label}
node={childNode}
treeNodeId={`${treeNodeId}/${childNode.label}`}
/>
))}
</Tree>
)}
</TreeItem>
);
};

View File

@ -3,10 +3,11 @@ import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext } from "ConfigContext";
import { MessageTypes } from "Contracts/ExplorerContracts";
import { getCopilotEnabled } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { getCopilotEnabled, isCopilotFeatureRegistered } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { IGalleryItem } from "Juno/JunoClient";
import { requestDatabaseResourceTokens } from "Platform/Fabric/FabricUtil";
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointValidation";
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import * as ko from "knockout";
import React from "react";
@ -37,7 +38,6 @@ import { fromContentUri, toRawContentUri } from "../Utils/GitHubUtils";
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../Utils/NotificationConsoleUtils";
import { update } from "../Utils/arm/generatedClients/cosmos/databaseAccounts";
import { listByDatabaseAccount } from "../Utils/arm/generatedClients/cosmosNotebooks/notebookWorkspaces";
import { useSidePanel } from "../hooks/useSidePanel";
import { useTabs } from "../hooks/useTabs";
import "./ComponentRegisterer";
@ -55,7 +55,6 @@ import { AddCollectionPanel } from "./Panes/AddCollectionPanel";
import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane";
import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane";
import { StringInputPane } from "./Panes/StringInputPane/StringInputPane";
import { UploadFilePane } from "./Panes/UploadFilePane/UploadFilePane";
import { UploadItemsPane } from "./Panes/UploadItemsPane/UploadItemsPane";
import { CassandraAPIDataClient, TableDataClient, TablesAPIDataClient } from "./Tables/TableDataClient";
import NotebookV2Tab, { NotebookTabOptions } from "./Tabs/NotebookV2Tab";
@ -137,14 +136,6 @@ export default class Explorer {
this.isTabsContentExpanded = ko.observable(false);
document.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
},
false,
);
$(() => {
$(document.body).click(() => $(".commandDropdownContainer").hide());
});
@ -264,61 +255,43 @@ export default class Explorer {
// TODO: return result
}
private getRandomInt(max: number) {
return Math.floor(Math.random() * max);
}
public openNPSSurveyDialog(): void {
if (!Platform.Portal) {
return;
}
const NINETY_DAYS_IN_MS = 7776000000;
const ONE_DAY_IN_MS = 86400000;
const THREE_DAYS_IN_MS = 259200000;
const isAccountNewerThanNinetyDays = isAccountNewerThanThresholdInMs(
userContext.databaseAccount?.systemData?.createdAt || "",
NINETY_DAYS_IN_MS,
);
const lastSubmitted: string = localStorage.getItem("lastSubmitted");
if (lastSubmitted !== null) {
let lastSubmittedDate: number = parseInt(lastSubmitted);
if (isNaN(lastSubmittedDate)) {
lastSubmittedDate = 0;
}
const nowMs: number = Date.now();
const millisecsSinceLastSubmitted = nowMs - lastSubmittedDate;
if (millisecsSinceLastSubmitted < NINETY_DAYS_IN_MS) {
return;
}
}
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)) {
this.sendNPSMessage();
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 {
// An existing account is older than 3 days but less than 90 days old. For existing account show to 100% of users in Data Explorer.
// Show survey when an existing account is older than 7 days
if (
!isAccountNewerThanThresholdInMs(userContext.databaseAccount?.systemData?.createdAt || "", THREE_DAYS_IN_MS) &&
isAccountNewerThanNinetyDays
!isAccountNewerThanThresholdInMs(userContext.databaseAccount?.systemData?.createdAt || "", SEVEN_DAYS_IN_MS)
) {
this.sendNPSMessage();
} else {
// An existing account is greater than 90 days. For existing account show to random 33% of users in Data Explorer.
if (this.getRandomInt(100) < 33) {
this.sendNPSMessage();
}
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 });
}
}
}
private sendNPSMessage() {
sendMessage({ type: MessageTypes.DisplayNPSSurvey });
localStorage.setItem("lastSubmitted", Date.now().toString());
public async openCESCVAFeedbackBlade(): Promise<void> {
sendMessage({ type: MessageTypes.OpenCESCVAFeedbackBlade });
Logger.logInfo(
`CES CVA Feedback logging current date when survey is shown ${Date.now().toString()}`,
"Explorer/openCESCVAFeedbackBlade",
);
}
public async refreshDatabaseForResourceToken(): Promise<void> {
@ -383,9 +356,7 @@ export default class Explorer {
public onRefreshResourcesClick = (): void => {
if (configContext.platform === Platform.Fabric) {
// Requesting the tokens will trigger a refresh of the databases
// TODO: Once the id is returned from Fabric, we can await this call and then refresh the databases here
requestDatabaseResourceTokens();
scheduleRefreshDatabaseResourceToken(true).then(() => this.refreshAllDatabases());
return;
}
@ -529,104 +500,6 @@ export default class Explorer {
.then((memoryUsageInfo) => useNotebook.getState().setMemoryUsageInfo(memoryUsageInfo));
}
public resetNotebookWorkspace(): void {
if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookClient) {
handleError(
"Attempt to reset notebook workspace, but notebook is not enabled",
"Explorer/resetNotebookWorkspace",
);
return;
}
const dialogContent = useNotebook.getState().isPhoenixNotebooks
? "Notebooks saved in the temporary workspace will be deleted. Do you want to proceed?"
: "This lets you keep your notebook files and the workspace will be restored to default. Proceed anyway?";
const resetConfirmationDialogProps: DialogProps = {
isModal: true,
title: "Reset Workspace",
subText: dialogContent,
primaryButtonText: "OK",
secondaryButtonText: "Cancel",
onPrimaryButtonClick: this._resetNotebookWorkspace,
onSecondaryButtonClick: () => useDialog.getState().closeDialog(),
};
useDialog.getState().openDialog(resetConfirmationDialogProps);
}
private async _containsDefaultNotebookWorkspace(databaseAccount: DataModels.DatabaseAccount): Promise<boolean> {
if (!databaseAccount) {
return false;
}
try {
const { value: workspaces } = await listByDatabaseAccount(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
);
return workspaces && workspaces.length > 0 && workspaces.some((workspace) => workspace.name === "default");
} catch (error) {
Logger.logError(getErrorMessage(error), "Explorer/_containsDefaultNotebookWorkspace");
return false;
}
}
private _resetNotebookWorkspace = async () => {
useDialog.getState().closeDialog();
const clearInProgressMessage = logConsoleProgress("Resetting notebook workspace");
let connectionStatus: ContainerConnectionInfo;
try {
const notebookServerInfo = useNotebook.getState().notebookServerInfo;
if (!notebookServerInfo || !notebookServerInfo.notebookServerEndpoint) {
const error = "No server endpoint detected";
Logger.logError(error, "NotebookContainerClient/resetWorkspace");
logConsoleError(error);
return;
}
TelemetryProcessor.traceStart(Action.PhoenixResetWorkspace, {
dataExplorerArea: Areas.Notebook,
});
if (useNotebook.getState().isPhoenixNotebooks) {
useTabs.getState().closeAllNotebookTabs(true);
connectionStatus = {
status: ConnectionStatusType.Connecting,
};
useNotebook.getState().setConnectionInfo(connectionStatus);
}
const connectionInfo = await this.notebookManager?.notebookClient.resetWorkspace();
if (connectionInfo?.status !== HttpStatusCodes.OK) {
throw new Error(`Reset Workspace: Received status code- ${connectionInfo?.status}`);
}
if (!connectionInfo?.data?.phoenixServiceUrl) {
throw new Error(`Reset Workspace: PhoenixServiceUrl is invalid!`);
}
if (useNotebook.getState().isPhoenixNotebooks) {
await this.setNotebookInfo(true, connectionInfo, connectionStatus);
useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed);
}
logConsoleInfo("Successfully reset notebook workspace");
TelemetryProcessor.traceSuccess(Action.PhoenixResetWorkspace, {
dataExplorerArea: Areas.Notebook,
});
} catch (error) {
logConsoleError(`Failed to reset notebook workspace: ${error}`);
TelemetryProcessor.traceFailure(Action.PhoenixResetWorkspace, {
dataExplorerArea: Areas.Notebook,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
});
if (useNotebook.getState().isPhoenixNotebooks) {
connectionStatus = {
status: ConnectionStatusType.Failed,
};
useNotebook.getState().resetContainerConnection(connectionStatus);
useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed);
}
throw error;
} finally {
clearInProgressMessage();
}
};
private getDeltaDatabases(
updatedDatabaseList: DataModels.Database[],
databases: ViewModels.Database[],
@ -1029,92 +902,6 @@ export default class Explorer {
);
}
/**
* This creates a new notebook file, then opens the notebook
*/
public async onNewNotebookClicked(parent?: NotebookContentItem, isGithubTree?: boolean): Promise<void> {
if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) {
const error = "Attempt to create new notebook, but notebook is not enabled";
handleError(error, "Explorer/onNewNotebookClicked");
throw new Error(error);
}
if (useNotebook.getState().isPhoenixNotebooks) {
if (isGithubTree) {
await this.allocateContainer(PoolIdType.DefaultPoolId);
parent = parent || this.resourceTree.myNotebooksContentRoot;
this.createNewNoteBook(parent, isGithubTree);
} else {
useDialog.getState().showOkCancelModalDialog(
Notebook.newNotebookModalTitle,
undefined,
"Create",
async () => {
await this.allocateContainer(PoolIdType.DefaultPoolId);
parent = parent || this.resourceTree.myNotebooksContentRoot;
this.createNewNoteBook(parent, isGithubTree);
},
"Cancel",
undefined,
this.getNewNoteWarningText(),
);
}
} else {
parent = parent || this.resourceTree.myNotebooksContentRoot;
this.createNewNoteBook(parent, isGithubTree);
}
}
private getNewNoteWarningText(): JSX.Element {
return (
<>
<p>{Notebook.newNotebookModalContent1}</p>
<br />
<p>
{Notebook.newNotebookModalContent2}
<Link href={Notebook.cosmosNotebookHomePageUrl} target="_blank">
{Notebook.learnMore}
</Link>
</p>
</>
);
}
private createNewNoteBook(parent?: NotebookContentItem, isGithubTree?: boolean): void {
const clearInProgressMessage = logConsoleProgress(`Creating new notebook in ${parent.path}`);
const startKey: number = TelemetryProcessor.traceStart(Action.CreateNewNotebook, {
dataExplorerArea: Constants.Areas.Notebook,
});
this.notebookManager?.notebookContentClient
.createNewNotebookFile(parent, isGithubTree)
.then((newFile: NotebookContentItem) => {
logConsoleInfo(`Successfully created: ${newFile.name}`);
TelemetryProcessor.traceSuccess(
Action.CreateNewNotebook,
{
dataExplorerArea: Constants.Areas.Notebook,
},
startKey,
);
return this.openNotebook(newFile);
})
.then(() => this.resourceTree.triggerRender())
.catch((error) => {
const errorMessage = `Failed to create a new notebook: ${getErrorMessage(error)}`;
logConsoleError(errorMessage);
TelemetryProcessor.traceFailure(
Action.CreateNewNotebook,
{
dataExplorerArea: Constants.Areas.Notebook,
error: errorMessage,
errorStack: getErrorStack(error),
},
startKey,
);
})
.finally(clearInProgressMessage);
}
// TODO: Delete this function when ResourceTreeAdapter is removed.
public async refreshContentItem(item: NotebookContentItem): Promise<void> {
if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) {
@ -1149,10 +936,6 @@ export default class Explorer {
let title: string;
switch (kind) {
case ViewModels.TerminalKind.Default:
title = "Terminal";
break;
case ViewModels.TerminalKind.Mongo:
title = "Mongo Shell";
break;
@ -1306,36 +1089,6 @@ export default class Explorer {
.openSidePanel("Input parameters", <ExecuteSprocParamsPane storedProcedure={storedProcedure} />);
}
public openUploadFilePanel(parent?: NotebookContentItem): void {
if (useNotebook.getState().isPhoenixNotebooks) {
useDialog.getState().showOkCancelModalDialog(
Notebook.newNotebookUploadModalTitle,
undefined,
"Upload",
async () => {
await this.allocateContainer(PoolIdType.DefaultPoolId);
parent = parent || this.resourceTree.myNotebooksContentRoot;
this.uploadFilePanel(parent);
},
"Cancel",
undefined,
this.getNewNoteWarningText(),
);
} else {
parent = parent || this.resourceTree.myNotebooksContentRoot;
this.uploadFilePanel(parent);
}
}
private uploadFilePanel(parent?: NotebookContentItem): void {
useSidePanel
.getState()
.openSidePanel(
"Upload file to notebook server",
<UploadFilePane uploadFile={(name: string, content: string) => this.uploadFile(name, content, parent)} />,
);
}
public getDownloadModalConent(fileName: string): JSX.Element {
if (useNotebook.getState().isPhoenixNotebooks) {
return (
@ -1389,16 +1142,25 @@ export default class Explorer {
if (userContext.apiType !== "SQL" || !userContext.subscriptionId) {
return;
}
const copilotEnabled = await getCopilotEnabled();
useQueryCopilot.getState().setCopilotEnabled(copilotEnabled);
useQueryCopilot.getState().setCopilotUserDBEnabled(copilotEnabled);
const copilotEnabledPromise = getCopilotEnabled();
const copilotUserDBEnabledPromise = isCopilotFeatureRegistered(userContext.subscriptionId);
const [copilotEnabled, copilotUserDBEnabled] = await Promise.all([
copilotEnabledPromise,
copilotUserDBEnabledPromise,
]);
const copilotSampleDBEnabled = LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true";
useQueryCopilot.getState().setCopilotEnabled(copilotEnabled && copilotUserDBEnabled);
useQueryCopilot.getState().setCopilotUserDBEnabled(copilotUserDBEnabled);
useQueryCopilot
.getState()
.setCopilotSampleDBEnabled(copilotEnabled && copilotUserDBEnabled && copilotSampleDBEnabled);
}
public async refreshSampleData(): Promise<void> {
try {
if (!userContext.sampleDataConnectionInfo) {
return;
}
const collection: DataModels.Collection = await readSampleCollection();
if (!collection) {
return;
@ -1411,5 +1173,9 @@ export default class Explorer {
const sampleDataResourceTokenCollection = new ResourceTokenCollection(this, databaseId, collection, true);
useDatabases.setState({ sampleDataResourceTokenCollection });
} catch (error) {
Logger.logError(getErrorMessage(error), "Explorer");
return;
}
}
}

View File

@ -162,7 +162,7 @@ export const addRootChildToGraph = (
* @param value
*/
export const escapeDoubleQuotes = (value: string): string => {
return value === undefined ? value : value.replace(/"/g, '\\"');
return value === undefined ? value : value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
};
/**
@ -186,5 +186,5 @@ export const getQuotedPropValue = (ip: ViewModels.InputPropertyValue): string =>
* @param value
*/
export const escapeSingleQuotes = (value: string): string => {
return value === undefined ? value : value.replace(/'/g, "\\'");
return value === undefined ? value : value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
};

View File

@ -349,7 +349,7 @@ export class NodePropertiesComponent extends React.Component<
onActivated={this.setIsDeleteConfirm.bind(this, true)}
aria-label="Delete this vertex"
>
<img src={DeleteIcon} alt="Delete" />
<img src={DeleteIcon} alt="Delete" role="button" />
</AccessibleElement>
);
} else {
@ -406,7 +406,7 @@ export class NodePropertiesComponent extends React.Component<
aria-label="Edit properties"
onActivated={expandClickHandler}
>
<img src={EditIcon} alt="Edit" />
<img src={EditIcon} alt="Edit" role="button" />
</AccessibleElement>
)}

View File

@ -184,12 +184,18 @@ export const NewVertexComponent: FunctionComponent<INewVertexComponentProps> = (
className="rightPaneTrashIcon rightPaneBtns"
tabIndex={0}
role="button"
aria-label={`Delete ${data.key}`}
onClick={(event: React.MouseEvent<HTMLDivElement>) => removeNewVertexProperty(event, index)}
onKeyPress={(event: React.KeyboardEvent<HTMLDivElement>) =>
removeNewVertexPropertyKeyPress(event, index)
}
>
<img className="refreshcol rightPaneTrashIconImg" src={DeleteIcon} alt="Remove property" />
<img
aria-label="hidden"
className="refreshcol rightPaneTrashIconImg"
src={DeleteIcon}
alt="Remove property"
/>
</div>
</div>
</div>

View File

@ -5,6 +5,7 @@
*/
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import { userContext } from "UserContext";
import * as React from "react";
import create, { UseStore } from "zustand";
@ -24,17 +25,23 @@ interface Props {
export interface CommandBarStore {
contextButtons: CommandButtonComponentProps[];
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => void;
isHidden: boolean;
setIsHidden: (isHidden: boolean) => void;
}
export const useCommandBar: UseStore<CommandBarStore> = create((set) => ({
contextButtons: [],
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => set((state) => ({ ...state, contextButtons })),
isHidden: false,
setIsHidden: (isHidden: boolean) => set((state) => ({ ...state, isHidden })),
}));
export const CommandBar: React.FC<Props> = ({ container }: Props) => {
const selectedNodeState = useSelectedNode();
const buttons = useCommandBar((state) => state.contextButtons);
const isHidden = useCommandBar((state) => state.isHidden);
const backgroundColor = StyleConstants.BaseLight;
const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR);
if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") {
const buttons =
@ -42,7 +49,7 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
? CommandBarComponentButtonFactory.createPostgreButtons(container)
: CommandBarComponentButtonFactory.createVCoreMongoButtons(container);
return (
<div className="commandBarContainer">
<div className="commandBarContainer" style={{ display: isHidden ? "none" : "initial" }}>
<FluentCommandBar
ariaLabel="Use left and right arrow keys to navigate between commands"
items={CommandBarUtil.convertButton(buttons, backgroundColor)}
@ -91,7 +98,7 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
? {
root: {
backgroundColor: "transparent",
padding: "0px 14px 0px 14px",
padding: "2px 8px 0px 8px",
},
}
: {
@ -100,8 +107,12 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
},
};
const allButtons = staticButtons.concat(contextButtons).concat(controlButtons);
const keyboardHandlers = CommandBarUtil.createKeyboardHandlers(allButtons);
setKeyboardHandlers(keyboardHandlers);
return (
<div className="commandBarContainer">
<div className="commandBarContainer" style={{ display: isHidden ? "none" : "initial" }}>
<FluentCommandBar
ariaLabel="Use left and right arrow keys to navigate between commands"
items={uiFabricStaticButtons.concat(uiFabricTabsButtons)}

View File

@ -2,10 +2,8 @@ import * as ko from "knockout";
import { AuthType } from "../../../AuthType";
import { DatabaseAccount } from "../../../Contracts/DataModels";
import { CollectionBase } from "../../../Contracts/ViewModels";
import { GitHubOAuthService } from "../../../GitHub/GitHubOAuthService";
import { updateUserContext } from "../../../UserContext";
import Explorer from "../../Explorer";
import NotebookManager from "../../Notebook/NotebookManager";
import { useNotebook } from "../../Notebook/useNotebook";
import { useDatabases } from "../../useDatabases";
import { useSelectedNode } from "../../useSelectedNode";
@ -72,181 +70,6 @@ describe("CommandBarComponentButtonFactory tests", () => {
});
});
describe("Enable notebook button", () => {
const enableNotebookBtnLabel = "Enable Notebooks (Preview)";
const selectedNodeState = useSelectedNode.getState();
beforeAll(() => {
mockExplorer = {} as Explorer;
updateUserContext({
portalEnv: "prod",
databaseAccount: {
properties: {
capabilities: [{ name: "EnableTable" }],
},
} as DatabaseAccount,
});
});
afterEach(() => {
updateUserContext({
portalEnv: "prod",
});
useNotebook.getState().setIsNotebookEnabled(false);
useNotebook.getState().setIsNotebooksEnabledForAccount(false);
});
it("Notebooks is already enabled - button should be hidden", () => {
useNotebook.getState().setIsNotebookEnabled(true);
useNotebook.getState().setIsNotebooksEnabledForAccount(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const enableNotebookBtn = buttons.find((button) => button.commandButtonLabel === enableNotebookBtnLabel);
expect(enableNotebookBtn).toBeUndefined();
});
it("Account is running on one of the national clouds - button should be hidden", () => {
updateUserContext({
portalEnv: "mooncake",
});
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const enableNotebookBtn = buttons.find((button) => button.commandButtonLabel === enableNotebookBtnLabel);
expect(enableNotebookBtn).toBeUndefined();
});
it("Notebooks is not enabled but is available - button should be shown and enabled", () => {
useNotebook.getState().setIsNotebooksEnabledForAccount(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const enableNotebookBtn = buttons.find((button) => button.commandButtonLabel === enableNotebookBtnLabel);
//TODO: modify once notebooks are available
expect(enableNotebookBtn).toBeUndefined();
//expect(enableNotebookBtn).toBeDefined();
//expect(enableNotebookBtn.disabled).toBe(false);
//expect(enableNotebookBtn.tooltipText).toBe("");
});
it("Notebooks is not enabled and is unavailable - button should be shown and disabled", () => {
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const enableNotebookBtn = buttons.find((button) => button.commandButtonLabel === enableNotebookBtnLabel);
//TODO: modify once notebooks are available
expect(enableNotebookBtn).toBeUndefined();
//expect(enableNotebookBtn).toBeDefined();
//expect(enableNotebookBtn.disabled).toBe(true);
//expect(enableNotebookBtn.tooltipText).toBe(
// "Notebooks are not yet available in your account's region. View supported regions here: https://aka.ms/cosmos-enable-notebooks."
//);
});
});
describe("Open Mongo shell button", () => {
const openMongoShellBtnLabel = "Open Mongo shell";
const selectedNodeState = useSelectedNode.getState();
beforeAll(() => {
mockExplorer = {} as Explorer;
updateUserContext({
databaseAccount: {
properties: {
capabilities: [{ name: "EnableTable" }],
},
} as DatabaseAccount,
});
});
afterAll(() => {
updateUserContext({
apiType: "SQL",
});
useNotebook.getState().setIsShellEnabled(false);
});
beforeEach(() => {
updateUserContext({
apiType: "Mongo",
});
useNotebook.getState().setIsShellEnabled(true);
});
afterEach(() => {
useNotebook.getState().setIsNotebookEnabled(false);
useNotebook.getState().setIsNotebooksEnabledForAccount(false);
});
it("Mongo Api not available - button should be hidden", () => {
updateUserContext({
apiType: "SQL",
});
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openMongoShellBtn = buttons.find((button) => button.commandButtonLabel === openMongoShellBtnLabel);
expect(openMongoShellBtn).toBeUndefined();
});
it("Running on a national cloud - button should be hidden", () => {
updateUserContext({
portalEnv: "mooncake",
});
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openMongoShellBtn = buttons.find((button) => button.commandButtonLabel === openMongoShellBtnLabel);
expect(openMongoShellBtn).toBeUndefined();
});
it("Notebooks is not enabled and is unavailable - button should be hidden", () => {
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openMongoShellBtn = buttons.find((button) => button.commandButtonLabel === openMongoShellBtnLabel);
expect(openMongoShellBtn).toBeUndefined();
});
it("Notebooks is not enabled and is available - button should be hidden", () => {
useNotebook.getState().setIsNotebooksEnabledForAccount(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openMongoShellBtn = buttons.find((button) => button.commandButtonLabel === openMongoShellBtnLabel);
expect(openMongoShellBtn).toBeUndefined();
});
it("Notebooks is enabled and is unavailable - button should be shown and enabled", () => {
useNotebook.getState().setIsNotebookEnabled(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openMongoShellBtn = buttons.find((button) => button.commandButtonLabel === openMongoShellBtnLabel);
expect(openMongoShellBtn).toBeDefined();
//TODO: modify once notebooks are available
expect(openMongoShellBtn.disabled).toBe(true);
//expect(openMongoShellBtn.disabled).toBe(false);
//expect(openMongoShellBtn.tooltipText).toBe("");
});
it("Notebooks is enabled and is available - button should be shown and enabled", () => {
useNotebook.getState().setIsNotebookEnabled(true);
useNotebook.getState().setIsNotebooksEnabledForAccount(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openMongoShellBtn = buttons.find((button) => button.commandButtonLabel === openMongoShellBtnLabel);
expect(openMongoShellBtn).toBeDefined();
//TODO: modify once notebooks are available
expect(openMongoShellBtn.disabled).toBe(true);
//expect(openMongoShellBtn.disabled).toBe(false);
//expect(openMongoShellBtn.tooltipText).toBe("");
});
it("Notebooks is enabled and is available, terminal is unavailable due to ipRules - button should be hidden", () => {
useNotebook.getState().setIsNotebookEnabled(true);
useNotebook.getState().setIsNotebooksEnabledForAccount(true);
useNotebook.getState().setIsShellEnabled(false);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openMongoShellBtn = buttons.find((button) => button.commandButtonLabel === openMongoShellBtnLabel);
expect(openMongoShellBtn).toBeUndefined();
});
});
describe("Open Cassandra shell button", () => {
const openCassandraShellBtnLabel = "Open Cassandra shell";
const selectedNodeState = useSelectedNode.getState();
@ -305,42 +128,6 @@ describe("CommandBarComponentButtonFactory tests", () => {
const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeUndefined();
});
it("Notebooks is not enabled and is available - button should be shown and enabled", () => {
useNotebook.getState().setIsNotebooksEnabledForAccount(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeUndefined();
});
it("Notebooks is enabled and is unavailable - button should be shown and enabled", () => {
useNotebook.getState().setIsNotebookEnabled(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeDefined();
//TODO: modify once notebooks are available
expect(openCassandraShellBtn.disabled).toBe(true);
//expect(openCassandraShellBtn.disabled).toBe(false);
//expect(openCassandraShellBtn.tooltipText).toBe("");
});
it("Notebooks is enabled and is available - button should be shown and enabled", () => {
useNotebook.getState().setIsNotebookEnabled(true);
useNotebook.getState().setIsNotebooksEnabledForAccount(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeDefined();
//TODO: modify once notebooks are available
expect(openCassandraShellBtn.disabled).toBe(true);
//expect(openCassandraShellBtn.disabled).toBe(false);
//expect(openCassandraShellBtn.tooltipText).toBe("");
});
});
describe("Open Postgres and vCore Mongo buttons", () => {
@ -368,62 +155,6 @@ describe("CommandBarComponentButtonFactory tests", () => {
});
});
describe("GitHub buttons", () => {
const connectToGitHubBtnLabel = "Connect to GitHub";
const manageGitHubSettingsBtnLabel = "Manage GitHub settings";
const selectedNodeState = useSelectedNode.getState();
beforeAll(() => {
mockExplorer = {} as Explorer;
updateUserContext({
databaseAccount: {
properties: {
capabilities: [{ name: "EnableTable" }],
},
} as DatabaseAccount,
});
mockExplorer.notebookManager = new NotebookManager();
mockExplorer.notebookManager.gitHubOAuthService = new GitHubOAuthService(undefined);
});
afterEach(() => {
jest.resetAllMocks();
useNotebook.getState().setIsNotebookEnabled(false);
});
it("Notebooks is enabled and GitHubOAuthService is not logged in - connect to github button should be visible", () => {
useNotebook.getState().setIsNotebookEnabled(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const connectToGitHubBtn = buttons.find((button) => button.commandButtonLabel === connectToGitHubBtnLabel);
expect(connectToGitHubBtn).toBeDefined();
});
it("Notebooks is enabled and GitHubOAuthService is logged in - manage github settings button should be visible", () => {
useNotebook.getState().setIsNotebookEnabled(true);
mockExplorer.notebookManager.gitHubOAuthService.isLoggedIn = jest.fn().mockReturnValue(true);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const manageGitHubSettingsBtn = buttons.find(
(button) => button.commandButtonLabel === manageGitHubSettingsBtnLabel,
);
expect(manageGitHubSettingsBtn).toBeDefined();
});
it("Notebooks is not enabled - connect to github and manage github settings buttons should be hidden", () => {
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const connectToGitHubBtn = buttons.find((button) => button.commandButtonLabel === connectToGitHubBtnLabel);
expect(connectToGitHubBtn).toBeUndefined();
const manageGitHubSettingsBtn = buttons.find(
(button) => button.commandButtonLabel === manageGitHubSettingsBtnLabel,
);
expect(manageGitHubSettingsBtn).toBeUndefined();
});
});
describe("Resource token", () => {
const mockCollection = { id: ko.observable("test") } as CollectionBase;
useSelectedNode.getState().setSelectedNode(mockCollection);

View File

@ -1,3 +1,5 @@
import { KeyboardAction } from "KeyboardShortcuts";
import { ReactTabKind, useTabs } from "hooks/useTabs";
import * as React from "react";
import AddCollectionIcon from "../../../../images/AddCollection.svg";
import AddDatabaseIcon from "../../../../images/AddDatabase.svg";
@ -6,13 +8,10 @@ import AddStoredProcedureIcon from "../../../../images/AddStoredProcedure.svg";
import AddTriggerIcon from "../../../../images/AddTrigger.svg";
import AddUdfIcon from "../../../../images/AddUdf.svg";
import BrowseQueriesIcon from "../../../../images/BrowseQuery.svg";
import CosmosTerminalIcon from "../../../../images/Cosmos-Terminal.svg";
import FeedbackIcon from "../../../../images/Feedback-Command.svg";
import HomeIcon from "../../../../images/Home_16.svg";
import HostedTerminalIcon from "../../../../images/Hosted-Terminal.svg";
import OpenQueryFromDiskIcon from "../../../../images/OpenQueryFromDisk.svg";
import GitHubIcon from "../../../../images/github.svg";
import NewNotebookIcon from "../../../../images/notebook/Notebook-new.svg";
import ResetWorkspaceIcon from "../../../../images/notebook/Notebook-reset-workspace.svg";
import OpenInTabIcon from "../../../../images/open-in-tab.svg";
import SettingsIcon from "../../../../images/settings_15x15.svg";
import SynapseIcon from "../../../../images/synapse-link.svg";
@ -20,7 +19,6 @@ import { AuthType } from "../../../AuthType";
import * as Constants from "../../../Common/Constants";
import { Platform, configContext } from "../../../ConfigContext";
import * as ViewModels from "../../../Contracts/ViewModels";
import { JunoClient } from "../../../Juno/JunoClient";
import { userContext } from "../../../UserContext";
import { getCollectionName, getDatabaseName } from "../../../Utils/APITypeUtils";
import { isRunningOnNationalCloud } from "../../../Utils/CloudUtils";
@ -31,7 +29,6 @@ import { useNotebook } from "../../Notebook/useNotebook";
import { OpenFullScreen } from "../../OpenFullScreen";
import { AddDatabasePanel } from "../../Panes/AddDatabasePanel/AddDatabasePanel";
import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane";
import { GitHubReposPanel } from "../../Panes/GitHubReposPanel/GitHubReposPanel";
import { LoadQueryPane } from "../../Panes/LoadQueryPane/LoadQueryPane";
import { SettingsPane } from "../../Panes/SettingsPane/SettingsPane";
import { useDatabases } from "../../useDatabases";
@ -57,7 +54,11 @@ export function createStaticCommandBarButtons(
};
if (configContext.platform !== Platform.Fabric) {
const homeBtn = createHomeButton();
buttons.push(homeBtn);
const newCollectionBtn = createNewCollectionGroup(container);
newCollectionBtn.keyboardAction = KeyboardAction.NEW_COLLECTION; // Just for the root button, not the child version we create below.
buttons.push(newCollectionBtn);
if (userContext.apiType !== "Tables" && userContext.apiType !== "Cassandra") {
const addSynapseLink = createOpenSynapseLinkDialogButton(container);
@ -75,57 +76,6 @@ export function createStaticCommandBarButtons(
}
}
if (useNotebook.getState().isNotebookEnabled) {
addDivider();
const notebookButtons: CommandButtonComponentProps[] = [];
const newNotebookButton = createNewNotebookButton(container);
newNotebookButton.children = [createNewNotebookButton(container), createuploadNotebookButton(container)];
notebookButtons.push(newNotebookButton);
if (container.notebookManager?.gitHubOAuthService) {
notebookButtons.push(createManageGitHubAccountButton(container));
}
if (useNotebook.getState().isPhoenixFeatures && configContext.isTerminalEnabled) {
notebookButtons.push(createOpenTerminalButton(container));
}
if (useNotebook.getState().isPhoenixNotebooks && selectedNodeState.isConnectedToContainer()) {
notebookButtons.push(createNotebookWorkspaceResetButton(container));
}
if (
(userContext.apiType === "Mongo" &&
useNotebook.getState().isShellEnabled &&
selectedNodeState.isDatabaseNodeOrNoneSelected()) ||
userContext.apiType === "Cassandra"
) {
notebookButtons.push(createDivider());
if (userContext.apiType === "Cassandra") {
notebookButtons.push(createOpenTerminalButtonByKind(container, ViewModels.TerminalKind.Cassandra));
} else {
notebookButtons.push(createOpenTerminalButtonByKind(container, ViewModels.TerminalKind.Mongo));
}
}
notebookButtons.forEach((btn) => {
if (btn.commandButtonLabel.indexOf("Cassandra") !== -1) {
if (!useNotebook.getState().isPhoenixFeatures) {
applyNotebooksTemporarilyDownStyle(btn, Constants.Notebook.cassandraShellTemporarilyDownMsg);
}
} else if (btn.commandButtonLabel.indexOf("Mongo") !== -1) {
if (!useNotebook.getState().isPhoenixFeatures) {
applyNotebooksTemporarilyDownStyle(btn, Constants.Notebook.mongoShellTemporarilyDownMsg);
}
} else if (btn.commandButtonLabel.indexOf("Open Terminal") !== -1) {
if (!useNotebook.getState().isPhoenixFeatures) {
applyNotebooksTemporarilyDownStyle(btn, Constants.Notebook.temporarilyDownMsg);
}
} else if (!useNotebook.getState().isPhoenixNotebooks) {
applyNotebooksTemporarilyDownStyle(btn, Constants.Notebook.temporarilyDownMsg);
}
buttons.push(btn);
});
}
if (!selectedNodeState.isDatabaseNodeOrNoneSelected()) {
const isQuerySupported = userContext.apiType === "SQL" || userContext.apiType === "Gremlin";
@ -135,7 +85,7 @@ export function createStaticCommandBarButtons(
buttons.push(newSqlQueryBtn);
}
if (isQuerySupported && selectedNodeState.findSelectedCollection()) {
if (isQuerySupported && selectedNodeState.findSelectedCollection() && configContext.platform !== Platform.Fabric) {
const openQueryBtn = createOpenQueryButton(container);
openQueryBtn.children = [createOpenQueryButton(container), createOpenQueryFromDiskButton()];
buttons.push(openQueryBtn);
@ -146,6 +96,7 @@ export function createStaticCommandBarButtons(
const newStoredProcedureBtn: CommandButtonComponentProps = {
iconSrc: AddStoredProcedureIcon,
iconAlt: label,
keyboardAction: KeyboardAction.NEW_SPROC,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
selectedCollection && selectedCollection.onNewStoredProcedureClick(selectedCollection);
@ -196,11 +147,15 @@ export function createContextCommandBarButtons(
}
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
const buttons: CommandButtonComponentProps[] = [
const buttons: CommandButtonComponentProps[] =
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
? []
: [
{
iconSrc: SettingsIcon,
iconAlt: "Settings",
onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", <SettingsPane />),
onCommandClick: () =>
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
commandButtonLabel: undefined,
ariaLabel: "Settings",
tooltipText: "Settings",
@ -231,12 +186,12 @@ export function createControlCommandBarButtons(container: Explorer): CommandButt
buttons.push(fullScreenButton);
}
if (configContext.platform !== Platform.Emulator) {
if (configContext.platform === Platform.Portal) {
const label = "Feedback";
const feedbackButtonOptions: CommandButtonComponentProps = {
iconSrc: FeedbackIcon,
iconAlt: label,
onCommandClick: () => container.provideFeedbackEmail(),
onCommandClick: () => container.openCESCVAFeedbackBlade(),
commandButtonLabel: undefined,
ariaLabel: label,
tooltipText: label,
@ -281,6 +236,18 @@ function createNewCollectionGroup(container: Explorer): CommandButtonComponentPr
};
}
function createHomeButton(): CommandButtonComponentProps {
const label = "Home";
return {
iconSrc: HomeIcon,
iconAlt: label,
onCommandClick: () => useTabs.getState().openAndActivateReactTab(ReactTabKind.Home),
commandButtonLabel: label,
hasPopup: false,
ariaLabel: label,
};
}
function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonComponentProps {
if (configContext.platform === Platform.Emulator) {
return undefined;
@ -313,6 +280,7 @@ function createNewDatabase(container: Explorer): CommandButtonComponentProps {
return {
iconSrc: AddDatabaseIcon,
iconAlt: label,
keyboardAction: KeyboardAction.NEW_DATABASE,
onCommandClick: async () => {
const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit;
if (throughputCap && throughputCap !== -1) {
@ -333,6 +301,7 @@ function createNewSQLQueryButton(selectedNodeState: SelectedNodeState): CommandB
id: "newQueryBtn",
iconSrc: AddSqlQueryIcon,
iconAlt: label,
keyboardAction: KeyboardAction.NEW_QUERY,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
selectedCollection && selectedCollection.onNewQueryClick(selectedCollection);
@ -348,6 +317,7 @@ function createNewSQLQueryButton(selectedNodeState: SelectedNodeState): CommandB
id: "newQueryBtn",
iconSrc: AddSqlQueryIcon,
iconAlt: label,
keyboardAction: KeyboardAction.NEW_QUERY,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
selectedCollection && selectedCollection.onNewMongoQueryClick(selectedCollection);
@ -373,6 +343,7 @@ export function createScriptCommandButtons(selectedNodeState: SelectedNodeState)
const newStoredProcedureBtn: CommandButtonComponentProps = {
iconSrc: AddStoredProcedureIcon,
iconAlt: label,
keyboardAction: KeyboardAction.NEW_SPROC,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
selectedCollection && selectedCollection.onNewStoredProcedureClick(selectedCollection);
@ -392,6 +363,7 @@ export function createScriptCommandButtons(selectedNodeState: SelectedNodeState)
const newUserDefinedFunctionBtn: CommandButtonComponentProps = {
iconSrc: AddUdfIcon,
iconAlt: label,
keyboardAction: KeyboardAction.NEW_UDF,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
selectedCollection && selectedCollection.onNewUserDefinedFunctionClick(selectedCollection);
@ -411,6 +383,7 @@ export function createScriptCommandButtons(selectedNodeState: SelectedNodeState)
const newTriggerBtn: CommandButtonComponentProps = {
iconSrc: AddTriggerIcon,
iconAlt: label,
keyboardAction: KeyboardAction.NEW_TRIGGER,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
selectedCollection && selectedCollection.onNewTriggerClick(selectedCollection);
@ -428,45 +401,12 @@ export function createScriptCommandButtons(selectedNodeState: SelectedNodeState)
return buttons;
}
function applyNotebooksTemporarilyDownStyle(buttonProps: CommandButtonComponentProps, tooltip: string): void {
if (!buttonProps.isDivider) {
buttonProps.disabled = true;
buttonProps.tooltipText = tooltip;
}
}
function createNewNotebookButton(container: Explorer): CommandButtonComponentProps {
const label = "New Notebook";
return {
id: "newNotebookBtn",
iconSrc: NewNotebookIcon,
iconAlt: label,
onCommandClick: () => container.onNewNotebookClicked(),
commandButtonLabel: label,
hasPopup: false,
disabled: useSelectedNode.getState().isQueryCopilotCollectionSelected(),
ariaLabel: label,
};
}
function createuploadNotebookButton(container: Explorer): CommandButtonComponentProps {
const label = "Upload to Notebook Server";
return {
iconSrc: NewNotebookIcon,
iconAlt: label,
onCommandClick: () => container.openUploadFilePanel(),
commandButtonLabel: label,
hasPopup: false,
disabled: useSelectedNode.getState().isQueryCopilotCollectionSelected(),
ariaLabel: label,
};
}
function createOpenQueryButton(container: Explorer): CommandButtonComponentProps {
const label = "Open Query";
return {
iconSrc: BrowseQueriesIcon,
iconAlt: label,
keyboardAction: KeyboardAction.OPEN_QUERY,
onCommandClick: () =>
useSidePanel.getState().openSidePanel("Open Saved Queries", <BrowseQueriesPane explorer={container} />),
commandButtonLabel: label,
@ -481,6 +421,7 @@ function createOpenQueryFromDiskButton(): CommandButtonComponentProps {
return {
iconSrc: OpenQueryFromDiskIcon,
iconAlt: label,
keyboardAction: KeyboardAction.OPEN_QUERY_FROM_DISK,
onCommandClick: () => useSidePanel.getState().openSidePanel("Load Query", <LoadQueryPane />),
commandButtonLabel: label,
ariaLabel: label,
@ -489,19 +430,6 @@ function createOpenQueryFromDiskButton(): CommandButtonComponentProps {
};
}
function createOpenTerminalButton(container: Explorer): CommandButtonComponentProps {
const label = "Open Terminal";
return {
iconSrc: CosmosTerminalIcon,
iconAlt: label,
onCommandClick: () => container.openNotebookTerminal(ViewModels.TerminalKind.Default),
commandButtonLabel: label,
hasPopup: false,
disabled: useSelectedNode.getState().isQueryCopilotCollectionSelected(),
ariaLabel: label,
};
}
function createOpenTerminalButtonByKind(
container: Explorer,
terminalKind: ViewModels.TerminalKind,
@ -541,45 +469,6 @@ function createOpenTerminalButtonByKind(
};
}
function createNotebookWorkspaceResetButton(container: Explorer): CommandButtonComponentProps {
const label = "Reset Workspace";
return {
iconSrc: ResetWorkspaceIcon,
iconAlt: label,
onCommandClick: () => container.resetNotebookWorkspace(),
commandButtonLabel: label,
hasPopup: false,
disabled: useSelectedNode.getState().isQueryCopilotCollectionSelected(),
ariaLabel: label,
};
}
function createManageGitHubAccountButton(container: Explorer): CommandButtonComponentProps {
const connectedToGitHub: boolean = container.notebookManager?.gitHubOAuthService.isLoggedIn();
const label = connectedToGitHub ? "Manage GitHub settings" : "Connect to GitHub";
const junoClient = new JunoClient();
return {
iconSrc: GitHubIcon,
iconAlt: label,
onCommandClick: () => {
useSidePanel
.getState()
.openSidePanel(
label,
<GitHubReposPanel
explorer={container}
gitHubClientProp={container.notebookManager.gitHubClient}
junoClientProp={junoClient}
/>,
);
},
commandButtonLabel: label,
hasPopup: false,
disabled: useSelectedNode.getState().isQueryCopilotCollectionSelected(),
ariaLabel: label,
};
}
function createStaticCommandBarButtonsForResourceToken(
container: Explorer,
selectedNodeState: SelectedNodeState,

View File

@ -7,6 +7,7 @@ import {
IDropdownStyles,
} from "@fluentui/react";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import { KeyboardHandlerMap } from "KeyboardShortcuts";
import * as React from "react";
import _ from "underscore";
import ChevronDownIcon from "../../../../images/Chevron_down.svg";
@ -25,7 +26,10 @@ import { MemoryTracker } from "./MemoryTrackerComponent";
* @param btns
*/
export const convertButton = (btns: CommandButtonComponentProps[], backgroundColor: string): ICommandBarItemProps[] => {
const buttonHeightPx = StyleConstants.CommandBarButtonHeight;
const buttonHeightPx =
configContext.platform == Platform.Fabric
? StyleConstants.FabricCommandBarButtonHeight
: StyleConstants.CommandBarButtonHeight;
const hoverColor =
configContext.platform == Platform.Fabric ? StyleConstants.FabricAccentLight : StyleConstants.AccentLight;
@ -34,7 +38,7 @@ export const convertButton = (btns: CommandButtonComponentProps[], backgroundCol
if (isDisabled) {
return StyleConstants.GrayScale;
}
return configContext.platform == Platform.Fabric ? StyleConstants.NoColor : undefined;
return configContext.platform == Platform.Fabric ? StyleConstants.FabricToolbarIconColor : undefined;
};
return btns
@ -56,21 +60,23 @@ export const convertButton = (btns: CommandButtonComponentProps[], backgroundCol
imageProps: btn.iconSrc ? { src: btn.iconSrc, alt: btn.iconAlt } : undefined,
iconName: btn.iconName,
},
onClick: (ev?: React.MouseEvent<HTMLElement, MouseEvent> | React.KeyboardEvent<HTMLElement>) => {
onClick: btn.onCommandClick
? (ev?: React.MouseEvent<HTMLElement, MouseEvent> | React.KeyboardEvent<HTMLElement>) => {
btn.onCommandClick(ev);
let copilotEnabled = false;
if (useQueryCopilot.getState().copilotEnabled && useQueryCopilot.getState().copilotUserDBEnabled) {
copilotEnabled = useQueryCopilot.getState().copilotEnabledforExecution;
}
TelemetryProcessor.trace(Action.ClickCommandBarButton, ActionModifiers.Mark, { label, copilotEnabled });
},
}
: undefined,
key: `${btn.commandButtonLabel}${index}`,
text: label,
"data-test": label,
title: btn.tooltipText,
name: label,
disabled: btn.disabled,
ariaLabel: btn.ariaLabel,
"data-test": `CommandBar/Button:${label}`,
buttonStyles: {
root: {
backgroundColor: backgroundColor,
@ -93,7 +99,12 @@ export const convertButton = (btns: CommandButtonComponentProps[], backgroundCol
},
width: 16,
},
label: { fontSize: StyleConstants.mediumFontSize },
label: {
fontSize:
configContext.platform == Platform.Fabric
? StyleConstants.DefaultFontSize
: StyleConstants.mediumFontSize,
},
rootHovered: { backgroundColor: hoverColor },
rootPressed: { backgroundColor: hoverColor },
splitButtonMenuButtonExpanded: {
@ -112,6 +123,7 @@ export const convertButton = (btns: CommandButtonComponentProps[], backgroundCol
splitButtonContainer: {
marginLeft: 5,
marginRight: 5,
height: buttonHeightPx,
},
},
className: btn.className,
@ -129,7 +141,12 @@ export const convertButton = (btns: CommandButtonComponentProps[], backgroundCol
// TODO Figure out how to do it the proper way with subComponentStyles.
// TODO Remove all this crazy styling once we adopt Ui-Fabric Azure themes
selectors: {
".ms-ContextualMenu-itemText": { fontSize: StyleConstants.mediumFontSize },
".ms-ContextualMenu-itemText": {
fontSize:
configContext.platform == Platform.Fabric
? StyleConstants.DefaultFontSize
: StyleConstants.mediumFontSize,
},
".ms-ContextualMenu-link:hover": { backgroundColor: hoverColor },
".ms-ContextualMenu-icon": { width: 16, height: 16 },
},
@ -219,3 +236,28 @@ export const createConnectionStatus = (container: Explorer, poolId: PoolIdType,
onRender: () => <ConnectionStatus container={container} poolId={poolId} />,
};
};
export function createKeyboardHandlers(allButtons: CommandButtonComponentProps[]): KeyboardHandlerMap {
const handlers: KeyboardHandlerMap = {};
function createHandlers(buttons: CommandButtonComponentProps[]) {
buttons.forEach((button) => {
if (!button.disabled && button.keyboardAction) {
handlers[button.keyboardAction] = (e) => {
button.onCommandClick(e);
// If the handler is bound, it means the button is visible and enabled, so we should prevent the default action
return true;
};
}
if (button.children && button.children.length > 0) {
createHandlers(button.children);
}
});
}
createHandlers(allButtons);
return handlers;
}

View File

@ -162,6 +162,7 @@ export class NotificationConsoleComponent extends React.Component<
role="button"
onKeyDown={(event: React.KeyboardEvent<HTMLSpanElement>) => this.onClearNotificationsKeyPress(event)}
tabIndex={0}
style={{ border: "1px solid black", borderRadius: "2px" }}
>
<img src={ClearIcon} alt="clear notifications image" />
Clear Notifications

View File

@ -146,6 +146,12 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
onClick={[Function]}
onKeyDown={[Function]}
role="button"
style={
Object {
"border": "1px solid black",
"borderRadius": "2px",
}
}
tabIndex={0}
>
<img
@ -311,6 +317,12 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
onClick={[Function]}
onKeyDown={[Function]}
role="button"
style={
Object {
"border": "1px solid black",
"borderRadius": "2px",
}
}
tabIndex={0}
>
<img

View File

@ -1,4 +1,5 @@
// TODO convert this file to an action registry in order to have actions and their handlers be more tightly coupled.
import { useDatabases } from "Explorer/useDatabases";
import React from "react";
import { ActionContracts } from "../../Contracts/ExplorerContracts";
import * as ViewModels from "../../Contracts/ViewModels";
@ -40,12 +41,26 @@ function openCollectionTab(
databases: ViewModels.Database[],
initialDatabaseIndex = 0,
) {
//if databases are not yet loaded, wait until loaded
if (!databases || databases.length === 0) {
const databaseActionHandler = (databases: ViewModels.Database[]) => {
databasesUnsubscription();
openCollectionTab(action, databases, 0);
return;
};
const databasesUnsubscription = useDatabases.subscribe(databaseActionHandler, (state) => state.databases);
} else {
for (let i = initialDatabaseIndex; i < databases.length; i++) {
const database: ViewModels.Database = databases[i];
if (!!action.databaseResourceId && database.id() !== action.databaseResourceId) {
continue;
}
//expand database first if not expanded to load the collections
if (!database.isDatabaseExpanded?.()) {
database.expandDatabase?.();
}
const collectionActionHandler = (collections: ViewModels.Collection[]) => {
if (!action.collectionResourceId && collections.length === 0) {
subscription.dispose();
@ -132,6 +147,7 @@ function openCollectionTab(
break;
}
}
}
function openPane(action: ActionContracts.OpenPane, explorer: Explorer) {
@ -154,7 +170,7 @@ function openPane(action: ActionContracts.OpenPane, explorer: Explorer) {
action.paneKind === ActionContracts.PaneKind.GlobalSettings ||
action.paneKind === ActionContracts.PaneKind[ActionContracts.PaneKind.GlobalSettings]
) {
useSidePanel.getState().openSidePanel("Settings", <SettingsPane />);
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={explorer} />);
}
}

View File

@ -21,6 +21,7 @@ import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import { configContext, Platform } from "ConfigContext";
import * as DataModels from "Contracts/DataModels";
import { SubscriptionType } from "Contracts/SubscriptionType";
import { AddVectorEmbeddingPolicyForm } from "Explorer/Panes/VectorSearchPanel/AddVectorEmbeddingPolicyForm";
import { useSidePanel } from "hooks/useSidePanel";
import { useTeachingBubble } from "hooks/useTeachingBubble";
import React from "react";
@ -29,7 +30,7 @@ import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
import { userContext } from "UserContext";
import { getCollectionName } from "Utils/APITypeUtils";
import { isCapabilityEnabled, isServerlessAccount } from "Utils/CapabilityUtils";
import { isCapabilityEnabled, isServerlessAccount, isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { getUpsellMessage } from "Utils/PricingUtils";
import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent";
import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput";
@ -81,6 +82,10 @@ export const AllPropertiesIndexed: DataModels.IndexingPolicy = {
excludedPaths: [],
};
export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = {
vectorEmbeddings: [],
};
export interface AddCollectionPanelState {
createNewDatabase: boolean;
newDatabaseId: string;
@ -101,6 +106,9 @@ export interface AddCollectionPanelState {
isExecuting: boolean;
isThroughputCapExceeded: boolean;
teachingBubbleStep: number;
vectorIndexingPolicy: DataModels.VectorIndex[];
vectorEmbeddingPolicy: DataModels.VectorEmbedding[];
vectorPolicyValidated: boolean;
}
export class AddCollectionPanel extends React.Component<AddCollectionPanelProps, AddCollectionPanelState> {
@ -136,6 +144,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
isExecuting: false,
isThroughputCapExceeded: false,
teachingBubbleStep: 0,
vectorEmbeddingPolicy: [],
vectorIndexingPolicy: [],
vectorPolicyValidated: true,
};
}
@ -145,11 +156,17 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
}
componentDidUpdate(_prevProps: AddCollectionPanelProps, prevState: AddCollectionPanelState): void {
if (this.state.errorMessage && this.state.errorMessage !== prevState.errorMessage) {
this.scrollToSection("panelContainer");
}
}
render(): JSX.Element {
const isFirstResourceCreated = useDatabases.getState().isFirstResourceCreated();
return (
<form className="panelFormWrapper" onSubmit={this.submit.bind(this)}>
<form className="panelFormWrapper" onSubmit={this.submit.bind(this)} id="panelContainer">
{this.state.errorMessage && (
<PanelInfoErrorComponent
message={this.state.errorMessage}
@ -382,6 +399,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
)}
{!this.state.createNewDatabase && (
<Dropdown
ariaLabel="Choose an existing database"
styles={{ title: { height: 27, lineHeight: 27 }, dropdownItem: { fontSize: 12 } }}
style={{ width: 300, fontSize: 12 }}
placeholder="Choose an existing database"
@ -558,9 +576,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</TooltipHost>
</Stack>
<Text variant="small" aria-label="pkDescription">
{this.getPartitionKeySubtext()}
</Text>
<Text variant="small">{this.getPartitionKeySubtext()}</Text>
<input
type="text"
@ -800,8 +816,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel="Enable analytical store capability to perform near real-time analytics on your operational data, without
impacting the performance of transactional workloads."
ariaLabel="Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads."
/>
</TooltipHost>
</Stack>
@ -863,17 +878,44 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
)}
</Stack>
)}
{this.shouldShowVectorSearchParameters() && (
<Stack>
<CollapsibleSectionComponent
title="Container Vector Policy"
isExpandedByDefault={false}
onExpand={() => {
this.scrollToSection("collapsibleVectorPolicySectionContent");
}}
tooltipContent={this.getContainerVectorPolicyTooltipContent()}
>
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
<Stack styles={{ root: { paddingLeft: 40 } }}>
<AddVectorEmbeddingPolicyForm
vectorEmbedding={this.state.vectorEmbeddingPolicy}
vectorIndex={this.state.vectorIndexingPolicy}
onVectorEmbeddingChange={(
vectorEmbeddingPolicy: DataModels.VectorEmbedding[],
vectorIndexingPolicy: DataModels.VectorIndex[],
vectorPolicyValidated: boolean,
) => {
this.setState({ vectorEmbeddingPolicy, vectorIndexingPolicy, vectorPolicyValidated });
}}
/>
</Stack>
</Stack>
</CollapsibleSectionComponent>
</Stack>
)}
{userContext.apiType !== "Tables" && (
<CollapsibleSectionComponent
title="Advanced"
isExpandedByDefault={false}
onExpand={() => {
TelemetryProcessor.traceOpen(Action.ExpandAddCollectionPaneAdvancedSection);
this.scrollToAdvancedSection();
this.scrollToSection("collapsibleAdvancedSectionContent");
}}
>
<Stack className="panelGroupSpacing" id="collapsibleSectionContent">
<Stack className="panelGroupSpacing" id="collapsibleAdvancedSectionContent">
{isCapabilityEnabled("EnableMongo") && !isCapabilityEnabled("EnableMongo16MBDocumentSupport") && (
<Stack className="panelGroupSpacing">
<Stack horizontal>
@ -924,10 +966,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
/>
<Text variant="small">
<Icon iconName="InfoSolid" className="removeIcon" tabIndex={0} /> To ensure compatibility with
older SDKs, the created container will use a legacy partitioning scheme that supports partition
key values of size only up to 101 bytes. If this is enabled, you will not be able to use
hierarchical partition keys.{" "}
<Icon iconName="InfoSolid" className="removeIcon" /> To ensure compatibility with older SDKs, the
created container will use a legacy partitioning scheme that supports partition key values of size
only up to 101 bytes. If this is enabled, you will not be able to use hierarchical partition keys.{" "}
<Link href="https://aka.ms/cosmos-large-pk" target="_blank">
Learn more
</Link>
@ -1070,6 +1111,18 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
}
private setVectorEmbeddingPolicy(vectorEmbeddingPolicy: DataModels.VectorEmbedding[]): void {
this.setState({
vectorEmbeddingPolicy,
});
}
private setVectorIndexingPolicy(vectorIndexingPolicy: DataModels.VectorIndex[]): void {
this.setState({
vectorIndexingPolicy,
});
}
private isSelectedDatabaseSharedThroughput(): boolean {
if (!this.state.selectedDatabaseId) {
return false;
@ -1150,6 +1203,18 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
);
}
private getContainerVectorPolicyTooltipContent(): JSX.Element {
return (
<Text variant="small">
Describe any properties in your data that contain vectors, so that they can be made available for similarity
queries.{" "}
<Link target="_blank" href="https://aka.ms/CosmosDBVectorSetup">
Learn more
</Link>
</Text>
);
}
private shouldShowCollectionThroughputInput(): boolean {
if (isServerlessAccount()) {
return false;
@ -1209,6 +1274,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
);
}
private shouldShowVectorSearchParameters() {
return isVectorSearchEnabled() && (isServerlessAccount() || this.shouldShowCollectionThroughputInput());
}
private parseUniqueKeys(): DataModels.UniqueKeyPolicy {
if (this.state.uniqueKeys?.length === 0) {
return undefined;
@ -1265,6 +1334,11 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return false;
}
if (this.shouldShowVectorSearchParameters() && !this.state.vectorPolicyValidated) {
this.setState({ errorMessage: "Please fix errors in container vector policy" });
return false;
}
return true;
}
@ -1287,8 +1361,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return Constants.AnalyticalStorageTtl.Disabled;
}
private scrollToAdvancedSection(): void {
document.getElementById("collapsibleSectionContent")?.scrollIntoView();
private scrollToSection(id: string): void {
document.getElementById(id)?.scrollIntoView();
}
private getSampleDBName(): string {
@ -1344,6 +1418,15 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
? AllPropertiesIndexed
: SharedDatabaseDefault;
let vectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy;
if (this.shouldShowVectorSearchParameters()) {
indexingPolicy.vectorIndexes = this.state.vectorIndexingPolicy;
vectorEmbeddingPolicy = {
vectorEmbeddings: this.state.vectorEmbeddingPolicy,
};
}
const telemetryData = {
database: {
id: databaseId,
@ -1402,6 +1485,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
partitionKey,
uniqueKeyPolicy,
createMongoWildcardIndex: this.state.createMongoWildCardIndex,
vectorEmbeddingPolicy,
};
this.setState({ isExecuting: true });

View File

@ -1,12 +1,11 @@
import { Checkbox, Stack, Text, TextField } from "@fluentui/react";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as Constants from "../../../Common/Constants";
import { createDatabase } from "../../../Common/dataAccess/createDatabase";
import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils";
import { InfoTooltip } from "../../../Common/Tooltip/InfoTooltip";
import { createDatabase } from "../../../Common/dataAccess/createDatabase";
import * as DataModels from "../../../Contracts/DataModels";
import { SubscriptionType } from "../../../Contracts/SubscriptionType";
import { useSidePanel } from "../../../hooks/useSidePanel";
import * as SharedConstants from "../../../Shared/Constants";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
@ -14,6 +13,7 @@ import { userContext } from "../../../UserContext";
import * as AutoPilotUtils from "../../../Utils/AutoPilotUtils";
import { isServerlessAccount } from "../../../Utils/CapabilityUtils";
import { getUpsellMessage } from "../../../Utils/PricingUtils";
import { useSidePanel } from "../../../hooks/useSidePanel";
import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput";
import Explorer from "../../Explorer";
import { useDatabases } from "../../useDatabases";
@ -63,9 +63,6 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
},
subscriptionType: SubscriptionType[subscriptionType],
subscriptionQuotaId: userContext.quotaId,
defaultsCheck: {
flight: userContext.addCollectionFlight,
},
dataExplorerArea: Constants.Areas.ContextualPane,
};
@ -75,7 +72,6 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
subscriptionQuotaId: userContext.quotaId,
defaultsCheck: {
throughput,
flight: userContext.addCollectionFlight,
},
dataExplorerArea: Constants.Areas.ContextualPane,
};

View File

@ -59,7 +59,6 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
defaultsCheck: {
storage: "u",
throughput: newKeySpaceThroughput || tableThroughput,
flight: userContext.addCollectionFlight,
},
dataExplorerArea: Constants.Areas.ContextualPane,
};
@ -203,8 +202,8 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
required={true}
autoComplete="off"
styles={getTextFieldStyles()}
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
pattern="[^/?#\\-]*[^/?#- \\]"
title="May not end with space nor contain characters '\' '/' '#' '?' '-'"
placeholder="Type a new keyspace id"
size={40}
value={newKeyspaceId}
@ -276,7 +275,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Enter CQL command to create the table.{" "}
<Link href="https://aka.ms/cassandra-create-table" target="_blank">
<Link className="underlinedLink" href="https://aka.ms/cassandra-create-table" target="_blank">
Learn More
</Link>
</Text>
@ -293,8 +292,8 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
required={true}
ariaLabel="addCollection-table Id Create table"
autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
pattern="[^/?#\\-]*[^/?#- \\]"
title="May not end with space nor contain characters '\' '/' '#' '?' '-'"
placeholder="Enter table Id"
size={20}
value={tableId}

View File

@ -0,0 +1,398 @@
import {
DefaultButton,
DirectionalHint,
Dropdown,
IDropdownOption,
Icon,
IconButton,
Link,
MessageBar,
Stack,
Text,
TooltipHost,
} from "@fluentui/react";
import * as Constants from "Common/Constants";
import { handleError } from "Common/ErrorHandlingUtils";
import { createCollection } from "Common/dataAccess/createCollection";
import { DataTransferParams, initiateDataTransfer } from "Common/dataAccess/dataTransfers";
import * as DataModels from "Contracts/DataModels";
import * as ViewModels from "Contracts/ViewModels";
import {
getPartitionKeyName,
getPartitionKeyPlaceHolder,
getPartitionKeySubtext,
getPartitionKeyTooltipText,
} from "Explorer/Controls/Settings/SettingsUtils";
import Explorer from "Explorer/Explorer";
import { RightPaneForm } from "Explorer/Panes/RightPaneForm/RightPaneForm";
import { useDatabases } from "Explorer/useDatabases";
import { userContext } from "UserContext";
import { getCollectionName } from "Utils/APITypeUtils";
import { useSidePanel } from "hooks/useSidePanel";
import * as React from "react";
export interface ChangePartitionKeyPaneProps {
sourceDatabase: ViewModels.Database;
sourceCollection: ViewModels.Collection;
explorer: Explorer;
onClose: () => Promise<void>;
}
export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
sourceDatabase,
sourceCollection,
explorer,
onClose,
}) => {
const [targetCollectionId, setTargetCollectionId] = React.useState<string>();
const [createNewContainer, setCreateNewContainer] = React.useState<boolean>(true);
const [formError, setFormError] = React.useState<string>();
const [isExecuting, setIsExecuting] = React.useState<boolean>(false);
const [subPartitionKeys, setSubPartitionKeys] = React.useState<string[]>([]);
const [partitionKey, setPartitionKey] = React.useState<string>();
const getCollectionOptions = (): IDropdownOption[] => {
return sourceDatabase
.collections()
.filter((collection) => collection.id !== sourceCollection.id)
.map((collection) => ({
key: collection.id(),
text: collection.id(),
}));
};
const submit = async () => {
if (!validateInputs()) {
return;
}
setIsExecuting(true);
try {
createNewContainer && (await createContainer());
await createDataTransferJob();
await onClose();
} catch (error) {
handleError(error, "ChangePartitionKey", "Failed to start data transfer job");
}
setIsExecuting(false);
useSidePanel.getState().closeSidePanel();
};
const validateInputs = (): boolean => {
if (!createNewContainer && !targetCollectionId) {
setFormError("Choose an existing container");
return false;
}
return true;
};
const createDataTransferJob = async () => {
const jobName = `Portal_${targetCollectionId}_${Math.floor(Date.now() / 1000)}`;
const dataTransferParams: DataTransferParams = {
jobName,
apiType: userContext.apiType,
subscriptionId: userContext.subscriptionId,
resourceGroupName: userContext.resourceGroup,
accountName: userContext.databaseAccount.name,
sourceDatabaseName: sourceDatabase.id(),
sourceCollectionName: sourceCollection.id(),
targetDatabaseName: sourceDatabase.id(),
targetCollectionName: targetCollectionId,
};
await initiateDataTransfer(dataTransferParams);
};
const createContainer = async () => {
const partitionKeyString = partitionKey.trim();
const partitionKeyData: DataModels.PartitionKey = partitionKeyString
? {
paths: [partitionKeyString, ...(subPartitionKeys.length > 0 ? subPartitionKeys : [])],
kind: subPartitionKeys.length > 0 ? "MultiHash" : "Hash",
version: 2,
}
: undefined;
const createCollectionParams: DataModels.CreateCollectionParams = {
createNewDatabase: false,
collectionId: targetCollectionId,
databaseId: sourceDatabase.id(),
databaseLevelThroughput: isSelectedDatabaseSharedThroughput(),
offerThroughput: sourceCollection.offer()?.manualThroughput,
autoPilotMaxThroughput: sourceCollection.offer()?.autoscaleMaxThroughput,
partitionKey: partitionKeyData,
};
await createCollection(createCollectionParams);
await explorer.refreshAllDatabases();
};
const isSelectedDatabaseSharedThroughput = (): boolean => {
const selectedDatabase = useDatabases
.getState()
.databases?.find((database) => database.id() === sourceDatabase.id());
return !!selectedDatabase?.offer();
};
return (
<RightPaneForm formError={formError} isExecuting={isExecuting} onSubmit={submit} submitButtonText="OK">
<Stack tokens={{ childrenGap: 10 }} className="panelMainContent">
<Text variant="small">
When changing a containers partition key, you will need to create a destination container with the correct
partition key. You may also select an existing destination container.&nbsp;
<Link
href="https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy#container-copy-within-an-azure-cosmos-db-account"
target="_blank"
underline
>
Learn more
</Link>
</Text>
<Stack>
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Database id
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={`A database is analogous to a namespace. It is the unit of management for a set of ${getCollectionName(
true,
).toLocaleLowerCase()}.`}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
aria-label={`A database is analogous to a namespace. It is the unit of management for a set of ${getCollectionName(
true,
).toLocaleLowerCase()}.`}
/>
</TooltipHost>
</Stack>
<Dropdown
styles={{ title: { height: 27, lineHeight: 27 }, dropdownItem: { fontSize: 12 } }}
style={{ width: 300, fontSize: 12 }}
options={[]}
placeholder={sourceDatabase.id()}
responsiveMode={999}
disabled={true}
/>
</Stack>
<Stack className="panelGroupSpacing" horizontal verticalAlign="center">
<div role="radiogroup">
<input
className="panelRadioBtn"
checked={createNewContainer}
aria-label="Create new container"
aria-checked={createNewContainer}
name="containerType"
type="radio"
role="radio"
id="containerCreateNew"
tabIndex={0}
onChange={() => setCreateNewContainer(true)}
/>
<span className="panelRadioBtnLabel">New container</span>
<input
className="panelRadioBtn"
checked={!createNewContainer}
aria-label="Use existing container"
aria-checked={!createNewContainer}
name="containerType"
type="radio"
role="radio"
tabIndex={0}
onChange={() => setCreateNewContainer(false)}
/>
<span className="panelRadioBtnLabel">Existing container</span>
</div>
</Stack>
{createNewContainer ? (
<Stack>
<MessageBar>All configurations except for unique keys will be copied from the source container</MessageBar>
<Stack className="panelGroupSpacing">
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
{`${getCollectionName()} id`}
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
>
<Icon
role="button"
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
/>
</TooltipHost>
</Stack>
<input
name="collectionId"
id="collectionId"
type="text"
aria-required
required
autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
placeholder={`e.g., ${getCollectionName()}1`}
size={40}
className="panelTextField"
aria-label={`${getCollectionName()} id, Example ${getCollectionName()}1`}
value={targetCollectionId}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setTargetCollectionId(event.target.value)}
/>
</Stack>
<Stack tokens={{ childrenGap: 10 }}>
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
{getPartitionKeyName(userContext.apiType)}
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={getPartitionKeyTooltipText(userContext.apiType)}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
aria-label={getPartitionKeyTooltipText(userContext.apiType)}
/>
</TooltipHost>
</Stack>
<Text variant="small">
{getPartitionKeySubtext(userContext.features.partitionKeyDefault, userContext.apiType)}
</Text>
<input
type="text"
id="addCollection-partitionKeyValue"
aria-required
required
size={40}
className="panelTextField"
placeholder={getPartitionKeyPlaceHolder(userContext.apiType)}
aria-label={getPartitionKeyName(userContext.apiType)}
pattern={".*"}
title={""}
value={partitionKey}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
if (!partitionKey && !event.target.value.startsWith("/")) {
setPartitionKey("/" + event.target.value);
} else {
setPartitionKey(event.target.value);
}
}}
/>
{subPartitionKeys.map((subPartitionKey: string, index: number) => {
return (
<Stack style={{ marginBottom: 8 }} key={`uniqueKey${index}`} horizontal>
<div
style={{
width: "20px",
border: "solid",
borderWidth: "0px 0px 1px 1px",
marginRight: "5px",
}}
></div>
<input
type="text"
id="addCollection-partitionKeyValue"
key={`addCollection-partitionKeyValue_${index}`}
aria-required
required
size={40}
tabIndex={index > 0 ? 1 : 0}
className="panelTextField"
autoComplete="off"
placeholder={getPartitionKeyPlaceHolder(userContext.apiType, index)}
aria-label={getPartitionKeyName(userContext.apiType)}
pattern={".*"}
title={""}
value={subPartitionKey}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const keys = [...subPartitionKeys];
if (!keys[index] && !event.target.value.startsWith("/")) {
keys[index] = "/" + event.target.value.trim();
setSubPartitionKeys(keys);
} else {
keys[index] = event.target.value.trim();
setSubPartitionKeys(keys);
}
}}
/>
<IconButton
iconProps={{ iconName: "Delete" }}
style={{ height: 27 }}
onClick={() => {
const keys = subPartitionKeys.filter((uniqueKey, j) => index !== j);
setSubPartitionKeys(keys);
}}
/>
</Stack>
);
})}
<Stack className="panelGroupSpacing">
<DefaultButton
styles={{ root: { padding: 0, width: 200, height: 30 }, label: { fontSize: 12 } }}
disabled={subPartitionKeys.length >= Constants.BackendDefaults.maxNumMultiHashPartition}
onClick={() => setSubPartitionKeys([...subPartitionKeys, ""])}
>
Add hierarchical partition key
</DefaultButton>
{subPartitionKeys.length > 0 && (
<Text variant="small">
<Icon iconName="InfoSolid" className="removeIcon" tabIndex={0} /> This feature allows you to
partition your data with up to three levels of keys for better data distribution. Requires .NET V3,
Java V4 SDK, or preview JavaScript V3 SDK.{" "}
<Link href="https://aka.ms/cosmos-hierarchical-partitioning" target="_blank">
Learn more
</Link>
</Text>
)}
</Stack>
</Stack>
</Stack>
) : (
<Stack>
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
{`${getCollectionName()}`}
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
>
<Icon
role="button"
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
/>
</TooltipHost>
</Stack>
<Dropdown
styles={{ title: { height: 27, lineHeight: 27 }, dropdownItem: { fontSize: 12 } }}
style={{ width: 300, fontSize: 12 }}
placeholder="Choose an existing container"
options={getCollectionOptions()}
onChange={(event: React.FormEvent<HTMLDivElement>, collection: IDropdownOption) => {
setTargetCollectionId(collection.key as string);
setFormError("");
}}
defaultSelectedKey={targetCollectionId}
responsiveMode={999}
/>
</Stack>
)}
</Stack>
</RightPaneForm>
);
};

View File

@ -379,6 +379,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
>
<CustomizedPrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -386,6 +387,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
>
<PrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -666,6 +668,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
>
<CustomizedDefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -948,6 +951,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
>
<DefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1231,6 +1235,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
<BaseButton
ariaLabel="OK"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2105,6 +2110,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
aria-label="OK"
className="ms-Button ms-Button--primary root-122"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}
@ -2140,6 +2146,11 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
</div>
</PanelFooterComponent>
</form>
<span
className="screenReaderOnly"
id="screenReaderStatus"
role="status"
/>
</RightPaneForm>
</DeleteCollectionConfirmationPane>
`;

View File

@ -9,6 +9,7 @@ import { DefaultExperienceUtility } from "Shared/DefaultExperienceUtility";
import { Action, ActionModifiers } from "Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
import { userContext } from "UserContext";
import { getDatabaseName } from "Utils/APITypeUtils";
import { logConsoleError } from "Utils/NotificationConsoleUtils";
import { useSidePanel } from "hooks/useSidePanel";
import { useTabs } from "hooks/useTabs";
@ -37,11 +38,11 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
const submit = async (): Promise<void> => {
if (selectedDatabase?.id() && databaseInput !== selectedDatabase.id()) {
setFormError(
`Input database name "${databaseInput}" does not match the selected database "${selectedDatabase.id()}"`,
`Input ${getDatabaseName()} name "${databaseInput}" does not match the selected ${getDatabaseName()} "${selectedDatabase.id()}"`,
);
logConsoleError(`Error while deleting collection ${selectedDatabase && selectedDatabase.id()}`);
logConsoleError(`Error while deleting ${getDatabaseName()} ${selectedDatabase && selectedDatabase.id()}`);
logConsoleError(
`Input database name "${databaseInput}" does not match the selected database "${selectedDatabase.id()}"`,
`Input ${getDatabaseName()} name "${databaseInput}" does not match the selected ${getDatabaseName()} "${selectedDatabase.id()}"`,
);
return;
}
@ -123,17 +124,18 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
message:
"Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.",
};
const confirmDatabase = "Confirm by typing the database id";
const reasonInfo = "Help us improve Azure Cosmos DB! What is the reason why you are deleting this database?";
const confirmDatabase = `Confirm by typing the ${getDatabaseName()} id`;
const reasonInfo = `Help us improve Azure Cosmos DB! What is the reason why you are deleting this ${getDatabaseName()}?`;
return (
<RightPaneForm {...props}>
{!formError && <PanelInfoErrorComponent {...errorProps} />}
<div className="panelMainContent">
<div className="confirmDeleteInput">
<span className="mandatoryStar">* </span>
<Text variant="small">Confirm by typing the database id</Text>
<Text variant="small">Confirm by typing the {getDatabaseName()} id</Text>
<TextField
id="confirmDatabaseId"
data-test="Input:confirmDatabaseId"
autoFocus
styles={{ fieldGroup: { width: 300 } }}
onChange={(event, newInput?: string) => {
@ -149,7 +151,7 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
Help us improve Azure Cosmos DB!
</Text>
<Text variant="small" block>
What is the reason why you are deleting this database?
What is the reason why you are deleting this {getDatabaseName()}?
</Text>
<TextField
id="deleteDatabaseFeedbackInput"

View File

@ -5312,6 +5312,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="Execute"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Execute"
@ -5319,6 +5320,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
>
<PrimaryButton
ariaLabel="Execute"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Execute"
@ -5599,6 +5601,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="Execute"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -5881,6 +5884,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
>
<DefaultButton
ariaLabel="Execute"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -6164,6 +6168,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
<BaseButton
ariaLabel="Execute"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -7038,6 +7043,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
aria-label="Execute"
className="ms-Button ms-Button--primary root-148"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}
@ -7073,6 +7079,11 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
</div>
</PanelFooterComponent>
</form>
<span
className="screenReaderOnly"
id="screenReaderStatus"
role="status"
/>
</RightPaneForm>
</ExecuteSprocParamsPane>
`;

View File

@ -18,7 +18,6 @@ exports[`GitHub Repos Panel should render Default properly 1`] = `
Object {
"container": Explorer {
"_isInitializingNotebooks": false,
"_resetNotebookWorkspace": [Function],
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],

View File

@ -48,6 +48,13 @@
font-size: @mediumFontSize;
padding: 0 @LargeSpace 0 @SmallSpace;
}
.panelSectionSpinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
}

View File

@ -53,12 +53,13 @@ export class PanelContainerComponent extends React.Component<PanelContainerProps
return (
<Panel
data-test={`Panel:${this.props.headerText}`}
headerText={this.props.headerText}
isOpen={this.props.isOpen}
onDismiss={this.onDissmiss}
isLightDismiss
type={PanelType.custom}
closeButtonAriaLabel="Close"
closeButtonAriaLabel={`Close ${this.props.headerText}`}
customWidth={this.props.panelWidth ? this.props.panelWidth : "440px"}
headerClassName="panelHeader"
onRenderNavigationContent={this.props.onRenderNavigationContent}

View File

@ -16,6 +16,7 @@ export const PanelFooterComponent: React.FunctionComponent<PanelFooterProps> = (
<PrimaryButton
type="submit"
id="sidePanelOkButton"
data-test="Panel/OkButton"
text={buttonLabel}
ariaLabel={buttonLabel}
disabled={!!isButtonDisabled}

View File

@ -2,6 +2,7 @@ import React, { CSSProperties, FunctionComponent, ReactNode } from "react";
import { PanelFooterComponent } from "../PanelFooterComponent";
import { PanelInfoErrorComponent } from "../PanelInfoErrorComponent";
import { PanelLoadingScreen } from "../PanelLoadingScreen";
import { labelToLoadingItemName } from "Explorer/Tables/Constants";
export interface RightPaneFormProps {
formError: string;
@ -27,6 +28,10 @@ export const RightPaneForm: FunctionComponent<RightPaneFormProps> = ({
const handleOnSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSubmit();
const screenReaderStatusElement = document.getElementById("screenReaderStatus");
if (screenReaderStatusElement) {
screenReaderStatusElement.innerHTML = labelToLoadingItemName[submitButtonText] || "Loading";
}
};
return (
@ -42,6 +47,7 @@ export const RightPaneForm: FunctionComponent<RightPaneFormProps> = ({
/>
)}
</form>
<span role="status" className="screenReaderOnly" id="screenReaderStatus"></span>
{isExecuting && <PanelLoadingScreen />}
</>
);

View File

@ -21,6 +21,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="Load"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Load"
@ -28,6 +29,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
>
<PrimaryButton
ariaLabel="Load"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Load"
@ -308,6 +310,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="Load"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -590,6 +593,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
>
<DefaultButton
ariaLabel="Load"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -873,6 +877,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
<BaseButton
ariaLabel="Load"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1747,6 +1752,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
aria-label="Load"
className="ms-Button ms-Button--primary root-109"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}
@ -1782,5 +1788,10 @@ exports[`Right Pane Form should render Default properly 1`] = `
</div>
</PanelFooterComponent>
</form>
<span
className="screenReaderOnly"
id="screenReaderStatus"
role="status"
/>
</RightPaneForm>
`;

View File

@ -6,7 +6,7 @@ import { SettingsPane } from "./SettingsPane";
describe("Settings Pane", () => {
it("should render Default properly", () => {
const wrapper = shallow(<SettingsPane />);
const wrapper = shallow(<SettingsPane explorer={null} />);
expect(wrapper).toMatchSnapshot();
});
@ -18,7 +18,7 @@ describe("Settings Pane", () => {
},
} as DatabaseAccount,
});
const wrapper = shallow(<SettingsPane />);
const wrapper = shallow(<SettingsPane explorer={null} />);
expect(wrapper).toMatchSnapshot();
});
});

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