mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-03-06 16:25:11 +00:00
Compare commits
42 Commits
users/sind
...
loca_batch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1dab2f7b8 | ||
|
|
12d535d000 | ||
|
|
915f549df9 | ||
|
|
77132be3b3 | ||
|
|
c343bad630 | ||
|
|
9579d1270b | ||
|
|
9496cf83ee | ||
|
|
dbe26654f1 | ||
|
|
b478f2732c | ||
|
|
204444b878 | ||
|
|
2e5c355479 | ||
|
|
5832170b2b | ||
|
|
cc26e2800e | ||
|
|
39ac7cf3f2 | ||
|
|
ed3a79f880 | ||
|
|
68ba19d406 | ||
|
|
81ad13508b | ||
|
|
3f959e2235 | ||
|
|
67250f0f6b | ||
|
|
df6312038a | ||
|
|
e6c27f39be | ||
|
|
937989a47d | ||
|
|
f166ef9c66 | ||
|
|
b83f54e253 | ||
|
|
a255dd502b | ||
|
|
2998f14d52 | ||
|
|
05407b3e0f | ||
|
|
703218debf | ||
|
|
f83a2c4442 | ||
|
|
2ff01c6379 | ||
|
|
31385950dd | ||
|
|
6dce2632c8 | ||
|
|
80ad5f10d4 | ||
|
|
f02611c90e | ||
|
|
9646dfcf04 | ||
|
|
90f3c3a79e | ||
|
|
b922086cc0 | ||
|
|
375ec350dc | ||
|
|
896b3e974e | ||
|
|
e6461cf079 | ||
|
|
92c8afd166 | ||
|
|
234e4181fc |
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -201,18 +201,18 @@ jobs:
|
||||
GREMLIN_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-gremlin.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$GREMLIN_TESTACCOUNT_TOKEN"
|
||||
echo GREMLIN_TESTACCOUNT_TOKEN=$GREMLIN_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
# CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
# echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
|
||||
# echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
# MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
# echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
|
||||
# echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
# MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
# echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
|
||||
# echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
# MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
# echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
|
||||
# echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
|
||||
echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
|
||||
echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
|
||||
echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
|
||||
echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
- name: List test files for shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
|
||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --list
|
||||
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
|
||||
|
||||
4
.github/workflows/cleanup.yml
vendored
4
.github/workflows/cleanup.yml
vendored
@@ -6,8 +6,8 @@ on:
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Once every two hours
|
||||
- cron: "0 */2 * * *"
|
||||
# Once every day at 7 AM PST
|
||||
- cron: "0 13 * * *"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ Contracts/*
|
||||
failure.png
|
||||
screenshots/*
|
||||
GettingStarted-ignore*.ipynb
|
||||
src/Localization/Keys.generated.ts
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
|
||||
14319
package-lock.json
generated
14319
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
69
package.json
69
package.json
@@ -4,7 +4,7 @@
|
||||
"description": "Cosmos Explorer",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@azure/arm-cosmosdb": "9.1.0",
|
||||
"@azure/arm-cosmosdb": "16.4.0",
|
||||
"@azure/cosmos": "4.7.0",
|
||||
"@azure/cosmos-language-service": "0.0.5",
|
||||
"@azure/identity": "4.5.0",
|
||||
@@ -14,12 +14,12 @@
|
||||
"@fluentui/react": "8.119.0",
|
||||
"@fluentui/react-components": "9.54.2",
|
||||
"@jupyterlab/services": "6.0.2",
|
||||
"@jupyterlab/terminal": "3.0.3",
|
||||
"@jupyterlab/terminal": "3.6.8",
|
||||
"@microsoft/applicationinsights-web": "2.6.1",
|
||||
"@nteract/commutable": "7.5.1",
|
||||
"@nteract/connected-components": "6.8.2",
|
||||
"@nteract/core": "15.1.9",
|
||||
"@nteract/data-explorer": "8.0.3",
|
||||
"@nteract/data-explorer": "8.2.12",
|
||||
"@nteract/directory-listing": "2.0.6",
|
||||
"@nteract/dropdown-menu": "1.0.1",
|
||||
"@nteract/editor": "10.1.12",
|
||||
@@ -31,7 +31,7 @@
|
||||
"@nteract/monaco-editor": "3.2.2",
|
||||
"@nteract/octicons": "2.0.0",
|
||||
"@nteract/outputs": "3.0.9",
|
||||
"@nteract/presentational-components": "3.0.7",
|
||||
"@nteract/presentational-components": "3.4.12",
|
||||
"@nteract/stateful-components": "1.7.0",
|
||||
"@nteract/styles": "2.0.2",
|
||||
"@nteract/transform-geojson": "5.1.8",
|
||||
@@ -39,25 +39,26 @@
|
||||
"@nteract/transform-plotly": "6.1.6",
|
||||
"@nteract/transform-vdom": "4.0.11",
|
||||
"@nteract/transform-vega": "7.0.6",
|
||||
"@octokit/request": "8.4.1",
|
||||
"@octokit/rest": "17.9.2",
|
||||
"@phosphor/widgets": "1.9.3",
|
||||
"@testing-library/jest-dom": "6.4.6",
|
||||
"@types/lodash": "4.14.171",
|
||||
"@types/mkdirp": "1.0.1",
|
||||
"@types/node-fetch": "2.5.7",
|
||||
"@types/node-fetch": "2.6.13",
|
||||
"@xmldom/xmldom": "0.7.13",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"allotment": "1.20.2",
|
||||
"applicationinsights": "1.8.0",
|
||||
"bootstrap": "3.4.1",
|
||||
"canvas": "2.11.2",
|
||||
"canvas": "3.2.1",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
"clipboard-copy": "4.0.1",
|
||||
"copy-webpack-plugin": "11.0.0",
|
||||
"crossroads": "0.12.2",
|
||||
"css-element-queries": "1.1.1",
|
||||
"d3": "7.8.5",
|
||||
"d3": "7.9.0",
|
||||
"datatables.net-colreorder-dt": "1.7.0",
|
||||
"datatables.net-dt": "1.13.8",
|
||||
"date-fns": "1.29.0",
|
||||
@@ -70,7 +71,8 @@
|
||||
"html2canvas": "1.0.0-rc.5",
|
||||
"i18next": "23.11.5",
|
||||
"i18next-browser-languagedetector": "6.0.1",
|
||||
"i18next-http-backend": "1.0.23",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"iframe-resizer-react": "1.1.0",
|
||||
"immer": "9.0.6",
|
||||
"immutable": "4.0.0-rc.12",
|
||||
@@ -79,12 +81,15 @@
|
||||
"jquery-typeahead": "2.11.1",
|
||||
"jquery-ui-dist": "1.13.2",
|
||||
"knockout": "3.5.1",
|
||||
"loader-utils": "2.0.3",
|
||||
"lodash": "4.17.23",
|
||||
"lodash-es": "4.17.23",
|
||||
"min-document": "2.19.1",
|
||||
"mkdirp": "1.0.4",
|
||||
"monaco-editor": "0.44.0",
|
||||
"ms": "2.1.3",
|
||||
"nanoid": "3.3.8",
|
||||
"p-retry": "6.2.1",
|
||||
"patch-package": "8.0.0",
|
||||
"patch-package": "8.0.1",
|
||||
"plotly.js-cartesian-dist-min": "1.52.3",
|
||||
"post-robot": "10.0.42",
|
||||
"q": "1.5.1",
|
||||
@@ -103,7 +108,6 @@
|
||||
"react-youtube": "9.0.1",
|
||||
"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",
|
||||
@@ -111,16 +115,30 @@
|
||||
"tinykeys": "2.1.0",
|
||||
"underscore": "1.12.1",
|
||||
"utility-types": "3.10.0",
|
||||
"web-vitals": "4.2.4",
|
||||
"uuid": "9.0.0",
|
||||
"web-vitals": "4.2.4",
|
||||
"ws": "8.17.1",
|
||||
"zustand": "3.5.0"
|
||||
},
|
||||
"overrides": {
|
||||
"d3-color": "3.1.0",
|
||||
"cross-spawn": "7.0.6",
|
||||
"less-vars-loader": {
|
||||
"json5": "1.0.2"
|
||||
},
|
||||
"trim": "0.0.3",
|
||||
"@octokit/plugin-paginate-rest": "9.2.2",
|
||||
"@octokit/request-error": "5.1.1",
|
||||
"@octokit/request": "8.4.1",
|
||||
"prismjs": "1.30.0",
|
||||
"sanitize-html": "2.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/preset-env": "7.24.7",
|
||||
"@babel/preset-react": "7.24.7",
|
||||
"@babel/preset-typescript": "7.24.7",
|
||||
"@playwright/test": "1.49.1",
|
||||
"@playwright/test": "1.55.1",
|
||||
"@testing-library/react": "11.2.3",
|
||||
"@types/applicationinsights-js": "1.0.7",
|
||||
"@types/codemirror": "0.0.56",
|
||||
@@ -134,7 +152,7 @@
|
||||
"@types/hasher": "0.0.31",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/jquery": "3.5.29",
|
||||
"@types/node": "12.11.1",
|
||||
"@types/node": "18.19.0",
|
||||
"@types/post-robot": "10.0.1",
|
||||
"@types/q": "1.5.1",
|
||||
"@types/react": "17.0.44",
|
||||
@@ -145,7 +163,7 @@
|
||||
"@types/react-window": "1.8.8",
|
||||
"@types/sanitize-html": "1.27.2",
|
||||
"@types/sinon": "2.3.3",
|
||||
"@types/styled-components": "5.1.1",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"@types/underscore": "1.7.36",
|
||||
"@types/youtube-player": "5.5.6",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.4",
|
||||
@@ -153,6 +171,7 @@
|
||||
"@webpack-cli/serve": "2.0.5",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "8.1.0",
|
||||
"brace-expansion": "1.1.12",
|
||||
"buffer": "5.1.0",
|
||||
"case-sensitive-paths-webpack-plugin": "2.4.0",
|
||||
"create-file-webpack": "1.0.2",
|
||||
@@ -170,6 +189,7 @@
|
||||
"html-inline-css-webpack-plugin": "1.11.2",
|
||||
"html-loader": "5.0.0",
|
||||
"html-webpack-plugin": "5.5.3",
|
||||
"i18next-resources-for-ts": "2.0.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-canvas-mock": "2.5.2",
|
||||
"jest-circus": "29.7.0",
|
||||
@@ -177,7 +197,8 @@
|
||||
"jest-html-loader": "1.0.0",
|
||||
"jest-react-hooks-shallow": "1.5.1",
|
||||
"jest-trx-results-processor": "3.0.2",
|
||||
"less": "3.8.1",
|
||||
"js-yaml": "3.14.2",
|
||||
"less": "4.5.1",
|
||||
"less-loader": "11.1.3",
|
||||
"less-vars-loader": "1.1.0",
|
||||
"mini-css-extract-plugin": "2.1.0",
|
||||
@@ -195,14 +216,17 @@
|
||||
"typedoc": "0.26.2",
|
||||
"typescript": "4.9.5",
|
||||
"url-loader": "4.1.1",
|
||||
"wait-on": "4.0.2",
|
||||
"webpack": "5.88.2",
|
||||
"webpack-bundle-analyzer": "4.9.1",
|
||||
"values-to-keys": "1.1.0",
|
||||
"wait-on": "9.0.3",
|
||||
"webpack": "5.104.1",
|
||||
"webpack-bundle-analyzer": "5.2.0",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "4.15.2"
|
||||
"webpack-dev-server": "5.2.3",
|
||||
"ws": "8.17.1"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"postinstall": "patch-package && npm run generate:i18n-keys",
|
||||
"prestart": "npm run generate:i18n-keys",
|
||||
"start": "webpack serve --mode development",
|
||||
"dev": "echo \"WARNING: npm run dev has been deprecated\" && npm run build",
|
||||
"build:dataExplorer:ci": "npm run build:ci",
|
||||
@@ -229,6 +253,7 @@
|
||||
"strict:find": "node ./strict-null-checks/find.js",
|
||||
"strict:add": "node ./strict-null-checks/auto-add.js",
|
||||
"compile:fullStrict": "tsc -p ./tsconfig.json --strictNullChecks",
|
||||
"generate:i18n-keys": "node utils/generateI18nKeys.mjs",
|
||||
"generateARMClients": "npx ts-node utils/armClientGenerator/generator.ts"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -26,15 +26,6 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
launchOptions: {
|
||||
args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: {
|
||||
|
||||
587
preview/package-lock.json
generated
587
preview/package-lock.json
generated
@@ -8,9 +8,10 @@
|
||||
"name": "cosmos-explorer-preview",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"body-parser": "^1.20.3",
|
||||
"express": "^4.21.2",
|
||||
"http-proxy-middleware": "^3.0.3",
|
||||
"body-parser": "^2.2.2",
|
||||
"express": "^5.2.1",
|
||||
"follow-redirects": "^1.15.6",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"node": "^20.19.5",
|
||||
"node-fetch": "^2.6.1",
|
||||
"path-to-regexp": "^0.1.12"
|
||||
@@ -18,8 +19,7 @@
|
||||
},
|
||||
"node_modules/@types/http-proxy": {
|
||||
"version": "1.17.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz",
|
||||
"integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -29,50 +29,40 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
"mime-types": "^3.0.0",
|
||||
"negotiator": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "2.4.1",
|
||||
"qs": "6.13.0",
|
||||
"raw-body": "2.5.2",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "1.0.0"
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.1",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -83,8 +73,7 @@
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -95,8 +84,7 @@
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
@@ -109,13 +97,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
@@ -127,20 +117,22 @@
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"license": "MIT"
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
@@ -160,18 +152,9 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -195,24 +178,21 @@
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
@@ -238,104 +218,77 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
"content-disposition": "^1.0.0",
|
||||
"content-type": "^1.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"cookie-signature": "^1.2.1",
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"finalhandler": "^2.1.0",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"merge-descriptors": "^2.0.0",
|
||||
"mime-types": "^3.0.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"once": "^1.4.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"qs": "^6.14.0",
|
||||
"range-parser": "^1.2.1",
|
||||
"router": "^2.2.0",
|
||||
"send": "^1.1.0",
|
||||
"serve-static": "^2.2.0",
|
||||
"statuses": "^2.0.1",
|
||||
"type-is": "^2.0.1",
|
||||
"vary": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "2.0.1",
|
||||
"unpipe": "~1.0.0"
|
||||
"debug": "^4.4.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"parseurl": "^1.3.3",
|
||||
"statuses": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.3",
|
||||
"version": "1.15.6",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
|
||||
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
@@ -353,25 +306,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
@@ -393,8 +344,7 @@
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
@@ -405,8 +355,7 @@
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -416,8 +365,7 @@
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -427,8 +375,7 @@
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
@@ -437,17 +384,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"toidentifier": "1.0.1"
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy": {
|
||||
@@ -480,8 +432,7 @@
|
||||
},
|
||||
"node_modules/http-proxy-middleware/node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
@@ -491,8 +442,7 @@
|
||||
},
|
||||
"node_modules/http-proxy-middleware/node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
@@ -502,16 +452,14 @@
|
||||
},
|
||||
"node_modules/http-proxy-middleware/node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware/node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
@@ -522,8 +470,7 @@
|
||||
},
|
||||
"node_modules/http-proxy-middleware/node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
@@ -532,14 +479,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
@@ -570,62 +521,58 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"license": "MIT",
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"license": "MIT",
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
@@ -633,32 +580,27 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"license": "MIT",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node": {
|
||||
"version": "20.19.5",
|
||||
"resolved": "https://registry.npmjs.org/node/-/node-20.19.5.tgz",
|
||||
"integrity": "sha512-9fJOHEP8AVrwpbhlUxnbudW8IbkseQVxl4yNQyI/rDfP+gNwKEmfPtBc/Luyf677i5Y0HKIBHiApiB9S9vvxKw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"node-bin-setup": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node": "bin/node"
|
||||
"node": "bin/node.exe"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-bin-setup": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/node-bin-setup/-/node-bin-setup-1.1.4.tgz",
|
||||
"integrity": "sha512-vWNHOne0ZUavArqPP5LJta50+S8R261Fr5SvGul37HbEDcowvLjwdvd0ZeSr0r2lTSrPxl6okq9QUw8BFGiAxA=="
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.6.7",
|
||||
"license": "MIT",
|
||||
@@ -677,10 +619,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node/node_modules/node-bin-setup": {
|
||||
"version": "1.1.4",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -698,6 +643,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -708,8 +661,7 @@
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
@@ -740,11 +692,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"version": "6.14.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -762,40 +713,46 @@
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.7.0",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
"is-promise": "^4.0.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"path-to-regexp": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/router/node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
@@ -803,61 +760,46 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "2.0.1"
|
||||
"debug": "^4.4.3",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"ms": "^2.1.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"range-parser": "^1.2.1",
|
||||
"statuses": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/debug/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/send/node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "0.19.0"
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"parseurl": "^1.3.3",
|
||||
"send": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
@@ -866,8 +808,7 @@
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
@@ -884,8 +825,7 @@
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
@@ -899,8 +839,7 @@
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -916,8 +855,7 @@
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -933,8 +871,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"license": "MIT",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -951,11 +890,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"license": "MIT",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
"content-type": "^1.0.5",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@@ -968,13 +909,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
@@ -993,6 +927,11 @@
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
"keywords": [],
|
||||
"author": "Microsoft Corporation",
|
||||
"dependencies": {
|
||||
"body-parser": "^1.20.3",
|
||||
"express": "^4.21.2",
|
||||
"http-proxy-middleware": "^3.0.3",
|
||||
"body-parser": "^2.2.2",
|
||||
"express": "^5.2.1",
|
||||
"follow-redirects": "^1.15.6",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"node": "^20.19.5",
|
||||
"node-fetch": "^2.6.1",
|
||||
"path-to-regexp": "^0.1.12"
|
||||
|
||||
11
src/@types/i18next.d.ts
vendored
Normal file
11
src/@types/i18next.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import "i18next";
|
||||
import Resources from "../Localization/en/Resources.json";
|
||||
|
||||
declare module "i18next" {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: "Resources";
|
||||
resources: {
|
||||
Resources: typeof Resources;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MessageTypes } from "../Contracts/ExplorerContracts";
|
||||
import { SubscriptionType } from "../Contracts/SubscriptionType";
|
||||
import { isExpectedError } from "../Metrics/ErrorClassification";
|
||||
import { scenarioMonitor } from "../Metrics/ScenarioMonitor";
|
||||
import { userContext } from "../UserContext";
|
||||
import { ARMError } from "../Utils/arm/request";
|
||||
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
|
||||
@@ -7,19 +9,36 @@ import { HttpStatusCodes } from "./Constants";
|
||||
import { logError } from "./Logger";
|
||||
import { sendMessage } from "./MessageHandler";
|
||||
|
||||
export const handleError = (error: string | ARMError | Error, area: string, consoleErrorPrefix?: string): void => {
|
||||
export interface HandleErrorOptions {
|
||||
/** Optional redacted error to use for telemetry logging instead of the original error */
|
||||
redactedError?: string | ARMError | Error;
|
||||
}
|
||||
|
||||
export const handleError = (
|
||||
error: string | ARMError | Error,
|
||||
area: string,
|
||||
consoleErrorPrefix?: string,
|
||||
options?: HandleErrorOptions,
|
||||
): void => {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
const errorCode = error instanceof ARMError ? error.code : undefined;
|
||||
|
||||
// logs error to data explorer console
|
||||
// logs error to data explorer console (always shows original, non-redacted message)
|
||||
const consoleErrorMessage = consoleErrorPrefix ? `${consoleErrorPrefix}:\n ${errorMessage}` : errorMessage;
|
||||
logConsoleError(consoleErrorMessage);
|
||||
|
||||
// logs error to both app insight and kusto
|
||||
logError(errorMessage, area, errorCode);
|
||||
// logs error to both app insight and kusto (use redacted message if provided)
|
||||
const telemetryErrorMessage = options?.redactedError ? getErrorMessage(options.redactedError) : errorMessage;
|
||||
logError(telemetryErrorMessage, area, errorCode);
|
||||
|
||||
// checks for errors caused by firewall and sends them to portal to handle
|
||||
sendNotificationForError(errorMessage, errorCode);
|
||||
|
||||
// Mark expected failures for health metrics (auth, firewall, permissions, etc.)
|
||||
// This ensures timeouts with expected failures emit healthy instead of unhealthy
|
||||
if (isExpectedError(error)) {
|
||||
scenarioMonitor.markExpectedFailure();
|
||||
}
|
||||
};
|
||||
|
||||
export const getErrorMessage = (error: string | Error = ""): string => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function queryIterator(databaseId: string, collection: Collection, query:
|
||||
let continuationToken: string;
|
||||
return {
|
||||
fetchNext: () => {
|
||||
return queryDocuments(databaseId, collection, false, query).then((response) => {
|
||||
return queryDocuments(databaseId, collection, false, query, continuationToken).then((response) => {
|
||||
continuationToken = response.continuationToken;
|
||||
const headers: { [key: string]: string | number } = {};
|
||||
response.headers.forEach((value, key) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SqlStoredProcedureCreateUpdateParameters,
|
||||
SqlStoredProcedureResource,
|
||||
} from "../../Utils/arm/generatedClients/cosmos/types";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { client } from "../CosmosClient";
|
||||
import { handleError } from "../ErrorHandlingUtils";
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function createStoredProcedure(
|
||||
): Promise<StoredProcedureDefinition & Resource> {
|
||||
const clearMessage = logConsoleProgress(`Creating stored procedure ${storedProcedure.id}`);
|
||||
try {
|
||||
let resource: StoredProcedureDefinition & Resource;
|
||||
if (
|
||||
userContext.authType === AuthType.AAD &&
|
||||
!userContext.features.enableSDKoperations &&
|
||||
@@ -60,14 +61,16 @@ export async function createStoredProcedure(
|
||||
storedProcedure.id,
|
||||
createSprocParams,
|
||||
);
|
||||
return rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
|
||||
resource = rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
|
||||
} else {
|
||||
const response = await client()
|
||||
.database(databaseId)
|
||||
.container(collectionId)
|
||||
.scripts.storedProcedures.create(storedProcedure);
|
||||
resource = response.resource;
|
||||
}
|
||||
|
||||
const response = await client()
|
||||
.database(databaseId)
|
||||
.container(collectionId)
|
||||
.scripts.storedProcedures.create(storedProcedure);
|
||||
return response?.resource;
|
||||
logConsoleInfo(`Successfully created stored procedure ${storedProcedure.id}`);
|
||||
return resource;
|
||||
} catch (error) {
|
||||
handleError(error, "CreateStoredProcedure", `Error while creating stored procedure ${storedProcedure.id}`);
|
||||
throw error;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AuthType } from "../../AuthType";
|
||||
import { userContext } from "../../UserContext";
|
||||
import { createUpdateSqlTrigger, getSqlTrigger } from "../../Utils/arm/generatedClients/cosmos/sqlResources";
|
||||
import { SqlTriggerCreateUpdateParameters, SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { client } from "../CosmosClient";
|
||||
import { handleError } from "../ErrorHandlingUtils";
|
||||
|
||||
@@ -14,6 +14,7 @@ export async function createTrigger(
|
||||
): Promise<TriggerDefinition | SqlTriggerResource> {
|
||||
const clearMessage = logConsoleProgress(`Creating trigger ${trigger.id}`);
|
||||
try {
|
||||
let resource: SqlTriggerResource | TriggerDefinition;
|
||||
if (
|
||||
userContext.authType === AuthType.AAD &&
|
||||
!userContext.features.enableSDKoperations &&
|
||||
@@ -52,14 +53,16 @@ export async function createTrigger(
|
||||
trigger.id,
|
||||
createTriggerParams,
|
||||
);
|
||||
return rpResponse && rpResponse.properties?.resource;
|
||||
resource = rpResponse && rpResponse.properties?.resource;
|
||||
} else {
|
||||
const sdkResponse = await client()
|
||||
.database(databaseId)
|
||||
.container(collectionId)
|
||||
.scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type
|
||||
resource = sdkResponse.resource;
|
||||
}
|
||||
|
||||
const response = await client()
|
||||
.database(databaseId)
|
||||
.container(collectionId)
|
||||
.scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type
|
||||
return response.resource;
|
||||
logConsoleInfo(`Successfully created trigger ${trigger.id}`);
|
||||
return resource;
|
||||
} catch (error) {
|
||||
handleError(error, "CreateTrigger", `Error while creating trigger ${trigger.id}`);
|
||||
throw error;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SqlUserDefinedFunctionCreateUpdateParameters,
|
||||
SqlUserDefinedFunctionResource,
|
||||
} from "../../Utils/arm/generatedClients/cosmos/types";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { client } from "../CosmosClient";
|
||||
import { handleError } from "../ErrorHandlingUtils";
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function createUserDefinedFunction(
|
||||
): Promise<UserDefinedFunctionDefinition & Resource> {
|
||||
const clearMessage = logConsoleProgress(`Creating user defined function ${userDefinedFunction.id}`);
|
||||
try {
|
||||
let resource: UserDefinedFunctionDefinition & Resource;
|
||||
if (
|
||||
userContext.authType === AuthType.AAD &&
|
||||
!userContext.features.enableSDKoperations &&
|
||||
@@ -60,14 +61,17 @@ export async function createUserDefinedFunction(
|
||||
userDefinedFunction.id,
|
||||
createUDFParams,
|
||||
);
|
||||
return rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
|
||||
}
|
||||
|
||||
const response = await client()
|
||||
.database(databaseId)
|
||||
.container(collectionId)
|
||||
.scripts.userDefinedFunctions.create(userDefinedFunction);
|
||||
return response?.resource;
|
||||
resource = rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
|
||||
} else {
|
||||
const response = await client()
|
||||
.database(databaseId)
|
||||
.container(collectionId)
|
||||
.scripts.userDefinedFunctions.create(userDefinedFunction);
|
||||
resource = response.resource;
|
||||
}
|
||||
logConsoleInfo(`Successfully created user defined function ${userDefinedFunction.id}`);
|
||||
return resource;
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
|
||||
@@ -44,7 +44,8 @@ export const deleteDocuments = async (
|
||||
documentIds: DocumentId[],
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<IBulkDeleteResult[]> => {
|
||||
const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`);
|
||||
const totalCount = documentIds.length;
|
||||
const clearMessage = logConsoleProgress(`Deleting ${totalCount} ${getEntityName(true)}`);
|
||||
try {
|
||||
const v2Container = await client().database(collection.databaseId).container(collection.id());
|
||||
|
||||
@@ -83,11 +84,7 @@ export const deleteDocuments = async (
|
||||
const flatAllResult = Array.prototype.concat.apply([], allResult);
|
||||
return flatAllResult;
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
"DeleteDocuments",
|
||||
`Error while deleting ${documentIds.length} ${getEntityName(documentIds.length > 1)}`,
|
||||
);
|
||||
handleError(error, "DeleteDocuments", `Error while deleting ${totalCount} ${getEntityName(totalCount > 1)}`);
|
||||
throw error;
|
||||
} finally {
|
||||
clearMessage();
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function deleteStoredProcedure(
|
||||
} else {
|
||||
await client().database(databaseId).container(collectionId).scripts.storedProcedure(storedProcedureId).delete();
|
||||
}
|
||||
logConsoleProgress(`Successfully deleted stored procedure ${storedProcedureId}`);
|
||||
} catch (error) {
|
||||
handleError(error, "DeleteStoredProcedure", `Error while deleting stored procedure ${storedProcedureId}`);
|
||||
throw error;
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function deleteTrigger(databaseId: string, collectionId: string, tr
|
||||
} else {
|
||||
await client().database(databaseId).container(collectionId).scripts.trigger(triggerId).delete();
|
||||
}
|
||||
logConsoleProgress(`Successfully deleted trigger ${triggerId}`);
|
||||
} catch (error) {
|
||||
handleError(error, "DeleteTrigger", `Error while deleting trigger ${triggerId}`);
|
||||
throw error;
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function deleteUserDefinedFunction(databaseId: string, collectionId
|
||||
} else {
|
||||
await client().database(databaseId).container(collectionId).scripts.userDefinedFunction(id).delete();
|
||||
}
|
||||
logConsoleProgress(`Successfully deleted user defined function ${id}`);
|
||||
} catch (error) {
|
||||
handleError(error, "DeleteUserDefinedFunction", `Error while deleting user defined function ${id}`);
|
||||
throw error;
|
||||
|
||||
171
src/Common/dataAccess/queryDocumentsPage.test.ts
Normal file
171
src/Common/dataAccess/queryDocumentsPage.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { redactSyntaxErrorMessage } from "./queryDocumentsPage";
|
||||
|
||||
/* Typical error to redact looks like this (the message property contains a JSON string with nested structure):
|
||||
{
|
||||
"message": "{\"code\":\"BadRequest\",\"message\":\"{\\\"errors\\\":[{\\\"severity\\\":\\\"Error\\\",\\\"location\\\":{\\\"start\\\":0,\\\"end\\\":5},\\\"code\\\":\\\"SC1001\\\",\\\"message\\\":\\\"Syntax error, incorrect syntax near 'Crazy'.\\\"}]}\\r\\nActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17, Windows/10.0.20348 cosmos-netstandard-sdk/3.18.0\"}"
|
||||
}
|
||||
*/
|
||||
|
||||
// Helper to create the nested error structure that matches what the SDK returns
|
||||
const createNestedError = (
|
||||
errors: Array<{ severity?: string; location?: { start: number; end: number }; code: string; message: string }>,
|
||||
activityId: string = "test-activity-id",
|
||||
): { message: string } => {
|
||||
const innerErrorsJson = JSON.stringify({ errors });
|
||||
const innerMessage = `${innerErrorsJson}\r\n${activityId}`;
|
||||
const outerJson = JSON.stringify({ code: "BadRequest", message: innerMessage });
|
||||
return { message: outerJson };
|
||||
};
|
||||
|
||||
// Helper to parse the redacted result
|
||||
const parseRedactedResult = (result: { message: string }) => {
|
||||
const outerParsed = JSON.parse(result.message);
|
||||
const [innerErrorsJson, activityIdPart] = outerParsed.message.split("\r\n");
|
||||
const innerErrors = JSON.parse(innerErrorsJson);
|
||||
return { outerParsed, innerErrors, activityIdPart };
|
||||
};
|
||||
|
||||
describe("redactSyntaxErrorMessage", () => {
|
||||
it("should redact SC1001 error message", () => {
|
||||
const error = createNestedError(
|
||||
[
|
||||
{
|
||||
severity: "Error",
|
||||
location: { start: 0, end: 5 },
|
||||
code: "SC1001",
|
||||
message: "Syntax error, incorrect syntax near 'Crazy'.",
|
||||
},
|
||||
],
|
||||
"ActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17",
|
||||
);
|
||||
|
||||
const result = redactSyntaxErrorMessage(error) as { message: string };
|
||||
const { outerParsed, innerErrors, activityIdPart } = parseRedactedResult(result);
|
||||
|
||||
expect(outerParsed.code).toBe("BadRequest");
|
||||
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
|
||||
expect(activityIdPart).toContain("ActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17");
|
||||
});
|
||||
|
||||
it("should redact SC2001 error message", () => {
|
||||
const error = createNestedError(
|
||||
[
|
||||
{
|
||||
severity: "Error",
|
||||
location: { start: 0, end: 10 },
|
||||
code: "SC2001",
|
||||
message: "Some sensitive syntax error message.",
|
||||
},
|
||||
],
|
||||
"ActivityId: abc123",
|
||||
);
|
||||
|
||||
const result = redactSyntaxErrorMessage(error) as { message: string };
|
||||
const { outerParsed, innerErrors, activityIdPart } = parseRedactedResult(result);
|
||||
|
||||
expect(outerParsed.code).toBe("BadRequest");
|
||||
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
|
||||
expect(activityIdPart).toContain("ActivityId: abc123");
|
||||
});
|
||||
|
||||
it("should redact multiple errors with SC1001 and SC2001 codes", () => {
|
||||
const error = createNestedError(
|
||||
[
|
||||
{ severity: "Error", code: "SC1001", message: "First error" },
|
||||
{ severity: "Error", code: "SC2001", message: "Second error" },
|
||||
],
|
||||
"ActivityId: xyz",
|
||||
);
|
||||
|
||||
const result = redactSyntaxErrorMessage(error) as { message: string };
|
||||
const { innerErrors } = parseRedactedResult(result);
|
||||
|
||||
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
|
||||
expect(innerErrors.errors[1].message).toBe("__REDACTED__");
|
||||
});
|
||||
|
||||
it("should not redact errors with other codes", () => {
|
||||
const error = createNestedError(
|
||||
[{ severity: "Error", code: "SC9999", message: "This should not be redacted." }],
|
||||
"ActivityId: test123",
|
||||
);
|
||||
|
||||
const result = redactSyntaxErrorMessage(error);
|
||||
|
||||
expect(result).toBe(error); // Should return original error unchanged
|
||||
});
|
||||
|
||||
it("should not modify non-BadRequest errors", () => {
|
||||
const innerMessage = JSON.stringify({ errors: [{ code: "SC1001", message: "Should not be redacted" }] });
|
||||
const error = {
|
||||
message: JSON.stringify({ code: "NotFound", message: innerMessage }),
|
||||
};
|
||||
|
||||
const result = redactSyntaxErrorMessage(error);
|
||||
|
||||
expect(result).toBe(error);
|
||||
});
|
||||
|
||||
it("should handle errors without message property", () => {
|
||||
const error = { code: "BadRequest" };
|
||||
|
||||
const result = redactSyntaxErrorMessage(error);
|
||||
|
||||
expect(result).toBe(error);
|
||||
});
|
||||
|
||||
it("should handle non-object errors", () => {
|
||||
const stringError = "Simple string error";
|
||||
const nullError: null = null;
|
||||
const undefinedError: undefined = undefined;
|
||||
|
||||
expect(redactSyntaxErrorMessage(stringError)).toBe(stringError);
|
||||
expect(redactSyntaxErrorMessage(nullError)).toBe(nullError);
|
||||
expect(redactSyntaxErrorMessage(undefinedError)).toBe(undefinedError);
|
||||
});
|
||||
|
||||
it("should handle malformed JSON in message", () => {
|
||||
const error = {
|
||||
message: "not valid json",
|
||||
};
|
||||
|
||||
const result = redactSyntaxErrorMessage(error);
|
||||
|
||||
expect(result).toBe(error);
|
||||
});
|
||||
|
||||
it("should handle message without ActivityId suffix", () => {
|
||||
const innerErrorsJson = JSON.stringify({
|
||||
errors: [{ severity: "Error", code: "SC1001", message: "Syntax error near something." }],
|
||||
});
|
||||
const error = {
|
||||
message: JSON.stringify({ code: "BadRequest", message: innerErrorsJson + "\r\n" }),
|
||||
};
|
||||
|
||||
const result = redactSyntaxErrorMessage(error) as { message: string };
|
||||
const { innerErrors } = parseRedactedResult(result);
|
||||
|
||||
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
|
||||
});
|
||||
|
||||
it("should preserve other error properties", () => {
|
||||
const baseError = createNestedError([{ code: "SC1001", message: "Error" }], "ActivityId: test");
|
||||
const error = {
|
||||
...baseError,
|
||||
statusCode: 400,
|
||||
additionalInfo: "extra data",
|
||||
};
|
||||
|
||||
const result = redactSyntaxErrorMessage(error) as {
|
||||
message: string;
|
||||
statusCode: number;
|
||||
additionalInfo: string;
|
||||
};
|
||||
|
||||
expect(result.statusCode).toBe(400);
|
||||
expect(result.additionalInfo).toBe("extra data");
|
||||
|
||||
const { innerErrors } = parseRedactedResult(result);
|
||||
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,51 @@ import { getEntityName } from "../DocumentUtility";
|
||||
import { handleError } from "../ErrorHandlingUtils";
|
||||
import { MinimalQueryIterator, nextPage } from "../IteratorUtilities";
|
||||
|
||||
// Redact sensitive information from BadRequest errors with specific codes
|
||||
export const redactSyntaxErrorMessage = (error: unknown): unknown => {
|
||||
const codesToRedact = ["SC1001", "SC2001", "SC1010"];
|
||||
|
||||
try {
|
||||
// Handle error objects with a message property
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
const errorObj = error as { code?: string; message?: string };
|
||||
if (typeof errorObj.message === "string") {
|
||||
// Parse the inner JSON from the message
|
||||
const innerJson = JSON.parse(errorObj.message);
|
||||
if (innerJson.code === "BadRequest" && typeof innerJson.message === "string") {
|
||||
const [innerErrorsJson, activityIdPart] = innerJson.message.split("\r\n");
|
||||
const innerErrorsObj = JSON.parse(innerErrorsJson);
|
||||
if (Array.isArray(innerErrorsObj.errors)) {
|
||||
let modified = false;
|
||||
innerErrorsObj.errors = innerErrorsObj.errors.map((err: { code?: string; message?: string }) => {
|
||||
if (err.code && codesToRedact.includes(err.code)) {
|
||||
modified = true;
|
||||
return { ...err, message: "__REDACTED__" };
|
||||
}
|
||||
return err;
|
||||
});
|
||||
|
||||
if (modified) {
|
||||
// Reconstruct the message with the redacted content
|
||||
const redactedMessage = JSON.stringify(innerErrorsObj) + `\r\n${activityIdPart}`;
|
||||
const redactedError = {
|
||||
...error,
|
||||
message: JSON.stringify({ ...innerJson, message: redactedMessage }),
|
||||
body: undefined as unknown, // Clear body to avoid sensitive data
|
||||
};
|
||||
return redactedError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, return the original error
|
||||
}
|
||||
|
||||
return error;
|
||||
};
|
||||
|
||||
export const queryDocumentsPage = async (
|
||||
resourceName: string,
|
||||
documentsIterator: MinimalQueryIterator,
|
||||
@@ -18,7 +63,12 @@ export const queryDocumentsPage = async (
|
||||
logConsoleInfo(`Successfully fetched ${itemCount} ${entityName} for container ${resourceName}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
handleError(error, "QueryDocumentsPage", `Failed to query ${entityName} for container ${resourceName}`);
|
||||
// Redact sensitive information for telemetry while showing original in console
|
||||
const redactedError = redactSyntaxErrorMessage(error);
|
||||
|
||||
handleError(error, "QueryDocumentsPage", `Failed to query ${entityName} for container ${resourceName}`, {
|
||||
redactedError: redactedError as Error,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
clearMessage();
|
||||
|
||||
@@ -77,6 +77,12 @@ let configContext: Readonly<ConfigContext> = {
|
||||
`^https:\\/\\/.*\\.fabric\\.microsoft\\.com$`,
|
||||
`^https:\\/\\/.*\\.powerbi\\.com$`,
|
||||
`^https:\\/\\/dataexplorer-preview\\.azurewebsites\\.net$`,
|
||||
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.fr$`,
|
||||
`^https:\\/\\/portal\\.sovcloud-azure\\.fr$`,
|
||||
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.de$`,
|
||||
`^https:\\/\\/portal\\.sovcloud-azure\\.de$`,
|
||||
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.sg$`,
|
||||
`^https:\\/\\/portal\\.sovcloud-azure\\.sg$`,
|
||||
], // Webpack injects this at build time
|
||||
gitSha: process.env.GIT_SHA,
|
||||
hostedExplorerURL: "https://cosmos.azure.com/",
|
||||
|
||||
@@ -46,6 +46,10 @@ export type DataExploreMessageV3 =
|
||||
params: {
|
||||
updateType: "created" | "deleted" | "settings";
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: FabricMessageTypes.RestoreContainer;
|
||||
params: [];
|
||||
};
|
||||
export interface GetCosmosTokenMessageOptions {
|
||||
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
|
||||
|
||||
@@ -275,8 +275,7 @@ export interface DataMaskingPolicy {
|
||||
startPosition: number;
|
||||
length: number;
|
||||
}>;
|
||||
excludedPaths: string[];
|
||||
isPolicyEnabled: boolean;
|
||||
excludedPaths?: string[];
|
||||
}
|
||||
|
||||
export interface MaterializedView {
|
||||
|
||||
@@ -25,7 +25,18 @@ export default {
|
||||
subscriptionDropdownPlaceholder: "Select a subscription",
|
||||
sourceAccountDropdownLabel: "Account",
|
||||
sourceAccountDropdownPlaceholder: "Select an account",
|
||||
migrationTypeCheckboxLabel: "Copy container in offline mode",
|
||||
migrationTypeOptions: {
|
||||
offline: {
|
||||
title: "Offline mode",
|
||||
description:
|
||||
"Offline container copy jobs let you copy data from a source container to a destination Cosmos DB container for supported APIs. To ensure data integrity between the source and destination, we recommend stopping updates on the source container before creating the copy job. Learn more about [offline copy jobs](https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql).",
|
||||
},
|
||||
online: {
|
||||
title: "Online mode",
|
||||
description:
|
||||
"Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started).",
|
||||
},
|
||||
},
|
||||
|
||||
// Select Source and Target Containers Screen
|
||||
selectSourceAndTargetContainersDescription:
|
||||
@@ -173,5 +184,10 @@ export default {
|
||||
Skipped: "Cancelled",
|
||||
Cancelled: "Cancelled",
|
||||
},
|
||||
dialog: {
|
||||
heading: "",
|
||||
confirmButtonText: "Confirm",
|
||||
cancelButtonText: "Cancel",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -395,6 +395,14 @@ describe("CopyJobUtils", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for different completion percentage", () => {
|
||||
const jobs1 = [createMockJob("job1", "Running")];
|
||||
const jobs2 = [{ ...createMockJob("job1", "Running"), CompletionPercentage: 75 }];
|
||||
|
||||
const result = CopyJobUtils.isEqual(jobs1, jobs2);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for empty arrays", () => {
|
||||
const result = CopyJobUtils.isEqual([], []);
|
||||
expect(result).toBe(true);
|
||||
|
||||
@@ -142,7 +142,7 @@ export function isEqual(prevJobs: CopyJobType[], newJobs: CopyJobType[]): boolea
|
||||
if (!newJob) {
|
||||
return false;
|
||||
}
|
||||
return prevJob.Status === newJob.Status;
|
||||
return prevJob.Status === newJob.Status && prevJob.CompletionPercentage === newJob.CompletionPercentage;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
||||
import { MigrationType } from "./MigrationType";
|
||||
|
||||
jest.mock("../../../../Context/CopyJobContext", () => ({
|
||||
useCopyJobContext: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("MigrationType", () => {
|
||||
const mockSetCopyJobState = jest.fn();
|
||||
|
||||
const defaultContextValue = {
|
||||
copyJobState: {
|
||||
jobName: "",
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
source: {
|
||||
subscription: null as any,
|
||||
account: null as any,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
target: {
|
||||
subscriptionId: "",
|
||||
account: null as any,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
sourceReadAccessFromTarget: false,
|
||||
},
|
||||
setCopyJobState: mockSetCopyJobState,
|
||||
flow: { currentScreen: "selectAccount" },
|
||||
setFlow: jest.fn(),
|
||||
contextError: "",
|
||||
setContextError: jest.fn(),
|
||||
explorer: {} as any,
|
||||
resetCopyJobState: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue(defaultContextValue);
|
||||
});
|
||||
|
||||
describe("Component Rendering", () => {
|
||||
it("should render migration type component with radio buttons", () => {
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
|
||||
expect(screen.getByRole("radiogroup")).toBeInTheDocument();
|
||||
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
|
||||
expect(offlineRadio).toBeInTheDocument();
|
||||
expect(onlineRadio).toBeInTheDocument();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render with online mode selected by default", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
|
||||
expect(onlineRadio).toBeChecked();
|
||||
expect(offlineRadio).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should render with offline mode selected when state is offline", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<MigrationType />);
|
||||
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
|
||||
expect(offlineRadio).toBeChecked();
|
||||
expect(onlineRadio).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Descriptions and Learn More Links", () => {
|
||||
it("should render online description and learn more link when online is selected", () => {
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type-description-online']")).toBeInTheDocument();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "online copy jobs",
|
||||
});
|
||||
expect(learnMoreLink).toBeInTheDocument();
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it("should render offline description and learn more link when offline is selected", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type-description-offline']")).toBeInTheDocument();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "offline copy jobs",
|
||||
});
|
||||
expect(learnMoreLink).toBeInTheDocument();
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Interactions", () => {
|
||||
it("should call setCopyJobState when offline radio button is clicked", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
fireEvent.click(offlineRadio);
|
||||
|
||||
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const result = updateFunction(defaultContextValue.copyJobState);
|
||||
|
||||
expect(result).toEqual({
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call setCopyJobState when online radio button is clicked", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<MigrationType />);
|
||||
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
fireEvent.click(onlineRadio);
|
||||
|
||||
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const result = updateFunction({
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("should have proper ARIA attributes", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
const choiceGroup = screen.getByRole("radiogroup");
|
||||
expect(choiceGroup).toBeInTheDocument();
|
||||
expect(choiceGroup).toHaveAttribute("aria-labelledby", "migrationTypeChoiceGroup");
|
||||
});
|
||||
|
||||
it("should have proper radio button labels", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle undefined migration type gracefully", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle null copyJobState gracefully", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: null,
|
||||
});
|
||||
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { ChoiceGroup, IChoiceGroupOption, Stack, Text } from "@fluentui/react";
|
||||
import MarkdownRender from "@nteract/markdown";
|
||||
import { useCopyJobContext } from "Explorer/ContainerCopy/Context/CopyJobContext";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
||||
|
||||
interface MigrationTypeProps {}
|
||||
const options: IChoiceGroupOption[] = [
|
||||
{
|
||||
key: CopyJobMigrationType.Offline,
|
||||
text: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
styles: { root: { width: "33%" } },
|
||||
},
|
||||
{
|
||||
key: CopyJobMigrationType.Online,
|
||||
text: ContainerCopyMessages.migrationTypeOptions.online.title,
|
||||
styles: { root: { width: "33%" } },
|
||||
},
|
||||
];
|
||||
|
||||
const choiceGroupStyles = {
|
||||
flexContainer: { display: "flex" as const },
|
||||
root: {
|
||||
selectors: {
|
||||
".ms-ChoiceField": {
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MigrationType: React.FC<MigrationTypeProps> = React.memo(() => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
const handleChange = (_ev?: React.FormEvent, option?: IChoiceGroupOption) => {
|
||||
if (option) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
migrationType: option.key as CopyJobMigrationType,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const selectedKey = copyJobState?.migrationType ?? "";
|
||||
const selectedKeyLowercase = selectedKey.toLowerCase() as keyof typeof ContainerCopyMessages.migrationTypeOptions;
|
||||
const selectedKeyContent = ContainerCopyMessages.migrationTypeOptions[selectedKeyLowercase];
|
||||
|
||||
return (
|
||||
<Stack data-test="migration-type" className="migrationTypeContainer">
|
||||
<Stack.Item>
|
||||
<ChoiceGroup
|
||||
selectedKey={selectedKey}
|
||||
options={options}
|
||||
onChange={handleChange}
|
||||
ariaLabelledBy="migrationTypeChoiceGroup"
|
||||
styles={choiceGroupStyles}
|
||||
/>
|
||||
</Stack.Item>
|
||||
{selectedKeyContent && (
|
||||
<Stack.Item styles={{ root: { marginTop: 10 } }}>
|
||||
<Text
|
||||
variant="small"
|
||||
className="migrationTypeDescription"
|
||||
data-test={`migration-type-description-${selectedKeyLowercase}`}
|
||||
>
|
||||
<MarkdownRender source={selectedKeyContent.description} linkTarget="_blank" />
|
||||
</Text>
|
||||
</Stack.Item>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { MigrationTypeCheckbox } from "./MigrationTypeCheckbox";
|
||||
|
||||
describe("MigrationTypeCheckbox", () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Component Rendering", () => {
|
||||
it("should render with default props (unchecked state)", () => {
|
||||
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render in checked state", () => {
|
||||
const { container } = render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
|
||||
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display the correct label text", () => {
|
||||
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
|
||||
const label = screen.getByText("Copy container in offline mode");
|
||||
expect(label).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have correct accessibility attributes when checked", () => {
|
||||
render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(checkbox).toHaveAttribute("checked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FluentUI Integration", () => {
|
||||
it("should render FluentUI Checkbox component correctly", () => {
|
||||
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).toHaveAttribute("type", "checkbox");
|
||||
});
|
||||
|
||||
it("should render FluentUI Stack component correctly", () => {
|
||||
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const stackContainer = document.querySelector(".migrationTypeRow");
|
||||
expect(stackContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply FluentUI Stack horizontal alignment correctly", () => {
|
||||
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const stackContainer = container.querySelector(".migrationTypeRow");
|
||||
expect(stackContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { Checkbox, ICheckboxStyles, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
|
||||
interface MigrationTypeCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
|
||||
}
|
||||
|
||||
const checkboxStyles: ICheckboxStyles = {
|
||||
text: { color: "var(--colorNeutralForeground1)" },
|
||||
checkbox: { borderColor: "var(--colorNeutralStroke1)" },
|
||||
root: {
|
||||
selectors: {
|
||||
":hover .ms-Checkbox-text": { color: "var(--colorNeutralForeground1)" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MigrationTypeCheckbox: React.FC<MigrationTypeCheckboxProps> = React.memo(({ checked, onChange }) => {
|
||||
return (
|
||||
<Stack horizontal horizontalAlign="space-between" className="migrationTypeRow" data-test="migration-type-checkbox">
|
||||
<Checkbox
|
||||
label={ContainerCopyMessages.migrationTypeCheckboxLabel}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
styles={checkboxStyles}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`MigrationType Component Rendering should render migration type component with radio buttons 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack migrationTypeContainer css-109"
|
||||
data-test="migration-type"
|
||||
>
|
||||
<div
|
||||
class="ms-StackItem css-110"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceFieldGroup root-111"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="migrationTypeChoiceGroup"
|
||||
role="radiogroup"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceFieldGroup-flexContainer flexContainer-112"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceField root-113"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceField-wrapper"
|
||||
>
|
||||
<input
|
||||
class="ms-ChoiceField-input input-114"
|
||||
id="ChoiceGroup0-offline"
|
||||
name="ChoiceGroup0"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="ms-ChoiceField-field field-115"
|
||||
for="ChoiceGroup0-offline"
|
||||
>
|
||||
<span
|
||||
class="ms-ChoiceFieldLabel"
|
||||
id="ChoiceGroupLabel1-offline"
|
||||
>
|
||||
Offline mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-ChoiceField root-113"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceField-wrapper"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="ms-ChoiceField-input input-114"
|
||||
id="ChoiceGroup0-online"
|
||||
name="ChoiceGroup0"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="ms-ChoiceField-field is-checked field-120"
|
||||
for="ChoiceGroup0-online"
|
||||
>
|
||||
<span
|
||||
class="ms-ChoiceFieldLabel"
|
||||
id="ChoiceGroupLabel1-online"
|
||||
>
|
||||
Online mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-StackItem css-123"
|
||||
>
|
||||
<span
|
||||
class="migrationTypeDescription css-124"
|
||||
data-test="migration-type-description-online"
|
||||
>
|
||||
<div
|
||||
class="markdown-body "
|
||||
>
|
||||
<p>
|
||||
Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the
|
||||
<a
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview"
|
||||
target="_blank"
|
||||
>
|
||||
All Versions and Delete
|
||||
</a>
|
||||
change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about
|
||||
<a
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started"
|
||||
target="_blank"
|
||||
>
|
||||
online copy jobs
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,82 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`MigrationTypeCheckbox Component Rendering should render in checked state 1`] = `
|
||||
<div
|
||||
class="ms-Stack migrationTypeRow css-109"
|
||||
data-test="migration-type-checkbox"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox is-checked is-enabled root-119"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="input-111"
|
||||
data-ktp-execute-target="true"
|
||||
id="checkbox-1"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
class="ms-Checkbox-label label-112"
|
||||
for="checkbox-1"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox-checkbox checkbox-120"
|
||||
data-ktp-target="true"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="ms-Checkbox-checkmark checkmark-122"
|
||||
data-icon-name="CheckMark"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
<span
|
||||
class="ms-Checkbox-text text-115"
|
||||
>
|
||||
Copy container in offline mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MigrationTypeCheckbox Component Rendering should render with default props (unchecked state) 1`] = `
|
||||
<div
|
||||
class="ms-Stack migrationTypeRow css-109"
|
||||
data-test="migration-type-checkbox"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox is-enabled root-110"
|
||||
>
|
||||
<input
|
||||
class="input-111"
|
||||
data-ktp-execute-target="true"
|
||||
id="checkbox-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
class="ms-Checkbox-label label-112"
|
||||
for="checkbox-0"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox-checkbox checkbox-113"
|
||||
data-ktp-target="true"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="ms-Checkbox-checkmark checkmark-118"
|
||||
data-icon-name="CheckMark"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
<span
|
||||
class="ms-Checkbox-text text-115"
|
||||
>
|
||||
Copy container in offline mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,5 +1,5 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
@@ -18,19 +18,8 @@ jest.mock("./Components/AccountDropdown", () => ({
|
||||
AccountDropdown: jest.fn(() => <div data-testid="account-dropdown">Account Dropdown</div>),
|
||||
}));
|
||||
|
||||
jest.mock("./Components/MigrationTypeCheckbox", () => ({
|
||||
MigrationTypeCheckbox: jest.fn(({ checked, onChange }: { checked: boolean; onChange: () => void }) => (
|
||||
<div data-testid="migration-type-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
data-testid="migration-checkbox-input"
|
||||
aria-label="Migration Type Checkbox"
|
||||
/>
|
||||
Copy container in offline mode
|
||||
</div>
|
||||
)),
|
||||
jest.mock("./Components/MigrationType", () => ({
|
||||
MigrationType: jest.fn(() => <div data-testid="migration-type">Migration Type</div>),
|
||||
}));
|
||||
|
||||
describe("SelectAccount", () => {
|
||||
@@ -83,7 +72,7 @@ describe("SelectAccount", () => {
|
||||
|
||||
expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("account-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("migration-type-checkbox")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render correctly with snapshot", () => {
|
||||
@@ -93,78 +82,20 @@ describe("SelectAccount", () => {
|
||||
});
|
||||
|
||||
describe("Migration Type Functionality", () => {
|
||||
it("should display migration type checkbox as unchecked when migrationType is Online", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
},
|
||||
});
|
||||
|
||||
it("should render migration type component", () => {
|
||||
render(<SelectAccount />);
|
||||
|
||||
const checkbox = screen.getByTestId("migration-checkbox-input");
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should display migration type checkbox as checked when migrationType is Offline", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<SelectAccount />);
|
||||
|
||||
const checkbox = screen.getByTestId("migration-checkbox-input");
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it("should call setCopyJobState with Online migration type when checkbox is unchecked", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<SelectAccount />);
|
||||
|
||||
const checkbox = screen.getByTestId("migration-checkbox-input");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const previousState = {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
};
|
||||
const result = updateFunction(previousState);
|
||||
|
||||
expect(result).toEqual({
|
||||
...previousState,
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
});
|
||||
const migrationTypeComponent = screen.getByTestId("migration-type");
|
||||
expect(migrationTypeComponent).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance and Optimization", () => {
|
||||
it("should maintain referential equality of handler functions between renders", async () => {
|
||||
it("should render without performance issues", () => {
|
||||
const { rerender } = render(<SelectAccount />);
|
||||
|
||||
const migrationCheckbox = (await import("./Components/MigrationTypeCheckbox")).MigrationTypeCheckbox as jest.Mock;
|
||||
const firstRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
|
||||
|
||||
rerender(<SelectAccount />);
|
||||
|
||||
const secondRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
|
||||
|
||||
expect(firstRenderHandler).toBe(secondRenderHandler);
|
||||
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import { Stack, Text } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
import { AccountDropdown } from "./Components/AccountDropdown";
|
||||
import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
|
||||
import { MigrationType } from "./Components/MigrationType";
|
||||
import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
|
||||
|
||||
const SelectAccount = React.memo(() => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
|
||||
const handleMigrationTypeChange = (_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
|
||||
}));
|
||||
};
|
||||
|
||||
const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline;
|
||||
|
||||
return (
|
||||
<Stack data-test="Panel:SelectAccountContainer" className="selectAccountContainer" tokens={{ childrenGap: 15 }}>
|
||||
<Text className="themeText">{ContainerCopyMessages.selectAccountDescription}</Text>
|
||||
@@ -27,7 +14,7 @@ const SelectAccount = React.memo(() => {
|
||||
|
||||
<AccountDropdown />
|
||||
|
||||
<MigrationTypeCheckbox checked={migrationTypeChecked} onChange={handleMigrationTypeChange} />
|
||||
<MigrationType />
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -21,14 +21,9 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot
|
||||
Account Dropdown
|
||||
</div>
|
||||
<div
|
||||
data-testid="migration-type-checkbox"
|
||||
data-testid="migration-type"
|
||||
>
|
||||
<input
|
||||
aria-label="Migration Type Checkbox"
|
||||
data-testid="migration-checkbox-input"
|
||||
type="checkbox"
|
||||
/>
|
||||
Copy container in offline mode
|
||||
Migration Type
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable jest/no-conditional-expect */
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
@@ -5,6 +6,20 @@ import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../E
|
||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
||||
import CopyJobActionMenu from "./CopyJobActionMenu";
|
||||
|
||||
const mockShowOkCancelModalDialog = jest.fn();
|
||||
const mockCloseDialog = jest.fn();
|
||||
const mockOpenDialog = jest.fn();
|
||||
|
||||
jest.mock("../../../Controls/Dialog", () => ({
|
||||
useDialog: {
|
||||
getState: () => ({
|
||||
showOkCancelModalDialog: mockShowOkCancelModalDialog,
|
||||
closeDialog: mockCloseDialog,
|
||||
openDialog: mockOpenDialog,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../ContainerCopyMessages", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
@@ -18,6 +33,11 @@ jest.mock("../../ContainerCopyMessages", () => ({
|
||||
cancel: "Cancel",
|
||||
complete: "Complete",
|
||||
},
|
||||
dialog: {
|
||||
heading: "Confirm Action",
|
||||
confirmButtonText: "Confirm",
|
||||
cancelButtonText: "Cancel",
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -50,6 +70,9 @@ describe("CopyJobActionMenu", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockShowOkCancelModalDialog.mockClear();
|
||||
mockCloseDialog.mockClear();
|
||||
mockOpenDialog.mockClear();
|
||||
});
|
||||
|
||||
describe("Component Rendering", () => {
|
||||
@@ -266,7 +289,29 @@ describe("CopyJobActionMenu", () => {
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function));
|
||||
});
|
||||
|
||||
it("should call handleClick when cancel action is clicked", () => {
|
||||
it("should show confirmation dialog when cancel action is clicked", () => {
|
||||
const job = createMockJob({ Name: "Test Job", Status: CopyJobStatusType.InProgress });
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
|
||||
"Confirm Action",
|
||||
null,
|
||||
"Confirm",
|
||||
expect.any(Function),
|
||||
"Cancel",
|
||||
null,
|
||||
expect.any(Object), // dialogBody content
|
||||
);
|
||||
});
|
||||
|
||||
it("should call handleClick when dialog is confirmed for cancel action", () => {
|
||||
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
@@ -277,6 +322,9 @@ describe("CopyJobActionMenu", () => {
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0];
|
||||
onOkCallback();
|
||||
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function));
|
||||
});
|
||||
|
||||
@@ -294,7 +342,33 @@ describe("CopyJobActionMenu", () => {
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.resume, expect.any(Function));
|
||||
});
|
||||
|
||||
it("should call handleClick when complete action is clicked", () => {
|
||||
it("should show confirmation dialog when complete action is clicked", () => {
|
||||
const job = createMockJob({
|
||||
Name: "Test Online Job",
|
||||
Status: CopyJobStatusType.InProgress,
|
||||
Mode: CopyJobMigrationType.Online,
|
||||
});
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const completeButton = screen.getByText("Complete");
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
|
||||
"Confirm Action",
|
||||
null,
|
||||
"Confirm",
|
||||
expect.any(Function),
|
||||
"Cancel",
|
||||
null,
|
||||
expect.any(Object), // dialogBody content
|
||||
);
|
||||
});
|
||||
|
||||
it("should call handleClick when dialog is confirmed for complete action", () => {
|
||||
const job = createMockJob({
|
||||
Status: CopyJobStatusType.InProgress,
|
||||
Mode: CopyJobMigrationType.Online,
|
||||
@@ -308,10 +382,87 @@ describe("CopyJobActionMenu", () => {
|
||||
const completeButton = screen.getByText("Complete");
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0];
|
||||
onOkCallback();
|
||||
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dialog Body Content", () => {
|
||||
it("should pass correct dialog body content for cancel action", () => {
|
||||
const job = createMockJob({ Name: "MyTestJob", Status: CopyJobStatusType.InProgress });
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
|
||||
"Confirm Action",
|
||||
null,
|
||||
"Confirm",
|
||||
expect.any(Function),
|
||||
"Cancel",
|
||||
null,
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
tokens: expect.any(Object),
|
||||
children: expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass correct dialog body content for complete action", () => {
|
||||
const job = createMockJob({
|
||||
Name: "OnlineTestJob",
|
||||
Status: CopyJobStatusType.InProgress,
|
||||
Mode: CopyJobMigrationType.Online,
|
||||
});
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const completeButton = screen.getByText("Complete");
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
|
||||
"Confirm Action",
|
||||
null,
|
||||
"Confirm",
|
||||
expect.any(Function),
|
||||
"Cancel",
|
||||
null,
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
tokens: expect.any(Object),
|
||||
children: expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not show dialog body for actions without confirmation", () => {
|
||||
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const pauseButton = screen.getByText("Pause");
|
||||
fireEvent.click(pauseButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disabled States During Updates", () => {
|
||||
const TestComponentWrapper: React.FC<{
|
||||
job: CopyJobType;
|
||||
@@ -339,8 +490,13 @@ describe("CopyJobActionMenu", () => {
|
||||
const pauseButton = screen.getByText("Pause");
|
||||
fireEvent.click(pauseButton);
|
||||
fireEvent.click(actionButton);
|
||||
const pauseButtonAfterClick = screen.getByText("Pause");
|
||||
|
||||
const pauseButtonAfterClick = screen.getByText("Pause").closest("button");
|
||||
expect(pauseButtonAfterClick).toBeInTheDocument();
|
||||
expect(pauseButtonAfterClick).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
const cancelButtonAfterClick = screen.getByText("Cancel").closest("button");
|
||||
expect(cancelButtonAfterClick).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("should not disable actions for different jobs when one is updating", () => {
|
||||
@@ -360,23 +516,7 @@ describe("CopyJobActionMenu", () => {
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should properly handle multiple action types being disabled for the same job", () => {
|
||||
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
|
||||
render(<TestComponentWrapper job={job} />);
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
|
||||
fireEvent.click(actionButton);
|
||||
fireEvent.click(screen.getByText("Pause"));
|
||||
|
||||
fireEvent.click(actionButton);
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
|
||||
fireEvent.click(actionButton);
|
||||
expect(screen.getByText("Pause")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle complete action disabled state for online jobs", () => {
|
||||
it("should disable complete action when job is being updated", () => {
|
||||
const job = createMockJob({
|
||||
Status: CopyJobStatusType.InProgress,
|
||||
Mode: CopyJobMigrationType.Online,
|
||||
@@ -390,8 +530,34 @@ describe("CopyJobActionMenu", () => {
|
||||
const completeButton = screen.getByText("Complete");
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
// Simulate dialog confirmation to trigger state update
|
||||
const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0];
|
||||
onOkCallback();
|
||||
|
||||
fireEvent.click(actionButton);
|
||||
expect(screen.getByText("Complete")).toBeInTheDocument();
|
||||
const completeButtonAfterClick = screen.getByText("Complete").closest("button");
|
||||
expect(completeButtonAfterClick).toBeInTheDocument();
|
||||
expect(completeButtonAfterClick).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("should disable complete action when any other action is being performed", () => {
|
||||
const job = createMockJob({
|
||||
Status: CopyJobStatusType.InProgress,
|
||||
Mode: CopyJobMigrationType.Online,
|
||||
});
|
||||
|
||||
render(<TestComponentWrapper job={job} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const pauseButton = screen.getByText("Pause");
|
||||
fireEvent.click(pauseButton);
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const completeButtonAfterClick = screen.getByText("Complete").closest("button");
|
||||
expect(completeButtonAfterClick).toBeInTheDocument();
|
||||
expect(completeButtonAfterClick).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -462,6 +628,7 @@ describe("CopyJobActionMenu", () => {
|
||||
|
||||
expect(actionButton).toHaveAttribute("aria-label", "Actions");
|
||||
expect(actionButton).toHaveAttribute("title", "Actions");
|
||||
expect(actionButton).toHaveAttribute("role", "button");
|
||||
|
||||
const moreIcon = actionButton.querySelector('[data-icon-name="More"]');
|
||||
expect(moreIcon || actionButton).toBeInTheDocument();
|
||||
@@ -608,4 +775,129 @@ describe("CopyJobActionMenu", () => {
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complete Coverage Tests", () => {
|
||||
it("should handle all possible dialog scenarios", () => {
|
||||
const dialogTests = [
|
||||
{ action: CopyJobActions.cancel, status: CopyJobStatusType.InProgress, shouldShowDialog: true },
|
||||
{
|
||||
action: CopyJobActions.complete,
|
||||
status: CopyJobStatusType.InProgress,
|
||||
mode: CopyJobMigrationType.Online,
|
||||
shouldShowDialog: true,
|
||||
},
|
||||
{ action: CopyJobActions.pause, status: CopyJobStatusType.InProgress, shouldShowDialog: false },
|
||||
{ action: CopyJobActions.resume, status: CopyJobStatusType.Paused, shouldShowDialog: false },
|
||||
];
|
||||
|
||||
dialogTests.forEach(({ action, status, mode = CopyJobMigrationType.Offline, shouldShowDialog }, index) => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const job = createMockJob({ Status: status, Mode: mode, Name: `DialogTestJob${index}` });
|
||||
const { unmount } = render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const actionText = action.charAt(0).toUpperCase() + action.slice(1);
|
||||
if (screen.queryByText(actionText)) {
|
||||
fireEvent.click(screen.getByText(actionText));
|
||||
|
||||
if (shouldShowDialog) {
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalled();
|
||||
} else {
|
||||
expect(mockShowOkCancelModalDialog).not.toHaveBeenCalled();
|
||||
expect(mockHandleClick).toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("should verify component handles state updates correctly", () => {
|
||||
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
|
||||
const stateUpdater = jest.fn();
|
||||
|
||||
const testHandleClick: HandleJobActionClickType = (job, action, setUpdatingJobAction) => {
|
||||
setUpdatingJobAction({ jobName: job.Name, action });
|
||||
stateUpdater(job.Name, action);
|
||||
};
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={testHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const pauseButton = screen.getByText("Pause");
|
||||
fireEvent.click(pauseButton);
|
||||
|
||||
expect(stateUpdater).toHaveBeenCalledWith(job.Name, CopyJobActions.pause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Full Integration Coverage", () => {
|
||||
it("should test complete workflow for cancel action with dialog", () => {
|
||||
const job = createMockJob({ Name: "Integration Test Job", Status: CopyJobStatusType.InProgress });
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
expect(actionButton).toHaveAttribute("data-test", "CopyJobActionMenu/Button:Integration Test Job");
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
|
||||
"Confirm Action", // title
|
||||
null, // subText
|
||||
"Confirm", // confirmLabel
|
||||
expect.any(Function), // onOk
|
||||
"Cancel", // cancelLabel
|
||||
null, // onCancel
|
||||
expect.any(Object), // contentHtml (dialogBody)
|
||||
);
|
||||
|
||||
const onOkCallback = mockShowOkCancelModalDialog.mock.calls[0][3];
|
||||
onOkCallback();
|
||||
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function));
|
||||
});
|
||||
|
||||
it("should test complete workflow for complete action with dialog", () => {
|
||||
const job = createMockJob({
|
||||
Name: "Online Integration Job",
|
||||
Status: CopyJobStatusType.Running,
|
||||
Mode: CopyJobMigrationType.Online,
|
||||
});
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const completeButton = screen.getByText("Complete");
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalled();
|
||||
|
||||
const dialogContent = mockShowOkCancelModalDialog.mock.calls[0][6];
|
||||
expect(dialogContent).toBeTruthy();
|
||||
|
||||
const onOkCallback = mockShowOkCancelModalDialog.mock.calls[0][3];
|
||||
onOkCallback();
|
||||
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function));
|
||||
});
|
||||
|
||||
it("should maintain proper component lifecycle", () => {
|
||||
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
|
||||
const { rerender, unmount } = render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
rerender(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
expect(screen.getByRole("button", { name: "Actions" })).toBeInTheDocument();
|
||||
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IconButton, IContextualMenuProps } from "@fluentui/react";
|
||||
import { DirectionalHint, IconButton, IContextualMenuProps, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { useDialog } from "../../../Controls/Dialog";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums";
|
||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
||||
@@ -9,6 +10,28 @@ interface CopyJobActionMenuProps {
|
||||
handleClick: HandleJobActionClickType;
|
||||
}
|
||||
|
||||
const dialogBody = {
|
||||
[CopyJobActions.cancel]: (jobName: string) => (
|
||||
<Stack tokens={{ childrenGap: 10 }}>
|
||||
<Stack.Item>
|
||||
You are about to cancel <b>{jobName}</b> copy job.
|
||||
</Stack.Item>
|
||||
<Stack.Item>Cancelling will stop the job immediately.</Stack.Item>
|
||||
</Stack>
|
||||
),
|
||||
[CopyJobActions.complete]: (jobName: string) => (
|
||||
<Stack tokens={{ childrenGap: 10 }}>
|
||||
<Stack.Item>
|
||||
You are about to complete <b>{jobName}</b> copy job.
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
Once completed, continuous data copy will stop after any pending documents are processed. To maintain data
|
||||
integrity, we recommend stopping updates to the source container before completing the job.
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
),
|
||||
};
|
||||
|
||||
const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick }) => {
|
||||
const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null);
|
||||
if (
|
||||
@@ -22,9 +45,22 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
|
||||
return null;
|
||||
}
|
||||
|
||||
const showActionConfirmationDialog = (job: CopyJobType, action: CopyJobActions): void => {
|
||||
useDialog
|
||||
.getState()
|
||||
.showOkCancelModalDialog(
|
||||
ContainerCopyMessages.MonitorJobs.dialog.heading,
|
||||
null,
|
||||
ContainerCopyMessages.MonitorJobs.dialog.confirmButtonText,
|
||||
() => handleClick(job, action, setUpdatingJobAction),
|
||||
ContainerCopyMessages.MonitorJobs.dialog.cancelButtonText,
|
||||
null,
|
||||
action in dialogBody ? dialogBody[action as keyof typeof dialogBody](job.Name) : null,
|
||||
);
|
||||
};
|
||||
|
||||
const getMenuItems = (): IContextualMenuProps["items"] => {
|
||||
const isThisJobUpdating = updatingJobAction?.jobName === job.Name;
|
||||
const updatingAction = updatingJobAction?.action;
|
||||
|
||||
const baseItems = [
|
||||
{
|
||||
@@ -32,21 +68,21 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.pause,
|
||||
iconProps: { iconName: "Pause" },
|
||||
onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause,
|
||||
disabled: isThisJobUpdating,
|
||||
},
|
||||
{
|
||||
key: CopyJobActions.cancel,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.cancel,
|
||||
iconProps: { iconName: "Cancel" },
|
||||
onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel,
|
||||
onClick: () => showActionConfirmationDialog(job, CopyJobActions.cancel),
|
||||
disabled: isThisJobUpdating,
|
||||
},
|
||||
{
|
||||
key: CopyJobActions.resume,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.resume,
|
||||
iconProps: { iconName: "Play" },
|
||||
onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume,
|
||||
disabled: isThisJobUpdating,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -67,8 +103,8 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
|
||||
key: CopyJobActions.complete,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
|
||||
iconProps: { iconName: "CheckMark" },
|
||||
onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete,
|
||||
onClick: () => showActionConfirmationDialog(job, CopyJobActions.complete),
|
||||
disabled: isThisJobUpdating,
|
||||
});
|
||||
}
|
||||
return filteredItems;
|
||||
@@ -86,8 +122,8 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
|
||||
data-test={`CopyJobActionMenu/Button:${job.Name}`}
|
||||
role="button"
|
||||
iconProps={{ iconName: "More", styles: { root: { fontSize: "20px", fontWeight: "bold" } } }}
|
||||
menuProps={{ items: getMenuItems() }}
|
||||
menuIconProps={{ iconName: "" }}
|
||||
menuProps={{ items: getMenuItems(), directionalHint: DirectionalHint.leftTopEdge, directionalHintFixed: false }}
|
||||
menuIconProps={{ iconName: "", className: "hidden" }}
|
||||
ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
title={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
/>
|
||||
|
||||
@@ -11,9 +11,17 @@ jest.mock("../../Actions/CopyJobActions", () => ({
|
||||
|
||||
jest.mock("./CopyJobColumns", () => ({
|
||||
getColumns: jest.fn(() => [
|
||||
{
|
||||
key: "LastUpdatedTime",
|
||||
name: "Date & time",
|
||||
fieldName: "LastUpdatedTime",
|
||||
minWidth: 140,
|
||||
maxWidth: 300,
|
||||
isResizable: true,
|
||||
},
|
||||
{
|
||||
key: "Name",
|
||||
name: "Name",
|
||||
name: "Job name",
|
||||
fieldName: "Name",
|
||||
minWidth: 140,
|
||||
maxWidth: 300,
|
||||
@@ -165,6 +173,165 @@ describe("CopyJobsList", () => {
|
||||
expect(screen.getByTestId("action-menu-job-2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("action-menu-job-3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders filter TextField with data-test attribute", () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterTextField = document.querySelector('[data-test="CopyJobsList/FilterTextField"]');
|
||||
expect(filterTextField).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders search TextField with correct placeholder", () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText("Search jobs...");
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filtering", () => {
|
||||
it("filters jobs by Name when text is entered", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Job 1" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("filters jobs case-insensitively", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "test job 1" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows all jobs when filter text is empty", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Job 1" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(filterInput, { target: { value: "" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Job 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Job 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("filters jobs by Status across all columns", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: CopyJobStatusType.Running } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("filters jobs by Mode across all columns", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Offline" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Test Job 2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows no results when filter matches no jobs", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "NonExistentJob" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("filters by partial text match", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Test" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Job 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Job 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("resets pagination when filter changes", async () => {
|
||||
const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({
|
||||
...mockJobs[0],
|
||||
ID: `job-${i + 1}`,
|
||||
Name: `Test Job ${i + 1}`,
|
||||
}));
|
||||
|
||||
render(<CopyJobsList jobs={manyJobs} handleActionClick={mockHandleActionClick} pageSize={10} />);
|
||||
|
||||
// Navigate to page 2
|
||||
fireEvent.click(screen.getByLabelText("Go to next page"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Showing 11 - 20 of 25 items")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Apply filter - should reset to page 1
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Job 1" } });
|
||||
|
||||
await waitFor(() => {
|
||||
// Filtered results show from the beginning
|
||||
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("updates filtered count in pager", async () => {
|
||||
const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({
|
||||
...mockJobs[0],
|
||||
ID: `job-${i + 1}`,
|
||||
Name: i < 5 ? `Alpha Job ${i + 1}` : `Beta Job ${i + 1}`,
|
||||
}));
|
||||
|
||||
render(<CopyJobsList jobs={manyJobs} handleActionClick={mockHandleActionClick} pageSize={10} />);
|
||||
|
||||
expect(screen.getByText("Showing 1 - 10 of 25 items")).toBeInTheDocument();
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Alpha" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Showing 1 - 10 of 25 items")).not.toBeInTheDocument();
|
||||
// Pager should not be visible since filtered results (5) are less than page size (10)
|
||||
expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pagination", () => {
|
||||
@@ -342,7 +509,7 @@ describe("CopyJobsList", () => {
|
||||
|
||||
describe("Component Props", () => {
|
||||
it("uses default page size when not provided", () => {
|
||||
const manyJobs: CopyJobType[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
const manyJobs: CopyJobType[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
...mockJobs[0],
|
||||
ID: `job-${i + 1}`,
|
||||
Name: `Test Job ${i + 1}`,
|
||||
@@ -351,7 +518,7 @@ describe("CopyJobsList", () => {
|
||||
render(<CopyJobsList jobs={manyJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
expect(screen.getByLabelText("Go to next page")).toBeInTheDocument();
|
||||
expect(screen.getByText("Showing 1 - 10 of 12 items")).toBeInTheDocument();
|
||||
expect(screen.getByText("Showing 1 - 15 of 20 items")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes correct props to getColumns function", async () => {
|
||||
@@ -440,7 +607,33 @@ describe("CopyJobsList", () => {
|
||||
render(<CopyJobsList jobs={largeJobsList} handleActionClick={mockHandleActionClick} />);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(screen.getByText("Showing 1 - 10 of 1000 items")).toBeInTheDocument();
|
||||
expect(screen.getByText("Showing 1 - 15 of 1000 items")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles filtering with null or undefined values gracefully", async () => {
|
||||
const jobsWithNullValues: CopyJobType[] = [
|
||||
{
|
||||
...mockJobs[0],
|
||||
ID: "job-with-values",
|
||||
Name: "Valid Job",
|
||||
},
|
||||
{
|
||||
...mockJobs[1],
|
||||
ID: "job-null-name",
|
||||
Name: undefined as unknown as string,
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => {
|
||||
render(<CopyJobsList jobs={jobsWithNullValues} handleActionClick={mockHandleActionClick} />);
|
||||
}).not.toThrow();
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Valid" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Valid Job")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
Stack,
|
||||
Sticky,
|
||||
StickyPositionType,
|
||||
TextField,
|
||||
} from "@fluentui/react";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import Pager from "../../../../Common/Pager";
|
||||
import { useThemeStore } from "../../../../hooks/useTheme";
|
||||
import { getThemeTokens } from "../../../Theme/ThemeUtil";
|
||||
@@ -30,9 +31,15 @@ interface CopyJobsListProps {
|
||||
const styles = {
|
||||
container: { height: "100%" } as React.CSSProperties,
|
||||
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
|
||||
filterContainer: {
|
||||
margin: "15px 5px",
|
||||
},
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
// Columns to search across
|
||||
const searchableFields = ["Name", "Status", "LastUpdatedTime", "Mode"];
|
||||
|
||||
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
|
||||
const isDarkMode = useThemeStore((state) => state.isDarkMode);
|
||||
@@ -41,6 +48,23 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
|
||||
const [sortedJobs, setSortedJobs] = React.useState<CopyJobType[]>(jobs);
|
||||
const [sortedColumnKey, setSortedColumnKey] = React.useState<string | undefined>(undefined);
|
||||
const [isSortedDescending, setIsSortedDescending] = React.useState<boolean>(false);
|
||||
const [filterText, setFilterText] = React.useState<string>("");
|
||||
|
||||
const filteredJobs = useMemo(() => {
|
||||
if (!filterText) {
|
||||
return sortedJobs;
|
||||
}
|
||||
const lowerFilterText = filterText.toLowerCase();
|
||||
return sortedJobs.filter((job: any) => {
|
||||
return searchableFields.some((field) => {
|
||||
const value = job[field];
|
||||
if (value === undefined || value === null) {
|
||||
return false;
|
||||
}
|
||||
return String(value).toLowerCase().includes(lowerFilterText);
|
||||
});
|
||||
});
|
||||
}, [sortedJobs, filterText]);
|
||||
|
||||
useEffect(() => {
|
||||
setSortedJobs(jobs);
|
||||
@@ -64,7 +88,15 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
|
||||
setStartIndex(0);
|
||||
};
|
||||
|
||||
const columns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending);
|
||||
const sortableColumns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending);
|
||||
|
||||
const handleFilterTextChange = (
|
||||
_event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
newValue?: string,
|
||||
) => {
|
||||
setFilterText(newValue || "");
|
||||
setStartIndex(0);
|
||||
};
|
||||
|
||||
const _handleRowClick = (job: CopyJobType) => {
|
||||
openCopyJobDetailsPanel(job);
|
||||
@@ -81,14 +113,25 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<Stack verticalFill={true}>
|
||||
<Stack.Item>
|
||||
<div style={styles.filterContainer}>
|
||||
<TextField
|
||||
data-test="CopyJobsList/FilterTextField"
|
||||
placeholder="Search jobs..."
|
||||
ariaLabel="Search jobs"
|
||||
value={filterText}
|
||||
onChange={handleFilterTextChange}
|
||||
/>
|
||||
</div>
|
||||
</Stack.Item>
|
||||
<Stack.Item verticalFill={true} grow={1} shrink={1} style={styles.stackItem}>
|
||||
<ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto}>
|
||||
<ShimmeredDetailsList
|
||||
className="CopyJobListContainer"
|
||||
onRenderRow={_onRenderRow}
|
||||
checkboxVisibility={2}
|
||||
columns={columns}
|
||||
items={sortedJobs.slice(startIndex, startIndex + pageSize)}
|
||||
columns={sortableColumns}
|
||||
items={filteredJobs.slice(startIndex, startIndex + pageSize)}
|
||||
enableShimmer={false}
|
||||
constrainMode={ConstrainMode.unconstrained}
|
||||
layoutMode={DetailsListLayoutMode.justified}
|
||||
@@ -117,12 +160,12 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
|
||||
/>
|
||||
</ScrollablePane>
|
||||
</Stack.Item>
|
||||
{sortedJobs.length > pageSize && (
|
||||
{filteredJobs.length > pageSize && (
|
||||
<Stack.Item>
|
||||
<Pager
|
||||
disabled={false}
|
||||
startIndex={startIndex}
|
||||
totalCount={sortedJobs.length}
|
||||
totalCount={filteredJobs.length}
|
||||
pageSize={pageSize}
|
||||
onLoadPage={(startIdx /* pageSize */) => {
|
||||
setStartIndex(startIdx);
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
@import "../../../less/Common/Constants.less";
|
||||
|
||||
.themedTextFieldStyles() {
|
||||
.ms-TextField {
|
||||
.ms-TextField-fieldGroup {
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
border-color: var(--colorNeutralStroke1);
|
||||
}
|
||||
|
||||
.ms-TextField-field {
|
||||
color: var(--colorNeutralForeground1);
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--colorNeutralForeground4);
|
||||
}
|
||||
}
|
||||
|
||||
.ms-Label {
|
||||
color: var(--colorNeutralForeground1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Common theme-aware classes
|
||||
.themeText {
|
||||
color: var(--colorNeutralForeground1);
|
||||
@@ -119,23 +141,14 @@
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.ms-TextField {
|
||||
.ms-TextField-fieldGroup {
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
border-color: var(--colorNeutralStroke1);
|
||||
}
|
||||
.themedTextFieldStyles();
|
||||
|
||||
.ms-TextField-field {
|
||||
.migrationTypeDescription {
|
||||
p {
|
||||
color: var(--colorNeutralForeground1);
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--colorNeutralForeground4);
|
||||
}
|
||||
}
|
||||
|
||||
.ms-Label {
|
||||
color: var(--colorNeutralForeground1);
|
||||
a {
|
||||
color: var(--colorBrandForeground1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +178,11 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
body.isDarkMode & {
|
||||
.themedTextFieldStyles();
|
||||
}
|
||||
|
||||
.ms-DetailsList {
|
||||
width: 100%;
|
||||
|
||||
@@ -181,6 +199,9 @@
|
||||
background-color: var(--colorNeutralBackground3);
|
||||
}
|
||||
}
|
||||
.ms-DetailsHeader-cellTitle {
|
||||
padding-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.ms-DetailsRow {
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
AddGlobalSecondaryIndexPanelProps,
|
||||
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { isFabric, isFabricNative, openRestoreContainerDialog } from "Platform/Fabric/FabricUtil";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
|
||||
import { ReactTabKind, useTabs } from "hooks/useTabs";
|
||||
@@ -24,6 +26,7 @@ import DeleteTriggerIcon from "../../images/DeleteTrigger.svg";
|
||||
import DeleteUDFIcon from "../../images/DeleteUDF.svg";
|
||||
import HostedTerminalIcon from "../../images/Hosted-Terminal.svg";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
import { extractFeatures } from "../Platform/Hosted/extractFeatures";
|
||||
import { userContext } from "../UserContext";
|
||||
import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils";
|
||||
import { useSidePanel } from "../hooks/useSidePanel";
|
||||
@@ -56,10 +59,21 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin
|
||||
{
|
||||
iconSrc: AddCollectionIcon,
|
||||
onClick: () => container.onNewCollectionClicked({ databaseId }),
|
||||
label: `New ${getCollectionName()}`,
|
||||
label: t(Keys.contextMenu.newContainer, { containerName: getCollectionName() }),
|
||||
},
|
||||
];
|
||||
|
||||
if (isFabricNative() && !userContext.fabricContext?.isReadOnly) {
|
||||
const features = extractFeatures();
|
||||
if (features?.enableRestoreContainer) {
|
||||
items.push({
|
||||
iconSrc: AddCollectionIcon,
|
||||
onClick: () => openRestoreContainerDialog(),
|
||||
label: t(Keys.contextMenu.restoreContainer, { containerName: getCollectionName() }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFabricNative() && (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations)) {
|
||||
items.push({
|
||||
iconSrc: DeleteDatabaseIcon,
|
||||
@@ -68,11 +82,11 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin
|
||||
useSidePanel
|
||||
.getState()
|
||||
.openSidePanel(
|
||||
"Delete " + getDatabaseName(),
|
||||
t(Keys.contextMenu.deleteDatabase, { databaseName: getDatabaseName() }),
|
||||
<DeleteDatabaseConfirmationPanel refreshDatabases={() => container.refreshAllDatabases()} />,
|
||||
);
|
||||
},
|
||||
label: `Delete ${getDatabaseName()}`,
|
||||
label: t(Keys.contextMenu.deleteDatabase, { databaseName: getDatabaseName() }),
|
||||
styleClass: "deleteDatabaseMenuItem",
|
||||
});
|
||||
}
|
||||
@@ -88,7 +102,7 @@ export const createCollectionContextMenuButton = (
|
||||
items.push({
|
||||
iconSrc: AddSqlQueryIcon,
|
||||
onClick: () => selectedCollection && selectedCollection.onNewQueryClick(selectedCollection, undefined),
|
||||
label: "New SQL Query",
|
||||
label: t(Keys.contextMenu.newSqlQuery),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,7 +110,7 @@ export const createCollectionContextMenuButton = (
|
||||
items.push({
|
||||
iconSrc: AddSqlQueryIcon,
|
||||
onClick: () => selectedCollection && selectedCollection.onNewMongoQueryClick(selectedCollection, undefined),
|
||||
label: "New Query",
|
||||
label: t(Keys.contextMenu.newQuery),
|
||||
});
|
||||
|
||||
items.push({
|
||||
@@ -111,8 +125,8 @@ export const createCollectionContextMenuButton = (
|
||||
},
|
||||
label:
|
||||
useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell
|
||||
? "Open Mongo Shell"
|
||||
: "New Shell",
|
||||
? t(Keys.contextMenu.openMongoShell)
|
||||
: t(Keys.contextMenu.newShell),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,7 +139,7 @@ export const createCollectionContextMenuButton = (
|
||||
onClick: () => {
|
||||
container.openNotebookTerminal(ViewModels.TerminalKind.Cassandra);
|
||||
},
|
||||
label: "Open Cassandra Shell",
|
||||
label: t(Keys.contextMenu.openCassandraShell),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,7 +152,7 @@ export const createCollectionContextMenuButton = (
|
||||
onClick: () => {
|
||||
selectedCollection && selectedCollection.onNewStoredProcedureClick(selectedCollection, undefined);
|
||||
},
|
||||
label: "New Stored Procedure",
|
||||
label: t(Keys.contextMenu.newStoredProcedure),
|
||||
});
|
||||
|
||||
items.push({
|
||||
@@ -146,7 +160,7 @@ export const createCollectionContextMenuButton = (
|
||||
onClick: () => {
|
||||
selectedCollection && selectedCollection.onNewUserDefinedFunctionClick(selectedCollection);
|
||||
},
|
||||
label: "New UDF",
|
||||
label: t(Keys.contextMenu.newUdf),
|
||||
});
|
||||
|
||||
items.push({
|
||||
@@ -154,7 +168,7 @@ export const createCollectionContextMenuButton = (
|
||||
onClick: () => {
|
||||
selectedCollection && selectedCollection.onNewTriggerClick(selectedCollection, undefined);
|
||||
},
|
||||
label: "New Trigger",
|
||||
label: t(Keys.contextMenu.newTrigger),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,11 +181,11 @@ export const createCollectionContextMenuButton = (
|
||||
useSidePanel
|
||||
.getState()
|
||||
.openSidePanel(
|
||||
"Delete " + getCollectionName(),
|
||||
t(Keys.contextMenu.deleteContainer, { containerName: getCollectionName() }),
|
||||
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
|
||||
);
|
||||
},
|
||||
label: `Delete ${getCollectionName()}`,
|
||||
label: t(Keys.contextMenu.deleteContainer, { containerName: getCollectionName() }),
|
||||
styleClass: "deleteCollectionMenuItem",
|
||||
});
|
||||
}
|
||||
@@ -208,14 +222,14 @@ export const createSampleCollectionContextMenuButton = (): TreeNodeMenuItem[] =>
|
||||
useTabs.getState().openAndActivateReactTab(ReactTabKind.QueryCopilot);
|
||||
traceOpen(Action.OpenQueryCopilotFromNewQuery, { apiType: userContext.apiType });
|
||||
},
|
||||
label: "New SQL Query",
|
||||
label: t(Keys.contextMenu.newSqlQuery),
|
||||
});
|
||||
} else if (copilotVersion === "v2.0") {
|
||||
const sampleCollection = useDatabases.getState().sampleDataResourceTokenCollection;
|
||||
items.push({
|
||||
iconSrc: AddSqlQueryIcon,
|
||||
onClick: () => sampleCollection && sampleCollection.onNewQueryClick(sampleCollection, undefined),
|
||||
label: "New SQL Query",
|
||||
label: t(Keys.contextMenu.newSqlQuery),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -235,7 +249,7 @@ export const createStoreProcedureContextMenuItems = (
|
||||
{
|
||||
iconSrc: DeleteSprocIcon,
|
||||
onClick: () => storedProcedure.delete(),
|
||||
label: "Delete Stored Procedure",
|
||||
label: t(Keys.contextMenu.deleteStoredProcedure),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -249,7 +263,7 @@ export const createTriggerContextMenuItems = (container: Explorer, trigger: Trig
|
||||
{
|
||||
iconSrc: DeleteTriggerIcon,
|
||||
onClick: () => trigger.delete(),
|
||||
label: "Delete Trigger",
|
||||
label: t(Keys.contextMenu.deleteTrigger),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -266,7 +280,7 @@ export const createUserDefinedFunctionContextMenuItems = (
|
||||
{
|
||||
iconSrc: DeleteUDFIcon,
|
||||
onClick: () => userDefinedFunction.delete(),
|
||||
label: "Delete User Defined Function",
|
||||
label: t(Keys.contextMenu.deleteUdf),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { IndexingPolicy } from "@azure/cosmos";
|
||||
import { act } from "@testing-library/react";
|
||||
import { AuthType } from "AuthType";
|
||||
import { shallow } from "enzyme";
|
||||
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import ko from "knockout";
|
||||
import React from "react";
|
||||
import { updateCollection } from "../../../Common/dataAccess/updateCollection";
|
||||
@@ -27,7 +30,6 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({
|
||||
dataMaskingPolicy: {
|
||||
includedPaths: [],
|
||||
excludedPaths: ["/excludedPath"],
|
||||
isPolicyEnabled: true,
|
||||
},
|
||||
indexes: [],
|
||||
}),
|
||||
@@ -304,12 +306,10 @@ describe("SettingsComponent", () => {
|
||||
dataMaskingContent: {
|
||||
includedPaths: [],
|
||||
excludedPaths: ["/excludedPath"],
|
||||
isPolicyEnabled: true,
|
||||
},
|
||||
dataMaskingContentBaseline: {
|
||||
includedPaths: [],
|
||||
excludedPaths: [],
|
||||
isPolicyEnabled: false,
|
||||
},
|
||||
isDataMaskingDirty: true,
|
||||
});
|
||||
@@ -323,7 +323,6 @@ describe("SettingsComponent", () => {
|
||||
expect(wrapper.state("dataMaskingContentBaseline")).toEqual({
|
||||
includedPaths: [],
|
||||
excludedPaths: ["/excludedPath"],
|
||||
isPolicyEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,7 +336,6 @@ describe("SettingsComponent", () => {
|
||||
const invalidPolicy: InvalidPolicy = {
|
||||
includedPaths: "invalid",
|
||||
excludedPaths: [],
|
||||
isPolicyEnabled: false,
|
||||
};
|
||||
// Use type assertion since we're deliberately testing with invalid data
|
||||
settingsComponentInstance["onDataMaskingContentChange"](invalidPolicy as unknown as DataModels.DataMaskingPolicy);
|
||||
@@ -346,7 +344,6 @@ describe("SettingsComponent", () => {
|
||||
expect(wrapper.state("dataMaskingContent")).toEqual({
|
||||
includedPaths: "invalid",
|
||||
excludedPaths: [],
|
||||
isPolicyEnabled: false,
|
||||
});
|
||||
expect(wrapper.state("dataMaskingValidationErrors")).toEqual(["includedPaths must be an array"]);
|
||||
|
||||
@@ -361,7 +358,6 @@ describe("SettingsComponent", () => {
|
||||
},
|
||||
],
|
||||
excludedPaths: ["/excludedPath"],
|
||||
isPolicyEnabled: true,
|
||||
};
|
||||
|
||||
settingsComponentInstance["onDataMaskingContentChange"](validPolicy);
|
||||
@@ -385,7 +381,6 @@ describe("SettingsComponent", () => {
|
||||
},
|
||||
],
|
||||
excludedPaths: ["/excludedPath1"],
|
||||
isPolicyEnabled: false,
|
||||
};
|
||||
|
||||
const modifiedPolicy = {
|
||||
@@ -398,7 +393,6 @@ describe("SettingsComponent", () => {
|
||||
},
|
||||
],
|
||||
excludedPaths: ["/excludedPath2"],
|
||||
isPolicyEnabled: true,
|
||||
};
|
||||
|
||||
// Set initial state
|
||||
@@ -444,3 +438,49 @@ describe("SettingsComponent", () => {
|
||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsComponent - indexing policy subscription", () => {
|
||||
const baseProps: SettingsComponentProps = {
|
||||
settingsTab: new CollectionSettingsTabV2({
|
||||
collection: collection,
|
||||
tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
node: undefined,
|
||||
}),
|
||||
};
|
||||
|
||||
it("subscribes to the correct container's indexing policy and updates state on change", async () => {
|
||||
const containerId = collection.id();
|
||||
const mockIndexingPolicy: IndexingPolicy = {
|
||||
automatic: false,
|
||||
indexingMode: "lazy",
|
||||
includedPaths: [{ path: "/foo/*" }],
|
||||
excludedPaths: [{ path: "/bar/*" }],
|
||||
compositeIndexes: [],
|
||||
spatialIndexes: [],
|
||||
vectorIndexes: [],
|
||||
fullTextIndexes: [],
|
||||
};
|
||||
|
||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||
const instance = wrapper.instance() as SettingsComponent;
|
||||
|
||||
await act(async () => {
|
||||
useIndexingPolicyStore.setState({
|
||||
indexingPolicies: {
|
||||
[containerId]: mockIndexingPolicy,
|
||||
},
|
||||
});
|
||||
// Wait for the async refreshCollectionData to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.state("indexingPolicyContent")).toEqual(mockIndexingPolicy);
|
||||
expect(wrapper.state("indexingPolicyContentBaseline")).toEqual(mockIndexingPolicy);
|
||||
// @ts-expect-error: rawDataModel is intentionally accessed for test validation
|
||||
expect(instance.collection.rawDataModel.indexingPolicy).toEqual(mockIndexingPolicy);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
ThroughputBucketsComponent,
|
||||
ThroughputBucketsComponentProps,
|
||||
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
|
||||
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
|
||||
import * as React from "react";
|
||||
import DiscardIcon from "../../../../images/discard.svg";
|
||||
@@ -69,11 +70,11 @@ import {
|
||||
getMongoNotification,
|
||||
getTabTitle,
|
||||
hasDatabaseSharedThroughput,
|
||||
isDataMaskingEnabled,
|
||||
isDirty,
|
||||
parseConflictResolutionMode,
|
||||
parseConflictResolutionProcedure,
|
||||
} from "./SettingsUtils";
|
||||
|
||||
interface SettingsV2TabInfo {
|
||||
tab: SettingsV2TabTypes;
|
||||
content: JSX.Element;
|
||||
@@ -182,7 +183,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
private totalThroughputUsed: number;
|
||||
private throughputBucketsEnabled: boolean;
|
||||
public mongoDBCollectionResource: MongoDBCollectionResource;
|
||||
|
||||
private unsubscribe: () => void;
|
||||
constructor(props: SettingsComponentProps) {
|
||||
super(props);
|
||||
|
||||
@@ -312,6 +313,13 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
if (this.isCollectionSettingsTab) {
|
||||
this.refreshIndexTransformationProgress();
|
||||
this.loadMongoIndexes();
|
||||
this.unsubscribe = useIndexingPolicyStore.subscribe(
|
||||
() => {
|
||||
this.refreshCollectionData();
|
||||
},
|
||||
(state) => state.indexingPolicies[this.collection?.id()],
|
||||
);
|
||||
this.refreshCollectionData();
|
||||
}
|
||||
|
||||
this.setBaseline();
|
||||
@@ -319,7 +327,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
}
|
||||
}
|
||||
componentDidUpdate(): void {
|
||||
if (this.props.settingsTab.isActive()) {
|
||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||
@@ -675,22 +687,14 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
this.setState({ isComputedPropertiesDirty: isComputedPropertiesDirty });
|
||||
|
||||
private onDataMaskingContentChange = (newDataMasking: DataModels.DataMaskingPolicy): void => {
|
||||
if (!newDataMasking.excludedPaths) {
|
||||
newDataMasking.excludedPaths = [];
|
||||
}
|
||||
if (!newDataMasking.includedPaths) {
|
||||
newDataMasking.includedPaths = [];
|
||||
}
|
||||
|
||||
const validationErrors = [];
|
||||
if (!Array.isArray(newDataMasking.includedPaths)) {
|
||||
if (newDataMasking.includedPaths === undefined || newDataMasking.includedPaths === null) {
|
||||
validationErrors.push("includedPaths is required");
|
||||
} else if (!Array.isArray(newDataMasking.includedPaths)) {
|
||||
validationErrors.push("includedPaths must be an array");
|
||||
}
|
||||
if (!Array.isArray(newDataMasking.excludedPaths)) {
|
||||
validationErrors.push("excludedPaths must be an array");
|
||||
}
|
||||
if (typeof newDataMasking.isPolicyEnabled !== "boolean") {
|
||||
validationErrors.push("isPolicyEnabled must be a boolean");
|
||||
if (newDataMasking.excludedPaths !== undefined && !Array.isArray(newDataMasking.excludedPaths)) {
|
||||
validationErrors.push("excludedPaths must be an array if provided");
|
||||
}
|
||||
|
||||
this.setState({
|
||||
@@ -831,7 +835,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
const dataMaskingContent: DataModels.DataMaskingPolicy = {
|
||||
includedPaths: this.collection.dataMaskingPolicy?.()?.includedPaths || [],
|
||||
excludedPaths: this.collection.dataMaskingPolicy?.()?.excludedPaths || [],
|
||||
isPolicyEnabled: this.collection.dataMaskingPolicy?.()?.isPolicyEnabled ?? true,
|
||||
};
|
||||
const conflictResolutionPolicy: DataModels.ConflictResolutionPolicy =
|
||||
this.collection.conflictResolutionPolicy && this.collection.conflictResolutionPolicy();
|
||||
@@ -849,7 +852,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
{ name: "name_of_property", query: "query_to_compute_property" },
|
||||
] as DataModels.ComputedProperties;
|
||||
}
|
||||
|
||||
const throughputBuckets = this.offer?.throughputBuckets;
|
||||
|
||||
return {
|
||||
@@ -1009,10 +1011,31 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
startKey,
|
||||
);
|
||||
};
|
||||
private refreshCollectionData = async (): Promise<void> => {
|
||||
const containerId = this.collection.id();
|
||||
const latestIndexingPolicy = useIndexingPolicyStore.getState().indexingPolicies[containerId];
|
||||
const rawPolicy = latestIndexingPolicy ?? this.collection.indexingPolicy();
|
||||
|
||||
const latestCollection: DataModels.IndexingPolicy = {
|
||||
automatic: rawPolicy?.automatic ?? true,
|
||||
indexingMode: rawPolicy?.indexingMode ?? "consistent",
|
||||
includedPaths: rawPolicy?.includedPaths ?? [],
|
||||
excludedPaths: rawPolicy?.excludedPaths ?? [],
|
||||
compositeIndexes: rawPolicy?.compositeIndexes ?? [],
|
||||
spatialIndexes: rawPolicy?.spatialIndexes ?? [],
|
||||
vectorIndexes: rawPolicy?.vectorIndexes ?? [],
|
||||
fullTextIndexes: rawPolicy?.fullTextIndexes ?? [],
|
||||
};
|
||||
|
||||
this.collection.rawDataModel.indexingPolicy = latestCollection;
|
||||
this.setState({
|
||||
indexingPolicyContent: latestCollection,
|
||||
indexingPolicyContentBaseline: latestCollection,
|
||||
});
|
||||
};
|
||||
|
||||
private saveCollectionSettings = async (startKey: number): Promise<void> => {
|
||||
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
|
||||
|
||||
if (
|
||||
this.state.isSubSettingsSaveable ||
|
||||
this.state.isContainerPolicyDirty ||
|
||||
@@ -1042,8 +1065,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
|
||||
newCollection.fullTextPolicy = this.state.fullTextPolicy;
|
||||
|
||||
// Only send data masking policy if it was modified (dirty)
|
||||
if (this.state.isDataMaskingDirty && isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) {
|
||||
// Only send data masking policy if it was modified (dirty) and data masking is enabled
|
||||
if (this.state.isDataMaskingDirty && isDataMaskingEnabled(this.collection.dataMaskingPolicy?.())) {
|
||||
newCollection.dataMaskingPolicy = this.state.dataMaskingContent;
|
||||
}
|
||||
|
||||
@@ -1252,7 +1275,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
onScaleDiscardableChange: this.onScaleDiscardableChange,
|
||||
throughputError: this.state.throughputError,
|
||||
};
|
||||
|
||||
if (!this.isCollectionSettingsTab) {
|
||||
return (
|
||||
<div className="settingsV2MainContainer">
|
||||
@@ -1433,15 +1455,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
});
|
||||
}
|
||||
|
||||
// Check if DDM should be enabled
|
||||
const shouldEnableDDM = (): boolean => {
|
||||
const hasDataMaskingCapability = isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking);
|
||||
const isSqlAccount = userContext.apiType === "SQL";
|
||||
|
||||
return isSqlAccount && hasDataMaskingCapability; // Only show for SQL accounts with DDM capability
|
||||
};
|
||||
|
||||
if (shouldEnableDDM()) {
|
||||
if (isDataMaskingEnabled(this.collection.dataMaskingPolicy?.())) {
|
||||
const dataMaskingComponentProps: DataMaskingComponentProps = {
|
||||
shouldDiscardDataMasking: this.state.shouldDiscardDataMasking,
|
||||
resetShouldDiscardDataMasking: this.resetShouldDiscardDataMasking,
|
||||
|
||||
@@ -155,7 +155,12 @@ export class ComputedPropertiesComponent extends React.Component<
|
||||
</Link>
|
||||
  about how to define computed properties and how to use them.
|
||||
</Text>
|
||||
<div className="settingsV2Editor" tabIndex={0} ref={this.computedPropertiesDiv}></div>
|
||||
<div
|
||||
className="settingsV2Editor"
|
||||
tabIndex={0}
|
||||
ref={this.computedPropertiesDiv}
|
||||
data-test="computed-properties-editor"
|
||||
></div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ describe("DataMaskingComponent", () => {
|
||||
},
|
||||
],
|
||||
excludedPaths: [],
|
||||
isPolicyEnabled: false,
|
||||
};
|
||||
|
||||
let changeContentCallback: () => void;
|
||||
@@ -78,7 +77,7 @@ describe("DataMaskingComponent", () => {
|
||||
<DataMaskingComponent
|
||||
{...mockProps}
|
||||
dataMaskingContent={samplePolicy}
|
||||
dataMaskingContentBaseline={{ ...samplePolicy, isPolicyEnabled: true }}
|
||||
dataMaskingContentBaseline={{ ...samplePolicy, excludedPaths: ["/excluded"] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -123,7 +122,7 @@ describe("DataMaskingComponent", () => {
|
||||
});
|
||||
|
||||
it("resets content when shouldDiscardDataMasking is true", async () => {
|
||||
const baselinePolicy = { ...samplePolicy, isPolicyEnabled: true };
|
||||
const baselinePolicy = { ...samplePolicy, excludedPaths: ["/excluded"] };
|
||||
|
||||
const wrapper = mount(
|
||||
<DataMaskingComponent
|
||||
@@ -159,7 +158,7 @@ describe("DataMaskingComponent", () => {
|
||||
wrapper.update();
|
||||
|
||||
// Update baseline to trigger componentDidUpdate
|
||||
const newBaseline = { ...samplePolicy, isPolicyEnabled: true };
|
||||
const newBaseline = { ...samplePolicy, excludedPaths: ["/excluded"] };
|
||||
wrapper.setProps({ dataMaskingContentBaseline: newBaseline });
|
||||
|
||||
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
|
||||
@@ -174,7 +173,6 @@ describe("DataMaskingComponent", () => {
|
||||
const invalidPolicy: Record<string, unknown> = {
|
||||
includedPaths: "not an array",
|
||||
excludedPaths: [] as string[],
|
||||
isPolicyEnabled: "not a boolean",
|
||||
};
|
||||
|
||||
mockGetValue.mockReturnValue(JSON.stringify(invalidPolicy));
|
||||
@@ -197,7 +195,7 @@ describe("DataMaskingComponent", () => {
|
||||
wrapper.update();
|
||||
|
||||
// First change
|
||||
const modifiedPolicy1 = { ...samplePolicy, isPolicyEnabled: true };
|
||||
const modifiedPolicy1 = { ...samplePolicy, excludedPaths: ["/path1"] };
|
||||
mockGetValue.mockReturnValue(JSON.stringify(modifiedPolicy1));
|
||||
changeContentCallback();
|
||||
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
|
||||
import * as monaco from "monaco-editor";
|
||||
import * as React from "react";
|
||||
import * as Constants from "../../../../Common/Constants";
|
||||
import * as DataModels from "../../../../Contracts/DataModels";
|
||||
import { isCapabilityEnabled } from "../../../../Utils/CapabilityUtils";
|
||||
import { loadMonaco } from "../../../LazyMonaco";
|
||||
import { titleAndInputStackProps, unsavedEditorWarningMessage } from "../SettingsRenderUtils";
|
||||
import { isDirty as isContentDirty } from "../SettingsUtils";
|
||||
import { isDirty as isContentDirty, isDataMaskingEnabled } from "../SettingsUtils";
|
||||
|
||||
export interface DataMaskingComponentProps {
|
||||
shouldDiscardDataMasking: boolean;
|
||||
@@ -24,16 +22,8 @@ interface DataMaskingComponentState {
|
||||
}
|
||||
|
||||
const emptyDataMaskingPolicy: DataModels.DataMaskingPolicy = {
|
||||
includedPaths: [
|
||||
{
|
||||
path: "/",
|
||||
strategy: "Default",
|
||||
startPosition: 0,
|
||||
length: -1,
|
||||
},
|
||||
],
|
||||
includedPaths: [],
|
||||
excludedPaths: [],
|
||||
isPolicyEnabled: true,
|
||||
};
|
||||
|
||||
export class DataMaskingComponent extends React.Component<DataMaskingComponentProps, DataMaskingComponentState> {
|
||||
@@ -140,7 +130,7 @@ export class DataMaskingComponent extends React.Component<DataMaskingComponentPr
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
if (!isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) {
|
||||
if (!isDataMaskingEnabled(this.props.dataMaskingContent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { MessageBar, MessageBarType } from "@fluentui/react";
|
||||
import * as React from "react";
|
||||
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
|
||||
import {
|
||||
mongoIndexTransformationRefreshingMessage,
|
||||
renderMongoIndexTransformationRefreshMessage,
|
||||
} from "../../SettingsRenderUtils";
|
||||
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
|
||||
import { isIndexTransforming } from "../../SettingsUtils";
|
||||
|
||||
export interface IndexingPolicyRefreshComponentProps {
|
||||
|
||||
@@ -302,8 +302,8 @@ export class SubSettingsComponent extends React.Component<SubSettingsComponentPr
|
||||
);
|
||||
|
||||
private geoSpatialConfigTypeChoiceGroupOptions: IChoiceGroupOption[] = [
|
||||
{ key: GeospatialConfigType.Geography, text: "Geography" },
|
||||
{ key: GeospatialConfigType.Geometry, text: "Geometry" },
|
||||
{ key: GeospatialConfigType.Geography, text: "Geography", ariaLabel: "geography-option" },
|
||||
{ key: GeospatialConfigType.Geometry, text: "Geometry", ariaLabel: "geometry-option" },
|
||||
];
|
||||
|
||||
private getGeoSpatialComponent = (): JSX.Element => (
|
||||
|
||||
@@ -31,6 +31,7 @@ exports[`ComputedPropertiesComponent renders 1`] = `
|
||||
</Text>
|
||||
<div
|
||||
className="settingsV2Editor"
|
||||
data-test="computed-properties-editor"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -167,10 +167,12 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
|
||||
options={
|
||||
[
|
||||
{
|
||||
"ariaLabel": "geography-option",
|
||||
"key": "Geography",
|
||||
"text": "Geography",
|
||||
},
|
||||
{
|
||||
"ariaLabel": "geometry-option",
|
||||
"key": "Geometry",
|
||||
"text": "Geometry",
|
||||
},
|
||||
@@ -652,10 +654,12 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
|
||||
options={
|
||||
[
|
||||
{
|
||||
"ariaLabel": "geography-option",
|
||||
"key": "Geography",
|
||||
"text": "Geography",
|
||||
},
|
||||
{
|
||||
"ariaLabel": "geometry-option",
|
||||
"key": "Geometry",
|
||||
"text": "Geometry",
|
||||
},
|
||||
@@ -1224,10 +1228,12 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
|
||||
options={
|
||||
[
|
||||
{
|
||||
"ariaLabel": "geography-option",
|
||||
"key": "Geography",
|
||||
"text": "Geography",
|
||||
},
|
||||
{
|
||||
"ariaLabel": "geometry-option",
|
||||
"key": "Geometry",
|
||||
"text": "Geometry",
|
||||
},
|
||||
@@ -1760,10 +1766,12 @@ exports[`SubSettingsComponent renders 1`] = `
|
||||
options={
|
||||
[
|
||||
{
|
||||
"ariaLabel": "geography-option",
|
||||
"key": "Geography",
|
||||
"text": "Geography",
|
||||
},
|
||||
{
|
||||
"ariaLabel": "geometry-option",
|
||||
"key": "Geometry",
|
||||
"text": "Geometry",
|
||||
},
|
||||
@@ -2330,10 +2338,12 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
|
||||
options={
|
||||
[
|
||||
{
|
||||
"ariaLabel": "geography-option",
|
||||
"key": "Geography",
|
||||
"text": "Geography",
|
||||
},
|
||||
{
|
||||
"ariaLabel": "geometry-option",
|
||||
"key": "Geometry",
|
||||
"text": "Geometry",
|
||||
},
|
||||
|
||||
@@ -2,6 +2,8 @@ import * as Constants from "../../../Common/Constants";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { isFabricNative } from "../../../Platform/Fabric/FabricUtil";
|
||||
import { userContext } from "../../../UserContext";
|
||||
import { isCapabilityEnabled } from "../../../Utils/CapabilityUtils";
|
||||
import { MongoIndex } from "../../../Utils/arm/generatedClients/cosmos/types";
|
||||
|
||||
const zeroValue = 0;
|
||||
@@ -88,6 +90,19 @@ export const hasDatabaseSharedThroughput = (collection: ViewModels.Collection):
|
||||
return database?.isDatabaseShared() && !collection.offer();
|
||||
};
|
||||
|
||||
export const isDataMaskingEnabled = (dataMaskingPolicy?: DataModels.DataMaskingPolicy): boolean => {
|
||||
const isSqlAccount = userContext.apiType === "SQL";
|
||||
if (!isSqlAccount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasDataMaskingCapability = isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking);
|
||||
const hasDataMaskingPolicyFromCollection =
|
||||
dataMaskingPolicy?.includedPaths?.length > 0 || dataMaskingPolicy?.excludedPaths?.length > 0;
|
||||
|
||||
return hasDataMaskingCapability || hasDataMaskingPolicyFromCollection;
|
||||
};
|
||||
|
||||
export const parseConflictResolutionMode = (modeFromBackend: string): DataModels.ConflictResolutionMode => {
|
||||
// Backend can contain different casing as it does case-insensitive comparisson
|
||||
if (!modeFromBackend) {
|
||||
|
||||
@@ -68,7 +68,6 @@ export const collection = {
|
||||
dataMaskingPolicy: ko.observable<DataModels.DataMaskingPolicy>({
|
||||
includedPaths: [],
|
||||
excludedPaths: ["/excludedPath"],
|
||||
isPolicyEnabled: true,
|
||||
}),
|
||||
readSettings: () => {
|
||||
return;
|
||||
|
||||
@@ -153,6 +153,16 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"partitionKey",
|
||||
],
|
||||
"rawDataModel": {
|
||||
"indexingPolicy": {
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
},
|
||||
"uniqueKeyPolicy": {
|
||||
"uniqueKeys": [
|
||||
{
|
||||
@@ -264,6 +274,16 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"partitionKey",
|
||||
],
|
||||
"rawDataModel": {
|
||||
"indexingPolicy": {
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
},
|
||||
"uniqueKeyPolicy": {
|
||||
"uniqueKeys": [
|
||||
{
|
||||
@@ -476,6 +496,16 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"partitionKey",
|
||||
],
|
||||
"rawDataModel": {
|
||||
"indexingPolicy": {
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
},
|
||||
"uniqueKeyPolicy": {
|
||||
"uniqueKeys": [
|
||||
{
|
||||
@@ -574,6 +604,58 @@ exports[`SettingsComponent renders 1`] = `
|
||||
/>
|
||||
</Stack>
|
||||
</PivotItem>
|
||||
<PivotItem
|
||||
headerButtonProps={
|
||||
{
|
||||
"data-test": "settings-tab-header/DataMaskingTab",
|
||||
}
|
||||
}
|
||||
headerText="Masking Policy (preview)"
|
||||
itemKey="DataMaskingTab"
|
||||
key="DataMaskingTab"
|
||||
style={
|
||||
{
|
||||
"backgroundColor": "var(--colorNeutralBackground1)",
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
"marginTop": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"backgroundColor": "var(--colorNeutralBackground1)",
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<DataMaskingComponent
|
||||
dataMaskingContent={
|
||||
{
|
||||
"excludedPaths": [
|
||||
"/excludedPath",
|
||||
],
|
||||
"includedPaths": [],
|
||||
}
|
||||
}
|
||||
dataMaskingContentBaseline={
|
||||
{
|
||||
"excludedPaths": [
|
||||
"/excludedPath",
|
||||
],
|
||||
"includedPaths": [],
|
||||
}
|
||||
}
|
||||
onDataMaskingContentChange={[Function]}
|
||||
onDataMaskingDirtyChange={[Function]}
|
||||
resetShouldDiscardDataMasking={[Function]}
|
||||
shouldDiscardDataMasking={false}
|
||||
validationErrors={[]}
|
||||
/>
|
||||
</Stack>
|
||||
</PivotItem>
|
||||
<PivotItem
|
||||
headerButtonProps={
|
||||
{
|
||||
@@ -653,6 +735,16 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"partitionKey",
|
||||
],
|
||||
"rawDataModel": {
|
||||
"indexingPolicy": {
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
},
|
||||
"uniqueKeyPolicy": {
|
||||
"uniqueKeys": [
|
||||
{
|
||||
|
||||
@@ -113,7 +113,7 @@ export class ContainerSampleGenerator {
|
||||
? await createMongoDocument(collection.databaseId, collection, shardKey, doc)
|
||||
: await createDocument(collection, doc);
|
||||
} catch (error) {
|
||||
NotificationConsoleUtils.logConsoleError(error);
|
||||
NotificationConsoleUtils.logConsoleError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -329,7 +329,10 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
}
|
||||
|
||||
private static extractHeaderStatus(consoleData: ConsoleData) {
|
||||
return consoleData?.message.split(":\n")[0];
|
||||
if (!consoleData?.message || typeof consoleData.message !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return consoleData.message.split(":\n")[0];
|
||||
}
|
||||
|
||||
private onConsoleWasExpanded = (): void => {
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import { useTeachingBubble } from "hooks/useTeachingBubble";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { DEFAULT_FABRIC_NATIVE_CONTAINER_THROUGHPUT, isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import React from "react";
|
||||
import { CollectionCreation } from "Shared/Constants";
|
||||
@@ -177,7 +179,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
messageType="info"
|
||||
showErrorDetails={false}
|
||||
link={Constants.Urls.freeTierInformation}
|
||||
linkText="Learn more"
|
||||
linkText={t(Keys.common.learnMore)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -274,17 +276,17 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
</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()}.`}
|
||||
content={t(Keys.panes.addCollection.databaseTooltip, {
|
||||
collectionName: getCollectionName(true).toLocaleLowerCase(),
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
iconName="Info"
|
||||
className="panelInfoIcon"
|
||||
tabIndex={0}
|
||||
ariaLabel={`A database is analogous to a namespace. It is the unit of management for a set of ${getCollectionName(
|
||||
true,
|
||||
).toLocaleLowerCase()}.`}
|
||||
ariaLabel={t(Keys.panes.addCollection.databaseTooltip, {
|
||||
collectionName: getCollectionName(true).toLocaleLowerCase(),
|
||||
})}
|
||||
/>
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
@@ -304,7 +306,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
tabIndex={0}
|
||||
onChange={this.onCreateNewDatabaseRadioBtnChange.bind(this)}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">Create new</span>
|
||||
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.createNew)}</span>
|
||||
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
@@ -317,7 +319,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
tabIndex={0}
|
||||
onChange={this.onUseExistingDatabaseRadioBtnChange.bind(this)}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">Use existing</span>
|
||||
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.useExisting)}</span>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -347,7 +349,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
{!isServerlessAccount() && (
|
||||
<Stack horizontal>
|
||||
<Checkbox
|
||||
label={`Share throughput across ${getCollectionName(true).toLocaleLowerCase()}`}
|
||||
label={t(Keys.panes.addCollection.shareThroughput, {
|
||||
collectionName: getCollectionName(true).toLocaleLowerCase(),
|
||||
})}
|
||||
checked={this.state.isSharedThroughputChecked}
|
||||
styles={{
|
||||
text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
|
||||
@@ -365,17 +369,17 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
/>
|
||||
<TooltipHost
|
||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
||||
content={`Throughput configured at the database level will be shared across all ${getCollectionName(
|
||||
true,
|
||||
).toLocaleLowerCase()} within the database.`}
|
||||
content={t(Keys.panes.addCollection.shareThroughputTooltip, {
|
||||
collectionName: getCollectionName(true).toLocaleLowerCase(),
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
iconName="Info"
|
||||
className="panelInfoIcon"
|
||||
tabIndex={0}
|
||||
ariaLabel={`Throughput configured at the database level will be shared across all ${getCollectionName(
|
||||
true,
|
||||
).toLocaleLowerCase()} within the database.`}
|
||||
ariaLabel={t(Keys.panes.addCollection.shareThroughputTooltip, {
|
||||
collectionName: getCollectionName(true).toLocaleLowerCase(),
|
||||
})}
|
||||
/>
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
@@ -424,14 +428,18 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
</Text>
|
||||
<TooltipHost
|
||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
||||
content={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
|
||||
content={t(Keys.panes.addCollection.collectionIdTooltip, {
|
||||
collectionName: getCollectionName().toLocaleLowerCase(),
|
||||
})}
|
||||
>
|
||||
<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.`}
|
||||
ariaLabel={t(Keys.panes.addCollection.collectionIdTooltip, {
|
||||
collectionName: getCollectionName().toLocaleLowerCase(),
|
||||
})}
|
||||
/>
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
@@ -445,10 +453,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
autoComplete="off"
|
||||
pattern={ValidCosmosDbIdInputPattern.source}
|
||||
title={ValidCosmosDbIdDescription}
|
||||
placeholder={`e.g., ${getCollectionName()}1`}
|
||||
placeholder={t(Keys.panes.addCollection.collectionIdPlaceholder, { collectionName: getCollectionName() })}
|
||||
size={40}
|
||||
className="panelTextField"
|
||||
aria-label={`${getCollectionName()} id, Example ${getCollectionName()}1`}
|
||||
aria-label={t(Keys.panes.addCollection.collectionIdAriaLabel, { collectionName: getCollectionName() })}
|
||||
value={this.state.collectionId}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
this.setState({ collectionId: event.target.value })
|
||||
@@ -462,7 +470,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
<Stack horizontal style={{ marginTop: -4, marginBottom: -5 }}>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Indexing
|
||||
{t(Keys.panes.addCollection.indexing)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -470,32 +478,32 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={this.state.enableIndexing}
|
||||
aria-label="Turn on indexing"
|
||||
aria-label={t(Keys.panes.addCollection.turnOnIndexing)}
|
||||
aria-checked={this.state.enableIndexing}
|
||||
type="radio"
|
||||
role="radio"
|
||||
tabIndex={0}
|
||||
onChange={this.onTurnOnIndexing.bind(this)}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">Automatic</span>
|
||||
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.automatic)}</span>
|
||||
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={!this.state.enableIndexing}
|
||||
aria-label="Turn off indexing"
|
||||
aria-label={t(Keys.panes.addCollection.turnOffIndexing)}
|
||||
aria-checked={!this.state.enableIndexing}
|
||||
type="radio"
|
||||
role="radio"
|
||||
tabIndex={0}
|
||||
onChange={this.onTurnOffIndexing.bind(this)}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">Off</span>
|
||||
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.off)}</span>
|
||||
</Stack>
|
||||
|
||||
<Text variant="small">
|
||||
{this.getFreeTierIndexingText()}{" "}
|
||||
<Link target="_blank" href="https://aka.ms/cosmos-indexing-policy">
|
||||
Learn more
|
||||
{t(Keys.common.learnMore)}
|
||||
</Link>
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -508,21 +516,17 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
<Stack horizontal style={{ marginTop: -5, marginBottom: -4 }}>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Sharding
|
||||
{t(Keys.panes.addCollection.sharding)}
|
||||
</Text>
|
||||
<TooltipHost
|
||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
||||
content={
|
||||
"Sharded collections split your data across many replica sets (shards) to achieve unlimited scalability. Sharded collections require choosing a shard key (field) to evenly distribute your data."
|
||||
}
|
||||
content={t(Keys.panes.addCollection.shardingTooltip)}
|
||||
>
|
||||
<Icon
|
||||
iconName="Info"
|
||||
className="panelInfoIcon"
|
||||
tabIndex={0}
|
||||
ariaLabel={
|
||||
"Sharded collections split your data across many replica sets (shards) to achieve unlimited scalability. Sharded collections require choosing a shard key (field) to evenly distribute your data."
|
||||
}
|
||||
ariaLabel={t(Keys.panes.addCollection.shardingTooltip)}
|
||||
/>
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
@@ -531,7 +535,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={!this.state.isSharded}
|
||||
aria-label="Unsharded"
|
||||
aria-label={t(Keys.panes.addCollection.unsharded)}
|
||||
aria-checked={!this.state.isSharded}
|
||||
name="unsharded"
|
||||
type="radio"
|
||||
@@ -540,12 +544,12 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
tabIndex={0}
|
||||
onChange={this.onUnshardedRadioBtnChange.bind(this)}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">Unsharded (20GB limit)</span>
|
||||
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.unshardedLabel)}</span>
|
||||
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={this.state.isSharded}
|
||||
aria-label="Sharded"
|
||||
aria-label={t(Keys.panes.addCollection.sharded)}
|
||||
aria-checked={this.state.isSharded}
|
||||
name="sharded"
|
||||
type="radio"
|
||||
@@ -554,7 +558,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
tabIndex={0}
|
||||
onChange={this.onShardedRadioBtnChange.bind(this)}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">Sharded</span>
|
||||
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.sharded)}</span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -679,15 +683,14 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
disabled={this.state.subPartitionKeys.length >= Constants.BackendDefaults.maxNumMultiHashPartition}
|
||||
onClick={() => this.setState({ subPartitionKeys: [...this.state.subPartitionKeys, ""] })}
|
||||
>
|
||||
Add hierarchical partition key
|
||||
{t(Keys.panes.addCollection.addPartitionKey)}
|
||||
</DefaultButton>
|
||||
{this.state.subPartitionKeys.length > 0 && (
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
<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.{" "}
|
||||
<Icon iconName="InfoSolid" className="removeIcon" tabIndex={0} />{" "}
|
||||
{t(Keys.panes.addCollection.hierarchicalPartitionKeyInfo)}{" "}
|
||||
<Link href="https://aka.ms/cosmos-hierarchical-partitioning" target="_blank">
|
||||
Learn more
|
||||
{t(Keys.common.learnMore)}
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
@@ -700,7 +703,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
{!isServerlessAccount() && !this.state.createNewDatabase && this.isSelectedDatabaseSharedThroughput() && (
|
||||
<Stack horizontal verticalAlign="center">
|
||||
<Checkbox
|
||||
label={`Provision dedicated throughput for this ${getCollectionName().toLocaleLowerCase()}`}
|
||||
label={t(Keys.panes.addCollection.provisionDedicatedThroughput, {
|
||||
collectionName: getCollectionName().toLocaleLowerCase(),
|
||||
})}
|
||||
checked={this.state.enableDedicatedThroughput}
|
||||
styles={{
|
||||
text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
|
||||
@@ -718,23 +723,19 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
/>
|
||||
<TooltipHost
|
||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
||||
content={`You can optionally provision dedicated throughput for a ${getCollectionName().toLocaleLowerCase()} within a database that has throughput
|
||||
provisioned. This dedicated throughput amount will not be shared with other ${getCollectionName(
|
||||
true,
|
||||
).toLocaleLowerCase()} in the database and
|
||||
does not count towards the throughput you provisioned for the database. This throughput amount will be
|
||||
billed in addition to the throughput amount you provisioned at the database level.`}
|
||||
content={t(Keys.panes.addCollection.provisionDedicatedThroughputTooltip, {
|
||||
collectionName: getCollectionName().toLocaleLowerCase(),
|
||||
collectionNamePlural: getCollectionName(true).toLocaleLowerCase(),
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
iconName="Info"
|
||||
className="panelInfoIcon"
|
||||
tabIndex={0}
|
||||
ariaLabel={`You can optionally provision dedicated throughput for a ${getCollectionName().toLocaleLowerCase()} within a database that has throughput
|
||||
provisioned. This dedicated throughput amount will not be shared with other ${getCollectionName(
|
||||
true,
|
||||
).toLocaleLowerCase()} in the database and
|
||||
does not count towards the throughput you provisioned for the database. This throughput amount will be
|
||||
billed in addition to the throughput amount you provisioned at the database level.`}
|
||||
ariaLabel={t(Keys.panes.addCollection.provisionDedicatedThroughputTooltip, {
|
||||
collectionName: getCollectionName().toLocaleLowerCase(),
|
||||
collectionNamePlural: getCollectionName(true).toLocaleLowerCase(),
|
||||
})}
|
||||
/>
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
@@ -769,8 +770,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
autoComplete="off"
|
||||
placeholder={
|
||||
userContext.apiType === "Mongo"
|
||||
? "Comma separated paths e.g. firstName,address.zipCode"
|
||||
: "Comma separated paths e.g. /firstName,/address/zipCode"
|
||||
? t(Keys.panes.addCollection.uniqueKeysPlaceholderMongo)
|
||||
: t(Keys.panes.addCollection.uniqueKeysPlaceholderSql)
|
||||
}
|
||||
className="panelTextField"
|
||||
value={uniqueKey}
|
||||
@@ -802,7 +803,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
styles={{ root: { padding: 0 }, label: { fontSize: 12, color: "var(--colorNeutralForeground1)" } }}
|
||||
onClick={() => this.setState({ uniqueKeys: [...this.state.uniqueKeys, ""] })}
|
||||
>
|
||||
Add unique key
|
||||
{t(Keys.panes.addCollection.addUniqueKey)}
|
||||
</ActionButton>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -823,7 +824,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
className="panelRadioBtn"
|
||||
checked={this.state.enableAnalyticalStore}
|
||||
disabled={!isSynapseLinkEnabled()}
|
||||
aria-label="Enable analytical store"
|
||||
aria-label={t(Keys.panes.addCollection.enableAnalyticalStore)}
|
||||
aria-checked={this.state.enableAnalyticalStore}
|
||||
name="analyticalStore"
|
||||
type="radio"
|
||||
@@ -832,13 +833,13 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
tabIndex={0}
|
||||
onChange={this.onEnableAnalyticalStoreRadioBtnChange.bind(this)}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">On</span>
|
||||
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.on)}</span>
|
||||
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={!this.state.enableAnalyticalStore}
|
||||
disabled={!isSynapseLinkEnabled()}
|
||||
aria-label="Disable analytical store"
|
||||
aria-label={t(Keys.panes.addCollection.disableAnalyticalStore)}
|
||||
aria-checked={!this.state.enableAnalyticalStore}
|
||||
name="analyticalStore"
|
||||
type="radio"
|
||||
@@ -847,26 +848,28 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
tabIndex={0}
|
||||
onChange={this.onDisableAnalyticalStoreRadioBtnChange.bind(this)}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">Off</span>
|
||||
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.off)}</span>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{!isSynapseLinkEnabled() && (
|
||||
<Stack className="panelGroupSpacing">
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
Azure Synapse Link is required for creating an analytical store{" "}
|
||||
{getCollectionName().toLocaleLowerCase()}. Enable Synapse Link for this Cosmos DB account. <br />
|
||||
{t(Keys.panes.addCollection.analyticalStoreSynapseLinkRequired, {
|
||||
collectionName: getCollectionName().toLocaleLowerCase(),
|
||||
})}{" "}
|
||||
<br />
|
||||
<Link
|
||||
href="https://aka.ms/cosmosdb-synapselink"
|
||||
target="_blank"
|
||||
aria-label={Constants.ariaLabelForLearnMoreLink.AzureSynapseLink}
|
||||
className="capacitycalculator-link"
|
||||
>
|
||||
Learn more
|
||||
{t(Keys.common.learnMore)}
|
||||
</Link>
|
||||
</Text>
|
||||
<DefaultButton
|
||||
text="Enable"
|
||||
text={t(Keys.panes.addCollection.enable)}
|
||||
onClick={() => this.props.explorer.openEnableSynapseLinkDialog()}
|
||||
style={{ height: 27, width: 80 }}
|
||||
styles={{ label: { fontSize: 12 } }}
|
||||
@@ -878,7 +881,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
{this.shouldShowVectorSearchParameters() && (
|
||||
<Stack>
|
||||
<CollapsibleSectionComponent
|
||||
title="Container Vector Policy"
|
||||
title={t(Keys.panes.addCollection.containerVectorPolicy)}
|
||||
isExpandedByDefault={false}
|
||||
onExpand={() => {
|
||||
scrollToSection("collapsibleVectorPolicySectionContent");
|
||||
@@ -906,7 +909,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
{this.shouldShowFullTextSearchParameters() && (
|
||||
<Stack>
|
||||
<CollapsibleSectionComponent
|
||||
title="Container Full Text Search Policy"
|
||||
title={t(Keys.panes.addCollection.containerFullTextSearchPolicy)}
|
||||
isExpandedByDefault={false}
|
||||
onExpand={() => {
|
||||
scrollToSection("collapsibleFullTextPolicySectionContent");
|
||||
@@ -935,7 +938,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
)}
|
||||
{!isFabricNative() && userContext.apiType !== "Tables" && (
|
||||
<CollapsibleSectionComponent
|
||||
title="Advanced"
|
||||
title={t(Keys.panes.addCollection.advanced)}
|
||||
isExpandedByDefault={false}
|
||||
onExpand={() => {
|
||||
TelemetryProcessor.traceOpen(Action.ExpandAddCollectionPaneAdvancedSection);
|
||||
@@ -948,23 +951,23 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Indexing
|
||||
{t(Keys.panes.addCollection.indexing)}
|
||||
</Text>
|
||||
<TooltipHost
|
||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
||||
content="The _id field is indexed by default. Creating a wildcard index for all fields will optimize queries and is recommended for development."
|
||||
content={t(Keys.panes.addCollection.mongoIndexingTooltip)}
|
||||
>
|
||||
<Icon
|
||||
iconName="Info"
|
||||
className="panelInfoIcon"
|
||||
tabIndex={0}
|
||||
ariaLabel="The _id field is indexed by default. Creating a wildcard index for all fields will optimize queries and is recommended for development."
|
||||
ariaLabel={t(Keys.panes.addCollection.mongoIndexingTooltip)}
|
||||
/>
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
|
||||
<Checkbox
|
||||
label="Create a Wildcard Index on all fields"
|
||||
label={t(Keys.panes.addCollection.createWildcardIndex)}
|
||||
checked={this.state.createMongoWildCardIndex}
|
||||
styles={{
|
||||
text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
|
||||
@@ -986,7 +989,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
{userContext.apiType === "SQL" && (
|
||||
<Stack className="panelGroupSpacing">
|
||||
<Checkbox
|
||||
label="My application uses an older Cosmos .NET or Java SDK version (.NET V1 or Java V2)"
|
||||
label={t(Keys.panes.addCollection.legacySdkCheckbox)}
|
||||
checked={this.state.useHashV1}
|
||||
styles={{
|
||||
text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
|
||||
@@ -1003,11 +1006,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
}
|
||||
/>
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
<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.{" "}
|
||||
<Icon iconName="InfoSolid" className="removeIcon" /> {t(Keys.panes.addCollection.legacySdkInfo)}{" "}
|
||||
<Link href="https://aka.ms/cosmos-large-pk" target="_blank">
|
||||
Learn more
|
||||
{t(Keys.common.learnMore)}
|
||||
</Link>
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -1018,7 +1019,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
</div>
|
||||
|
||||
{!this.props.isCopyJobFlow && (
|
||||
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={this.state.isThroughputCapExceeded} />
|
||||
<PanelFooterComponent buttonLabel={t(Keys.common.ok)} isButtonDisabled={this.state.isThroughputCapExceeded} />
|
||||
)}
|
||||
|
||||
{this.state.isExecuting && (
|
||||
@@ -1044,7 +1045,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
progressTrack: { backgroundColor: "#A6A6A6" },
|
||||
progressBar: { background: "white" },
|
||||
}}
|
||||
label="Adding sample data set"
|
||||
label={t(Keys.panes.addCollection.addingSampleDataSet)}
|
||||
/>
|
||||
</TeachingBubble>
|
||||
)}
|
||||
@@ -1150,8 +1151,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
|
||||
private getFreeTierIndexingText(): string {
|
||||
return this.state.enableIndexing
|
||||
? "All properties in your documents will be indexed by default for flexible and efficient queries."
|
||||
: "Indexing will be turned off. Recommended if you don't need to run queries or only have key value operations.";
|
||||
? t(Keys.panes.addCollection.indexingOnInfo)
|
||||
: t(Keys.panes.addCollection.indexingOffInfo);
|
||||
}
|
||||
|
||||
private getPartitionKeySubtext(): string {
|
||||
@@ -1249,14 +1250,14 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
const throughput = this.state.createNewDatabase ? this.newDatabaseThroughput : this.collectionThroughput;
|
||||
if (throughput > CollectionCreation.DefaultCollectionRUs100K && !this.isCostAcknowledged) {
|
||||
const errorMessage = this.isNewDatabaseAutoscale
|
||||
? "Please acknowledge the estimated monthly spend."
|
||||
: "Please acknowledge the estimated daily spend.";
|
||||
? t(Keys.panes.addCollection.acknowledgeSpendErrorMonthly)
|
||||
: t(Keys.panes.addCollection.acknowledgeSpendErrorDaily);
|
||||
this.setState({ errorMessage });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (throughput > CollectionCreation.MaxRUPerPartition && !this.state.isSharded) {
|
||||
this.setState({ errorMessage: "Unsharded collections support up to 10,000 RUs" });
|
||||
this.setState({ errorMessage: t(Keys.panes.addCollection.unshardedMaxRuError) });
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1270,12 +1271,12 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
|
||||
if (this.shouldShowVectorSearchParameters()) {
|
||||
if (!this.state.vectorPolicyValidated) {
|
||||
this.setState({ errorMessage: "Please fix errors in container vector policy" });
|
||||
this.setState({ errorMessage: t(Keys.panes.addCollection.vectorPolicyError) });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.state.fullTextPolicyValidated) {
|
||||
this.setState({ errorMessage: "Please fix errors in container full text search polilcy" });
|
||||
this.setState({ errorMessage: t(Keys.panes.addCollection.fullTextSearchPolicyError) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,28 +3,33 @@ import * as Constants from "Common/Constants";
|
||||
import { configContext, Platform } from "ConfigContext";
|
||||
import * as DataModels from "Contracts/DataModels";
|
||||
import { getFullTextLanguageOptions } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
|
||||
export function getPartitionKeyTooltipText(): string {
|
||||
if (userContext.apiType === "Mongo") {
|
||||
return "The shard key (field) is used to split your data across many replica sets (shards) to achieve unlimited scalability. It’s critical to choose a field that will evenly distribute your data.";
|
||||
return t(Keys.panes.addCollectionUtility.shardKeyTooltip);
|
||||
}
|
||||
|
||||
let tooltipText = `The ${getPartitionKeyName(
|
||||
true,
|
||||
)} is used to automatically distribute data across partitions for scalability. Choose a property in your JSON document that has a wide range of values and evenly distributes request volume.`;
|
||||
let tooltipText = t(Keys.panes.addCollectionUtility.partitionKeyTooltip, {
|
||||
partitionKeyName: getPartitionKeyName(true),
|
||||
});
|
||||
|
||||
if (userContext.apiType === "SQL") {
|
||||
tooltipText += " For small read-heavy workloads or write-heavy workloads of any size, id is often a good choice.";
|
||||
tooltipText += t(Keys.panes.addCollectionUtility.partitionKeyTooltipSqlSuffix);
|
||||
}
|
||||
|
||||
return tooltipText;
|
||||
}
|
||||
|
||||
export function getPartitionKeyName(isLowerCase?: boolean): string {
|
||||
const partitionKeyName = userContext.apiType === "Mongo" ? "Shard key" : "Partition key";
|
||||
const partitionKeyName =
|
||||
userContext.apiType === "Mongo"
|
||||
? t(Keys.panes.addCollectionUtility.shardKeyLabel)
|
||||
: t(Keys.panes.addCollectionUtility.partitionKeyLabel);
|
||||
|
||||
return isLowerCase ? partitionKeyName.toLocaleLowerCase() : partitionKeyName;
|
||||
}
|
||||
@@ -32,19 +37,19 @@ export function getPartitionKeyName(isLowerCase?: boolean): string {
|
||||
export function getPartitionKeyPlaceHolder(index?: number): string {
|
||||
switch (userContext.apiType) {
|
||||
case "Mongo":
|
||||
return "e.g., categoryId";
|
||||
return t(Keys.panes.addCollectionUtility.shardKeyPlaceholder);
|
||||
case "Gremlin":
|
||||
return "e.g., /address";
|
||||
return t(Keys.panes.addCollectionUtility.partitionKeyPlaceholderDefault);
|
||||
case "SQL":
|
||||
return `${
|
||||
index === undefined
|
||||
? "Required - first partition key e.g., /TenantId"
|
||||
? t(Keys.panes.addCollectionUtility.partitionKeyPlaceholderFirst)
|
||||
: index === 0
|
||||
? "second partition key e.g., /UserId"
|
||||
: "third partition key e.g., /SessionId"
|
||||
? t(Keys.panes.addCollectionUtility.partitionKeyPlaceholderSecond)
|
||||
: t(Keys.panes.addCollectionUtility.partitionKeyPlaceholderThird)
|
||||
}`;
|
||||
default:
|
||||
return "e.g., /address/zipCode";
|
||||
return t(Keys.panes.addCollectionUtility.partitionKeyPlaceholderGraph);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,13 +74,12 @@ export function isFreeTierAccount(): boolean {
|
||||
}
|
||||
|
||||
export function UniqueKeysHeader(): JSX.Element {
|
||||
const tooltipContent =
|
||||
"Unique keys provide developers with the ability to add a layer of data integrity to their database. By creating a unique key policy when a container is created, you ensure the uniqueness of one or more values per partition key.";
|
||||
const tooltipContent = t(Keys.panes.addCollectionUtility.uniqueKeysTooltip);
|
||||
|
||||
return (
|
||||
<Stack horizontal style={{ marginBottom: -2 }}>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Unique keys
|
||||
{t(Keys.panes.addCollectionUtility.uniqueKeysLabel)}
|
||||
</Text>
|
||||
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={tooltipContent}>
|
||||
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} ariaLabel={tooltipContent} />
|
||||
@@ -99,12 +103,11 @@ export function shouldShowAnalyticalStoreOptions(): boolean {
|
||||
}
|
||||
|
||||
export function AnalyticalStoreHeader(): JSX.Element {
|
||||
const tooltipContent =
|
||||
"Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads.";
|
||||
const tooltipContent = t(Keys.panes.addCollectionUtility.analyticalStoreTooltip);
|
||||
return (
|
||||
<Stack horizontal style={{ marginBottom: -2 }}>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Analytical Store
|
||||
{t(Keys.panes.addCollectionUtility.analyticalStoreLabel)}
|
||||
</Text>
|
||||
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={tooltipContent}>
|
||||
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} ariaLabel={tooltipContent} />
|
||||
@@ -116,14 +119,13 @@ export function AnalyticalStoreHeader(): JSX.Element {
|
||||
export function AnalyticalStorageContent(): JSX.Element {
|
||||
return (
|
||||
<Text variant="small">
|
||||
Enable analytical store capability to perform near real-time analytics on your operational data, without impacting
|
||||
the performance of transactional workloads.{" "}
|
||||
{t(Keys.panes.addCollectionUtility.analyticalStoreDescription)}{" "}
|
||||
<Link
|
||||
aria-label={Constants.ariaLabelForLearnMoreLink.AnalyticalStore}
|
||||
target="_blank"
|
||||
href="https://aka.ms/analytical-store-overview"
|
||||
>
|
||||
Learn more
|
||||
{t(Keys.common.learnMore)}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
@@ -155,10 +157,9 @@ export function scrollToSection(id: string): void {
|
||||
export function ContainerVectorPolicyTooltipContent(): JSX.Element {
|
||||
return (
|
||||
<Text variant="small">
|
||||
Describe any properties in your data that contain vectors, so that they can be made available for similarity
|
||||
queries.{" "}
|
||||
{t(Keys.panes.addCollectionUtility.vectorPolicyTooltip)}{" "}
|
||||
<Link target="_blank" href="https://aka.ms/CosmosDBVectorSetup">
|
||||
Learn more
|
||||
{t(Keys.common.learnMore)}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -482,10 +482,8 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
||||
}
|
||||
variant="small"
|
||||
>
|
||||
Azure Synapse Link is required for creating an analytical store
|
||||
Azure Synapse Link is required for creating an analytical store container. Enable Synapse Link for this Cosmos DB account.
|
||||
|
||||
container
|
||||
. Enable Synapse Link for this Cosmos DB account.
|
||||
<br />
|
||||
<StyledLinkBase
|
||||
aria-label="Learn more about Azure Synapse Link."
|
||||
@@ -608,7 +606,8 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
||||
className="removeIcon"
|
||||
iconName="InfoSolid"
|
||||
/>
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
<StyledLinkBase
|
||||
href="https://aka.ms/cosmos-large-pk"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Checkbox, Stack, Text, TextField } from "@fluentui/react";
|
||||
import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
@@ -40,15 +42,18 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
|
||||
const isCassandraAccount: boolean = userContext.apiType === "Cassandra";
|
||||
const databaseLabel: string = isCassandraAccount ? "keyspace" : "database";
|
||||
const collectionsLabel: string = isCassandraAccount ? "tables" : "collections";
|
||||
const databaseIdLabel: string = isCassandraAccount ? "Keyspace id" : "Database id";
|
||||
const databaseIdPlaceHolder: string = isCassandraAccount ? "Type a new keyspace id" : "Type a new database id";
|
||||
const databaseIdLabel: string = isCassandraAccount
|
||||
? t(Keys.panes.addDatabase.keyspaceIdLabel)
|
||||
: t(Keys.panes.addDatabase.databaseIdLabel);
|
||||
const databaseIdPlaceHolder: string = t(Keys.panes.addDatabase.databaseIdPlaceholder, { databaseLabel });
|
||||
|
||||
const [databaseId, setDatabaseId] = useState<string>("");
|
||||
const databaseIdTooltipText = `A ${
|
||||
isCassandraAccount ? "keyspace" : "database"
|
||||
} is a logical container of one or more ${isCassandraAccount ? "tables" : "collections"}`;
|
||||
const databaseIdTooltipText = t(Keys.panes.addDatabase.databaseTooltip, { databaseLabel, collectionsLabel });
|
||||
|
||||
const databaseLevelThroughputTooltipText = `Provisioned throughput at the ${databaseLabel} level will be shared across all ${collectionsLabel} within the ${databaseLabel}.`;
|
||||
const databaseLevelThroughputTooltipText = t(Keys.panes.addDatabase.shareThroughputTooltip, {
|
||||
databaseLabel,
|
||||
collectionsLabel,
|
||||
});
|
||||
const [databaseCreateNewShared, setDatabaseCreateNewShared] = useState<boolean>(
|
||||
getNewDatabaseSharedThroughputDefault(),
|
||||
);
|
||||
@@ -144,15 +149,15 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
|
||||
// TODO add feature flag that disables validation for customers with custom accounts
|
||||
if (isAutoscaleSelected) {
|
||||
if (!AutoPilotUtils.isValidAutoPilotThroughput(throughput)) {
|
||||
setFormErrors(
|
||||
`Please enter a value greater than ${AutoPilotUtils.autoPilotThroughput1K} for autopilot throughput`,
|
||||
);
|
||||
setFormErrors(t(Keys.panes.addDatabase.greaterThanError, { minValue: AutoPilotUtils.autoPilotThroughput1K }));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) {
|
||||
setFormErrors(`Please acknowledge the estimated ${isAutoscaleSelected ? "monthly" : "daily"} spend.`);
|
||||
setFormErrors(
|
||||
t(Keys.panes.addDatabase.acknowledgeSpendError, { period: isAutoscaleSelected ? "monthly" : "daily" }),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -169,7 +174,7 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
|
||||
const props: RightPaneFormProps = {
|
||||
formError: formErrors,
|
||||
isExecuting,
|
||||
submitButtonText: "OK",
|
||||
submitButtonText: t(Keys.common.ok),
|
||||
isSubmitButtonDisabled: isThroughputCapExceeded,
|
||||
onSubmit,
|
||||
};
|
||||
@@ -187,7 +192,7 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
|
||||
messageType="info"
|
||||
showErrorDetails={false}
|
||||
link={Constants.Urls.freeTierInformation}
|
||||
linkText="Learn more"
|
||||
linkText={t(Keys.common.learnMore)}
|
||||
/>
|
||||
)}
|
||||
<div className="panelMainContent">
|
||||
|
||||
@@ -40,6 +40,8 @@ import { PanelInfoErrorComponent } from "Explorer/Panes/PanelInfoErrorComponent"
|
||||
import { PanelLoadingScreen } from "Explorer/Panes/PanelLoadingScreen";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import React, { MutableRefObject, useEffect, useRef, useState } from "react";
|
||||
import { CollectionCreation } from "Shared/Constants";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
@@ -168,19 +170,19 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
|
||||
}
|
||||
|
||||
if (globalSecondaryIndexThroughput > CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) {
|
||||
const errorMessage: string = "Please acknowledge the estimated monthly spend.";
|
||||
const errorMessage: string = t(Keys.panes.addCollection.acknowledgeSpendErrorMonthly);
|
||||
setErrorMessage(errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (showVectorSearchParameters()) {
|
||||
if (!vectorPolicyValidated) {
|
||||
setErrorMessage("Please fix errors in container vector policy");
|
||||
setErrorMessage(t(Keys.panes.addCollection.vectorPolicyError));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!fullTextPolicyValidated) {
|
||||
setErrorMessage("Please fix errors in container full text search policy");
|
||||
setErrorMessage(t(Keys.panes.addCollection.fullTextSearchPolicyError));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -307,7 +309,7 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Global secondary index container id
|
||||
{t(Keys.panes.addGlobalSecondaryIndex.globalSecondaryIndexId)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<input
|
||||
@@ -318,7 +320,7 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
|
||||
autoComplete="off"
|
||||
pattern={ValidCosmosDbIdInputPattern.source}
|
||||
title={ValidCosmosDbIdDescription}
|
||||
placeholder={`e.g., indexbyEmailId`}
|
||||
placeholder={t(Keys.panes.addGlobalSecondaryIndex.globalSecondaryIndexIdPlaceholder)}
|
||||
size={40}
|
||||
className="panelTextField"
|
||||
value={globalSecondaryIndexId}
|
||||
@@ -336,7 +338,7 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
|
||||
target="blank"
|
||||
>
|
||||
Learn more about defining global secondary indexes.
|
||||
{t(Keys.panes.addGlobalSecondaryIndex.projectionQueryTooltip)}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
@@ -349,7 +351,7 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
|
||||
aria-required
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder={"SELECT c.email, c.accountId FROM c"}
|
||||
placeholder={t(Keys.panes.addGlobalSecondaryIndex.projectionQueryPlaceholder)}
|
||||
size={40}
|
||||
className="panelTextField"
|
||||
value={definition || ""}
|
||||
@@ -393,7 +395,7 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
|
||||
<AdvancedComponent {...{ useHashV1, setUseHashV1, setSubPartitionKeys }} />
|
||||
</Stack>
|
||||
</div>
|
||||
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={isThroughputCapExceeded} />
|
||||
<PanelFooterComponent buttonLabel={t(Keys.common.ok)} isButtonDisabled={isThroughputCapExceeded} />
|
||||
{isExecuting && <PanelLoadingScreen />}
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -2,14 +2,16 @@ import { Checkbox, Dropdown, IDropdownOption, Link, Stack, Text, TextField } fro
|
||||
import * as Constants from "Common/Constants";
|
||||
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
|
||||
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import React, { FunctionComponent, useState } from "react";
|
||||
import * as SharedConstants from "Shared/Constants";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
|
||||
import { userContext } from "UserContext";
|
||||
import { isServerlessAccount } from "Utils/CapabilityUtils";
|
||||
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import React, { FunctionComponent, useState } from "react";
|
||||
import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput";
|
||||
import Explorer from "../../Explorer";
|
||||
import { CassandraAPIDataClient } from "../../Tables/TableDataClient";
|
||||
@@ -71,8 +73,8 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
|
||||
if (throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) {
|
||||
const errorMessage =
|
||||
isNewKeySpaceAutoscale || isTableAutoscale
|
||||
? "Please acknowledge the estimated monthly spend."
|
||||
: "Please acknowledge the estimated daily spend.";
|
||||
? t(Keys.panes.addCollection.acknowledgeSpendErrorMonthly)
|
||||
: t(Keys.panes.addCollection.acknowledgeSpendErrorDaily);
|
||||
setFormError(errorMessage);
|
||||
return;
|
||||
}
|
||||
@@ -149,7 +151,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
|
||||
const props: RightPaneFormProps = {
|
||||
formError,
|
||||
isExecuting,
|
||||
submitButtonText: "OK",
|
||||
submitButtonText: t(Keys.common.ok),
|
||||
isSubmitButtonDisabled: isThroughputCapExceeded,
|
||||
onSubmit,
|
||||
};
|
||||
@@ -161,7 +163,8 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Keyspace name <InfoTooltip>Select an existing keyspace or enter a new keyspace id.</InfoTooltip>
|
||||
{t(Keys.panes.cassandraAddCollection.keyspaceLabel)}{" "}
|
||||
<InfoTooltip>{t(Keys.panes.cassandraAddCollection.keyspaceTooltip)}</InfoTooltip>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -179,7 +182,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
|
||||
setExistingKeyspaceId("");
|
||||
}}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">Create new</span>
|
||||
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.createNew)}</span>
|
||||
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
@@ -193,7 +196,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
|
||||
setIsKeyspaceShared(false);
|
||||
}}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">Use existing</span>
|
||||
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.useExisting)}</span>
|
||||
</Stack>
|
||||
|
||||
{keyspaceCreateNew && (
|
||||
@@ -275,9 +278,9 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Enter CQL command to create the table.{" "}
|
||||
{t(Keys.panes.cassandraAddCollection.tableIdLabel)}{" "}
|
||||
<Link className="underlinedLink" href="https://aka.ms/cassandra-create-table" target="_blank">
|
||||
Learn More
|
||||
{t(Keys.common.learnMore)}
|
||||
</Link>
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -295,7 +298,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
|
||||
autoComplete="off"
|
||||
pattern={ValidCosmosDbIdInputPattern.source}
|
||||
title={ValidCosmosDbIdDescription}
|
||||
placeholder="Enter table Id"
|
||||
placeholder={t(Keys.panes.cassandraAddCollection.enterTableId)}
|
||||
size={20}
|
||||
value={tableId}
|
||||
onChange={(e, newValue) => setTableId(newValue)}
|
||||
@@ -307,7 +310,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
|
||||
multiline
|
||||
id="editor-area"
|
||||
rows={5}
|
||||
ariaLabel="Table schema"
|
||||
ariaLabel={t(Keys.panes.cassandraAddCollection.tableSchemaAriaLabel)}
|
||||
value={userTableQuery}
|
||||
onChange={(e, newValue) => setUserTableQuery(newValue)}
|
||||
/>
|
||||
@@ -318,17 +321,12 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
|
||||
<input
|
||||
type="checkbox"
|
||||
id="tableSharedThroughput"
|
||||
title="Provision dedicated throughput for this table"
|
||||
title={t(Keys.panes.cassandraAddCollection.provisionDedicatedThroughput)}
|
||||
checked={dedicateTableThroughput}
|
||||
onChange={(e) => setDedicateTableThroughput(e.target.checked)}
|
||||
/>
|
||||
<span>Provision dedicated throughput for this table</span>
|
||||
<InfoTooltip>
|
||||
You can optionally provision dedicated throughput for a table within a keyspace that has throughput
|
||||
provisioned. This dedicated throughput amount will not be shared with other tables in the keyspace and
|
||||
does not count towards the throughput you provisioned for the keyspace. This throughput amount will be
|
||||
billed in addition to the throughput amount you provisioned at the keyspace level.
|
||||
</InfoTooltip>
|
||||
<span>{t(Keys.panes.cassandraAddCollection.provisionDedicatedThroughput)}</span>
|
||||
<InfoTooltip>{t(Keys.panes.cassandraAddCollection.provisionDedicatedThroughputTooltip)}</InfoTooltip>
|
||||
</Stack>
|
||||
)}
|
||||
{!isServerlessAccount() && (!isKeyspaceShared || dedicateTableThroughput) && (
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { RightPaneForm } from "Explorer/Panes/RightPaneForm/RightPaneForm";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { userContext } from "UserContext";
|
||||
import { getCollectionName } from "Utils/APITypeUtils";
|
||||
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
|
||||
@@ -72,7 +74,7 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
await createDataTransferJob();
|
||||
await onClose();
|
||||
} catch (error) {
|
||||
handleError(error, "ChangePartitionKey", "Failed to start data transfer job");
|
||||
handleError(error, "ChangePartitionKey", t(Keys.panes.changePartitionKey.failedToStartError));
|
||||
}
|
||||
setIsExecuting(false);
|
||||
useSidePanel.getState().closeSidePanel();
|
||||
@@ -133,17 +135,21 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<RightPaneForm formError={formError} isExecuting={isExecuting} onSubmit={submit} submitButtonText="OK">
|
||||
<RightPaneForm
|
||||
formError={formError}
|
||||
isExecuting={isExecuting}
|
||||
onSubmit={submit}
|
||||
submitButtonText={t(Keys.common.ok)}
|
||||
>
|
||||
<Stack tokens={{ childrenGap: 10 }} className="panelMainContent">
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
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.
|
||||
{t(Keys.panes.changePartitionKey.description)}
|
||||
<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
|
||||
{t(Keys.common.learnMore)}
|
||||
</Link>
|
||||
</Text>
|
||||
<Stack>
|
||||
@@ -218,14 +224,18 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
</Text>
|
||||
<TooltipHost
|
||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
||||
content={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
|
||||
content={t(Keys.panes.changePartitionKey.collectionIdTooltip, {
|
||||
collectionName: getCollectionName().toLocaleLowerCase(),
|
||||
})}
|
||||
>
|
||||
<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.`}
|
||||
ariaLabel={t(Keys.panes.changePartitionKey.collectionIdTooltip, {
|
||||
collectionName: getCollectionName().toLocaleLowerCase(),
|
||||
})}
|
||||
/>
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
@@ -239,10 +249,14 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
autoComplete="off"
|
||||
pattern={ValidCosmosDbIdInputPattern.source}
|
||||
title={ValidCosmosDbIdDescription}
|
||||
placeholder={`e.g., ${getCollectionName()}1`}
|
||||
placeholder={t(Keys.panes.changePartitionKey.collectionIdPlaceholder, {
|
||||
collectionName: getCollectionName(),
|
||||
})}
|
||||
size={40}
|
||||
className="panelTextField"
|
||||
aria-label={`${getCollectionName()} id, Example ${getCollectionName()}1`}
|
||||
aria-label={t(Keys.panes.changePartitionKey.collectionIdAriaLabel, {
|
||||
collectionName: getCollectionName(),
|
||||
})}
|
||||
value={targetCollectionId}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setTargetCollectionId(event.target.value)}
|
||||
/>
|
||||
@@ -349,7 +363,7 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
disabled={subPartitionKeys.length >= Constants.BackendDefaults.maxNumMultiHashPartition}
|
||||
onClick={() => setSubPartitionKeys([...subPartitionKeys, ""])}
|
||||
>
|
||||
Add hierarchical partition key
|
||||
{t(Keys.panes.addCollection.addPartitionKey)}
|
||||
</DefaultButton>
|
||||
{subPartitionKeys.length > 0 && (
|
||||
<Text
|
||||
@@ -357,11 +371,10 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
variant="small"
|
||||
style={{ color: "var(--colorNeutralForeground1)" }}
|
||||
>
|
||||
<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.{" "}
|
||||
<Icon iconName="InfoSolid" className="removeIcon" tabIndex={0} />{" "}
|
||||
{t(Keys.panes.addCollection.hierarchicalPartitionKeyInfo)}{" "}
|
||||
<Link href="https://aka.ms/cosmos-hierarchical-partitioning" target="_blank">
|
||||
Learn more
|
||||
{t(Keys.common.learnMore)}
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
@@ -377,14 +390,18 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
</Text>
|
||||
<TooltipHost
|
||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
||||
content={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
|
||||
content={t(Keys.panes.changePartitionKey.collectionIdTooltip, {
|
||||
collectionName: getCollectionName().toLocaleLowerCase(),
|
||||
})}
|
||||
>
|
||||
<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.`}
|
||||
ariaLabel={t(Keys.panes.changePartitionKey.collectionIdTooltip, {
|
||||
collectionName: getCollectionName().toLocaleLowerCase(),
|
||||
})}
|
||||
/>
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
@@ -400,7 +417,7 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
}}
|
||||
defaultSelectedKey={targetCollectionId}
|
||||
responsiveMode={999}
|
||||
ariaLabel="Existing Containers"
|
||||
ariaLabel={t(Keys.panes.changePartitionKey.existingContainers)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { HttpStatusCodes, PoolIdType } from "../../../Common/Constants";
|
||||
import { getErrorMessage, handleError } from "../../../Common/ErrorHandlingUtils";
|
||||
import { GitHubOAuthService } from "../../../GitHub/GitHubOAuthService";
|
||||
import { IPinnedRepo, JunoClient } from "../../../Juno/JunoClient";
|
||||
import { Keys } from "../../../Localization/Keys.generated";
|
||||
import { t } from "../../../Localization/t";
|
||||
import * as GitHubUtils from "../../../Utils/GitHubUtils";
|
||||
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
@@ -82,14 +84,14 @@ export const CopyNotebookPane: FunctionComponent<CopyNotebookPanelProps> = ({
|
||||
|
||||
const notebookContentItem = await copyNotebook(selectedLocation);
|
||||
if (!notebookContentItem) {
|
||||
throw new Error(`Failed to upload ${name}`);
|
||||
throw new Error(t(Keys.panes.copyNotebook.uploadFailedError, { name }));
|
||||
}
|
||||
|
||||
NotificationConsoleUtils.logConsoleInfo(`Successfully copied ${name} to ${destination}`);
|
||||
closeSidePanel();
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
setFormError(`Failed to copy ${name} to ${destination}`);
|
||||
setFormError(t(Keys.panes.copyNotebook.copyFailedError, { name, destination }));
|
||||
handleError(errorMessage, "CopyNotebookPaneAdapter/submit", formError);
|
||||
} finally {
|
||||
clearMessage && clearMessage();
|
||||
@@ -136,7 +138,7 @@ export const CopyNotebookPane: FunctionComponent<CopyNotebookPanelProps> = ({
|
||||
const props: RightPaneFormProps = {
|
||||
formError,
|
||||
isExecuting: isExecuting,
|
||||
submitButtonText: "OK",
|
||||
submitButtonText: t(Keys.common.ok),
|
||||
onSubmit: () => submit(),
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { GitHubReposTitle } from "Explorer/Tree/ResourceTree";
|
||||
import React, { FormEvent, FunctionComponent } from "react";
|
||||
import { IPinnedRepo } from "../../../Juno/JunoClient";
|
||||
import { Keys } from "../../../Localization/Keys.generated";
|
||||
import { t } from "../../../Localization/t";
|
||||
import * as GitHubUtils from "../../../Utils/GitHubUtils";
|
||||
import { useNotebook } from "../../Notebook/useNotebook";
|
||||
|
||||
@@ -96,8 +98,8 @@ export const CopyNotebookPaneComponent: FunctionComponent<CopyNotebookPaneProps>
|
||||
return options;
|
||||
};
|
||||
const dropDownProps: IDropdownProps = {
|
||||
label: "Location",
|
||||
ariaLabel: "Location",
|
||||
label: t(Keys.panes.copyNotebook.location),
|
||||
ariaLabel: t(Keys.panes.copyNotebook.locationAriaLabel),
|
||||
placeholder: "Select an option",
|
||||
onRenderTitle: onRenderDropDownTitle,
|
||||
onRenderOption: onRenderDropDownOption,
|
||||
@@ -109,7 +111,7 @@ export const CopyNotebookPaneComponent: FunctionComponent<CopyNotebookPaneProps>
|
||||
<div className="paneMainContent">
|
||||
<Stack tokens={{ childrenGap: 10 }}>
|
||||
<Stack.Item>
|
||||
<Label htmlFor="notebookName">Name</Label>
|
||||
<Label htmlFor="notebookName">{t(Keys.panes.copyNotebook.name)}</Label>
|
||||
<Text id="notebookName">{name}</Text>
|
||||
</Stack.Item>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import DeleteFeedback from "Common/DeleteFeedback";
|
||||
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
|
||||
import { deleteCollection } from "Common/dataAccess/deleteCollection";
|
||||
import { Collection } from "Contracts/ViewModels";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { DefaultExperienceUtility } from "Shared/DefaultExperienceUtility";
|
||||
import { Action, ActionModifiers } from "Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
|
||||
@@ -34,12 +36,15 @@ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectio
|
||||
useDatabases.getState().isLastCollection() && !useDatabases.getState().findSelectedDatabase()?.isDatabaseShared();
|
||||
|
||||
const collectionName = getCollectionName().toLocaleLowerCase();
|
||||
const paneTitle = "Delete " + collectionName;
|
||||
const paneTitle = t(Keys.panes.deleteCollection.panelTitle, { collectionName });
|
||||
|
||||
const onSubmit = async (): Promise<void> => {
|
||||
const collection = useSelectedNode.getState().findSelectedCollection();
|
||||
if (!collection || inputCollectionName !== collection.id()) {
|
||||
const errorMessage = "Input id " + inputCollectionName + " does not match the selected " + collection.id();
|
||||
const errorMessage = t(Keys.panes.deleteCollection.inputMismatch, {
|
||||
input: inputCollectionName,
|
||||
selectedId: collection.id(),
|
||||
});
|
||||
setFormError(errorMessage);
|
||||
NotificationConsoleUtils.logConsoleError(
|
||||
`Error while deleting ${collectionName} ${collection.id()}: ${errorMessage}`,
|
||||
@@ -106,18 +111,23 @@ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectio
|
||||
const props: RightPaneFormProps = {
|
||||
formError: formError,
|
||||
isExecuting,
|
||||
submitButtonText: "OK",
|
||||
submitButtonText: t(Keys.common.ok),
|
||||
onSubmit,
|
||||
};
|
||||
const confirmContainer = `Confirm by typing the ${collectionName.toLowerCase()} id`;
|
||||
const reasonInfo = `Help us improve Azure Cosmos DB! What is the reason why you are deleting this ${collectionName}?`;
|
||||
const confirmContainer = t(Keys.panes.deleteCollection.confirmPrompt, {
|
||||
collectionName: collectionName.toLowerCase(),
|
||||
});
|
||||
const reasonInfo =
|
||||
t(Keys.panes.deleteCollection.feedbackTitle) +
|
||||
" " +
|
||||
t(Keys.panes.deleteCollection.feedbackReason, { collectionName });
|
||||
return (
|
||||
<RightPaneForm {...props}>
|
||||
<div className="panelFormWrapper">
|
||||
<div className="panelMainContent">
|
||||
<div className="confirmDeleteInput">
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text variant="small">Confirm by typing the {collectionName.toLowerCase()} id</Text>
|
||||
<Text variant="small">{confirmContainer}</Text>
|
||||
<TextField
|
||||
id="confirmCollectionId"
|
||||
autoFocus
|
||||
@@ -133,10 +143,10 @@ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectio
|
||||
{shouldRecordFeedback() && (
|
||||
<div className="deleteCollectionFeedback">
|
||||
<Text variant="small" block>
|
||||
Help us improve Azure Cosmos DB!
|
||||
{t(Keys.panes.deleteCollection.feedbackTitle)}
|
||||
</Text>
|
||||
<Text variant="small" block>
|
||||
What is the reason why you are deleting this {collectionName}?
|
||||
{t(Keys.panes.deleteCollection.feedbackReason, { collectionName })}
|
||||
</Text>
|
||||
<TextField
|
||||
id="deleteCollectionFeedbackInput"
|
||||
|
||||
@@ -34,9 +34,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
|
||||
<span
|
||||
className="css-109"
|
||||
>
|
||||
Confirm by typing the
|
||||
container
|
||||
id
|
||||
Confirm by typing the container id
|
||||
</span>
|
||||
</Text>
|
||||
<StyledTextFieldBase
|
||||
|
||||
@@ -5,6 +5,8 @@ import DeleteFeedback from "Common/DeleteFeedback";
|
||||
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
|
||||
import { deleteDatabase } from "Common/dataAccess/deleteDatabase";
|
||||
import { Collection, Database } from "Contracts/ViewModels";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { DefaultExperienceUtility } from "Shared/DefaultExperienceUtility";
|
||||
import { Action, ActionModifiers } from "Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
|
||||
@@ -38,11 +40,19 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
|
||||
const submit = async (): Promise<void> => {
|
||||
if (selectedDatabase?.id() && databaseInput !== selectedDatabase.id()) {
|
||||
setFormError(
|
||||
`Input ${getDatabaseName()} name "${databaseInput}" does not match the selected ${getDatabaseName()} "${selectedDatabase.id()}"`,
|
||||
t(Keys.panes.deleteDatabase.inputMismatch, {
|
||||
databaseName: getDatabaseName(),
|
||||
input: databaseInput,
|
||||
selectedId: selectedDatabase.id(),
|
||||
}),
|
||||
);
|
||||
logConsoleError(`Error while deleting ${getDatabaseName()} ${selectedDatabase && selectedDatabase.id()}`);
|
||||
logConsoleError(
|
||||
`Input ${getDatabaseName()} name "${databaseInput}" does not match the selected ${getDatabaseName()} "${selectedDatabase.id()}"`,
|
||||
t(Keys.panes.deleteDatabase.inputMismatch, {
|
||||
databaseName: getDatabaseName(),
|
||||
input: databaseInput,
|
||||
selectedId: selectedDatabase.id(),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -114,18 +124,20 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
|
||||
const props: RightPaneFormProps = {
|
||||
formError,
|
||||
isExecuting: isLoading,
|
||||
submitButtonText: "OK",
|
||||
submitButtonText: t(Keys.common.ok),
|
||||
onSubmit: () => submit(),
|
||||
};
|
||||
|
||||
const errorProps: PanelInfoErrorProps = {
|
||||
messageType: "warning",
|
||||
showErrorDetails: false,
|
||||
message:
|
||||
"Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.",
|
||||
message: t(Keys.panes.deleteDatabase.warningMessage),
|
||||
};
|
||||
const confirmDatabase = `Confirm by typing the ${getDatabaseName()} id (name)`;
|
||||
const reasonInfo = `Help us improve Azure Cosmos DB! What is the reason why you are deleting this ${getDatabaseName()}?`;
|
||||
const confirmDatabase = t(Keys.panes.deleteDatabase.confirmPrompt, { databaseName: getDatabaseName() });
|
||||
const reasonInfo =
|
||||
t(Keys.panes.deleteDatabase.feedbackTitle) +
|
||||
" " +
|
||||
t(Keys.panes.deleteDatabase.feedbackReason, { databaseName: getDatabaseName() });
|
||||
return (
|
||||
<RightPaneForm {...props}>
|
||||
{!formError && <PanelInfoErrorComponent {...errorProps} />}
|
||||
@@ -148,10 +160,10 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
|
||||
{isLastNonEmptyDatabase() && (
|
||||
<div className="deleteDatabaseFeedback">
|
||||
<Text variant="small" block>
|
||||
Help us improve Azure Cosmos DB!
|
||||
{t(Keys.panes.deleteDatabase.feedbackTitle)}
|
||||
</Text>
|
||||
<Text variant="small" block>
|
||||
What is the reason why you are deleting this {getDatabaseName()}?
|
||||
{t(Keys.panes.deleteDatabase.feedbackReason, { databaseName: getDatabaseName() })}
|
||||
</Text>
|
||||
<TextField
|
||||
id="deleteDatabaseFeedbackInput"
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useBoolean } from "@fluentui/react-hooks";
|
||||
import React, { FunctionComponent, useRef, useState } from "react";
|
||||
import AddPropertyIcon from "../../../../images/Add-property.svg";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
import { Keys } from "../../../Localization/Keys.generated";
|
||||
import { t } from "../../../Localization/t";
|
||||
import { logConsoleError } from "../../../Utils/NotificationConsoleUtils";
|
||||
import StoredProcedure from "../../Tree/StoredProcedure";
|
||||
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||
@@ -45,8 +47,8 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
|
||||
};
|
||||
|
||||
const setInvalidParamError = (invalidParam: string): void => {
|
||||
setFormError(`Invalid param specified: ${invalidParam}`);
|
||||
logConsoleError(`Invalid param specified: ${invalidParam} is not a valid literal value`);
|
||||
setFormError(t(Keys.panes.executeStoredProcedure.invalidParamError, { invalidParam }));
|
||||
logConsoleError(t(Keys.panes.executeStoredProcedure.invalidParamConsoleError, { invalidParam }));
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
@@ -96,7 +98,7 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
|
||||
const props: RightPaneFormProps = {
|
||||
formError: formError,
|
||||
isExecuting: isLoading,
|
||||
submitButtonText: "Execute",
|
||||
submitButtonText: t(Keys.common.execute),
|
||||
onSubmit: () => submit(),
|
||||
};
|
||||
|
||||
@@ -107,9 +109,9 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
|
||||
inputParameters.push(
|
||||
<InputParameter
|
||||
key={paramKeyValue.text + i}
|
||||
dropdownLabel={i === 0 ? "Key" : ""}
|
||||
inputParameterTitle={i === 0 ? "Enter input parameters (if any)" : ""}
|
||||
inputLabel={i === 0 ? "Param" : ""}
|
||||
dropdownLabel={i === 0 ? t(Keys.panes.executeStoredProcedure.key) : ""}
|
||||
inputParameterTitle={i === 0 ? t(Keys.panes.executeStoredProcedure.enterInputParameters) : ""}
|
||||
inputLabel={i === 0 ? t(Keys.panes.executeStoredProcedure.param) : ""}
|
||||
isAddRemoveVisible={true}
|
||||
onDeleteParamKeyPress={() => deleteParamAtIndex(i)}
|
||||
onAddNewParamKeyPress={() => addNewParamAtIndex(i + 1)}
|
||||
@@ -130,9 +132,9 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
|
||||
<RightPaneForm {...props}>
|
||||
<div className="panelMainContent">
|
||||
<InputParameter
|
||||
dropdownLabel="Key"
|
||||
inputParameterTitle="Partition key value"
|
||||
inputLabel="Value"
|
||||
dropdownLabel={t(Keys.panes.executeStoredProcedure.key)}
|
||||
inputParameterTitle={t(Keys.panes.executeStoredProcedure.partitionKeyValue)}
|
||||
inputLabel={t(Keys.panes.executeStoredProcedure.value)}
|
||||
isAddRemoveVisible={false}
|
||||
onParamValueChange={(_event, newInput?: string) => (partitionValueRef.current = newInput)}
|
||||
onParamKeyChange={(_event: React.FormEvent<HTMLDivElement>, item: IDropdownOption) =>
|
||||
@@ -143,8 +145,8 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
|
||||
/>
|
||||
{getInputParameterComponent()}
|
||||
<Stack horizontal onClick={() => addNewParamAtLastIndex()} tabIndex={0}>
|
||||
<Image {...imageProps} src={AddPropertyIcon} alt="Add param" />
|
||||
<Text className="addNewParamStyle">Add New Param</Text>
|
||||
<Image {...imageProps} src={AddPropertyIcon} alt={t(Keys.panes.executeStoredProcedure.addParam)} />
|
||||
<Text className="addNewParamStyle">{t(Keys.panes.executeStoredProcedure.addNewParam)}</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
</RightPaneForm>
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import React, { FunctionComponent } from "react";
|
||||
import AddPropertyIcon from "../../../../images/Add-property.svg";
|
||||
import EntityCancelIcon from "../../../../images/Entity_cancel.svg";
|
||||
import { Keys } from "../../../Localization/Keys.generated";
|
||||
import { t } from "../../../Localization/t";
|
||||
|
||||
const dropdownStyles: Partial<IDropdownStyles> = { dropdown: { width: 100 } };
|
||||
const options = [
|
||||
@@ -74,7 +76,7 @@ export const InputParameter: FunctionComponent<InputParameterProps> = ({
|
||||
<Image
|
||||
{...imageProps}
|
||||
src={EntityCancelIcon}
|
||||
alt="Delete param"
|
||||
alt={t(Keys.panes.executeStoredProcedure.deleteParam)}
|
||||
id="deleteparam"
|
||||
role="button"
|
||||
onClick={onDeleteParamKeyPress}
|
||||
@@ -84,7 +86,7 @@ export const InputParameter: FunctionComponent<InputParameterProps> = ({
|
||||
<Image
|
||||
{...imageProps}
|
||||
src={AddPropertyIcon}
|
||||
alt="Add param"
|
||||
alt={t(Keys.panes.executeStoredProcedure.addParam)}
|
||||
id="addparam"
|
||||
role="button"
|
||||
onClick={onAddNewParamKeyPress}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { Keys } from "../../../Localization/Keys.generated";
|
||||
import { t } from "../../../Localization/t";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
import { GraphStyleComponent } from "../../Graph/GraphStyleComponent/GraphStyleComponent";
|
||||
import { IGraphConfig } from "../../Tabs/GraphTab";
|
||||
@@ -17,7 +19,7 @@ export const GraphStylingPanel: FunctionComponent<GraphStylingProps> = ({
|
||||
}: GraphStylingProps): JSX.Element => {
|
||||
const closeSidePanel = useSidePanel((state) => state.closeSidePanel);
|
||||
|
||||
const buttonLabel = "Ok";
|
||||
const buttonLabel = t(Keys.common.ok);
|
||||
|
||||
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -5,6 +5,8 @@ import folderIcon from "../../../../images/folder_16x16.svg";
|
||||
import { logError } from "../../../Common/Logger";
|
||||
import { Collection } from "../../../Contracts/ViewModels";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
import { Keys } from "../../../Localization/Keys.generated";
|
||||
import { t } from "../../../Localization/t";
|
||||
import { userContext } from "../../../UserContext";
|
||||
import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../../../Utils/NotificationConsoleUtils";
|
||||
import { useSelectedNode } from "../../useSelectedNode";
|
||||
@@ -33,8 +35,8 @@ export const LoadQueryPane: FunctionComponent = (): JSX.Element => {
|
||||
const submit = async (): Promise<void> => {
|
||||
setFormError("");
|
||||
if (!selectedFiles || selectedFiles.length === 0) {
|
||||
setFormError("No file specified");
|
||||
logConsoleError("Could not load query -- No file specified. Please input a file.");
|
||||
setFormError(t(Keys.panes.loadQuery.noFileSpecifiedError));
|
||||
logConsoleError(t(Keys.panes.loadQuery.noFileSpecifiedError));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,7 +50,7 @@ export const LoadQueryPane: FunctionComponent = (): JSX.Element => {
|
||||
setLoadingFalse();
|
||||
} catch (error) {
|
||||
setLoadingFalse();
|
||||
setFormError("Failed to load query");
|
||||
setFormError(t(Keys.panes.loadQuery.failedToLoadQueryError));
|
||||
logConsoleError(`Failed to load query from file ${file.name}: ${error}`);
|
||||
}
|
||||
};
|
||||
@@ -71,7 +73,7 @@ export const LoadQueryPane: FunctionComponent = (): JSX.Element => {
|
||||
};
|
||||
|
||||
reader.onerror = (): void => {
|
||||
setFormError("Failed to load query");
|
||||
setFormError(t(Keys.panes.loadQuery.failedToLoadQueryFromFileError, { fileName: file.name }));
|
||||
logConsoleError(`Failed to load query from file ${file.name}`);
|
||||
};
|
||||
return reader.readAsText(file);
|
||||
@@ -79,7 +81,7 @@ export const LoadQueryPane: FunctionComponent = (): JSX.Element => {
|
||||
const props: RightPaneFormProps = {
|
||||
formError: formError,
|
||||
isExecuting: isLoading,
|
||||
submitButtonText: "Load",
|
||||
submitButtonText: t(Keys.common.load),
|
||||
onSubmit: () => submit(),
|
||||
};
|
||||
|
||||
@@ -90,7 +92,7 @@ export const LoadQueryPane: FunctionComponent = (): JSX.Element => {
|
||||
<Stack horizontal>
|
||||
<TextField
|
||||
id="confirmCollectionId"
|
||||
label="Select a query document"
|
||||
label={t(Keys.panes.loadQuery.selectFilesToOpen)}
|
||||
value={selectedFileName}
|
||||
autoFocus
|
||||
readOnly
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useBoolean } from "@fluentui/react-hooks";
|
||||
import React, { FunctionComponent, useState } from "react";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { Keys } from "../../../Localization/Keys.generated";
|
||||
import { t } from "../../../Localization/t";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
import { NewVertexComponent } from "../../Graph/NewVertexComponent/NewVertexComponent";
|
||||
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||
@@ -41,7 +43,7 @@ export const NewVertexPanel: FunctionComponent<INewVertexPanelProps> = ({
|
||||
const props: RightPaneFormProps = {
|
||||
formError: errorMessage,
|
||||
isExecuting: isLoading,
|
||||
submitButtonText: "OK",
|
||||
submitButtonText: t(Keys.common.ok),
|
||||
onSubmit: () => submit(),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Icon, Link, Stack, Text } from "@fluentui/react";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import React from "react";
|
||||
import { useNotificationConsole } from "../../hooks/useNotificationConsole";
|
||||
|
||||
@@ -20,13 +22,17 @@ export const PanelInfoErrorComponent: React.FunctionComponent<PanelInfoErrorProp
|
||||
}: PanelInfoErrorProps): JSX.Element => {
|
||||
const expandConsole = useNotificationConsole((state) => state.expandConsole);
|
||||
|
||||
let icon: JSX.Element = <Icon iconName="InfoSolid" className="panelLargeInfoIcon" aria-label="Infomation" />;
|
||||
let icon: JSX.Element = (
|
||||
<Icon iconName="InfoSolid" className="panelLargeInfoIcon" aria-label={t(Keys.panes.panelInfo.information)} />
|
||||
);
|
||||
if (messageType === "error") {
|
||||
icon = <Icon iconName="StatusErrorFull" className="panelErrorIcon" aria-label="error" />;
|
||||
} else if (messageType === "warning") {
|
||||
icon = <Icon iconName="WarningSolid" className="panelWarningIcon" aria-label="warning" />;
|
||||
} else if (messageType === "info") {
|
||||
icon = <Icon iconName="InfoSolid" className="panelLargeInfoIcon" aria-label="Infomation" />;
|
||||
icon = (
|
||||
<Icon iconName="InfoSolid" className="panelLargeInfoIcon" aria-label={t(Keys.panes.panelInfo.information)} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -43,7 +49,7 @@ export const PanelInfoErrorComponent: React.FunctionComponent<PanelInfoErrorProp
|
||||
</Text>
|
||||
{showErrorDetails && (
|
||||
<a className="paneErrorLink" role="button" onClick={expandConsole} tabIndex={0} onKeyPress={expandConsole}>
|
||||
More details
|
||||
{t(Keys.panes.panelInfo.moreDetails)}
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { getErrorMessage, getErrorStack, handleError } from "../../../Common/Err
|
||||
import { useNotebookSnapshotStore } from "../../../hooks/useNotebookSnapshotStore";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
import { JunoClient } from "../../../Juno/JunoClient";
|
||||
import { Keys } from "../../../Localization/Keys.generated";
|
||||
import { t } from "../../../Localization/t";
|
||||
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
import { traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
|
||||
@@ -91,7 +93,7 @@ export const PublishNotebookPane: FunctionComponent<PublishNotebookPaneAProps> =
|
||||
let startKey: number;
|
||||
|
||||
if (!notebookName || !notebookDescription || !author || !imageSrc) {
|
||||
setFormError(`Failed to publish ${notebookName} to gallery`);
|
||||
setFormError(t(Keys.panes.publishNotebook.publishFailedError, { notebookName }));
|
||||
setFormErrorDetail("Name, description, author and cover image are required");
|
||||
createFormError(formError, formErrorDetail, "PublishNotebookPaneAdapter/submit");
|
||||
setIsExecuting(false);
|
||||
@@ -143,7 +145,11 @@ export const PublishNotebookPane: FunctionComponent<PublishNotebookPaneAProps> =
|
||||
);
|
||||
|
||||
const errorMessage = getErrorMessage(error);
|
||||
setFormError(`Failed to publish ${FileSystemUtil.stripExtension(notebookName, "ipynb")} to gallery`);
|
||||
setFormError(
|
||||
t(Keys.panes.publishNotebook.publishFailedError, {
|
||||
notebookName: FileSystemUtil.stripExtension(notebookName, "ipynb"),
|
||||
}),
|
||||
);
|
||||
setFormErrorDetail(`${errorMessage}`);
|
||||
handleError(errorMessage, "PublishNotebookPaneAdapter/submit", formError);
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Dropdown, IDropdownProps, ITextFieldProps, Stack, Text, TextField } from "@fluentui/react";
|
||||
import { ImmutableNotebook } from "@nteract/commutable";
|
||||
import React, { FunctionComponent, useState } from "react";
|
||||
import { Keys } from "../../../Localization/Keys.generated";
|
||||
import { t } from "../../../Localization/t";
|
||||
import { GalleryCardComponent } from "../../Controls/NotebookGallery/Cards/GalleryCardComponent";
|
||||
import * as FileSystemUtil from "../../Notebook/FileSystemUtil";
|
||||
import { SnapshotRequest } from "../../Notebook/NotebookComponent/types";
|
||||
@@ -57,13 +59,11 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
|
||||
|
||||
const maxImageSizeInMib = 1.5;
|
||||
|
||||
const descriptionPara1 =
|
||||
"When published, this notebook will appear in the Azure Cosmos DB notebooks public gallery. Make sure you have removed any sensitive data or output before publishing.";
|
||||
const descriptionPara1 = t(Keys.panes.publishNotebook.publishDescription);
|
||||
|
||||
const descriptionPara2 = `Would you like to publish and share "${FileSystemUtil.stripExtension(
|
||||
notebookName,
|
||||
"ipynb",
|
||||
)}" to the gallery?`;
|
||||
const descriptionPara2 = t(Keys.panes.publishNotebook.publishPrompt, {
|
||||
name: FileSystemUtil.stripExtension(notebookName, "ipynb"),
|
||||
});
|
||||
|
||||
const options: ImageTypes[] = [ImageTypes.CustomImage, ImageTypes.Url];
|
||||
if (onTakeSnapshot) {
|
||||
@@ -74,9 +74,9 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
|
||||
}
|
||||
|
||||
const thumbnailSelectorProps: IDropdownProps = {
|
||||
label: "Cover image",
|
||||
label: t(Keys.panes.publishNotebook.coverImage),
|
||||
selectedKey: type,
|
||||
ariaLabel: "Cover image",
|
||||
ariaLabel: t(Keys.panes.publishNotebook.coverImage),
|
||||
options: options.map((value: string) => ({ text: value, key: value })),
|
||||
onChange: async (event, options) => {
|
||||
setImageSrc("");
|
||||
@@ -99,7 +99,7 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
|
||||
notebookContentRef,
|
||||
});
|
||||
} else {
|
||||
firstOutputErrorHandler(new Error("Output does not exist for any of the cells."));
|
||||
firstOutputErrorHandler(new Error(t(Keys.panes.publishNotebook.outputDoesNotExist)));
|
||||
}
|
||||
}
|
||||
setType(options.text);
|
||||
@@ -107,8 +107,8 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
|
||||
};
|
||||
|
||||
const thumbnailUrlProps: ITextFieldProps = {
|
||||
label: "Cover image url",
|
||||
ariaLabel: "Cover image url",
|
||||
label: t(Keys.panes.publishNotebook.coverImageUrl),
|
||||
ariaLabel: t(Keys.panes.publishNotebook.coverImageUrl),
|
||||
required: true,
|
||||
onChange: (event, newValue) => {
|
||||
setImageSrc(newValue);
|
||||
@@ -116,7 +116,7 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
|
||||
};
|
||||
|
||||
const firstOutputErrorHandler = (error: Error) => {
|
||||
const formError = "Failed to capture first output";
|
||||
const formError = t(Keys.panes.publishNotebook.failedToCaptureOutput);
|
||||
const formErrorDetail = `${error}`;
|
||||
const area = "PublishNotebookPaneComponent/UseFirstOutput";
|
||||
onError(formError, formErrorDetail, area);
|
||||
@@ -130,7 +130,7 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
|
||||
};
|
||||
|
||||
reader.onerror = (error) => {
|
||||
const formError = `Failed to convert ${file.name} to base64 format`;
|
||||
const formError = t(Keys.panes.publishNotebook.failedToConvertError, { fileName: file.name });
|
||||
const formErrorDetail = `${error}`;
|
||||
const area = "PublishNotebookPaneComponent/selectImageFile";
|
||||
onError(formError, formErrorDetail, area);
|
||||
@@ -151,7 +151,7 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
|
||||
const file = event.target.files[0];
|
||||
if (file.size / 1024 ** 2 > maxImageSizeInMib) {
|
||||
event.target.value = "";
|
||||
const formError = `Failed to upload ${file.name}`;
|
||||
const formError = t(Keys.panes.publishNotebook.failedToUploadError, { fileName: file.name });
|
||||
const formErrorDetail = `Image is larger than ${maxImageSizeInMib} MiB. Please Choose a different image.`;
|
||||
const area = "PublishNotebookPaneComponent/selectImageFile";
|
||||
|
||||
@@ -185,8 +185,8 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
|
||||
|
||||
<Stack.Item>
|
||||
<TextField
|
||||
label="Name"
|
||||
ariaLabel="Name"
|
||||
label={t(Keys.panes.publishNotebook.name)}
|
||||
ariaLabel={t(Keys.panes.publishNotebook.name)}
|
||||
defaultValue={FileSystemUtil.stripExtension(notebookName, "ipynb")}
|
||||
required
|
||||
onChange={(event, newValue) => {
|
||||
@@ -198,8 +198,8 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
|
||||
|
||||
<Stack.Item>
|
||||
<TextField
|
||||
label="Description"
|
||||
ariaLabel="Description"
|
||||
label={t(Keys.panes.publishNotebook.description)}
|
||||
ariaLabel={t(Keys.panes.publishNotebook.description)}
|
||||
multiline
|
||||
rows={3}
|
||||
required
|
||||
@@ -211,9 +211,9 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
|
||||
|
||||
<Stack.Item>
|
||||
<TextField
|
||||
label="Tags"
|
||||
ariaLabel="Tags"
|
||||
placeholder="Optional tag 1, Optional tag 2"
|
||||
label={t(Keys.panes.publishNotebook.tags)}
|
||||
ariaLabel={t(Keys.panes.publishNotebook.tags)}
|
||||
placeholder={t(Keys.panes.publishNotebook.tagsPlaceholder)}
|
||||
onChange={(event, newValue) => {
|
||||
setNotebookTags(newValue);
|
||||
}}
|
||||
@@ -227,7 +227,7 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
|
||||
<Stack.Item>{renderThumbnailSelectors(type)}</Stack.Item>
|
||||
|
||||
<Stack.Item>
|
||||
<Text>Preview</Text>
|
||||
<Text>{t(Keys.panes.publishNotebook.preview)}</Text>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<GalleryCardComponent
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { FunctionComponent, useState } from "react";
|
||||
import { Areas, SavedQueries } from "../../../Common/Constants";
|
||||
import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils";
|
||||
import { Query } from "../../../Contracts/DataModels";
|
||||
import { Keys } from "../../../Localization/Keys.generated";
|
||||
import { t } from "../../../Localization/t";
|
||||
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
import { traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { logConsoleError } from "../../../Utils/NotificationConsoleUtils";
|
||||
@@ -28,27 +30,27 @@ export const SaveQueryPane: FunctionComponent<SaveQueryPaneProps> = ({
|
||||
const [formError, setFormError] = useState<string>("");
|
||||
const [queryName, setQueryName] = useState<string>("");
|
||||
|
||||
const setupSaveQueriesText = `For compliance reasons, we save queries in a container in your Azure Cosmos account, in a separate database called “${SavedQueries.DatabaseName}”. To proceed, we need to create a container in your account, estimated additional cost is $0.77 daily.`;
|
||||
const title = "Save Query";
|
||||
const setupSaveQueriesText = t(Keys.panes.saveQuery.setupCostMessage, { databaseName: SavedQueries.DatabaseName });
|
||||
const title = t(Keys.panes.saveQuery.panelTitle);
|
||||
const isSaveQueryEnabled = useDatabases((state) => state.isSaveQueryEnabled);
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
setFormError("");
|
||||
if (!isSaveQueryEnabled()) {
|
||||
setFormError("Cannot save query");
|
||||
logConsoleError("Failed to save query: account not setup to save queries");
|
||||
logConsoleError(t(Keys.panes.saveQuery.accountNotSetupError));
|
||||
}
|
||||
|
||||
const queryTab = useTabs.getState().activeTab as NewQueryTab;
|
||||
const query: string = queryToSave || queryTab?.iTabAccessor.onSaveClickEvent();
|
||||
|
||||
if (!queryName || queryName.length === 0) {
|
||||
setFormError("No query name specified");
|
||||
logConsoleError("Could not save query -- No query name specified. Please specify a query name.");
|
||||
setFormError(t(Keys.panes.saveQuery.noQueryNameError));
|
||||
logConsoleError(t(Keys.panes.saveQuery.noQueryNameError));
|
||||
return;
|
||||
} else if (!query || query.length === 0) {
|
||||
setFormError("Invalid query content specified");
|
||||
logConsoleError("Could not save query -- Invalid query content specified. Please enter query content.");
|
||||
setFormError(t(Keys.panes.saveQuery.invalidQueryContentError));
|
||||
logConsoleError(t(Keys.panes.saveQuery.invalidQueryContentError));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -80,8 +82,8 @@ export const SaveQueryPane: FunctionComponent<SaveQueryPaneProps> = ({
|
||||
} catch (error) {
|
||||
setLoadingFalse();
|
||||
const errorMessage = getErrorMessage(error);
|
||||
setFormError("Failed to save query");
|
||||
logConsoleError(`Failed to save query: ${errorMessage}`);
|
||||
setFormError(t(Keys.panes.saveQuery.failedToSaveQueryError, { queryName }));
|
||||
logConsoleError(t(Keys.panes.saveQuery.failedToSaveQueryError, { queryName }) + ": " + errorMessage);
|
||||
traceFailure(
|
||||
Action.SaveQuery,
|
||||
{
|
||||
@@ -126,8 +128,8 @@ export const SaveQueryPane: FunctionComponent<SaveQueryPaneProps> = ({
|
||||
},
|
||||
startKey,
|
||||
);
|
||||
setFormError("Failed to setup a container for saved queries");
|
||||
logConsoleError(`Failed to setup a container for saved queries: ${errorMessage}`);
|
||||
setFormError(t(Keys.panes.saveQuery.failedToSetupContainerError));
|
||||
logConsoleError(t(Keys.panes.saveQuery.failedToSetupContainerError) + ": " + errorMessage);
|
||||
} finally {
|
||||
setLoadingFalse();
|
||||
}
|
||||
@@ -136,7 +138,7 @@ export const SaveQueryPane: FunctionComponent<SaveQueryPaneProps> = ({
|
||||
const props: RightPaneFormProps = {
|
||||
formError: formError,
|
||||
isExecuting: isLoading,
|
||||
submitButtonText: isSaveQueryEnabled() ? "Save" : "Complete setup",
|
||||
submitButtonText: isSaveQueryEnabled() ? t(Keys.common.save) : t(Keys.panes.saveQuery.completeSetup),
|
||||
onSubmit: () => {
|
||||
isSaveQueryEnabled() ? submit() : setupQueries();
|
||||
},
|
||||
@@ -160,7 +162,7 @@ export const SaveQueryPane: FunctionComponent<SaveQueryPaneProps> = ({
|
||||
) : (
|
||||
<TextField
|
||||
id="saveQueryInput"
|
||||
label="Name"
|
||||
label={t(Keys.panes.saveQuery.name)}
|
||||
autoFocus
|
||||
styles={{ fieldGroup: { width: 300 } }}
|
||||
onChange={(event, newInput?: string) => {
|
||||
|
||||
@@ -24,6 +24,8 @@ import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { useDialog } from "Explorer/Controls/Dialog";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import {
|
||||
AppStateComponentNames,
|
||||
@@ -235,7 +237,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
const regionOptions: IDropdownOption[] = [];
|
||||
regionOptions.push({
|
||||
key: userContext?.databaseAccount?.properties?.documentEndpoint,
|
||||
text: `Global (Default)`,
|
||||
text: t(Keys.panes.settings.globalDefault),
|
||||
data: {
|
||||
isGlobal: true,
|
||||
writeEnabled: true,
|
||||
@@ -246,7 +248,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
uniqueAccountRegions.add(loc.locationName);
|
||||
regionOptions.push({
|
||||
key: loc.documentEndpoint,
|
||||
text: `${loc.locationName} (Read/Write)`,
|
||||
text: `${loc.locationName} ${t(Keys.panes.settings.readWrite)}`,
|
||||
data: {
|
||||
isGlobal: false,
|
||||
writeEnabled: true,
|
||||
@@ -259,7 +261,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
uniqueAccountRegions.add(loc.locationName);
|
||||
regionOptions.push({
|
||||
key: loc.documentEndpoint,
|
||||
text: `${loc.locationName} (Read)`,
|
||||
text: `${loc.locationName} ${t(Keys.panes.settings.read)}`,
|
||||
data: {
|
||||
isGlobal: false,
|
||||
writeEnabled: false,
|
||||
@@ -317,13 +319,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
authError instanceof msalAuthError &&
|
||||
authError.errorCode === msalBrowserAuthErrorMessage.popUpWindowError.code
|
||||
) {
|
||||
logConsoleError(
|
||||
`We were unable to establish authorization for this account, due to pop-ups being disabled in the browser.\nPlease enable pop-ups for this site and click on "Login for Entra ID" button`,
|
||||
);
|
||||
logConsoleError(t(Keys.panes.settings.popupsDisabledError));
|
||||
} else {
|
||||
logConsoleError(
|
||||
`"Failed to acquire authorization token automatically. Please click on "Login for Entra ID" button to enable Entra ID RBAC operations`,
|
||||
);
|
||||
logConsoleError(t(Keys.panes.settings.failedToAcquireTokenError));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -485,33 +483,33 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
const genericPaneProps: RightPaneFormProps = {
|
||||
formError: "",
|
||||
isExecuting,
|
||||
submitButtonText: "Apply",
|
||||
submitButtonText: t(Keys.common.apply),
|
||||
onSubmit: () => handlerOnSubmit(),
|
||||
};
|
||||
const pageOptionList: IChoiceGroupOption[] = [
|
||||
{ key: Constants.Queries.CustomPageOption, text: "Custom" },
|
||||
{ key: Constants.Queries.UnlimitedPageOption, text: "Unlimited" },
|
||||
{ key: Constants.Queries.CustomPageOption, text: t(Keys.panes.settings.custom) },
|
||||
{ key: Constants.Queries.UnlimitedPageOption, text: t(Keys.panes.settings.unlimited) },
|
||||
];
|
||||
|
||||
const graphAutoOptionList: IChoiceGroupOption[] = [
|
||||
{ key: "false", text: "Graph" },
|
||||
{ key: "true", text: "JSON" },
|
||||
{ key: "false", text: t(Keys.panes.settings.graph) },
|
||||
{ key: "true", text: t(Keys.panes.settings.json) },
|
||||
];
|
||||
|
||||
const priorityLevelOptionList: IChoiceGroupOption[] = [
|
||||
{ key: Constants.PriorityLevel.Low, text: "Low" },
|
||||
{ key: Constants.PriorityLevel.High, text: "High" },
|
||||
{ key: Constants.PriorityLevel.Low, text: t(Keys.panes.settings.low) },
|
||||
{ key: Constants.PriorityLevel.High, text: t(Keys.panes.settings.high) },
|
||||
];
|
||||
|
||||
const dataPlaneRBACOptionsList: IChoiceGroupOption[] = [
|
||||
{ key: Constants.RBACOptions.setAutomaticRBACOption, text: "Automatic" },
|
||||
{ key: Constants.RBACOptions.setTrueRBACOption, text: "True" },
|
||||
{ key: Constants.RBACOptions.setFalseRBACOption, text: "False" },
|
||||
{ key: Constants.RBACOptions.setAutomaticRBACOption, text: t(Keys.panes.settings.automatic) },
|
||||
{ key: Constants.RBACOptions.setTrueRBACOption, text: t(Keys.panes.settings["true"]) },
|
||||
{ key: Constants.RBACOptions.setFalseRBACOption, text: t(Keys.panes.settings["false"]) },
|
||||
];
|
||||
|
||||
const defaultQueryResultsViewOptionList: IChoiceGroupOption[] = [
|
||||
{ key: SplitterDirection.Vertical, text: "Vertical" },
|
||||
{ key: SplitterDirection.Horizontal, text: "Horizontal" },
|
||||
{ key: SplitterDirection.Vertical, text: t(Keys.tabs.query.vertical) },
|
||||
{ key: SplitterDirection.Horizontal, text: t(Keys.tabs.query.horizontal) },
|
||||
];
|
||||
|
||||
const mongoGuidRepresentationDropdownOptions: IDropdownOption[] = [
|
||||
@@ -724,13 +722,12 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{shouldShowQueryPageOptions && (
|
||||
<AccordionItem value="1">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Page Options</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.pageOptions)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as
|
||||
many query results per page.
|
||||
{t(Keys.panes.settings.pageOptionsDescription)}
|
||||
</div>
|
||||
<ChoiceGroup
|
||||
ariaLabelledBy="pageOptions"
|
||||
@@ -744,14 +741,14 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{isCustomPageOptionSelected() && (
|
||||
<div className="tabcontent">
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Query results per page{" "}
|
||||
{t(Keys.panes.settings.queryResultsPerPage)}{" "}
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
Enter the number of query results that should be shown per page.
|
||||
{t(Keys.panes.settings.queryResultsPerPageTooltip)}
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
<SpinButton
|
||||
ariaLabel="Custom query items per page"
|
||||
ariaLabel={t(Keys.panes.settings.customQueryItemsPerPage)}
|
||||
value={"" + customItemPerPage}
|
||||
onIncrement={(newValue) => {
|
||||
setCustomItemPerPage(parseInt(newValue) + 1 || customItemPerPage);
|
||||
@@ -761,8 +758,8 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
min={1}
|
||||
step={1}
|
||||
className="textfontclr"
|
||||
incrementButtonAriaLabel="Increase value by 1"
|
||||
decrementButtonAriaLabel="Decrease value by 1"
|
||||
incrementButtonAriaLabel={t(Keys.common.increaseValueBy1)}
|
||||
decrementButtonAriaLabel={t(Keys.common.decreaseValueBy1)}
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
</div>
|
||||
@@ -774,20 +771,19 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{showEnableEntraIdRbac && (
|
||||
<AccordionItem value="2">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Enable Entra ID RBAC</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.entraIdRbac)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra
|
||||
ID RBAC.
|
||||
{t(Keys.panes.settings.entraIdRbacDescription)}
|
||||
<a
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{" "}
|
||||
Learn more{" "}
|
||||
{t(Keys.common.learnMore)}{" "}
|
||||
</a>
|
||||
</div>
|
||||
<ChoiceGroup
|
||||
@@ -804,17 +800,17 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && !isFabric() && (
|
||||
<AccordionItem value="3">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Region Selection</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.regionSelection)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Changes region the Cosmos Client uses to access account.
|
||||
{t(Keys.panes.settings.regionSelectionDescription)}
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.subHeader}>Select Region</span>
|
||||
<span className={styles.subHeader}>{t(Keys.panes.settings.selectRegion)}</span>
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
Changes the account endpoint used to perform client operations.
|
||||
{t(Keys.panes.settings.selectRegionTooltip)}
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<Dropdown
|
||||
@@ -865,17 +861,16 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
<>
|
||||
<AccordionItem value="4">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Query Timeout</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.queryTimeout)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
When a query reaches a specified time limit, a popup with an option to cancel the query will
|
||||
show unless automatic cancellation has been enabled.
|
||||
{t(Keys.panes.settings.queryTimeoutDescription)}
|
||||
</div>
|
||||
<Toggle
|
||||
styles={toggleStyles}
|
||||
label="Enable query timeout"
|
||||
label={t(Keys.panes.settings.enableQueryTimeout)}
|
||||
onChange={handleOnQueryTimeoutToggleChange}
|
||||
defaultChecked={queryTimeoutEnabled}
|
||||
/>
|
||||
@@ -883,18 +878,18 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{queryTimeoutEnabled && (
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<SpinButton
|
||||
label="Query timeout (ms)"
|
||||
label={t(Keys.panes.settings.queryTimeoutMs)}
|
||||
labelPosition={Position.top}
|
||||
defaultValue={(queryTimeout || 5000).toString()}
|
||||
min={100}
|
||||
step={1000}
|
||||
onChange={handleOnQueryTimeoutSpinButtonChange}
|
||||
incrementButtonAriaLabel="Increase value by 1000"
|
||||
decrementButtonAriaLabel="Decrease value by 1000"
|
||||
incrementButtonAriaLabel={t(Keys.panes.settings.increaseValueBy1000)}
|
||||
decrementButtonAriaLabel={t(Keys.panes.settings.decreaseValueBy1000)}
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
<Toggle
|
||||
label="Automatically cancel query after timeout"
|
||||
label={t(Keys.panes.settings.automaticallyCancelQuery)}
|
||||
styles={toggleStyles}
|
||||
onChange={handleOnAutomaticallyCancelQueryToggleChange}
|
||||
defaultChecked={automaticallyCancelQueryAfterTimeout}
|
||||
@@ -905,16 +900,16 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
</AccordionItem>
|
||||
<AccordionItem value="5">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>RU Limit</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.ruLimit)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
If a query exceeds a configured RU limit, the query will be aborted.
|
||||
{t(Keys.panes.settings.ruLimitDescription)}
|
||||
</div>
|
||||
<Toggle
|
||||
styles={toggleStyles}
|
||||
label="Enable RU limit"
|
||||
label={t(Keys.panes.settings.enableRuLimit)}
|
||||
onChange={handleOnRUThresholdToggleChange}
|
||||
defaultChecked={ruThresholdEnabled}
|
||||
/>
|
||||
@@ -922,14 +917,14 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{ruThresholdEnabled && (
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<SpinButton
|
||||
label="RU Limit (RU)"
|
||||
label={t(Keys.panes.settings.ruLimitLabel)}
|
||||
labelPosition={Position.top}
|
||||
defaultValue={(ruThreshold || DefaultRUThreshold).toString()}
|
||||
min={1}
|
||||
step={1000}
|
||||
onChange={handleOnRUThresholdSpinButtonChange}
|
||||
incrementButtonAriaLabel="Increase value by 1000"
|
||||
decrementButtonAriaLabel="Decrease value by 1000"
|
||||
incrementButtonAriaLabel={t(Keys.panes.settings.increaseValueBy1000)}
|
||||
decrementButtonAriaLabel={t(Keys.panes.settings.decreaseValueBy1000)}
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
</div>
|
||||
@@ -939,12 +934,12 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
|
||||
<AccordionItem value="6">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Default Query Results View</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.defaultQueryResults)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Select the default view to use when displaying query results.
|
||||
{t(Keys.panes.settings.defaultQueryResultsDescription)}
|
||||
</div>
|
||||
<ChoiceGroup
|
||||
ariaLabelledBy="defaultQueryResultsView"
|
||||
@@ -962,17 +957,17 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{showRetrySettings && (
|
||||
<AccordionItem value="7">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Retry Settings</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.retrySettings)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Retry policy associated with throttled requests during CosmosDB queries.
|
||||
{t(Keys.panes.settings.retrySettingsDescription)}
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.subHeader}>Max retry attempts</span>
|
||||
<span className={styles.subHeader}>{t(Keys.panes.settings.maxRetryAttempts)}</span>
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
Max number of retries to be performed for a request. Default value 9.
|
||||
{t(Keys.panes.settings.maxRetryAttemptsTooltip)}
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<SpinButton
|
||||
@@ -981,18 +976,17 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
step={1}
|
||||
value={"" + retryAttempts}
|
||||
onChange={handleOnQueryRetryAttemptsSpinButtonChange}
|
||||
incrementButtonAriaLabel="Increase value by 1"
|
||||
decrementButtonAriaLabel="Decrease value by 1"
|
||||
incrementButtonAriaLabel={t(Keys.common.increaseValueBy1)}
|
||||
decrementButtonAriaLabel={t(Keys.common.decreaseValueBy1)}
|
||||
onIncrement={(newValue) => setRetryAttempts(parseInt(newValue) + 1 || retryAttempts)}
|
||||
onDecrement={(newValue) => setRetryAttempts(parseInt(newValue) - 1 || retryAttempts)}
|
||||
onValidate={(newValue) => setRetryAttempts(parseInt(newValue) || retryAttempts)}
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
<div>
|
||||
<span className={styles.subHeader}>Fixed retry interval (ms)</span>
|
||||
<span className={styles.subHeader}>{t(Keys.panes.settings.fixedRetryInterval)}</span>
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned
|
||||
as part of the response. Default value is 0 milliseconds.
|
||||
{t(Keys.panes.settings.fixedRetryIntervalTooltip)}
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<SpinButton
|
||||
@@ -1001,18 +995,17 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
step={1000}
|
||||
value={"" + retryInterval}
|
||||
onChange={handleOnRetryIntervalSpinButtonChange}
|
||||
incrementButtonAriaLabel="Increase value by 1000"
|
||||
decrementButtonAriaLabel="Decrease value by 1000"
|
||||
incrementButtonAriaLabel={t(Keys.panes.settings.increaseValueBy1000)}
|
||||
decrementButtonAriaLabel={t(Keys.panes.settings.decreaseValueBy1000)}
|
||||
onIncrement={(newValue) => setRetryInterval(parseInt(newValue) + 1000 || retryInterval)}
|
||||
onDecrement={(newValue) => setRetryInterval(parseInt(newValue) - 1000 || retryInterval)}
|
||||
onValidate={(newValue) => setRetryInterval(parseInt(newValue) || retryInterval)}
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
<div>
|
||||
<span className={styles.subHeader}>Max wait time (s)</span>
|
||||
<span className={styles.subHeader}>{t(Keys.panes.settings.maxWaitTime)}</span>
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
Max wait time in seconds to wait for a request while the retries are happening. Default value 30
|
||||
seconds.
|
||||
{t(Keys.panes.settings.maxWaitTimeTooltip)}
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<SpinButton
|
||||
@@ -1021,8 +1014,8 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
step={1}
|
||||
value={"" + MaxWaitTimeInSeconds}
|
||||
onChange={handleOnMaxWaitTimeSpinButtonChange}
|
||||
incrementButtonAriaLabel="Increase value by 1"
|
||||
decrementButtonAriaLabel="Decrease value by 1"
|
||||
incrementButtonAriaLabel={t(Keys.common.increaseValueBy1)}
|
||||
decrementButtonAriaLabel={t(Keys.common.decreaseValueBy1)}
|
||||
onIncrement={(newValue) =>
|
||||
setMaxWaitTimeInSeconds(parseInt(newValue) + 1 || MaxWaitTimeInSeconds)
|
||||
}
|
||||
@@ -1039,24 +1032,26 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{!isEmulator && (
|
||||
<AccordionItem value="8">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Enable container pagination</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.enableContainerPagination)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order.
|
||||
{t(Keys.panes.settings.enableContainerPaginationDescription)}
|
||||
</div>
|
||||
<Checkbox
|
||||
styles={{
|
||||
label: { padding: 0 },
|
||||
}}
|
||||
className="padding"
|
||||
ariaLabel="Enable container pagination"
|
||||
ariaLabel={t(Keys.panes.settings.enableContainerPagination)}
|
||||
checked={containerPaginationEnabled}
|
||||
onChange={() => setContainerPaginationEnabled(!containerPaginationEnabled)}
|
||||
label="Enable container pagination"
|
||||
label={t(Keys.panes.settings.enableContainerPagination)}
|
||||
onRenderLabel={() => (
|
||||
<span style={{ color: "var(--colorNeutralForeground1)" }}>Enable container pagination</span>
|
||||
<span style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
{t(Keys.panes.settings.enableContainerPagination)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1066,24 +1061,25 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{shouldShowCrossPartitionOption && (
|
||||
<AccordionItem value="9">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Enable cross-partition query</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.enableCrossPartitionQuery)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Send more than one request while executing a query. More than one request is necessary if the
|
||||
query is not scoped to single partition key value.
|
||||
{t(Keys.panes.settings.enableCrossPartitionQueryDescription)}
|
||||
</div>
|
||||
<Checkbox
|
||||
styles={{
|
||||
label: { padding: 0 },
|
||||
}}
|
||||
className="padding"
|
||||
ariaLabel="Enable cross partition query"
|
||||
ariaLabel={t(Keys.panes.settings.enableCrossPartitionQuery)}
|
||||
checked={crossPartitionQueryEnabled}
|
||||
onChange={() => setCrossPartitionQueryEnabled(!crossPartitionQueryEnabled)}
|
||||
onRenderLabel={() => (
|
||||
<span style={{ color: "var(--colorNeutralForeground1)" }}>Enable cross-partition query</span>
|
||||
<span style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
{t(Keys.panes.settings.enableCrossPartitionQuery)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1093,19 +1089,19 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{shouldShowEnhancedQueryControl && (
|
||||
<AccordionItem value="10">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Enhanced query control</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.enhancedQueryControl)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Query up to the max degree of parallelism.
|
||||
{t(Keys.panes.settings.maxDegreeOfParallelismQuery)}
|
||||
<a
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/performance-tips-query-sdk?tabs=v3&pivots=programming-language-nodejs#enhanced-query-control"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{" "}
|
||||
Learn more{" "}
|
||||
{t(Keys.common.learnMore)}{" "}
|
||||
</a>
|
||||
</div>
|
||||
<Checkbox
|
||||
@@ -1113,11 +1109,13 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
label: { padding: 0 },
|
||||
}}
|
||||
className="padding"
|
||||
ariaLabel="EnableQueryControl"
|
||||
ariaLabel={t(Keys.panes.settings.enableQueryControl)}
|
||||
checked={queryControlEnabled}
|
||||
onChange={() => setQueryControlEnabled(!queryControlEnabled)}
|
||||
onRenderLabel={() => (
|
||||
<span style={{ color: "var(--colorNeutralForeground1)" }}>Enable query control</span>
|
||||
<span style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
{t(Keys.panes.settings.enableQueryControl)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1127,14 +1125,12 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{shouldShowParallelismOption && (
|
||||
<AccordionItem value="10">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Max degree of parallelism</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.maxDegreeOfParallelism)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Gets or sets the number of concurrent operations run client side during parallel query execution.
|
||||
A positive property value limits the number of concurrent operations to the set value. If it is
|
||||
set to less than 0, the system automatically decides the number of concurrent operations to run.
|
||||
{t(Keys.panes.settings.maxDegreeOfParallelismDescription)}
|
||||
</div>
|
||||
<SpinButton
|
||||
min={-1}
|
||||
@@ -1150,8 +1146,8 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
setMaxDegreeOfParallelism(parseInt(newValue) - 1 || maxDegreeOfParallelism)
|
||||
}
|
||||
onValidate={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) || maxDegreeOfParallelism)}
|
||||
ariaLabel="Max degree of parallelism"
|
||||
label="Max degree of parallelism"
|
||||
ariaLabel={t(Keys.panes.settings.maxDegreeOfParallelism)}
|
||||
label={t(Keys.panes.settings.maxDegreeOfParallelism)}
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
</div>
|
||||
@@ -1161,14 +1157,12 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{shouldShowPriorityLevelOption && (
|
||||
<AccordionItem value="11">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Priority Level</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.priorityLevel)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Sets the priority level for data-plane requests from Data Explorer when using Priority-Based
|
||||
Execution. If "None" is selected, Data Explorer will not specify priority level, and the
|
||||
server-side default priority level will be used.
|
||||
{t(Keys.panes.settings.priorityLevelDescription)}
|
||||
</div>
|
||||
<ChoiceGroup
|
||||
ariaLabelledBy="priorityLevel"
|
||||
@@ -1184,19 +1178,18 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{shouldShowGraphAutoVizOption && (
|
||||
<AccordionItem value="12">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Display Gremlin query results as: </div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.displayGremlinQueryResults)} </div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Select Graph to automatically visualize the query results as a Graph or JSON to display the
|
||||
results as JSON.
|
||||
{t(Keys.panes.settings.displayGremlinQueryResultsDescription)}
|
||||
</div>
|
||||
<ChoiceGroup
|
||||
selectedKey={graphAutoVizDisabled}
|
||||
options={graphAutoOptionList}
|
||||
onChange={handleOnGremlinChange}
|
||||
aria-label="Graph Auto-visualization"
|
||||
aria-label={t(Keys.panes.settings.graphAutoVisualization)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
@@ -1205,25 +1198,25 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{shouldShowCopilotSampleDBOption && (
|
||||
<AccordionItem value="13">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Enable sample database</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.enableSampleDatabase)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
This is a sample database and collection with synthetic product data you can use to explore using
|
||||
NoSQL queries. This will appear as another database in the Data Explorer UI, and is created by,
|
||||
and maintained by Microsoft at no cost to you.
|
||||
{t(Keys.panes.settings.enableSampleDatabaseDescription)}
|
||||
</div>
|
||||
<Checkbox
|
||||
styles={{
|
||||
label: { padding: 0 },
|
||||
}}
|
||||
className="padding"
|
||||
ariaLabel="Enable sample db for query exploration"
|
||||
ariaLabel={t(Keys.panes.settings.enableSampleDbAriaLabel)}
|
||||
checked={copilotSampleDBEnabled}
|
||||
onChange={handleSampleDatabaseChange}
|
||||
onRenderLabel={() => (
|
||||
<span style={{ color: "var(--colorNeutralForeground1)" }}>Enable sample database</span>
|
||||
<span style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
{t(Keys.panes.settings.enableSampleDatabase)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1233,13 +1226,12 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{shouldShowMongoGuidRepresentationOption && (
|
||||
<AccordionItem value="14">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Guid Representation</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.guidRepresentation)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
GuidRepresentation in MongoDB refers to how Globally Unique Identifiers (GUIDs) are serialized and
|
||||
deserialized when stored in BSON documents. This will apply to all document operations.
|
||||
{t(Keys.panes.settings.guidRepresentationDescription)}
|
||||
</div>
|
||||
<Dropdown
|
||||
aria-labelledby="mongoGuidRepresentation"
|
||||
@@ -1253,7 +1245,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
)}
|
||||
<AccordionItem value="15">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Advanced Settings</div>
|
||||
<div className={styles.header}>{t(Keys.panes.settings.advancedSettings)}</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
@@ -1283,14 +1275,13 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
},
|
||||
}}
|
||||
className="padding"
|
||||
ariaLabel="Ignore partition key on document update"
|
||||
ariaLabel={t(Keys.panes.settings.ignorePartitionKey)}
|
||||
checked={ignorePartitionKeyOnDocumentUpdate}
|
||||
onChange={handleOnIgnorePartitionKeyOnDocumentUpdateChange}
|
||||
label="Ignore partition key on document update"
|
||||
label={t(Keys.panes.settings.ignorePartitionKey)}
|
||||
/>
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
If checked, the partition key value will not be used to locate the document during update
|
||||
operations. Only use this if document updates are failing due to an abnormal partition key.
|
||||
{t(Keys.panes.settings.ignorePartitionKeyTooltip)}
|
||||
</InfoTooltip>
|
||||
</Stack>
|
||||
</div>
|
||||
@@ -1320,9 +1311,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
}}
|
||||
onClick={() => {
|
||||
useDialog.getState().showOkCancelModalDialog(
|
||||
"Clear History",
|
||||
t(Keys.panes.settings.clearHistory),
|
||||
undefined,
|
||||
"Are you sure you want to proceed?",
|
||||
t(Keys.panes.settings.clearHistoryConfirm),
|
||||
() => {
|
||||
deleteAllStates();
|
||||
updateUserContext({
|
||||
@@ -1332,35 +1323,33 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
});
|
||||
useClientWriteEnabled.setState({ clientWriteEnabled: true });
|
||||
},
|
||||
"Cancel",
|
||||
t(Keys.common.cancel),
|
||||
undefined,
|
||||
<>
|
||||
<span>
|
||||
This action will clear the all customizations for this account in this browser, including:
|
||||
</span>
|
||||
<span>{t(Keys.panes.settings.clearHistoryDescription)}</span>
|
||||
<ul className={styles.bulletList}>
|
||||
<li>Reset your customized tab layout, including the splitter positions</li>
|
||||
<li>Erase your table column preferences, including any custom columns</li>
|
||||
<li>Clear your filter history</li>
|
||||
<li>Reset region selection to global</li>
|
||||
<li>{t(Keys.panes.settings.clearHistoryTabLayout)}</li>
|
||||
<li>{t(Keys.panes.settings.clearHistoryTableColumns)}</li>
|
||||
<li>{t(Keys.panes.settings.clearHistoryFilters)}</li>
|
||||
<li>{t(Keys.panes.settings.clearHistoryRegion)}</li>
|
||||
</ul>
|
||||
</>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Clear History
|
||||
{t(Keys.panes.settings.clearHistory)}
|
||||
</DefaultButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settingsSection">
|
||||
<div className={`settingsSectionPart ${styles.settingsSectionContainer}`}>
|
||||
<div className="settingsSectionLabel">Explorer Version</div>
|
||||
<div className="settingsSectionLabel">{t(Keys.panes.settings.explorerVersion)}</div>
|
||||
<div>{explorerVersion}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div className="settingsSectionLabel">Session ID</div>
|
||||
<div className="settingsSectionLabel">{t(Keys.panes.settings.sessionId)}</div>
|
||||
<div>{sessionId}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -660,7 +660,7 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
Send more than one request while executing a query. More than one request is necessary if the query is not scoped to single partition key value.
|
||||
</div>
|
||||
<StyledCheckboxBase
|
||||
ariaLabel="Enable cross partition query"
|
||||
ariaLabel="Enable cross-partition query"
|
||||
checked={false}
|
||||
className="padding"
|
||||
onChange={[Function]}
|
||||
@@ -705,7 +705,7 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
</a>
|
||||
</div>
|
||||
<StyledCheckboxBase
|
||||
ariaLabel="EnableQueryControl"
|
||||
ariaLabel="Enable query control"
|
||||
checked={false}
|
||||
className="padding"
|
||||
onChange={[Function]}
|
||||
@@ -1190,7 +1190,8 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
<div
|
||||
className="___j7dlp70_0000000 fq02s40 f19n0e5"
|
||||
>
|
||||
Display Gremlin query results as:
|
||||
Display Gremlin query results as:
|
||||
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { configContext } from "ConfigContext";
|
||||
import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent";
|
||||
import { CosmosFluentProvider, getPlatformTheme } from "Explorer/Theme/ThemeUtil";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import React from "react";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
|
||||
@@ -113,13 +115,13 @@ export const TableColumnSelectionPane: React.FC<TableColumnSelectionPaneProps> =
|
||||
<CosmosFluentProvider>
|
||||
<div className="panelFormWrapper">
|
||||
<div className="panelMainContent" style={{ display: "flex", flexDirection: "column" }}>
|
||||
<Text>Select which columns to display in your view of items in your container.</Text>
|
||||
<Text>{t(Keys.panes.tableColumnSelection.selectColumns)}</Text>
|
||||
<div /* Wrap <SearchBox> to avoid margin-bottom set by panelMainContent css */>
|
||||
<SearchBox
|
||||
className={styles.searchBox}
|
||||
value={columnSearchText}
|
||||
onChange={onSearchChange}
|
||||
placeholder="Search fields"
|
||||
placeholder={t(Keys.panes.tableColumnSelection.searchFields)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -130,7 +132,9 @@ export const TableColumnSelectionPane: React.FC<TableColumnSelectionPaneProps> =
|
||||
key={columnDefinition.id}
|
||||
label={{
|
||||
className: styles.checkboxLabel,
|
||||
children: `${columnDefinition.label}${columnDefinition.isPartitionKey ? " (partition key)" : ""}`,
|
||||
children: `${columnDefinition.label}${
|
||||
columnDefinition.isPartitionKey ? t(Keys.panes.tableColumnSelection.partitionKeySuffix) : ""
|
||||
}`,
|
||||
}}
|
||||
checked={selectedColumnIdsSet.has(columnDefinition.id)}
|
||||
onChange={(_, data) => onCheckedValueChange(columnDefinition.id, data)}
|
||||
@@ -138,15 +142,15 @@ export const TableColumnSelectionPane: React.FC<TableColumnSelectionPaneProps> =
|
||||
))}
|
||||
</div>
|
||||
<Button appearance="secondary" size="small" onClick={() => setNewSelectedColumnIds(defaultSelection)}>
|
||||
Reset
|
||||
{t(Keys.panes.tableColumnSelection.reset)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="panelFooter" style={{ display: "flex", gap: theme.spacingHorizontalS }}>
|
||||
<Button appearance="primary" onClick={onSave}>
|
||||
Save
|
||||
{t(Keys.common.save)}
|
||||
</Button>
|
||||
<Button appearance="secondary" onClick={closeSidePanel}>
|
||||
Cancel
|
||||
{t(Keys.common.cancel)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IDropdownOption, Image, Label, Stack, Text, TextField } from "@fluentui/react";
|
||||
import { useBoolean } from "@fluentui/react-hooks";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { logConsoleError } from "Utils/NotificationConsoleUtils";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import * as _ from "underscore";
|
||||
@@ -100,8 +102,8 @@ export const AddTableEntityPanel: FunctionComponent<AddTableEntityPanelProps> =
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const { property, type, value } = entities[i];
|
||||
if ((property === "PartitionKey" && value === "") || (property === "RowKey" && value === "")) {
|
||||
logConsoleError(`${property} cannot be empty. Please input a value for ${property}`);
|
||||
setFormError(`${property} cannot be empty. Please input a value for ${property}`);
|
||||
logConsoleError(t(Keys.panes.tables.propertyEmptyError, { property }));
|
||||
setFormError(t(Keys.panes.tables.propertyEmptyError, { property }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,13 +111,13 @@ export const AddTableEntityPanel: FunctionComponent<AddTableEntityPanelProps> =
|
||||
(property === "PartitionKey" && containsAnyWhiteSpace(value) === true) ||
|
||||
(property === "RowKey" && containsAnyWhiteSpace(value) === true)
|
||||
) {
|
||||
logConsoleError(`${property} cannot have whitespace. Please input a value for ${property} without whitespace`);
|
||||
setFormError(`${property} cannot have whitespace. Please input a value for ${property} without whitespace`);
|
||||
logConsoleError(t(Keys.panes.tables.whitespaceError, { property }));
|
||||
setFormError(t(Keys.panes.tables.whitespaceError, { property }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
setFormError(`Property type cannot be empty. Please select a type from the dropdown for property ${property}`);
|
||||
setFormError(t(Keys.panes.tables.propertyTypeEmptyError, { property }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IDropdownOption, Image, Label, Stack, Text, TextField } from "@fluentui/react";
|
||||
import { useBoolean } from "@fluentui/react-hooks";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { logConsoleError } from "Utils/NotificationConsoleUtils";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import * as _ from "underscore";
|
||||
@@ -198,7 +200,7 @@ export const EditTableEntityPanel: FunctionComponent<EditTableEntityPanelProps>
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
setFormError(`Property type cannot be empty. Please select a type from the dropdown for property ${property}`);
|
||||
setFormError(t(Keys.panes.tables.propertyTypeEmptyError, { property }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -208,8 +210,8 @@ export const EditTableEntityPanel: FunctionComponent<EditTableEntityPanelProps>
|
||||
(property === "RowKey" && value === "") ||
|
||||
(property === "RowKey" && value === undefined)
|
||||
) {
|
||||
logConsoleError(`${property} cannot be empty. Please input a value for ${property}`);
|
||||
setFormError(`${property} cannot be empty. Please input a value for ${property}`);
|
||||
logConsoleError(t(Keys.panes.tables.propertyEmptyError, { property }));
|
||||
setFormError(t(Keys.panes.tables.propertyEmptyError, { property }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -403,7 +405,7 @@ export const EditTableEntityPanel: FunctionComponent<EditTableEntityPanelProps>
|
||||
)}
|
||||
</div>
|
||||
<div className="panelNullWarning" style={{ padding: "20px", color: "red" }}>
|
||||
Warning: Null fields will not be displayed for editing.
|
||||
{t(Keys.panes.tables.nullFieldsWarning)}
|
||||
</div>
|
||||
</RightPaneForm>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Checkbox, Text } from "@fluentui/react";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import { userContext } from "../../../../UserContext";
|
||||
import { useSidePanel } from "../../../../hooks/useSidePanel";
|
||||
@@ -35,7 +37,7 @@ export const TableQuerySelectPanel: FunctionComponent<TableQuerySelectPanelProps
|
||||
const props: RightPaneFormProps = {
|
||||
formError: "",
|
||||
isExecuting: false,
|
||||
submitButtonText: "OK",
|
||||
submitButtonText: t(Keys.common.ok),
|
||||
onSubmit,
|
||||
};
|
||||
|
||||
@@ -121,11 +123,11 @@ export const TableQuerySelectPanel: FunctionComponent<TableQuerySelectPanelProps
|
||||
<RightPaneForm {...props}>
|
||||
<div className="panelFormWrapper">
|
||||
<div className="panelMainContent">
|
||||
<Text>Select the columns that you want to query.</Text>
|
||||
<Text>{t(Keys.panes.tableQuerySelect.selectColumns)}</Text>
|
||||
<div className="column-select-view">
|
||||
<Checkbox
|
||||
id="availableCheckbox"
|
||||
label="Available Columns"
|
||||
label={t(Keys.panes.tableQuerySelect.availableColumns)}
|
||||
checked={isAvailableColumnChecked}
|
||||
onChange={availableColumnsCheckboxClick}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
} from "@fluentui/react";
|
||||
import { Upload } from "Common/Upload/Upload";
|
||||
import { UploadDetailsRecord } from "Contracts/ViewModels";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { logConsoleError } from "Utils/NotificationConsoleUtils";
|
||||
import React, { ChangeEvent, FunctionComponent, useReducer, useState } from "react";
|
||||
import { getErrorMessage } from "../../Tables/Utilities";
|
||||
@@ -63,8 +65,8 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
||||
const onSubmit = () => {
|
||||
setFormError("");
|
||||
if (!files || files.length === 0) {
|
||||
setFormError("No files were specified. Please input at least one file.");
|
||||
logConsoleError("Could not upload items -- No files were specified. Please input at least one file.");
|
||||
setFormError(t(Keys.panes.uploadItems.noFilesSpecifiedError));
|
||||
logConsoleError(t(Keys.panes.uploadItems.noFilesSpecifiedError));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,7 +152,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
||||
},
|
||||
{
|
||||
key: "fileName",
|
||||
name: "FILE NAME",
|
||||
name: t(Keys.panes.uploadItems.fileNameColumn),
|
||||
fieldName: "fileName",
|
||||
minWidth: 120,
|
||||
maxWidth: 140,
|
||||
@@ -169,7 +171,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
name: "STATUS",
|
||||
name: t(Keys.panes.uploadItems.statusColumn),
|
||||
fieldName: "numSucceeded",
|
||||
minWidth: 120,
|
||||
maxWidth: 140,
|
||||
@@ -178,7 +180,11 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
||||
data: "string",
|
||||
isPadded: true,
|
||||
onRender: (item: UploadDetailsRecord, index: number, column: IColumn) => {
|
||||
const fieldContent = `${item.numSucceeded} created, ${item.numThrottled} throttled, ${item.numFailed} errors`;
|
||||
const fieldContent = t(Keys.panes.uploadItems.uploadStatus, {
|
||||
numSucceeded: item.numSucceeded,
|
||||
numThrottled: item.numThrottled,
|
||||
numFailed: item.numFailed,
|
||||
});
|
||||
return (
|
||||
<TooltipHost
|
||||
content={fieldContent}
|
||||
@@ -197,15 +203,15 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
||||
<div className="paneMainContent">
|
||||
<Upload
|
||||
key={reducer} // Force re-render on state change
|
||||
label="Select JSON Files"
|
||||
label={t(Keys.panes.uploadItems.selectJsonFiles)}
|
||||
onUpload={updateSelectedFiles}
|
||||
accept="application/json"
|
||||
multiple
|
||||
tabIndex={0}
|
||||
tooltip="Select one or more JSON files to upload. Each file can contain a single JSON document or an array of JSON documents. The combined size of all files in an individual upload operation must be less than 2 MB. You can perform multiple upload operations for larger data sets."
|
||||
tooltip={t(Keys.panes.uploadItems.selectJsonFilesTooltip)}
|
||||
/>
|
||||
{uploadFileData?.length > 0 && (
|
||||
<div className="fileUploadSummaryContainer">
|
||||
<div className="fileUploadSummaryContainer" data-test="file-upload-status">
|
||||
<b style={{ color: "var(--colorNeutralForeground1)" }}>File upload status</b>
|
||||
<DetailsList
|
||||
items={uploadFileData}
|
||||
|
||||
@@ -714,9 +714,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
|
||||
<span
|
||||
className="css-126"
|
||||
>
|
||||
What is the reason why you are deleting this
|
||||
Database
|
||||
?
|
||||
What is the reason why you are deleting this Database?
|
||||
</span>
|
||||
</Text>
|
||||
<StyledTextFieldBase
|
||||
|
||||
@@ -6,6 +6,8 @@ import { DocumentAddRegular, LinkMultipleRegular, OpenRegular } from "@fluentui/
|
||||
import { SampleDataConfiguration, SampleDataImportDialog } from "Explorer/SplashScreen/SampleDataImportDialog";
|
||||
import { SampleDataFile } from "Explorer/SplashScreen/SampleUtil";
|
||||
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { isFabricNative, isFabricNativeReadOnly } from "Platform/Fabric/FabricUtil";
|
||||
import * as React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
@@ -159,8 +161,8 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
||||
const getSplashScreenButtons = (): JSX.Element => {
|
||||
const buttons: FabricHomeScreenButtonProps[] = [
|
||||
{
|
||||
title: "New container",
|
||||
description: "Create a destination container to store your data",
|
||||
title: t(Keys.splashScreen.fabric.newContainer.title),
|
||||
description: t(Keys.splashScreen.fabric.newContainer.description),
|
||||
icon: <DocumentAddRegular />,
|
||||
onClick: () => {
|
||||
const databaseId = isFabricNative() ? userContext.fabricContext?.databaseName : undefined;
|
||||
@@ -168,8 +170,8 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Sample Data",
|
||||
description: "Load sample data in your database",
|
||||
title: t(Keys.splashScreen.fabric.sampleData.title),
|
||||
description: t(Keys.splashScreen.fabric.sampleData.description),
|
||||
icon: <img src={CosmosDbBlackIcon} alt={"Azure Cosmos DB icon"} aria-hidden="true" />,
|
||||
onClick: () => {
|
||||
setSelectedSampleDataConfiguration({
|
||||
@@ -181,8 +183,8 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Sample Vector Data",
|
||||
description: "Load sample vector data with text-embedding-ada-002",
|
||||
title: t(Keys.splashScreen.fabric.sampleVectorData.title),
|
||||
description: t(Keys.splashScreen.fabric.sampleVectorData.description),
|
||||
icon: <img src={AzureOpenAiIcon} alt={"Azure Open AI icon"} aria-hidden="true" />,
|
||||
onClick: () => {
|
||||
setSelectedSampleDataConfiguration({
|
||||
@@ -194,14 +196,14 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "App development",
|
||||
description: "Start here to use an SDK to build your apps",
|
||||
title: t(Keys.splashScreen.fabric.appDevelopment.title),
|
||||
description: t(Keys.splashScreen.fabric.appDevelopment.description),
|
||||
icon: <LinkMultipleRegular />,
|
||||
onClick: () => window.open("https://aka.ms/cosmosdbfabricsdk", "_blank"),
|
||||
},
|
||||
{
|
||||
title: "Sample Gallery",
|
||||
description: "Get real-world end-to-end samples",
|
||||
title: t(Keys.splashScreen.fabric.sampleGallery.title),
|
||||
description: t(Keys.splashScreen.fabric.sampleGallery.description),
|
||||
icon: <img src={GithubIcon} alt={"GitHub icon"} aria-hidden="true" />,
|
||||
onClick: () => window.open("https://aka.ms/CosmosFabricSamplesGallery", "_blank"),
|
||||
},
|
||||
@@ -222,7 +224,9 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
||||
);
|
||||
};
|
||||
|
||||
const title = isFabricNativeReadOnly() ? "Use your database" : "Build your database";
|
||||
const title = isFabricNativeReadOnly()
|
||||
? t(Keys.splashScreen.fabric.useTitle)
|
||||
: t(Keys.splashScreen.fabric.buildTitle);
|
||||
return (
|
||||
<>
|
||||
<CosmosFluentProvider className={styles.homeContainer}>
|
||||
@@ -238,9 +242,9 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
||||
{getSplashScreenButtons()}
|
||||
{
|
||||
<div className={styles.footer}>
|
||||
Need help?{" "}
|
||||
{t(Keys.splashScreen.sections.needHelp)}{" "}
|
||||
<Link href="https://learn.microsoft.com/fabric/database/cosmos-db/overview" target="_blank">
|
||||
Learn more <OpenRegular />
|
||||
{t(Keys.common.learnMore)} <OpenRegular />
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from "@fluentui/react-components";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { checkContainerExists, createContainer, importData, SampleDataFile } from "Explorer/SplashScreen/SampleUtil";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
|
||||
@@ -59,7 +61,7 @@ export const SampleDataImportDialog: React.FC<{
|
||||
setStatus("creating");
|
||||
const databaseName = props.sampleDataConfiguration.databaseName;
|
||||
if (checkContainerExists(databaseName, containerName)) {
|
||||
const msg = `The container "${containerName}" in database "${databaseName}" already exists. Please delete it and retry.`;
|
||||
const msg = t(Keys.splashScreen.sampleDataDialog.errorContainerExists, { containerName, databaseName });
|
||||
setStatus("error");
|
||||
setErrorMessage(msg);
|
||||
return;
|
||||
@@ -75,7 +77,11 @@ export const SampleDataImportDialog: React.FC<{
|
||||
);
|
||||
} catch (error) {
|
||||
setStatus("error");
|
||||
setErrorMessage(`Failed to create container: ${error instanceof Error ? error.message : String(error)}`);
|
||||
setErrorMessage(
|
||||
t(Keys.splashScreen.sampleDataDialog.errorCreateContainer, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,7 +92,11 @@ export const SampleDataImportDialog: React.FC<{
|
||||
setStatus("completed");
|
||||
} catch (error) {
|
||||
setStatus("error");
|
||||
setErrorMessage(`Failed to import data: ${error instanceof Error ? error.message : String(error)}`);
|
||||
setErrorMessage(
|
||||
t(Keys.splashScreen.sampleDataDialog.errorImportData, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -112,14 +122,26 @@ export const SampleDataImportDialog: React.FC<{
|
||||
const renderContent = () => {
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return `Create a container "${containerName}" and import sample data into it. This may take a few minutes.`;
|
||||
return t(Keys.splashScreen.sampleDataDialog.createPrompt, { containerName });
|
||||
|
||||
case "creating":
|
||||
return <Spinner size="small" labelPosition="above" label={`Creating container "${containerName}"...`} />;
|
||||
return (
|
||||
<Spinner
|
||||
size="small"
|
||||
labelPosition="above"
|
||||
label={t(Keys.splashScreen.sampleDataDialog.creatingContainer, { containerName })}
|
||||
/>
|
||||
);
|
||||
case "importing":
|
||||
return <Spinner size="small" labelPosition="above" label={`Importing data into "${containerName}"...`} />;
|
||||
return (
|
||||
<Spinner
|
||||
size="small"
|
||||
labelPosition="above"
|
||||
label={t(Keys.splashScreen.sampleDataDialog.importingData, { containerName })}
|
||||
/>
|
||||
);
|
||||
case "completed":
|
||||
return `Successfully created "${containerName}" with sample data.`;
|
||||
return t(Keys.splashScreen.sampleDataDialog.success, { containerName });
|
||||
case "error":
|
||||
return (
|
||||
<div style={{ color: "red" }}>
|
||||
@@ -132,14 +154,14 @@ export const SampleDataImportDialog: React.FC<{
|
||||
const getButtonLabel = () => {
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return "Start";
|
||||
return t(Keys.splashScreen.sampleDataDialog.startButton);
|
||||
case "creating":
|
||||
case "importing":
|
||||
return "Close";
|
||||
return t(Keys.common.close);
|
||||
case "completed":
|
||||
return "Close";
|
||||
return t(Keys.common.close);
|
||||
case "error":
|
||||
return "Close";
|
||||
return t(Keys.common.close);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,7 +169,7 @@ export const SampleDataImportDialog: React.FC<{
|
||||
<Dialog open={props.open} onOpenChange={(event, data) => props.setOpen(data.open)}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>Sample Data</DialogTitle>
|
||||
<DialogTitle>{t(Keys.splashScreen.sampleDataDialog.title)}</DialogTitle>
|
||||
<DialogContent>
|
||||
<div className={styles.dialogContent}>{renderContent()}</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -54,6 +54,6 @@
|
||||
.mainButtonsContainer {
|
||||
display: flex;
|
||||
gap: 0 16px;
|
||||
margin-bottom: 10px
|
||||
margin: 40px auto
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { sendMessage } from "Common/MessageHandler";
|
||||
import { MessageTypes } from "Contracts/ExplorerContracts";
|
||||
import { TerminalKind } from "Contracts/ViewModels";
|
||||
import { SplashScreenButton } from "Explorer/SplashScreen/SplashScreenButton";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
|
||||
import { useCarousel } from "hooks/useCarousel";
|
||||
@@ -164,6 +166,23 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
const container = explorer;
|
||||
const subscriptions: Array<{ dispose: () => void }> = [];
|
||||
|
||||
let title: string;
|
||||
let subtitle: string;
|
||||
|
||||
switch (userContext.apiType) {
|
||||
case "Postgres":
|
||||
title = t(Keys.splashScreen.title.postgres);
|
||||
subtitle = t(Keys.splashScreen.subtitle.getStarted);
|
||||
break;
|
||||
case "VCoreMongo":
|
||||
title = t(Keys.splashScreen.title.vcoreMongo);
|
||||
subtitle = t(Keys.splashScreen.subtitle.getStarted);
|
||||
break;
|
||||
default:
|
||||
title = t(Keys.splashScreen.title.default);
|
||||
subtitle = t(Keys.splashScreen.subtitle.default);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
subscriptions.push(
|
||||
{
|
||||
@@ -232,8 +251,8 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
<Stack className="splashStackRow" horizontal>
|
||||
<SplashScreenButton
|
||||
imgSrc={QuickStartIcon}
|
||||
title={"Launch quick start"}
|
||||
description={"Launch a quick start tutorial to get started with sample data"}
|
||||
title={t(Keys.splashScreen.quickStart.title)}
|
||||
description={t(Keys.splashScreen.quickStart.description)}
|
||||
onClick={() => {
|
||||
container.onNewCollectionClicked({ isQuickstart: true });
|
||||
traceOpen(Action.LaunchQuickstart, { apiType: userContext.apiType });
|
||||
@@ -241,8 +260,8 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
/>
|
||||
<SplashScreenButton
|
||||
imgSrc={ContainersIcon}
|
||||
title={`New ${getCollectionName()}`}
|
||||
description={"Create a new container for storage and throughput"}
|
||||
title={t(Keys.splashScreen.newCollection.title, { collectionName: getCollectionName() })}
|
||||
description={t(Keys.splashScreen.newCollection.description)}
|
||||
onClick={() => {
|
||||
container.onNewCollectionClicked();
|
||||
traceOpen(Action.NewContainerHomepage, { apiType: userContext.apiType });
|
||||
@@ -253,10 +272,8 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
<SplashScreenButton
|
||||
imgSrc={CosmosDBIcon}
|
||||
imgSize={35}
|
||||
title={"Azure Cosmos DB Samples Gallery"}
|
||||
description={
|
||||
"Discover samples that showcase scalable, intelligent app patterns. Try one now to see how fast you can go from concept to code with Cosmos DB"
|
||||
}
|
||||
title={t(Keys.splashScreen.samplesGallery.title)}
|
||||
description={t(Keys.splashScreen.samplesGallery.description)}
|
||||
onClick={() => {
|
||||
window.open("https://azurecosmosdb.github.io/gallery/?tags=example", "_blank");
|
||||
traceOpen(Action.LearningResourcesClicked, { apiType: userContext.apiType });
|
||||
@@ -264,8 +281,8 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
/>
|
||||
<SplashScreenButton
|
||||
imgSrc={ConnectIcon}
|
||||
title={"Connect"}
|
||||
description={"Prefer using your own choice of tooling? Find the connection string you need to connect"}
|
||||
title={t(Keys.splashScreen.connectCard.title)}
|
||||
description={t(Keys.splashScreen.connectCard.description)}
|
||||
onClick={() => useTabs.getState().openAndActivateReactTab(ReactTabKind.Connect)}
|
||||
/>
|
||||
</Stack>
|
||||
@@ -280,7 +297,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
usePostgres.getState().showPostgreTeachingBubble &&
|
||||
!usePostgres.getState().showResetPasswordBubble && (
|
||||
<TeachingBubble
|
||||
headline="New to Cosmos DB PGSQL?"
|
||||
headline={t(Keys.splashScreen.teachingBubble.newToPostgres.headline)}
|
||||
target={"#mainButton-quickstartDescription"}
|
||||
hasCloseButton
|
||||
onDismiss={() => usePostgres.getState().setShowPostgreTeachingBubble(false)}
|
||||
@@ -292,15 +309,14 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
preventDismissOnScroll: true,
|
||||
}}
|
||||
primaryButtonProps={{
|
||||
text: "Get started",
|
||||
text: t(Keys.common.getStarted),
|
||||
onClick: () => {
|
||||
useTabs.getState().openAndActivateReactTab(ReactTabKind.Quickstart);
|
||||
usePostgres.getState().setShowPostgreTeachingBubble(false);
|
||||
},
|
||||
}}
|
||||
>
|
||||
Welcome! If you are new to Cosmos DB PGSQL and need help with getting started, here is where you can find
|
||||
sample data, query.
|
||||
{t(Keys.splashScreen.teachingBubble.newToPostgres.body)}
|
||||
</TeachingBubble>
|
||||
)}
|
||||
{/*TODO: convert below to use SplashScreenButton */}
|
||||
@@ -332,7 +348,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
))}
|
||||
{userContext.apiType === "Postgres" && usePostgres.getState().showResetPasswordBubble && (
|
||||
<TeachingBubble
|
||||
headline="Create your password"
|
||||
headline={t(Keys.splashScreen.teachingBubble.resetPassword.headline)}
|
||||
target={"#mainButton-quickstartDescription"}
|
||||
hasCloseButton
|
||||
onDismiss={() => {
|
||||
@@ -347,7 +363,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
preventDismissOnScroll: true,
|
||||
}}
|
||||
primaryButtonProps={{
|
||||
text: "Create",
|
||||
text: t(Keys.common.create),
|
||||
onClick: () => {
|
||||
localStorage.setItem(userContext.databaseAccount.id, "true");
|
||||
sendMessage({
|
||||
@@ -357,7 +373,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
If you haven't changed your password yet, change it now.
|
||||
{t(Keys.splashScreen.teachingBubble.resetPassword.body)}
|
||||
</TeachingBubble>
|
||||
)}
|
||||
</div>
|
||||
@@ -376,8 +392,8 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
const launchQuickstartBtn = {
|
||||
id: "quickstartDescription",
|
||||
iconSrc: QuickStartIcon,
|
||||
title: "Launch quick start",
|
||||
description: "Launch a quick start tutorial to get started with sample data",
|
||||
title: t(Keys.splashScreen.quickStart.title),
|
||||
description: t(Keys.splashScreen.quickStart.description),
|
||||
onClick: () => {
|
||||
if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") {
|
||||
useTabs.getState().openAndActivateReactTab(ReactTabKind.Quickstart);
|
||||
@@ -399,8 +415,8 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
if (userContext.apiType === "Postgres") {
|
||||
return {
|
||||
iconSrc: PowerShellIcon,
|
||||
title: "PostgreSQL Shell",
|
||||
description: "Create table and interact with data using PostgreSQL's shell interface",
|
||||
title: t(Keys.splashScreen.shell.postgres.title),
|
||||
description: t(Keys.splashScreen.shell.postgres.description),
|
||||
onClick: () => container.openNotebookTerminal(TerminalKind.Postgres),
|
||||
};
|
||||
}
|
||||
@@ -408,16 +424,16 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
if (userContext.apiType === "VCoreMongo") {
|
||||
return {
|
||||
iconSrc: PowerShellIcon,
|
||||
title: "Mongo Shell",
|
||||
description: "Create a collection and interact with data using MongoDB's shell interface",
|
||||
title: t(Keys.splashScreen.shell.vcoreMongo.title),
|
||||
description: t(Keys.splashScreen.shell.vcoreMongo.description),
|
||||
onClick: () => container.openNotebookTerminal(TerminalKind.VCoreMongo),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
iconSrc: ContainersIcon,
|
||||
title: `New ${getCollectionName()}`,
|
||||
description: "Create a new container for storage and throughput",
|
||||
title: t(Keys.splashScreen.newCollection.title, { collectionName: getCollectionName() }),
|
||||
description: t(Keys.splashScreen.newCollection.description),
|
||||
onClick: () => {
|
||||
container.onNewCollectionClicked();
|
||||
traceOpen(Action.NewContainerHomepage, { apiType: userContext.apiType });
|
||||
@@ -427,19 +443,19 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
|
||||
const getThirdCard = (): SplashScreenItem => {
|
||||
let icon = ConnectIcon;
|
||||
let title = "Connect";
|
||||
let description = "Prefer using your own choice of tooling? Find the connection string you need to connect";
|
||||
let title = t(Keys.splashScreen.connectCard.title);
|
||||
let description = t(Keys.splashScreen.connectCard.description);
|
||||
let onClick = () => useTabs.getState().openAndActivateReactTab(ReactTabKind.Connect);
|
||||
|
||||
if (userContext.apiType === "Postgres") {
|
||||
title = "Connect with pgAdmin";
|
||||
description = "Prefer pgAdmin? Find your connection strings here";
|
||||
title = t(Keys.splashScreen.connectCard.pgAdmin.title);
|
||||
description = t(Keys.splashScreen.connectCard.pgAdmin.description);
|
||||
}
|
||||
|
||||
if (userContext.apiType === "VCoreMongo") {
|
||||
icon = VisualStudioIcon;
|
||||
title = "Connect with VS Code";
|
||||
description = "Query and Manage your MongoDB and DocumentDB clusters in Visual Studio Code";
|
||||
title = t(Keys.splashScreen.connectCard.vsCode.title);
|
||||
description = t(Keys.splashScreen.connectCard.vsCode.description);
|
||||
onClick = () => container?.openInVsCode && container.openInVsCode();
|
||||
}
|
||||
|
||||
@@ -468,7 +484,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
info: activity.path,
|
||||
iconSrc: NotebookIcon,
|
||||
title: activity.name,
|
||||
description: "Notebook",
|
||||
description: t(Keys.splashScreen.sections.notebook),
|
||||
onClick: () => {
|
||||
const notebookItem = container.createNotebookContentItemFile(activity.name, activity.path);
|
||||
notebookItem && container.openNotebook(notebookItem);
|
||||
@@ -507,18 +523,18 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
items = [
|
||||
{
|
||||
link: "https://aka.ms/msl-modeling-partitioning-2",
|
||||
title: "Advanced Modeling Patterns",
|
||||
description: "Learn advanced strategies to optimize your database.",
|
||||
title: t(Keys.splashScreen.top3Items.sql.advancedModeling.title),
|
||||
description: t(Keys.splashScreen.top3Items.sql.advancedModeling.description),
|
||||
},
|
||||
{
|
||||
link: "https://aka.ms/msl-modeling-partitioning-1",
|
||||
title: "Partitioning Best Practices",
|
||||
description: "Learn to apply data model and partitioning strategies.",
|
||||
title: t(Keys.splashScreen.top3Items.sql.partitioning.title),
|
||||
description: t(Keys.splashScreen.top3Items.sql.partitioning.description),
|
||||
},
|
||||
{
|
||||
link: "https://aka.ms/msl-resource-planning",
|
||||
title: "Plan Your Resource Requirements",
|
||||
description: "Get to know the different configuration choices.",
|
||||
title: t(Keys.splashScreen.top3Items.sql.resourcePlanning.title),
|
||||
description: t(Keys.splashScreen.top3Items.sql.resourcePlanning.description),
|
||||
},
|
||||
];
|
||||
break;
|
||||
@@ -526,18 +542,18 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
items = [
|
||||
{
|
||||
link: "https://aka.ms/mongodbintro",
|
||||
title: "What is the MongoDB API?",
|
||||
description: "Understand Azure Cosmos DB for MongoDB and its features.",
|
||||
title: t(Keys.splashScreen.top3Items.mongo.whatIsMongo.title),
|
||||
description: t(Keys.splashScreen.top3Items.mongo.whatIsMongo.description),
|
||||
},
|
||||
{
|
||||
link: "https://aka.ms/mongodbfeaturesupport",
|
||||
title: "Features and Syntax",
|
||||
description: "Discover the advantages and features",
|
||||
title: t(Keys.splashScreen.top3Items.mongo.features.title),
|
||||
description: t(Keys.splashScreen.top3Items.mongo.features.description),
|
||||
},
|
||||
{
|
||||
link: "https://aka.ms/mongodbpremigration",
|
||||
title: "Migrate Your Data",
|
||||
description: "Pre-migration steps for moving data",
|
||||
title: t(Keys.splashScreen.top3Items.mongo.migrate.title),
|
||||
description: t(Keys.splashScreen.top3Items.mongo.migrate.description),
|
||||
},
|
||||
];
|
||||
break;
|
||||
@@ -545,18 +561,18 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
items = [
|
||||
{
|
||||
link: "https://aka.ms/cassandrajava",
|
||||
title: "Build a Java App",
|
||||
description: "Create a Java app using an SDK.",
|
||||
title: t(Keys.splashScreen.top3Items.cassandra.buildJavaApp.title),
|
||||
description: t(Keys.splashScreen.top3Items.cassandra.buildJavaApp.description),
|
||||
},
|
||||
{
|
||||
link: "https://aka.ms/cassandrapartitioning",
|
||||
title: "Partitioning Best Practices",
|
||||
description: "Learn how partitioning works.",
|
||||
title: t(Keys.splashScreen.top3Items.cassandra.partitioning.title),
|
||||
description: t(Keys.splashScreen.top3Items.cassandra.partitioning.description),
|
||||
},
|
||||
{
|
||||
link: "https://aka.ms/cassandraRu",
|
||||
title: "Request Units (RUs)",
|
||||
description: "Understand RU charges.",
|
||||
title: t(Keys.splashScreen.top3Items.cassandra.requestUnits.title),
|
||||
description: t(Keys.splashScreen.top3Items.cassandra.requestUnits.description),
|
||||
},
|
||||
];
|
||||
break;
|
||||
@@ -564,18 +580,18 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
items = [
|
||||
{
|
||||
link: "https://aka.ms/Graphdatamodeling",
|
||||
title: "Data Modeling",
|
||||
description: "Graph data modeling recommendations",
|
||||
title: t(Keys.splashScreen.top3Items.gremlin.dataModeling.title),
|
||||
description: t(Keys.splashScreen.top3Items.gremlin.dataModeling.description),
|
||||
},
|
||||
{
|
||||
link: "https://aka.ms/graphpartitioning",
|
||||
title: "Partitioning Best Practices",
|
||||
description: "Learn how partitioning works",
|
||||
title: t(Keys.splashScreen.top3Items.gremlin.partitioning.title),
|
||||
description: t(Keys.splashScreen.top3Items.gremlin.partitioning.description),
|
||||
},
|
||||
{
|
||||
link: "https://aka.ms/graphapiquery",
|
||||
title: "Query Data",
|
||||
description: "Querying data with Gremlin",
|
||||
title: t(Keys.splashScreen.top3Items.gremlin.queryData.title),
|
||||
description: t(Keys.splashScreen.top3Items.gremlin.queryData.description),
|
||||
},
|
||||
];
|
||||
break;
|
||||
@@ -583,18 +599,18 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
items = [
|
||||
{
|
||||
link: "https://aka.ms/tableintro",
|
||||
title: "What is the Table API?",
|
||||
description: "Understand Azure Cosmos DB for Table and its features",
|
||||
title: t(Keys.splashScreen.top3Items.tables.whatIsTable.title),
|
||||
description: t(Keys.splashScreen.top3Items.tables.whatIsTable.description),
|
||||
},
|
||||
{
|
||||
link: "https://aka.ms/tableimport",
|
||||
title: "Migrate your data",
|
||||
description: "Learn how to migrate your data",
|
||||
title: t(Keys.splashScreen.top3Items.tables.migrate.title),
|
||||
description: t(Keys.splashScreen.top3Items.tables.migrate.description),
|
||||
},
|
||||
{
|
||||
link: "https://aka.ms/tablefaq",
|
||||
title: "Azure Cosmos DB for Table FAQs",
|
||||
description: "Common questions about Azure Cosmos DB for Table",
|
||||
title: t(Keys.splashScreen.top3Items.tables.faq.title),
|
||||
description: t(Keys.splashScreen.top3Items.tables.faq.description),
|
||||
},
|
||||
];
|
||||
break;
|
||||
@@ -651,7 +667,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
</ul>
|
||||
{recentItems.length > 0 && (
|
||||
<Link onClick={() => clearMostRecent()} className={styles.listItemTitle}>
|
||||
Clear Recents
|
||||
{t(Keys.splashScreen.sections.clearRecents)}
|
||||
</Link>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -666,15 +682,15 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
}
|
||||
const cdbLiveTv: item = {
|
||||
link: "https://developer.azurecosmosdb.com/tv",
|
||||
title: "Learn the Fundamentals",
|
||||
description: "Watch Azure Cosmos DB Live TV show introductory and how to videos.",
|
||||
title: t(Keys.splashScreen.learningResources.liveTv.title),
|
||||
description: t(Keys.splashScreen.learningResources.liveTv.description),
|
||||
};
|
||||
|
||||
const commonItems: item[] = [
|
||||
{
|
||||
link: "https://learn.microsoft.com/azure/cosmos-db/data-explorer-shortcuts",
|
||||
title: "Data Explorer keyboard shortcuts",
|
||||
description: "Learn keyboard shortcuts to navigate Data Explorer.",
|
||||
title: t(Keys.splashScreen.learningResources.shortcuts.title),
|
||||
description: t(Keys.splashScreen.learningResources.shortcuts.description),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -685,14 +701,14 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
apiItems = [
|
||||
{
|
||||
link: "https://aka.ms/msl-sdk-connect",
|
||||
title: "Get Started using an SDK",
|
||||
description: "Learn about the Azure Cosmos DB SDK.",
|
||||
title: t(Keys.splashScreen.learningResources.sql.sdk.title),
|
||||
description: t(Keys.splashScreen.learningResources.sql.sdk.description),
|
||||
},
|
||||
cdbLiveTv,
|
||||
{
|
||||
link: "https://aka.ms/msl-move-data",
|
||||
title: "Migrate Your Data",
|
||||
description: "Migrate data using Azure services and open-source solutions.",
|
||||
title: t(Keys.splashScreen.learningResources.sql.migrate.title),
|
||||
description: t(Keys.splashScreen.learningResources.sql.migrate.description),
|
||||
},
|
||||
];
|
||||
break;
|
||||
@@ -700,13 +716,13 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
apiItems = [
|
||||
{
|
||||
link: "https://aka.ms/mongonodejs",
|
||||
title: "Build an app with Node.js",
|
||||
description: "Create a Node.js app.",
|
||||
title: t(Keys.splashScreen.learningResources.mongo.nodejs.title),
|
||||
description: t(Keys.splashScreen.learningResources.mongo.nodejs.description),
|
||||
},
|
||||
{
|
||||
link: "https://aka.ms/mongopython",
|
||||
title: "Getting Started Guide",
|
||||
description: "Learn the basics to get started.",
|
||||
title: t(Keys.splashScreen.learningResources.mongo.gettingStarted.title),
|
||||
description: t(Keys.splashScreen.learningResources.mongo.gettingStarted.description),
|
||||
},
|
||||
cdbLiveTv,
|
||||
];
|
||||
@@ -715,14 +731,14 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
apiItems = [
|
||||
{
|
||||
link: "https://aka.ms/cassandracontainer",
|
||||
title: "Create a Container",
|
||||
description: "Get to know the create a container options.",
|
||||
title: t(Keys.splashScreen.learningResources.cassandra.createContainer.title),
|
||||
description: t(Keys.splashScreen.learningResources.cassandra.createContainer.description),
|
||||
},
|
||||
cdbLiveTv,
|
||||
{
|
||||
link: "https://aka.ms/Cassandrathroughput",
|
||||
title: "Provision Throughput",
|
||||
description: "Learn how to configure throughput.",
|
||||
title: t(Keys.splashScreen.learningResources.cassandra.throughput.title),
|
||||
description: t(Keys.splashScreen.learningResources.cassandra.throughput.description),
|
||||
},
|
||||
];
|
||||
break;
|
||||
@@ -730,13 +746,13 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
apiItems = [
|
||||
{
|
||||
link: "https://aka.ms/graphquickstart",
|
||||
title: "Get Started ",
|
||||
description: "Create, query, and traverse using the Gremlin console",
|
||||
title: t(Keys.splashScreen.learningResources.gremlin.getStarted.title),
|
||||
description: t(Keys.splashScreen.learningResources.gremlin.getStarted.description),
|
||||
},
|
||||
{
|
||||
link: "https://aka.ms/graphimport",
|
||||
title: "Import Graph Data",
|
||||
description: "Learn Bulk ingestion data using BulkExecutor",
|
||||
title: t(Keys.splashScreen.learningResources.gremlin.importData.title),
|
||||
description: t(Keys.splashScreen.learningResources.gremlin.importData.description),
|
||||
},
|
||||
cdbLiveTv,
|
||||
];
|
||||
@@ -745,13 +761,13 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
apiItems = [
|
||||
{
|
||||
link: "https://aka.ms/tabledotnet",
|
||||
title: "Build a .NET App",
|
||||
description: "How to access Azure Cosmos DB for Table from a .NET app.",
|
||||
title: t(Keys.splashScreen.learningResources.tables.dotnet.title),
|
||||
description: t(Keys.splashScreen.learningResources.tables.dotnet.description),
|
||||
},
|
||||
{
|
||||
link: "https://aka.ms/Tablejava",
|
||||
title: "Build a Java App",
|
||||
description: "Create a Azure Cosmos DB for Table app with Java SDK ",
|
||||
title: t(Keys.splashScreen.learningResources.tables.java.title),
|
||||
description: t(Keys.splashScreen.learningResources.tables.java.description),
|
||||
},
|
||||
cdbLiveTv,
|
||||
];
|
||||
@@ -790,17 +806,17 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
const postgresNextStepItems: { link: string; title: string; description: string }[] = [
|
||||
{
|
||||
link: "https://go.microsoft.com/fwlink/?linkid=2208312",
|
||||
title: "Data Modeling",
|
||||
title: t(Keys.splashScreen.nextStepItems.postgres.dataModeling),
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
link: " https://go.microsoft.com/fwlink/?linkid=2206941 ",
|
||||
title: "How to choose a Distribution Column",
|
||||
title: t(Keys.splashScreen.nextStepItems.postgres.distributionColumn),
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
link: "https://go.microsoft.com/fwlink/?linkid=2207425",
|
||||
title: "Build Apps with Python/Java/Django",
|
||||
title: t(Keys.splashScreen.nextStepItems.postgres.buildApps),
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
@@ -808,17 +824,17 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
const vcoreMongoNextStepItems: { link: string; title: string; description: string }[] = [
|
||||
{
|
||||
link: "https://learn.microsoft.com/azure/cosmos-db/mongodb/vcore/migration-options",
|
||||
title: "Migrate Data",
|
||||
title: t(Keys.splashScreen.nextStepItems.vcoreMongo.migrateData),
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
link: "https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/vector-search-ai",
|
||||
title: "Build AI apps with Vector Search",
|
||||
title: t(Keys.splashScreen.nextStepItems.vcoreMongo.vectorSearch),
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
link: "https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/tutorial-nodejs-web-app?tabs=github-codespaces",
|
||||
title: "Build Apps with Nodejs",
|
||||
title: t(Keys.splashScreen.nextStepItems.vcoreMongo.buildApps),
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
@@ -846,17 +862,17 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
const postgresLearnMoreItems: { link: string; title: string; description: string }[] = [
|
||||
{
|
||||
link: "https://go.microsoft.com/fwlink/?linkid=2207226",
|
||||
title: "Performance Tuning",
|
||||
title: t(Keys.splashScreen.learnMoreItems.postgres.performanceTuning),
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
link: "https://go.microsoft.com/fwlink/?linkid=2208037",
|
||||
title: "Useful Diagnostic Queries",
|
||||
title: t(Keys.splashScreen.learnMoreItems.postgres.diagnosticQueries),
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
link: "https://go.microsoft.com/fwlink/?linkid=2205270",
|
||||
title: "Distributed SQL Reference",
|
||||
title: t(Keys.splashScreen.learnMoreItems.postgres.sqlReference),
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
@@ -864,17 +880,17 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
const vcoreMongoLearnMoreItems: { link: string; title: string; description: string }[] = [
|
||||
{
|
||||
link: "https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/vector-search",
|
||||
title: "Vector Search",
|
||||
title: t(Keys.splashScreen.learnMoreItems.vcoreMongo.vectorSearch),
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
link: "https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/how-to-create-text-index",
|
||||
title: "Text Indexing",
|
||||
title: t(Keys.splashScreen.learnMoreItems.vcoreMongo.textIndexing),
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
link: "https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/troubleshoot-common-issues",
|
||||
title: "Troubleshoot common issues",
|
||||
title: t(Keys.splashScreen.learnMoreItems.vcoreMongo.troubleshoot),
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
@@ -902,10 +918,11 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
return (
|
||||
<div className={styles.splashScreenContainer}>
|
||||
<div className={styles.splashScreen}>
|
||||
<h2 className={styles.title} role="heading" aria-label="Welcome to Azure Cosmos DB">
|
||||
Welcome to Azure Cosmos DB<span className="activePatch"></span>
|
||||
<h2 className={styles.title} role="heading" aria-label={title}>
|
||||
{title}
|
||||
<span className="activePatch"></span>
|
||||
</h2>
|
||||
<div className={styles.subtitle}>Globally distributed, multi-model database service for any scale</div>
|
||||
<div className={styles.subtitle}>{subtitle}</div>
|
||||
{getSplashScreenButtons()}
|
||||
{useCarousel.getState().showCoachMark && (
|
||||
<Coachmark
|
||||
@@ -914,24 +931,25 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
persistentBeak
|
||||
>
|
||||
<TeachingBubbleContent
|
||||
headline={`Start with sample ${getCollectionName().toLocaleLowerCase()}`}
|
||||
headline={t(Keys.splashScreen.teachingBubble.coachMark.headline, {
|
||||
collectionName: getCollectionName().toLocaleLowerCase(),
|
||||
})}
|
||||
hasCloseButton
|
||||
closeButtonAriaLabel="Close"
|
||||
closeButtonAriaLabel={t(Keys.common.close)}
|
||||
primaryButtonProps={{
|
||||
text: "Get started",
|
||||
text: t(Keys.common.getStarted),
|
||||
onClick: () => {
|
||||
useCarousel.getState().setShowCoachMark(false);
|
||||
container.onNewCollectionClicked({ isQuickstart: true });
|
||||
},
|
||||
}}
|
||||
secondaryButtonProps={{
|
||||
text: "Cancel",
|
||||
text: t(Keys.common.cancel),
|
||||
onClick: () => useCarousel.getState().setShowCoachMark(false),
|
||||
}}
|
||||
onDismiss={() => useCarousel.getState().setShowCoachMark(false)}
|
||||
>
|
||||
You will be guided to create a sample container with sample data, then we will give you a tour of data
|
||||
explorer. You can also cancel launching this tour and explore yourself
|
||||
{t(Keys.splashScreen.teachingBubble.coachMark.body)}
|
||||
</TeachingBubbleContent>
|
||||
</Coachmark>
|
||||
)}
|
||||
@@ -945,7 +963,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
fontFamily: '"Segoe UI Semibold", "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif',
|
||||
}}
|
||||
>
|
||||
Next steps
|
||||
{t(Keys.splashScreen.sections.nextSteps)}
|
||||
</Text>
|
||||
{getNextStepItems()}
|
||||
</Stack>
|
||||
@@ -957,7 +975,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
fontFamily: '"Segoe UI Semibold", "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif',
|
||||
}}
|
||||
>
|
||||
Tips & learn more
|
||||
{t(Keys.splashScreen.sections.tipsAndLearnMore)}
|
||||
</Text>
|
||||
{getTipsAndLearnMoreItems()}
|
||||
</Stack>
|
||||
@@ -966,15 +984,15 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
) : (
|
||||
<div className={styles.moreStuffContainer}>
|
||||
<div className={styles.moreStuffColumn}>
|
||||
<h2 className={styles.columnTitle}>Recents</h2>
|
||||
<h2 className={styles.columnTitle}>{t(Keys.splashScreen.sections.recents)}</h2>
|
||||
{getRecentItems()}
|
||||
</div>
|
||||
<div className={styles.moreStuffColumn}>
|
||||
<h2 className={styles.columnTitle}>Top 3 things you need to know</h2>
|
||||
<h2 className={styles.columnTitle}>{t(Keys.splashScreen.sections.top3)}</h2>
|
||||
{top3Items()}
|
||||
</div>
|
||||
<div className={styles.moreStuffColumn}>
|
||||
<h2 className={styles.columnTitle}>Learning Resources</h2>
|
||||
<h2 className={styles.columnTitle}>{t(Keys.splashScreen.sections.learningResources)}</h2>
|
||||
{getLearningResourceItems()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,8 @@ import { queryConflicts } from "../../Common/dataAccess/queryConflicts";
|
||||
import { updateDocument } from "../../Common/dataAccess/updateDocument";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Keys } from "../../Localization/Keys.generated";
|
||||
import { t } from "../../Localization/t";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
|
||||
@@ -57,7 +59,7 @@ export default class ConflictsTab extends TabsBase {
|
||||
|
||||
private _documentsIterator: MinimalQueryIterator;
|
||||
private _container: Explorer;
|
||||
private _acceptButtonLabel: ko.Observable<string> = ko.observable("Save");
|
||||
private _acceptButtonLabel: ko.Observable<string> = ko.observable(t(Keys.common.save));
|
||||
|
||||
constructor(options: ViewModels.ConflictsTabOptions) {
|
||||
super(options);
|
||||
@@ -213,9 +215,9 @@ export default class ConflictsTab extends TabsBase {
|
||||
this.selectedConflictContent.subscribe((newContent: string) => this._onEditorContentChange(newContent));
|
||||
|
||||
this.conflictOperation.subscribe((newOperationType: string) => {
|
||||
let operationLabel = "Save";
|
||||
let operationLabel = t(Keys.common.save);
|
||||
if (newOperationType === Constants.ConflictOperationType.Replace) {
|
||||
operationLabel = "Update";
|
||||
operationLabel = t(Keys.common.update);
|
||||
}
|
||||
|
||||
this._acceptButtonLabel(operationLabel);
|
||||
@@ -229,7 +231,7 @@ export default class ConflictsTab extends TabsBase {
|
||||
this._documentsIterator = this.createIterator();
|
||||
await this.loadNextPage();
|
||||
} catch (error) {
|
||||
useDialog.getState().showOkModalDialog("Refresh documents grid failed", getErrorMessage(error));
|
||||
useDialog.getState().showOkModalDialog(t(Keys.tabs.conflicts.refreshGridFailed), getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,11 +259,11 @@ export default class ConflictsTab extends TabsBase {
|
||||
useDialog
|
||||
.getState()
|
||||
.showOkCancelModalDialog(
|
||||
"Unsaved changes",
|
||||
"Changes will be lost. Do you want to continue?",
|
||||
"OK",
|
||||
t(Keys.tabs.conflicts.unsavedChanges),
|
||||
t(Keys.tabs.conflicts.changesWillBeLost),
|
||||
t(Keys.common.ok),
|
||||
async () => await this.resolveConflict(),
|
||||
"Cancel",
|
||||
t(Keys.common.cancel),
|
||||
undefined,
|
||||
);
|
||||
} else {
|
||||
@@ -332,7 +334,7 @@ export default class ConflictsTab extends TabsBase {
|
||||
} catch (error) {
|
||||
this.isExecutionError(true);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
useDialog.getState().showOkModalDialog("Resolve conflict failed", errorMessage);
|
||||
useDialog.getState().showOkModalDialog(t(Keys.tabs.conflicts.resolveConflictFailed), errorMessage);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.ResolveConflict,
|
||||
{
|
||||
@@ -386,7 +388,7 @@ export default class ConflictsTab extends TabsBase {
|
||||
} catch (error) {
|
||||
this.isExecutionError(true);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
useDialog.getState().showOkModalDialog("Delete conflict failed", errorMessage);
|
||||
useDialog.getState().showOkModalDialog(t(Keys.tabs.conflicts.deleteConflictFailed), errorMessage);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.DeleteConflict,
|
||||
{
|
||||
@@ -617,7 +619,7 @@ export default class ConflictsTab extends TabsBase {
|
||||
}
|
||||
|
||||
if (this.discardButton.visible()) {
|
||||
const label = "Discard";
|
||||
const label = t(Keys.common.discard);
|
||||
buttons.push({
|
||||
iconSrc: DiscardIcon,
|
||||
iconAlt: label,
|
||||
@@ -630,7 +632,7 @@ export default class ConflictsTab extends TabsBase {
|
||||
}
|
||||
|
||||
if (this.deleteButton.visible()) {
|
||||
const label = "Delete";
|
||||
const label = t(Keys.common.delete);
|
||||
buttons.push({
|
||||
iconSrc: DeleteIcon,
|
||||
iconAlt: label,
|
||||
|
||||
@@ -41,6 +41,8 @@ import { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||
import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
|
||||
import { useSelectedNode } from "Explorer/useSelectedNode";
|
||||
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { isFabric } from "Platform/Fabric/FabricUtil";
|
||||
import { QueryConstants } from "Shared/Constants";
|
||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
@@ -349,7 +351,7 @@ export const getTabsButtons = ({
|
||||
}
|
||||
|
||||
const buttons: CommandButtonComponentProps[] = [];
|
||||
const label = !isPreferredApiMongoDB ? "New Item" : "New Document";
|
||||
const label = !isPreferredApiMongoDB ? t(Keys.tabs.documents.newItem) : t(Keys.tabs.documents.newDocument);
|
||||
if (getNewDocumentButtonState(editorState).visible) {
|
||||
buttons.push({
|
||||
iconSrc: NewDocumentIcon,
|
||||
@@ -368,7 +370,7 @@ export const getTabsButtons = ({
|
||||
}
|
||||
|
||||
if (getSaveNewDocumentButtonState(editorState).visible) {
|
||||
const label = "Save";
|
||||
const label = t(Keys.common.save);
|
||||
buttons.push({
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: label,
|
||||
@@ -386,7 +388,7 @@ export const getTabsButtons = ({
|
||||
}
|
||||
|
||||
if (getDiscardNewDocumentChangesButtonState(editorState).visible) {
|
||||
const label = "Discard";
|
||||
const label = t(Keys.common.discard);
|
||||
buttons.push({
|
||||
iconSrc: DiscardIcon,
|
||||
iconAlt: label,
|
||||
@@ -403,7 +405,7 @@ export const getTabsButtons = ({
|
||||
}
|
||||
|
||||
if (getSaveExistingDocumentButtonState(editorState).visible) {
|
||||
const label = "Update";
|
||||
const label = t(Keys.common.update);
|
||||
buttons.push({
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: label,
|
||||
@@ -421,7 +423,7 @@ export const getTabsButtons = ({
|
||||
}
|
||||
|
||||
if (getDiscardExistingDocumentChangesButtonState(editorState).visible) {
|
||||
const label = "Discard";
|
||||
const label = t(Keys.common.discard);
|
||||
buttons.push({
|
||||
iconSrc: DiscardIcon,
|
||||
iconAlt: label,
|
||||
@@ -438,7 +440,7 @@ export const getTabsButtons = ({
|
||||
}
|
||||
|
||||
if (selectedRows.size > 0) {
|
||||
const label = "Delete";
|
||||
const label = t(Keys.common.delete);
|
||||
buttons.push({
|
||||
iconSrc: DeleteDocumentIcon,
|
||||
iconAlt: label,
|
||||
@@ -453,7 +455,7 @@ export const getTabsButtons = ({
|
||||
}
|
||||
|
||||
if (!isPreferredApiMongoDB) {
|
||||
const label = "Upload Item";
|
||||
const label = t(Keys.tabs.documents.uploadItem);
|
||||
buttons.push({
|
||||
id: UPLOAD_BUTTON_ID,
|
||||
iconSrc: UploadIcon,
|
||||
@@ -737,17 +739,18 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
} else if (result.statusCode >= 400) {
|
||||
newFailed.push(result.documentId);
|
||||
logConsoleError(
|
||||
`Failed to delete document ${result.documentId.id()} with status code ${result.statusCode}`,
|
||||
t(Keys.tabs.documents.deleteDocumentFailedLog, {
|
||||
documentId: result.documentId.id(),
|
||||
statusCode: result.statusCode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
logConsoleInfo(`Successfully deleted ${newSuccessful.length} document(s)`);
|
||||
logConsoleInfo(t(Keys.tabs.documents.deleteSuccessLog, { count: newSuccessful.length }));
|
||||
|
||||
if (newThrottled.length > 0) {
|
||||
logConsoleError(
|
||||
`Failed to delete ${newThrottled.length} document(s) due to "Request too large" (429) error. Retrying...`,
|
||||
);
|
||||
logConsoleError(t(Keys.tabs.documents.deleteThrottledLog, { count: newThrottled.length }));
|
||||
}
|
||||
|
||||
// Update result of the bulk delete: method is called again, because the state variables changed
|
||||
@@ -789,7 +792,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
);
|
||||
let partitionKeyProperties = useMemo(() => {
|
||||
return partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) =>
|
||||
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""),
|
||||
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, "").replace(/["]+/g, ""),
|
||||
);
|
||||
}, [partitionKeyPropertyHeaders]);
|
||||
|
||||
@@ -917,11 +920,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
useDialog
|
||||
.getState()
|
||||
.showOkCancelModalDialog(
|
||||
"Unsaved changes",
|
||||
"Your unsaved changes will be lost. Do you want to continue?",
|
||||
"OK",
|
||||
t(Keys.tabs.documents.unsavedChanges),
|
||||
t(Keys.tabs.documents.unsavedChangesMessage),
|
||||
t(Keys.common.ok),
|
||||
onDiscard,
|
||||
"Cancel",
|
||||
t(Keys.common.cancel),
|
||||
onCancelDiscard,
|
||||
);
|
||||
} else {
|
||||
@@ -1011,7 +1014,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
(error) => {
|
||||
onExecutionErrorChange(true);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
useDialog.getState().showOkModalDialog("Create document failed", errorMessage);
|
||||
useDialog.getState().showOkModalDialog(t(Keys.tabs.documents.createDocumentFailed), errorMessage);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.CreateDocument,
|
||||
{
|
||||
@@ -1097,7 +1100,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
selectedDocumentId.partitionKeyValue = originalPartitionKeyValue;
|
||||
onExecutionErrorChange(true);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
useDialog.getState().showOkModalDialog("Update document failed", errorMessage);
|
||||
useDialog.getState().showOkModalDialog(t(Keys.tabs.documents.updateDocumentFailed), errorMessage);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.UpdateDocument,
|
||||
{
|
||||
@@ -1174,7 +1177,12 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
// Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should
|
||||
// always be called for NoSQL.
|
||||
deletePromise = deleteNoSqlDocument(_collection, toDeleteDocumentIds[0]).then(() => {
|
||||
useDialog.getState().showOkModalDialog("Delete document", "Document successfully deleted.");
|
||||
useDialog
|
||||
.getState()
|
||||
.showOkModalDialog(
|
||||
t(Keys.tabs.documents.deleteDocumentDialogTitle),
|
||||
t(Keys.tabs.documents.documentDeleted),
|
||||
);
|
||||
return [toDeleteDocumentIds[0]];
|
||||
});
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
@@ -1251,17 +1259,20 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
useDialog
|
||||
.getState()
|
||||
.showOkModalDialog(
|
||||
"Delete documents",
|
||||
`Some documents failed to delete due to a rate limiting error. Please try again later. To prevent this in the future, consider increasing the throughput on your container or database.`,
|
||||
t(Keys.tabs.documents.deleteDocumentsDialogTitle),
|
||||
t(Keys.tabs.documents.throttlingError),
|
||||
{
|
||||
linkText: "Learn More",
|
||||
linkText: t(Keys.common.learnMore),
|
||||
linkUrl: MONGO_THROTTLING_DOC_URL,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
useDialog
|
||||
.getState()
|
||||
.showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`);
|
||||
.showOkModalDialog(
|
||||
t(Keys.tabs.documents.deleteDocumentsDialogTitle),
|
||||
t(Keys.tabs.documents.deleteFailed, { error: error.message }),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1275,21 +1286,21 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
const isPlural = selectedRows.size > 1;
|
||||
const documentName = !isPreferredApiMongoDB
|
||||
? isPlural
|
||||
? `the selected ${selectedRows.size} items`
|
||||
: "the selected item"
|
||||
? t(Keys.tabs.documents.selectedItems, { count: selectedRows.size })
|
||||
: t(Keys.tabs.documents.selectedItem)
|
||||
: isPlural
|
||||
? `the selected ${selectedRows.size} documents`
|
||||
: "the selected document";
|
||||
const msg = `Are you sure you want to delete ${documentName}?`;
|
||||
? t(Keys.tabs.documents.selectedDocuments, { count: selectedRows.size })
|
||||
: t(Keys.tabs.documents.selectedDocument);
|
||||
const msg = t(Keys.tabs.documents.confirmDelete, { documentName });
|
||||
|
||||
useDialog
|
||||
.getState()
|
||||
.showOkCancelModalDialog(
|
||||
"Confirm delete",
|
||||
t(Keys.tabs.documents.confirmDeleteTitle),
|
||||
msg,
|
||||
"Delete",
|
||||
t(Keys.common.delete),
|
||||
() => deleteDocuments(Array.from(selectedRows).map((index) => documentIds[index as number])),
|
||||
"Cancel",
|
||||
t(Keys.common.cancel),
|
||||
undefined,
|
||||
);
|
||||
}, [deleteDocuments, documentIds, isPreferredApiMongoDB, selectedRows]);
|
||||
@@ -1470,7 +1481,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
const partitionKey = _partitionKey || (_collection && _collection.partitionKey);
|
||||
const partitionKeyPropertyHeaders = _collection?.partitionKeyPropertyHeaders || partitionKey?.paths;
|
||||
const partitionKeyProperties = partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) =>
|
||||
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""),
|
||||
partitionKeyPropertyHeader
|
||||
.replace(/[/]+/g, ".")
|
||||
.substring(1)
|
||||
.replace(/[']+/g, "")
|
||||
.replace(/["]+/g, ""),
|
||||
);
|
||||
|
||||
return newDocumentId(rawDocument, partitionKeyProperties, partitionKeyValue);
|
||||
@@ -1819,8 +1834,8 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
|
||||
const partitionKeyProperty = partitionKeyProperties?.[0];
|
||||
if (partitionKeyProperty !== "_id" && !_hasShardKeySpecified(documentContent)) {
|
||||
const message = `The document is lacking the shard property: ${partitionKeyProperty}`;
|
||||
useDialog.getState().showOkModalDialog("Create document failed", message);
|
||||
const message = t(Keys.tabs.documents.missingShardProperty, { partitionKeyProperty });
|
||||
useDialog.getState().showOkModalDialog(t(Keys.tabs.documents.createDocumentFailed), message);
|
||||
onExecutionErrorChange(true);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.CreateDocument,
|
||||
@@ -1831,7 +1846,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
},
|
||||
startKey,
|
||||
);
|
||||
Logger.logError("Failed to save new document: Document shard key not defined", "MongoDocumentsTab");
|
||||
Logger.logError(t(Keys.tabs.documents.missingShardKeyLog), "MongoDocumentsTab");
|
||||
throw new Error("Document without shard key");
|
||||
}
|
||||
|
||||
@@ -1874,7 +1889,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
(error) => {
|
||||
onExecutionErrorChange(true);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
useDialog.getState().showOkModalDialog("Create document failed", errorMessage);
|
||||
useDialog.getState().showOkModalDialog(t(Keys.tabs.documents.createDocumentFailed), errorMessage);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.CreateDocument,
|
||||
{
|
||||
@@ -1945,7 +1960,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
(error) => {
|
||||
onExecutionErrorChange(true);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
useDialog.getState().showOkModalDialog("Update document failed", errorMessage);
|
||||
useDialog.getState().showOkModalDialog(t(Keys.tabs.documents.updateDocumentFailed), errorMessage);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.UpdateDocument,
|
||||
{
|
||||
@@ -2054,7 +2069,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
useDialog.getState().showOkModalDialog("Refresh documents grid failed", getErrorMessage(error));
|
||||
useDialog.getState().showOkModalDialog(t(Keys.tabs.documents.refreshGridFailed), getErrorMessage(error));
|
||||
}
|
||||
},
|
||||
[createIterator, filterContent],
|
||||
@@ -2066,18 +2081,17 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
* @returns 429 warning message
|
||||
*/
|
||||
const get429WarningMessageNoSql = (): string => {
|
||||
let message = 'Some delete requests failed due to a "Request too large" exception (429)';
|
||||
let message = t(Keys.tabs.documents.requestTooLargeBase);
|
||||
|
||||
if (bulkDeleteOperation.count === bulkDeleteProcess.successfulIds.length) {
|
||||
message += ", but were successfully retried.";
|
||||
message += ", " + t(Keys.tabs.documents.retriedSuccessfully);
|
||||
} else if (bulkDeleteMode === "inProgress" || bulkDeleteMode === "aborting") {
|
||||
message += ". Retrying now.";
|
||||
message += ". " + t(Keys.tabs.documents.retryingNow);
|
||||
} else {
|
||||
message += ".";
|
||||
}
|
||||
|
||||
return (message +=
|
||||
" To prevent this in the future, consider increasing the throughput on your container or database.");
|
||||
return (message += " " + t(Keys.tabs.documents.increaseThroughputTip));
|
||||
};
|
||||
|
||||
const onColumnSelectionChange = (newSelectedColumnIds: string[]): void => {
|
||||
@@ -2124,7 +2138,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
const nonBlankLastFilters = lastFilterContents.filter((filter) => filter.trim() !== "");
|
||||
if (nonBlankLastFilters.length > 0) {
|
||||
options.push({
|
||||
label: "Saved filters",
|
||||
label: t(Keys.tabs.documents.savedFilters),
|
||||
options: nonBlankLastFilters,
|
||||
});
|
||||
}
|
||||
@@ -2153,14 +2167,14 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
dropdownOptions={getFilterChoices()}
|
||||
placeholder={
|
||||
isPreferredApiMongoDB
|
||||
? "Type a query predicate (e.g., {´a´:´foo´}), or choose one from the drop down list, or leave empty to query all documents."
|
||||
: "Type a query predicate (e.g., WHERE c.id=´1´), or choose one from the drop down list, or leave empty to query all documents."
|
||||
? t(Keys.tabs.documents.mongoFilterPlaceholder)
|
||||
: t(Keys.tabs.documents.sqlFilterPlaceholder)
|
||||
}
|
||||
title="Type a query predicate or choose one from the list."
|
||||
title={t(Keys.tabs.documents.filterTooltip)}
|
||||
value={filterContent}
|
||||
onChange={updateFilterContent}
|
||||
onKeyDown={onFilterKeyDown}
|
||||
bottomLink={{ text: "Learn more", url: DATA_EXPLORER_DOC_URL }}
|
||||
bottomLink={{ text: t(Keys.common.learnMore), url: DATA_EXPLORER_DOC_URL }}
|
||||
/>
|
||||
<Button
|
||||
appearance="primary"
|
||||
@@ -2176,10 +2190,12 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
}
|
||||
}}
|
||||
disabled={isExecuting && isPreferredApiMongoDB}
|
||||
aria-label={!isExecuting || isPreferredApiMongoDB ? "Apply filter" : "Cancel"}
|
||||
aria-label={
|
||||
!isExecuting || isPreferredApiMongoDB ? t(Keys.tabs.documents.applyFilter) : t(Keys.common.cancel)
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
{!isExecuting || isPreferredApiMongoDB ? "Apply Filter" : "Cancel"}
|
||||
{!isExecuting || isPreferredApiMongoDB ? t(Keys.tabs.documents.applyFilter) : t(Keys.common.cancel)}
|
||||
</Button>
|
||||
</div>
|
||||
<Allotment
|
||||
@@ -2223,14 +2239,14 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
</div>
|
||||
{tableContainerSizePx?.width >= calculateOffset(selectedColumnIds.length) + 200 && (
|
||||
<div
|
||||
title="Refresh"
|
||||
title={t(Keys.common.refresh)}
|
||||
className={styles.refreshBtn}
|
||||
role="button"
|
||||
onClick={() => refreshDocumentsGrid(false)}
|
||||
aria-label="Refresh"
|
||||
aria-label={t(Keys.common.refresh)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<img src={RefreshIcon} alt="Refresh" />
|
||||
<img src={RefreshIcon} alt={t(Keys.common.refresh)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2243,7 +2259,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
onClick={() => loadNextPage(documentsIterator.iterator, false)}
|
||||
onKeyDown={onLoadMoreKeyInput}
|
||||
>
|
||||
Load more
|
||||
{t(Keys.tabs.documents.loadMore)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -2255,7 +2271,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
language={"json"}
|
||||
content={selectedDocumentContent}
|
||||
isReadOnly={false}
|
||||
ariaLabel={"Document editor"}
|
||||
ariaLabel={t(Keys.tabs.documents.documentEditor)}
|
||||
lineNumbers={"on"}
|
||||
theme={"_theme"}
|
||||
onContentChanged={_onEditorContentChange}
|
||||
@@ -2263,7 +2279,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
/>
|
||||
)}
|
||||
{selectedRows.size > 1 && (
|
||||
<span style={{ margin: 10 }}>Number of selected documents: {selectedRows.size}</span>
|
||||
<span style={{ margin: 10 }}>
|
||||
{t(Keys.tabs.documents.numberOfSelectedDocuments, { count: selectedRows.size })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
@@ -2272,42 +2290,43 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
{bulkDeleteOperation && (
|
||||
<ProgressModalDialog
|
||||
isOpen={isBulkDeleteDialogOpen}
|
||||
dismissText="Abort"
|
||||
dismissText={t(Keys.tabs.documents.abort)}
|
||||
onDismiss={() => {
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
setBulkDeleteOperation(undefined);
|
||||
}}
|
||||
onCancel={() => setBulkDeleteMode("aborting")}
|
||||
title={`Deleting ${bulkDeleteOperation.count} document(s)`}
|
||||
message={`Successfully deleted ${bulkDeleteProcess.successfulIds.length} document(s).`}
|
||||
title={t(Keys.tabs.documents.deletingDocuments, { count: bulkDeleteOperation.count })}
|
||||
message={t(Keys.tabs.documents.deletedDocumentsSuccess, { count: bulkDeleteProcess.successfulIds.length })}
|
||||
maxValue={bulkDeleteOperation.count}
|
||||
value={bulkDeleteProcess.successfulIds.length}
|
||||
mode={bulkDeleteMode}
|
||||
>
|
||||
<div className={styles.deleteProgressContent}>
|
||||
{(bulkDeleteMode === "aborting" || bulkDeleteMode === "aborted") && (
|
||||
<div style={{ paddingBottom: tokens.spacingVerticalL }}>Deleting document(s) was aborted.</div>
|
||||
<div style={{ paddingBottom: tokens.spacingVerticalL }}>{t(Keys.tabs.documents.deleteAborted)}</div>
|
||||
)}
|
||||
{(bulkDeleteProcess.failedIds.length > 0 ||
|
||||
(bulkDeleteProcess.throttledIds.length > 0 && bulkDeleteMode !== "inProgress")) && (
|
||||
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalL }}>
|
||||
<MessageBarBody>
|
||||
<MessageBarTitle>Error</MessageBarTitle>
|
||||
Failed to delete{" "}
|
||||
{bulkDeleteMode === "inProgress"
|
||||
? bulkDeleteProcess.failedIds.length
|
||||
: bulkDeleteProcess.failedIds.length + bulkDeleteProcess.throttledIds.length}{" "}
|
||||
document(s).
|
||||
<MessageBarTitle>{t(Keys.tabs.documents.error)}</MessageBarTitle>
|
||||
{t(Keys.tabs.documents.failedToDeleteDocuments, {
|
||||
count:
|
||||
bulkDeleteMode === "inProgress"
|
||||
? bulkDeleteProcess.failedIds.length
|
||||
: bulkDeleteProcess.failedIds.length + bulkDeleteProcess.throttledIds.length,
|
||||
})}
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{bulkDeleteProcess.hasBeenThrottled && (
|
||||
<MessageBar intent="warning">
|
||||
<MessageBarBody>
|
||||
<MessageBarTitle>Warning</MessageBarTitle>
|
||||
<MessageBarTitle>{t(Keys.tabs.documents.warning)}</MessageBarTitle>
|
||||
{get429WarningMessageNoSql()}{" "}
|
||||
<Link href={NO_SQL_THROTTLING_DOC_URL} target="_blank">
|
||||
Learn More
|
||||
{t(Keys.common.learnMore)}
|
||||
</Link>
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
|
||||
@@ -44,13 +44,13 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
}
|
||||
onChange={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
placeholder="Type a query predicate (e.g., WHERE c.id=´1´), or choose one from the drop down list, or leave empty to query all documents."
|
||||
placeholder="Type a query predicate (e.g., WHERE c.id="1"), or choose one from the drop down list, or leave empty to query all documents."
|
||||
title="Type a query predicate or choose one from the list."
|
||||
value=""
|
||||
/>
|
||||
<Button
|
||||
appearance="primary"
|
||||
aria-label="Apply filter"
|
||||
aria-label="Apply Filter"
|
||||
data-test="DocumentsTab/ApplyFilter"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { Component } from "react";
|
||||
import { configContext } from "../../../ConfigContext";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { Keys } from "../../../Localization/Keys.generated";
|
||||
import { t } from "../../../Localization/t";
|
||||
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { userContext } from "../../../UserContext";
|
||||
@@ -212,7 +214,7 @@ export default class MongoShellTabComponent extends Component<
|
||||
src={this.state.url}
|
||||
id={this.props.tabsBaseInstance.tabId}
|
||||
onLoad={(event) => this.setContentFocus(event)}
|
||||
title="Mongo Shell"
|
||||
title={t(Keys.tabs.mongoShell.title)}
|
||||
role="tabpanel"
|
||||
></iframe>
|
||||
);
|
||||
|
||||
107
src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx
Normal file
107
src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { CircleFilled } from "@fluentui/react-icons";
|
||||
import type { IIndexMetric } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import { useIndexAdvisorStyles } from "Explorer/Tabs/QueryTab/StylesAdvisor";
|
||||
import * as React from "react";
|
||||
|
||||
// SDK response format
|
||||
export interface IndexMetricsResponse {
|
||||
UtilizedIndexes?: {
|
||||
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
|
||||
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
|
||||
};
|
||||
PotentialIndexes?: {
|
||||
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
|
||||
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIndexMetrics(indexMetrics: IndexMetricsResponse): {
|
||||
included: IIndexMetric[];
|
||||
notIncluded: IIndexMetric[];
|
||||
} {
|
||||
const included: IIndexMetric[] = [];
|
||||
const notIncluded: IIndexMetric[] = [];
|
||||
|
||||
// Process UtilizedIndexes (Included in Current Policy)
|
||||
if (indexMetrics.UtilizedIndexes) {
|
||||
// Single indexes
|
||||
indexMetrics.UtilizedIndexes.SingleIndexes?.forEach((index) => {
|
||||
included.push({
|
||||
index: index.IndexSpec,
|
||||
impact: index.IndexImpactScore || "Utilized",
|
||||
section: "Included",
|
||||
path: index.IndexSpec,
|
||||
});
|
||||
});
|
||||
|
||||
// Composite indexes
|
||||
indexMetrics.UtilizedIndexes.CompositeIndexes?.forEach((index) => {
|
||||
const compositeSpec = index.IndexSpecs.join(", ");
|
||||
included.push({
|
||||
index: compositeSpec,
|
||||
impact: index.IndexImpactScore || "Utilized",
|
||||
section: "Included",
|
||||
composite: index.IndexSpecs.map((spec) => {
|
||||
const [path, order] = spec.trim().split(/\s+/);
|
||||
return {
|
||||
path: path.trim(),
|
||||
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Process PotentialIndexes (Not Included in Current Policy)
|
||||
if (indexMetrics.PotentialIndexes) {
|
||||
// Single indexes
|
||||
indexMetrics.PotentialIndexes.SingleIndexes?.forEach((index) => {
|
||||
notIncluded.push({
|
||||
index: index.IndexSpec,
|
||||
impact: index.IndexImpactScore || "Unknown",
|
||||
section: "Not Included",
|
||||
path: index.IndexSpec,
|
||||
});
|
||||
});
|
||||
|
||||
// Composite indexes
|
||||
indexMetrics.PotentialIndexes.CompositeIndexes?.forEach((index) => {
|
||||
const compositeSpec = index.IndexSpecs.join(", ");
|
||||
notIncluded.push({
|
||||
index: compositeSpec,
|
||||
impact: index.IndexImpactScore || "Unknown",
|
||||
section: "Not Included",
|
||||
composite: index.IndexSpecs.map((spec) => {
|
||||
const [path, order] = spec.trim().split(/\s+/);
|
||||
return {
|
||||
path: path.trim(),
|
||||
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { included, notIncluded };
|
||||
}
|
||||
|
||||
export const renderImpactDots = (impact: string): JSX.Element => {
|
||||
const style = useIndexAdvisorStyles();
|
||||
let count = 0;
|
||||
|
||||
if (impact === "High") {
|
||||
count = 3;
|
||||
} else if (impact === "Medium") {
|
||||
count = 2;
|
||||
} else if (impact === "Low") {
|
||||
count = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.indexAdvisorImpactDots}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<CircleFilled key={i} className={style.indexAdvisorImpactDot} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,18 +3,21 @@ import QueryError from "Common/QueryError";
|
||||
import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar";
|
||||
import { MessageBanner } from "Explorer/Controls/MessageBanner";
|
||||
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import useZoomLevel from "hooks/useZoomLevel";
|
||||
import React from "react";
|
||||
import { conditionalClass } from "Utils/StyleUtils";
|
||||
import RunQuery from "../../../../images/RunQuery.png";
|
||||
import { QueryResults } from "../../../Contracts/ViewModels";
|
||||
import { ErrorList } from "./ErrorList";
|
||||
import { ResultsView } from "./ResultsView";
|
||||
import useZoomLevel from "hooks/useZoomLevel";
|
||||
import { conditionalClass } from "Utils/StyleUtils";
|
||||
|
||||
export interface ResultsViewProps {
|
||||
isMongoDB: boolean;
|
||||
queryResults: QueryResults;
|
||||
executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>;
|
||||
queryEditorContent?: string;
|
||||
databaseId?: string;
|
||||
containerId?: string;
|
||||
}
|
||||
|
||||
interface QueryResultProps extends ResultsViewProps {
|
||||
@@ -49,6 +52,8 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
|
||||
queryResults,
|
||||
executeQueryDocumentsPage,
|
||||
isExecuting,
|
||||
databaseId,
|
||||
containerId,
|
||||
}: QueryResultProps): JSX.Element => {
|
||||
const styles = useQueryTabStyles();
|
||||
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
|
||||
@@ -91,6 +96,9 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
|
||||
queryResults={queryResults}
|
||||
executeQueryDocumentsPage={executeQueryDocumentsPage}
|
||||
isMongoDB={isMongoDB}
|
||||
queryEditorContent={queryEditorContent}
|
||||
databaseId={databaseId}
|
||||
containerId={containerId}
|
||||
/>
|
||||
) : (
|
||||
<ExecuteQueryCallToAction />
|
||||
|
||||
@@ -52,8 +52,9 @@ describe("QueryTabComponent", () => {
|
||||
copilotVersion: "v3.0",
|
||||
},
|
||||
});
|
||||
|
||||
const propsMock: Readonly<IQueryTabComponentProps> = {
|
||||
collection: { databaseId: "CopilotSampleDB" },
|
||||
collection: { databaseId: "CopilotSampleDB", id: () => "CopilotContainer" },
|
||||
onTabAccessor: () => jest.fn(),
|
||||
isExecutionError: false,
|
||||
tabId: "mockTabId",
|
||||
|
||||
@@ -17,6 +17,8 @@ import { QueryTabStyles, useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles
|
||||
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
|
||||
import { useSelectedNode } from "Explorer/useSelectedNode";
|
||||
import { KeyboardAction } from "KeyboardShortcuts";
|
||||
import { Keys } from "Localization/Keys.generated";
|
||||
import { t } from "Localization/t";
|
||||
import { QueryConstants } from "Shared/Constants";
|
||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
@@ -28,6 +30,7 @@ import { useMonacoTheme } from "hooks/useTheme";
|
||||
import React, { Fragment, createRef } from "react";
|
||||
import "react-splitter-layout/lib/index.css";
|
||||
import { format } from "react-string-format";
|
||||
import create from "zustand";
|
||||
//TODO: Uncomment next two lines when query copilot is reinstated in DE
|
||||
// import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
|
||||
// import LaunchCopilot from "../../../../images/CopilotTabIcon.svg";
|
||||
@@ -57,6 +60,20 @@ import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane";
|
||||
import TabsBase from "../TabsBase";
|
||||
import "./QueryTabComponent.less";
|
||||
|
||||
export interface QueryMetadataStore {
|
||||
userQuery: string;
|
||||
databaseId: string;
|
||||
containerId: string;
|
||||
setMetadata: (query1: string, db: string, container: string) => void;
|
||||
}
|
||||
|
||||
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
|
||||
userQuery: "",
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
|
||||
}));
|
||||
|
||||
enum ToggleState {
|
||||
Result,
|
||||
QueryMetrics,
|
||||
@@ -264,6 +281,10 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
}
|
||||
|
||||
public onExecuteQueryClick = async (): Promise<void> => {
|
||||
const query1 = this.state.sqlQueryEditorContent;
|
||||
const db = this.props.collection.databaseId;
|
||||
const container = this.props.collection.id();
|
||||
useQueryMetadataStore.getState().setMetadata(query1, db, container);
|
||||
this._iterator = undefined;
|
||||
|
||||
setTimeout(async () => {
|
||||
@@ -296,7 +317,9 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
};
|
||||
|
||||
public onSaveQueryClick = (): void => {
|
||||
useSidePanel.getState().openSidePanel("Save Query", <SaveQueryPane explorer={this.props.collection.container} />);
|
||||
useSidePanel
|
||||
.getState()
|
||||
.openSidePanel(t(Keys.tabs.query.saveQuery), <SaveQueryPane explorer={this.props.collection.container} />);
|
||||
};
|
||||
|
||||
public launchQueryCopilotChat = (): void => {
|
||||
@@ -306,7 +329,10 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
public onSavedQueriesClick = (): void => {
|
||||
useSidePanel
|
||||
.getState()
|
||||
.openSidePanel("Open Saved Queries", <BrowseQueriesPane explorer={this.props.collection.container} />);
|
||||
.openSidePanel(
|
||||
t(Keys.tabs.query.openSavedQueries),
|
||||
<BrowseQueriesPane explorer={this.props.collection.container} />,
|
||||
);
|
||||
};
|
||||
|
||||
public toggleResult(): void {
|
||||
@@ -454,7 +480,8 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
protected getTabsButtons(): CommandButtonComponentProps[] {
|
||||
const buttons: CommandButtonComponentProps[] = [];
|
||||
if (this.executeQueryButton.visible) {
|
||||
const label = this.state.selectedContent?.length > 0 ? "Execute Selection" : "Execute Query";
|
||||
const label =
|
||||
this.state.selectedContent?.length > 0 ? t(Keys.tabs.query.executeSelection) : t(Keys.tabs.query.executeQuery);
|
||||
buttons.push({
|
||||
iconSrc: ExecuteQueryIcon,
|
||||
iconAlt: label,
|
||||
@@ -471,7 +498,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
|
||||
if (this.saveQueryButton.visible) {
|
||||
if (configContext.platform !== Platform.Fabric) {
|
||||
const label = "Save Query";
|
||||
const label = t(Keys.tabs.query.saveQuery);
|
||||
buttons.push({
|
||||
iconSrc: SaveQueryIcon,
|
||||
iconAlt: label,
|
||||
@@ -488,11 +515,11 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
|
||||
buttons.push({
|
||||
iconSrc: DownloadQueryIcon,
|
||||
iconAlt: "Download Query",
|
||||
iconAlt: t(Keys.tabs.query.downloadQuery),
|
||||
keyboardAction: KeyboardAction.DOWNLOAD_ITEM,
|
||||
onCommandClick: this.onDownloadQueryClick,
|
||||
commandButtonLabel: "Download Query",
|
||||
ariaLabel: "Download Query",
|
||||
commandButtonLabel: t(Keys.tabs.query.downloadQuery),
|
||||
ariaLabel: t(Keys.tabs.query.downloadQuery),
|
||||
hasPopup: false,
|
||||
disabled: !this.saveQueryButton.enabled,
|
||||
});
|
||||
@@ -549,7 +576,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
// }
|
||||
|
||||
if (!this.props.isPreferredApiMongoDB && this.state.isExecuting) {
|
||||
const label = "Cancel query";
|
||||
const label = t(Keys.tabs.query.cancelQuery);
|
||||
buttons.push({
|
||||
iconSrc: CancelQueryIcon,
|
||||
iconAlt: label,
|
||||
@@ -570,23 +597,23 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
const verticalButton: CommandButtonComponentProps = {
|
||||
isSelected: this.state.queryResultsView === SplitterDirection.Vertical,
|
||||
iconSrc: this.state.queryResultsView === SplitterDirection.Vertical ? CheckIcon : undefined,
|
||||
commandButtonLabel: "Vertical",
|
||||
ariaLabel: "Vertical",
|
||||
commandButtonLabel: t(Keys.tabs.query.vertical),
|
||||
ariaLabel: t(Keys.tabs.query.vertical),
|
||||
onCommandClick: () => this._setViewLayout(SplitterDirection.Vertical),
|
||||
hasPopup: false,
|
||||
};
|
||||
const horizontalButton: CommandButtonComponentProps = {
|
||||
isSelected: this.state.queryResultsView === SplitterDirection.Horizontal,
|
||||
iconSrc: this.state.queryResultsView === SplitterDirection.Horizontal ? CheckIcon : undefined,
|
||||
commandButtonLabel: "Horizontal",
|
||||
ariaLabel: "Horizontal",
|
||||
commandButtonLabel: t(Keys.tabs.query.horizontal),
|
||||
ariaLabel: t(Keys.tabs.query.horizontal),
|
||||
onCommandClick: () => this._setViewLayout(SplitterDirection.Horizontal),
|
||||
hasPopup: false,
|
||||
};
|
||||
|
||||
return {
|
||||
commandButtonLabel: "View",
|
||||
ariaLabel: "View",
|
||||
commandButtonLabel: t(Keys.tabs.query.view),
|
||||
ariaLabel: t(Keys.tabs.query.view),
|
||||
hasPopup: true,
|
||||
children: [verticalButton, horizontalButton],
|
||||
};
|
||||
@@ -763,7 +790,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
modelMarkers={this.state.modelMarkers}
|
||||
isReadOnly={false}
|
||||
wordWrap={"on"}
|
||||
ariaLabel={"Editing Query"}
|
||||
ariaLabel={t(Keys.tabs.query.editingQuery)}
|
||||
lineNumbers={"on"}
|
||||
theme={this.props.monacoTheme}
|
||||
onContentChanged={(newContent: string) => this.onChangeContent(newContent)}
|
||||
@@ -780,6 +807,8 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
errors={this.props.copilotStore?.errors}
|
||||
isExecuting={this.props.copilotStore?.isExecuting}
|
||||
queryResults={this.props.copilotStore?.queryResults}
|
||||
databaseId={this.props.collection.databaseId}
|
||||
containerId={this.props.collection.id()}
|
||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||
QueryDocumentsPerPage(
|
||||
firstItemIndex,
|
||||
@@ -795,6 +824,8 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
errors={this.state.errors}
|
||||
isExecuting={this.state.isExecuting}
|
||||
queryResults={this.state.queryResults}
|
||||
databaseId={this.props.collection.databaseId}
|
||||
containerId={this.props.collection.id()}
|
||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||
this._executeQueryDocumentsPage(firstItemIndex)
|
||||
}
|
||||
|
||||
170
src/Explorer/Tabs/QueryTab/ResultsView.test.tsx
Normal file
170
src/Explorer/Tabs/QueryTab/ResultsView.test.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { IndexAdvisorTab } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import React from "react";
|
||||
|
||||
const mockReplace = jest.fn();
|
||||
const mockFetchAll = jest.fn();
|
||||
const mockRead = jest.fn();
|
||||
const mockLogConsoleProgress = jest.fn();
|
||||
const mockHandleError = jest.fn();
|
||||
|
||||
const indexMetricsResponse = {
|
||||
UtilizedIndexes: {
|
||||
SingleIndexes: [{ IndexSpec: "/foo/?", IndexImpactScore: "High" }],
|
||||
CompositeIndexes: [{ IndexSpecs: ["/baz/? DESC", "/qux/? ASC"], IndexImpactScore: "Low" }],
|
||||
},
|
||||
PotentialIndexes: {
|
||||
SingleIndexes: [{ IndexSpec: "/bar/?", IndexImpactScore: "Medium" }],
|
||||
CompositeIndexes: [] as Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>,
|
||||
},
|
||||
};
|
||||
|
||||
const mockQueryResults = {
|
||||
documents: [] as unknown[],
|
||||
hasMoreResults: false,
|
||||
itemCount: 0,
|
||||
firstItemIndex: 0,
|
||||
lastItemIndex: 0,
|
||||
requestCharge: 0,
|
||||
activityId: "test-activity-id",
|
||||
};
|
||||
|
||||
mockRead.mockResolvedValue({
|
||||
resource: {
|
||||
indexingPolicy: {
|
||||
automatic: true,
|
||||
indexingMode: "consistent",
|
||||
includedPaths: [{ path: "/*" }, { path: "/foo/?" }],
|
||||
excludedPaths: [],
|
||||
},
|
||||
partitionKey: "pk",
|
||||
},
|
||||
});
|
||||
|
||||
mockReplace.mockResolvedValue({
|
||||
resource: {
|
||||
indexingPolicy: {
|
||||
automatic: true,
|
||||
indexingMode: "consistent",
|
||||
includedPaths: [{ path: "/*" }],
|
||||
excludedPaths: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.mock("Common/CosmosClient", () => ({
|
||||
client: () => ({
|
||||
database: () => ({
|
||||
container: () => ({
|
||||
items: {
|
||||
query: () => ({
|
||||
fetchAll: mockFetchAll,
|
||||
}),
|
||||
},
|
||||
read: mockRead,
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("./StylesAdvisor", () => ({
|
||||
useIndexAdvisorStyles: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock("../../../Utils/NotificationConsoleUtils", () => ({
|
||||
logConsoleProgress: (...args: unknown[]) => {
|
||||
mockLogConsoleProgress(...args);
|
||||
return () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../Common/ErrorHandlingUtils", () => ({
|
||||
handleError: (...args: unknown[]) => mockHandleError(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchAll.mockResolvedValue({ indexMetrics: indexMetricsResponse });
|
||||
});
|
||||
|
||||
describe("IndexAdvisorTab Basic Tests", () => {
|
||||
test("component renders without crashing", () => {
|
||||
const { container } = render(
|
||||
<IndexAdvisorTab queryEditorContent="SELECT * FROM c" databaseId="db1" containerId="col1" />,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
test("renders component and handles missing parameters", () => {
|
||||
const { container } = render(<IndexAdvisorTab />);
|
||||
expect(container).toBeTruthy();
|
||||
// Should not crash when parameters are missing
|
||||
});
|
||||
|
||||
test("fetches index metrics with query results", async () => {
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test("displays content after loading", async () => {
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
// Wait for the component to finish loading
|
||||
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
|
||||
// Component should have rendered some content
|
||||
expect(screen.getByText(/Index Advisor/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls log console progress when fetching metrics", async () => {
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(mockLogConsoleProgress).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test("handles error when fetch fails", async () => {
|
||||
mockFetchAll.mockRejectedValueOnce(new Error("fetch failed"));
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(mockHandleError).toHaveBeenCalled(), { timeout: 3000 });
|
||||
});
|
||||
|
||||
test("renders with all required props", () => {
|
||||
const { container } = render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="testDb"
|
||||
containerId="testContainer"
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { CompositePath, IndexingPolicy } from "@azure/cosmos";
|
||||
import { FontIcon } from "@fluentui/react";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DataGrid,
|
||||
DataGridBody,
|
||||
DataGridCell,
|
||||
@@ -8,28 +11,45 @@ import {
|
||||
DataGridRow,
|
||||
SelectTabData,
|
||||
SelectTabEvent,
|
||||
Spinner,
|
||||
Tab,
|
||||
TabList,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumnDefinition,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
createTableColumn,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
|
||||
import { ArrowDownloadRegular, ChevronDown20Regular, ChevronRight20Regular, CopyRegular } from "@fluentui/react-icons";
|
||||
import copy from "clipboard-copy";
|
||||
import { HttpHeaders } from "Common/Constants";
|
||||
import MongoUtility from "Common/MongoUtility";
|
||||
import { QueryMetrics } from "Contracts/DataModels";
|
||||
import { QueryResults } from "Contracts/ViewModels";
|
||||
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
||||
import {
|
||||
parseIndexMetrics,
|
||||
renderImpactDots,
|
||||
type IndexMetricsResponse,
|
||||
} from "Explorer/Tabs/QueryTab/IndexAdvisorUtils";
|
||||
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
||||
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import copy from "clipboard-copy";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
|
||||
import create from "zustand";
|
||||
import { client } from "../../../Common/CosmosClient";
|
||||
import { handleError } from "../../../Common/ErrorHandlingUtils";
|
||||
import { sampleDataClient } from "../../../Common/SampleDataClient";
|
||||
import { ResultsViewProps } from "./QueryResultSection";
|
||||
|
||||
import { useIndexAdvisorStyles } from "./StylesAdvisor";
|
||||
enum ResultsTabs {
|
||||
Results = "results",
|
||||
QueryStats = "queryStats",
|
||||
IndexAdvisor = "indexadv",
|
||||
}
|
||||
|
||||
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
|
||||
const styles = useQueryTabStyles();
|
||||
/* eslint-disable react/prop-types */
|
||||
@@ -523,14 +543,331 @@ const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ query
|
||||
);
|
||||
};
|
||||
|
||||
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
|
||||
export interface IIndexMetric {
|
||||
index: string;
|
||||
impact: string;
|
||||
section: "Included" | "Not Included" | "Header";
|
||||
path?: string;
|
||||
composite?: { path: string; order: string }[];
|
||||
}
|
||||
export const IndexAdvisorTab: React.FC<{
|
||||
queryResults?: QueryResults;
|
||||
queryEditorContent?: string;
|
||||
databaseId?: string;
|
||||
containerId?: string;
|
||||
}> = ({ queryResults, queryEditorContent, databaseId, containerId }) => {
|
||||
const style = useIndexAdvisorStyles();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [indexMetrics, setIndexMetrics] = useState<IndexMetricsResponse | null>(null);
|
||||
const [showIncluded, setShowIncluded] = useState(true);
|
||||
const [showNotIncluded, setShowNotIncluded] = useState(true);
|
||||
const [selectedIndexes, setSelectedIndexes] = useState<IIndexMetric[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [updateMessageShown, setUpdateMessageShown] = useState(false);
|
||||
const [included, setIncludedIndexes] = useState<IIndexMetric[]>([]);
|
||||
const [notIncluded, setNotIncludedIndexes] = useState<IIndexMetric[]>([]);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [justUpdatedPolicy, setJustUpdatedPolicy] = useState(false);
|
||||
const indexingMetricsDocLink = "https://learn.microsoft.com/azure/cosmos-db/nosql/index-metrics";
|
||||
|
||||
const fetchIndexMetrics = async () => {
|
||||
if (!queryEditorContent || !databaseId || !containerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const clearMessage = logConsoleProgress(`Querying items with IndexMetrics in container ${containerId}`);
|
||||
try {
|
||||
const querySpec = {
|
||||
query: queryEditorContent,
|
||||
};
|
||||
|
||||
// Use sampleDataClient for CopilotSampleDB, regular client for other databases
|
||||
const cosmosClient = databaseId === "CopilotSampleDB" ? sampleDataClient() : client();
|
||||
|
||||
const sdkResponse = await cosmosClient
|
||||
.database(databaseId)
|
||||
.container(containerId)
|
||||
.items.query(querySpec, {
|
||||
populateIndexMetrics: true,
|
||||
})
|
||||
.fetchAll();
|
||||
|
||||
const parsedMetrics =
|
||||
typeof sdkResponse.indexMetrics === "string" ? JSON.parse(sdkResponse.indexMetrics) : sdkResponse.indexMetrics;
|
||||
|
||||
setIndexMetrics(parsedMetrics);
|
||||
} catch (error) {
|
||||
handleError(error, "queryItemsWithIndexMetrics", `Error querying items from ${containerId}`);
|
||||
} finally {
|
||||
clearMessage();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch index metrics when query results change (i.e., when Execute Query is clicked)
|
||||
useEffect(() => {
|
||||
if (queryEditorContent && databaseId && containerId && queryResults) {
|
||||
fetchIndexMetrics();
|
||||
}
|
||||
}, [queryResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!indexMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { included, notIncluded } = parseIndexMetrics(indexMetrics);
|
||||
setIncludedIndexes(included);
|
||||
setNotIncludedIndexes(notIncluded);
|
||||
if (justUpdatedPolicy) {
|
||||
setJustUpdatedPolicy(false);
|
||||
} else {
|
||||
setUpdateMessageShown(false);
|
||||
}
|
||||
}, [indexMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const allSelected =
|
||||
notIncluded.length > 0 && notIncluded.every((item) => selectedIndexes.some((s) => s.index === item.index));
|
||||
setSelectAll(allSelected);
|
||||
}, [selectedIndexes, notIncluded]);
|
||||
|
||||
const handleCheckboxChange = (indexObj: IIndexMetric, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIndexes((prev) => [...prev, indexObj]);
|
||||
} else {
|
||||
setSelectedIndexes((prev) => prev.filter((item) => item.index !== indexObj.index));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
setSelectAll(checked);
|
||||
setSelectedIndexes(checked ? notIncluded : []);
|
||||
};
|
||||
|
||||
const handleUpdatePolicy = async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const containerRef = client().database(databaseId).container(containerId);
|
||||
const { resource: containerDef } = await containerRef.read();
|
||||
|
||||
const newIncludedPaths = selectedIndexes
|
||||
.filter((index) => !index.composite)
|
||||
.map((index) => {
|
||||
return {
|
||||
path: index.path,
|
||||
};
|
||||
});
|
||||
|
||||
const newCompositeIndexes: CompositePath[][] = selectedIndexes
|
||||
.filter((index) => Array.isArray(index.composite))
|
||||
.map(
|
||||
(index) =>
|
||||
(index.composite as { path: string; order: string }[]).map((comp) => ({
|
||||
path: comp.path,
|
||||
order: comp.order === "descending" ? "descending" : "ascending",
|
||||
})) as CompositePath[],
|
||||
);
|
||||
|
||||
const updatedPolicy: IndexingPolicy = {
|
||||
...containerDef.indexingPolicy,
|
||||
includedPaths: [...(containerDef.indexingPolicy?.includedPaths || []), ...newIncludedPaths],
|
||||
compositeIndexes: [...(containerDef.indexingPolicy?.compositeIndexes || []), ...newCompositeIndexes],
|
||||
automatic: containerDef.indexingPolicy?.automatic ?? true,
|
||||
indexingMode: containerDef.indexingPolicy?.indexingMode ?? "consistent",
|
||||
excludedPaths: containerDef.indexingPolicy?.excludedPaths ?? [],
|
||||
};
|
||||
await containerRef.replace({
|
||||
id: containerId,
|
||||
partitionKey: containerDef.partitionKey,
|
||||
indexingPolicy: updatedPolicy,
|
||||
});
|
||||
useIndexingPolicyStore.getState().setIndexingPolicyFor(containerId, updatedPolicy);
|
||||
const selectedIndexSet = new Set(selectedIndexes.map((s) => s.index));
|
||||
const updatedNotIncluded: typeof notIncluded = [];
|
||||
const newlyIncluded: typeof included = [];
|
||||
for (const item of notIncluded) {
|
||||
if (selectedIndexSet.has(item.index)) {
|
||||
newlyIncluded.push(item);
|
||||
} else {
|
||||
updatedNotIncluded.push(item);
|
||||
}
|
||||
}
|
||||
const newIncluded = [...included, ...newlyIncluded];
|
||||
const newNotIncluded = updatedNotIncluded;
|
||||
setIncludedIndexes(newIncluded);
|
||||
setNotIncludedIndexes(newNotIncluded);
|
||||
setSelectedIndexes([]);
|
||||
setSelectAll(false);
|
||||
setUpdateMessageShown(true);
|
||||
setJustUpdatedPolicy(true);
|
||||
} catch (err) {
|
||||
console.error("Failed to update indexing policy:", err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderRow = (item: IIndexMetric, index: number) => {
|
||||
const isHeader = item.section === "Header";
|
||||
const isNotIncluded = item.section === "Not Included";
|
||||
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell colSpan={2}>
|
||||
<div className={style.indexAdvisorGrid}>
|
||||
{isNotIncluded ? (
|
||||
<Checkbox
|
||||
checked={selectedIndexes.some((selected) => selected.index === item.index)}
|
||||
onChange={(_, data) => handleCheckboxChange(item, data.checked === true)}
|
||||
/>
|
||||
) : isHeader && item.index === "Not Included in Current Policy" && notIncluded.length > 0 ? (
|
||||
<Checkbox checked={selectAll} onChange={(_, data) => handleSelectAll(data.checked === true)} />
|
||||
) : (
|
||||
<div className={style.indexAdvisorCheckboxSpacer}></div>
|
||||
)}
|
||||
{isHeader ? (
|
||||
<span
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
if (item.index === "Included in Current Policy") {
|
||||
setShowIncluded(!showIncluded);
|
||||
} else if (item.index === "Not Included in Current Policy") {
|
||||
setShowNotIncluded(!showNotIncluded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.index === "Included in Current Policy" ? (
|
||||
showIncluded ? (
|
||||
<ChevronDown20Regular />
|
||||
) : (
|
||||
<ChevronRight20Regular />
|
||||
)
|
||||
) : showNotIncluded ? (
|
||||
<ChevronDown20Regular />
|
||||
) : (
|
||||
<ChevronRight20Regular />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<div className={style.indexAdvisorChevronSpacer}></div>
|
||||
)}
|
||||
<div className={isHeader ? style.indexAdvisorRowBold : style.indexAdvisorRowNormal}>{item.index}</div>
|
||||
<div className={isHeader ? style.indexAdvisorRowImpactHeader : style.indexAdvisorRowImpact}>
|
||||
{!isHeader && item.impact}
|
||||
</div>
|
||||
<div>{!isHeader && renderImpactDots(item.impact)}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
const indexMetricItems = React.useMemo(() => {
|
||||
const items: IIndexMetric[] = [];
|
||||
items.push({ index: "Not Included in Current Policy", impact: "", section: "Header" });
|
||||
if (showNotIncluded) {
|
||||
notIncluded.forEach((item) => items.push({ ...item, section: "Not Included" }));
|
||||
}
|
||||
items.push({ index: "Included in Current Policy", impact: "", section: "Header" });
|
||||
if (showIncluded) {
|
||||
included.forEach((item) => items.push({ ...item, section: "Included" }));
|
||||
}
|
||||
return items;
|
||||
}, [included, notIncluded, showIncluded, showNotIncluded]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<Spinner
|
||||
size="small"
|
||||
style={
|
||||
{
|
||||
"--spinner-size": "16px",
|
||||
"--spinner-thickness": "2px",
|
||||
"--spinner-color": "#0078D4",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={style.indexAdvisorMessage}>
|
||||
{updateMessageShown ? (
|
||||
<>
|
||||
<span className={style.indexAdvisorSuccessIcon}>
|
||||
<FontIcon iconName="CheckMark" style={{ color: "white", fontSize: 12 }} />
|
||||
</span>
|
||||
<span>
|
||||
Your indexing policy has been updated with the new included paths. You may review the changes in Scale &
|
||||
Settings.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
Index Advisor uses Indexing Metrics to suggest query paths that, when included in your indexing policy,
|
||||
can improve the performance of this query by reducing RU costs and lowering latency.{" "}
|
||||
<a href={indexingMetricsDocLink} target="_blank" rel="noopener noreferrer">
|
||||
Learn more about Indexing Metrics
|
||||
</a>
|
||||
.{" "}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.indexAdvisorTitle}>Indexes analysis</div>
|
||||
<Table className={style.indexAdvisorTable}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<div className={style.indexAdvisorGrid}>
|
||||
<div className={style.indexAdvisorCheckboxSpacer}></div>
|
||||
<div className={style.indexAdvisorChevronSpacer}></div>
|
||||
<div>Index</div>
|
||||
<div>
|
||||
<span style={{ whiteSpace: "nowrap" }}>Estimated Impact</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{indexMetricItems.map(renderRow)}</TableBody>
|
||||
</Table>
|
||||
{selectedIndexes.length > 0 && (
|
||||
<div className={style.indexAdvisorButtonBar}>
|
||||
{isUpdating ? (
|
||||
<div className={style.indexAdvisorButtonSpinner}>
|
||||
<Spinner size="tiny" />{" "}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleUpdatePolicy} className={style.indexAdvisorButton}>
|
||||
Update Indexing Policy with selected index(es)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const ResultsView: React.FC<ResultsViewProps> = ({
|
||||
isMongoDB,
|
||||
queryResults,
|
||||
executeQueryDocumentsPage,
|
||||
queryEditorContent,
|
||||
databaseId,
|
||||
containerId,
|
||||
}) => {
|
||||
const styles = useQueryTabStyles();
|
||||
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
|
||||
|
||||
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
|
||||
setActiveTab(data.value as ResultsTabs);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
|
||||
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
|
||||
@@ -548,6 +885,13 @@ export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResult
|
||||
>
|
||||
Query Stats
|
||||
</Tab>
|
||||
<Tab
|
||||
data-test="QueryTab/ResultsPane/ResultsView/IndexAdvisorTab"
|
||||
id={ResultsTabs.IndexAdvisor}
|
||||
value={ResultsTabs.IndexAdvisor}
|
||||
>
|
||||
Index Advisor
|
||||
</Tab>
|
||||
</TabList>
|
||||
<div className={styles.queryResultsTabContentContainer}>
|
||||
{activeTab === ResultsTabs.Results && (
|
||||
@@ -558,7 +902,30 @@ export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResult
|
||||
/>
|
||||
)}
|
||||
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
|
||||
{activeTab === ResultsTabs.IndexAdvisor && (
|
||||
<IndexAdvisorTab
|
||||
queryResults={queryResults}
|
||||
queryEditorContent={queryEditorContent}
|
||||
databaseId={databaseId}
|
||||
containerId={containerId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export interface IndexingPolicyStore {
|
||||
indexingPolicies: { [containerId: string]: IndexingPolicy };
|
||||
setIndexingPolicyFor: (containerId: string, indexingPolicy: IndexingPolicy) => void;
|
||||
}
|
||||
|
||||
export const useIndexingPolicyStore = create<IndexingPolicyStore>((set) => ({
|
||||
indexingPolicies: {},
|
||||
setIndexingPolicyFor: (containerId, indexingPolicy) =>
|
||||
set((state) => ({
|
||||
indexingPolicies: {
|
||||
...state.indexingPolicies,
|
||||
[containerId]: { ...indexingPolicy },
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
95
src/Explorer/Tabs/QueryTab/StylesAdvisor.ts
Normal file
95
src/Explorer/Tabs/QueryTab/StylesAdvisor.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { makeStyles } from "@fluentui/react-components";
|
||||
export type IndexAdvisorStyles = ReturnType<typeof useIndexAdvisorStyles>;
|
||||
export const useIndexAdvisorStyles = makeStyles({
|
||||
indexAdvisorMessage: {
|
||||
padding: "1rem",
|
||||
fontSize: "1.2rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
indexAdvisorSuccessIcon: {
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#107C10",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
indexAdvisorTitle: {
|
||||
padding: "1rem",
|
||||
fontSize: "1.3rem",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
indexAdvisorTable: {
|
||||
display: "block",
|
||||
alignItems: "center",
|
||||
marginBottom: "7rem",
|
||||
},
|
||||
indexAdvisorGrid: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "30px 30px 1fr 50px 120px",
|
||||
alignItems: "center",
|
||||
gap: "15px",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
indexAdvisorCheckboxSpacer: {
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
},
|
||||
indexAdvisorChevronSpacer: {
|
||||
width: "24px",
|
||||
},
|
||||
indexAdvisorRowBold: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
indexAdvisorRowNormal: {
|
||||
fontWeight: "normal",
|
||||
},
|
||||
indexAdvisorRowImpactHeader: {
|
||||
fontSize: 0,
|
||||
},
|
||||
indexAdvisorRowImpact: {
|
||||
fontWeight: "normal",
|
||||
},
|
||||
indexAdvisorImpactDot: {
|
||||
color: "#0078D4",
|
||||
fontSize: "12px",
|
||||
display: "inline-flex",
|
||||
},
|
||||
indexAdvisorImpactDots: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
},
|
||||
indexAdvisorButtonBar: {
|
||||
padding: "1rem",
|
||||
marginTop: "-7rem",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
indexAdvisorButtonSpinner: {
|
||||
marginTop: "1rem",
|
||||
minWidth: "320px",
|
||||
minHeight: "40px",
|
||||
display: "flex",
|
||||
alignItems: "left",
|
||||
justifyContent: "left",
|
||||
marginLeft: "10rem",
|
||||
},
|
||||
indexAdvisorButton: {
|
||||
backgroundColor: "#0078D4",
|
||||
color: "white",
|
||||
padding: "8px 16px",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
marginTop: "1rem",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 500,
|
||||
transition: "background 0.2s",
|
||||
":hover": {
|
||||
backgroundColor: "#005a9e",
|
||||
},
|
||||
},
|
||||
});
|
||||
15
src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts
Normal file
15
src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import create from "zustand";
|
||||
|
||||
interface QueryMetadataStore {
|
||||
userQuery: string;
|
||||
databaseId: string;
|
||||
containerId: string;
|
||||
setMetadata: (query1: string, db: string, container: string) => void;
|
||||
}
|
||||
|
||||
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
|
||||
userQuery: "",
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
|
||||
}));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user