Merge branch 'master' of https://github.com/Azure/cosmos-explorer into 2819223
This commit is contained in:
commit
199ce64bdb
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
@ -17,3 +17,7 @@ Contracts/*
|
|||
failure.png
|
||||
screenshots/*
|
||||
GettingStarted-ignore*.ipynb
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"JUNO_ENDPOINT": "https://tools-staging.cosmos.azure.com",
|
||||
"JUNO_ENDPOINT": "https://tools.cosmos.azure.com",
|
||||
"isTerminalEnabled": true,
|
||||
"isPhoenixEnabled": true
|
||||
}
|
|
@ -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 |
|
@ -1,13 +0,0 @@
|
|||
const isCI = require("is-ci");
|
||||
|
||||
module.exports = {
|
||||
exitOnPageError: false,
|
||||
launchOptions: {
|
||||
headless: isCI,
|
||||
slowMo: 10,
|
||||
timeout: 60000,
|
||||
},
|
||||
contextOptions: {
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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"],
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
});
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
***********************************************************************************************/
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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";
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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 {
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Data Explorer -> Fabric communication.
|
||||
*/
|
||||
export enum FabricMessageTypes {
|
||||
GetAuthorizationToken = "GetAuthorizationToken",
|
||||
GetAllResourceTokens = "GetAllResourceTokens",
|
||||
Ready = "Ready",
|
||||
}
|
||||
|
||||
export interface AuthorizationToken {
|
||||
XDate: string;
|
||||
PrimaryReadWriteToken: string;
|
||||
}
|
|
@ -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,36 +32,51 @@ 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 = {
|
||||
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -25,7 +25,14 @@
|
|||
font-family: @DataExplorerFont;
|
||||
}
|
||||
|
||||
.settingsV2IndexingPolicyEditor {
|
||||
.settingsV2Editor {
|
||||
width: 100%;
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.settingsV2EditorSpinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
  about how to define computed properties and how to use them.
|
||||
</Text>
|
||||
<div className="settingsV2Editor" tabIndex={0} ref={this.computedPropertiesDiv}></div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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()}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -12,7 +12,7 @@ exports[`IndexingPolicyComponent renders 1`] = `
|
|||
refreshIndexTransformationProgress={[Function]}
|
||||
/>
|
||||
<div
|
||||
className="settingsV2IndexingPolicyEditor"
|
||||
className="settingsV2Editor"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</Stack>
|
||||
|
|
|
@ -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. It’s 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";
|
||||
}
|
||||
};
|
||||
|
|
|
@ -40,6 +40,12 @@ export const collection = {
|
|||
version: 2,
|
||||
},
|
||||
partitionKeyProperties: ["partitionKey"],
|
||||
computedProperties: ko.observable<DataModels.ComputedProperties>([
|
||||
{
|
||||
name: "queryName",
|
||||
query: "query",
|
||||
},
|
||||
]),
|
||||
readSettings: () => {
|
||||
return;
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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
|
||||
<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>
|
||||
.
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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) {
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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, "\\'");
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
@ -133,6 +148,7 @@ function openCollectionTab(
|
|||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openPane(action: ActionContracts.OpenPane, explorer: Explorer) {
|
||||
if (
|
||||
|
@ -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} />);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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">* </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}
|
||||
|
|
|
@ -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 container’s partition key, you will need to create a destination container with the correct
|
||||
partition key. You may also select an existing destination container.
|
||||
<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">* </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">* </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">* </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">* </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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -48,6 +48,13 @@
|
|||
font-size: @mediumFontSize;
|
||||
padding: 0 @LargeSpace 0 @SmallSpace;
|
||||
}
|
||||
|
||||
.panelSectionSpinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 />}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue