Compare commits

..

7 Commits

Author SHA1 Message Date
Sindhu Balasubramanian
de11ece337 Cleanup CORSByPass 2026-01-12 17:07:23 -08:00
Sindhu Balasubramanian
90c694d33c Fix error in tests 2026-01-12 14:13:29 -08:00
Sindhu Balasubramanian
865e9c906b Run npm format 2026-01-08 13:16:22 -08:00
Sindhu Balasubramanian
1c34425dd8 Merge branch 'master' of https://github.com/Azure/cosmos-explorer 2026-01-08 13:00:47 -08:00
Sindhu Balasubramanian
50a244e6f9 Add Mongo Pagination tests 2026-01-08 12:55:24 -08:00
Sindhu Balasubramanian
9dad75c2f9 Remove localhost 2025-12-30 23:21:56 -08:00
Sindhu Balasubramanian
876b531248 Add changes for Load more option to work 2025-12-30 22:54:42 -08:00
175 changed files with 13204 additions and 16213 deletions

View File

@@ -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']}}

View File

@@ -6,8 +6,8 @@ on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
schedule:
# Once every day at 7 AM PST
- cron: "0 13 * * *"
# Once every two hours
- cron: "0 */2 * * *"
permissions:
id-token: write

1
.gitignore vendored
View File

@@ -17,7 +17,6 @@ Contracts/*
failure.png
screenshots/*
GettingStarted-ignore*.ipynb
src/Localization/Keys.generated.ts
/test-results/
/playwright-report/
/blob-report/

14305
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "Cosmos Explorer",
"main": "index.js",
"dependencies": {
"@azure/arm-cosmosdb": "16.4.0",
"@azure/arm-cosmosdb": "9.1.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.6.8",
"@jupyterlab/terminal": "3.0.3",
"@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.2.12",
"@nteract/data-explorer": "8.0.3",
"@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.4.12",
"@nteract/presentational-components": "3.0.7",
"@nteract/stateful-components": "1.7.0",
"@nteract/styles": "2.0.2",
"@nteract/transform-geojson": "5.1.8",
@@ -39,26 +39,25 @@
"@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.6.13",
"@types/node-fetch": "2.5.7",
"@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": "3.2.1",
"canvas": "2.11.2",
"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.9.0",
"d3": "7.8.5",
"datatables.net-colreorder-dt": "1.7.0",
"datatables.net-dt": "1.13.8",
"date-fns": "1.29.0",
@@ -71,8 +70,7 @@
"html2canvas": "1.0.0-rc.5",
"i18next": "23.11.5",
"i18next-browser-languagedetector": "6.0.1",
"i18next-http-backend": "3.0.2",
"i18next-resources-to-backend": "1.2.1",
"i18next-http-backend": "1.0.23",
"iframe-resizer-react": "1.1.0",
"immer": "9.0.6",
"immutable": "4.0.0-rc.12",
@@ -81,15 +79,12 @@
"jquery-typeahead": "2.11.1",
"jquery-ui-dist": "1.13.2",
"knockout": "3.5.1",
"lodash": "4.17.23",
"lodash-es": "4.17.23",
"min-document": "2.19.1",
"loader-utils": "2.0.3",
"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.1",
"patch-package": "8.0.0",
"plotly.js-cartesian-dist-min": "1.52.3",
"post-robot": "10.0.42",
"q": "1.5.1",
@@ -108,6 +103,7 @@
"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",
@@ -115,30 +111,16 @@
"tinykeys": "2.1.0",
"underscore": "1.12.1",
"utility-types": "3.10.0",
"uuid": "9.0.0",
"web-vitals": "4.2.4",
"ws": "8.17.1",
"uuid": "9.0.0",
"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.29.0",
"@babel/core": "7.24.7",
"@babel/preset-env": "7.24.7",
"@babel/preset-react": "7.24.7",
"@babel/preset-typescript": "7.24.7",
"@playwright/test": "1.55.1",
"@playwright/test": "1.49.1",
"@testing-library/react": "11.2.3",
"@types/applicationinsights-js": "1.0.7",
"@types/codemirror": "0.0.56",
@@ -152,7 +134,7 @@
"@types/hasher": "0.0.31",
"@types/jest": "29.5.12",
"@types/jquery": "3.5.29",
"@types/node": "18.19.0",
"@types/node": "12.11.1",
"@types/post-robot": "10.0.1",
"@types/q": "1.5.1",
"@types/react": "17.0.44",
@@ -163,7 +145,7 @@
"@types/react-window": "1.8.8",
"@types/sanitize-html": "1.27.2",
"@types/sinon": "2.3.3",
"@types/styled-components": "5.1.32",
"@types/styled-components": "5.1.1",
"@types/underscore": "1.7.36",
"@types/youtube-player": "5.5.6",
"@typescript-eslint/eslint-plugin": "6.7.4",
@@ -171,7 +153,6 @@
"@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",
@@ -189,7 +170,6 @@
"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",
@@ -197,8 +177,7 @@
"jest-html-loader": "1.0.0",
"jest-react-hooks-shallow": "1.5.1",
"jest-trx-results-processor": "3.0.2",
"js-yaml": "3.14.2",
"less": "4.5.1",
"less": "3.8.1",
"less-loader": "11.1.3",
"less-vars-loader": "1.1.0",
"mini-css-extract-plugin": "2.1.0",
@@ -216,17 +195,14 @@
"typedoc": "0.26.2",
"typescript": "4.9.5",
"url-loader": "4.1.1",
"values-to-keys": "1.1.0",
"wait-on": "9.0.3",
"webpack": "5.104.1",
"webpack-bundle-analyzer": "5.2.0",
"wait-on": "4.0.2",
"webpack": "5.88.2",
"webpack-bundle-analyzer": "4.9.1",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.2.3",
"ws": "8.17.1"
"webpack-dev-server": "4.15.2"
},
"scripts": {
"postinstall": "patch-package && npm run generate:i18n-keys",
"prestart": "npm run generate:i18n-keys",
"postinstall": "patch-package",
"start": "webpack serve --mode development",
"dev": "echo \"WARNING: npm run dev has been deprecated\" && npm run build",
"build:dataExplorer:ci": "npm run build:ci",
@@ -253,7 +229,6 @@
"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": {

View File

@@ -26,6 +26,15 @@ export default defineConfig({
},
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
launchOptions: {
args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"],
},
},
},
{
name: "firefox",
use: {

View File

@@ -8,10 +8,9 @@
"name": "cosmos-explorer-preview",
"version": "1.0.0",
"dependencies": {
"body-parser": "^2.2.2",
"express": "^5.2.1",
"follow-redirects": "^1.15.6",
"http-proxy-middleware": "^3.0.5",
"body-parser": "^1.20.3",
"express": "^4.21.2",
"http-proxy-middleware": "^3.0.3",
"node": "^20.19.5",
"node-fetch": "^2.6.1",
"path-to-regexp": "^0.1.12"
@@ -19,7 +18,8 @@
},
"node_modules/@types/http-proxy": {
"version": "1.17.16",
"license": "MIT",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz",
"integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==",
"dependencies": {
"@types/node": "*"
}
@@ -29,40 +29,50 @@
"license": "MIT"
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"version": "1.3.8",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"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==",
"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==",
"dependencies": {
"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"
"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"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"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",
@@ -73,7 +83,8 @@
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"license": "MIT",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
@@ -84,7 +95,8 @@
},
"node_modules/call-bound": {
"version": "1.0.4",
"license": "MIT",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
@@ -97,15 +109,13 @@
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"engines": {
"node": ">=18"
"version": "0.5.4",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
@@ -117,22 +127,20 @@
},
"node_modules/cookie": {
"version": "0.7.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"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"
}
"version": "1.0.6",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"license": "MIT",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
@@ -152,9 +160,18 @@
"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",
"license": "MIT",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
@@ -178,21 +195,24 @@
},
"node_modules/es-define-property": {
"version": "1.0.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
@@ -218,77 +238,104 @@
"license": "MIT"
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"dependencies": {
"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"
"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"
},
"engines": {
"node": ">= 18"
"node": ">= 0.10.0"
},
"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": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"dependencies": {
"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"
"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"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.8"
}
},
"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.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
"version": "1.15.3",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
@@ -306,23 +353,25 @@
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"engines": {
"node": ">= 0.8"
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
@@ -344,7 +393,8 @@
},
"node_modules/get-proto": {
"version": "1.0.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
@@ -355,7 +405,8 @@
},
"node_modules/gopd": {
"version": "1.2.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
@@ -365,7 +416,8 @@
},
"node_modules/has-symbols": {
"version": "1.1.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
@@ -375,7 +427,8 @@
},
"node_modules/hasown": {
"version": "2.0.2",
"license": "MIT",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -384,22 +437,17 @@
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"version": "2.0.0",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/http-proxy": {
@@ -432,7 +480,8 @@
},
"node_modules/http-proxy-middleware/node_modules/braces": {
"version": "3.0.3",
"license": "MIT",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dependencies": {
"fill-range": "^7.1.1"
},
@@ -442,7 +491,8 @@
},
"node_modules/http-proxy-middleware/node_modules/fill-range": {
"version": "7.1.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dependencies": {
"to-regex-range": "^5.0.1"
},
@@ -452,14 +502,16 @@
},
"node_modules/http-proxy-middleware/node_modules/is-number": {
"version": "7.0.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/http-proxy-middleware/node_modules/micromatch": {
"version": "4.0.8",
"license": "MIT",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
@@ -470,7 +522,8 @@
},
"node_modules/http-proxy-middleware/node_modules/to-regex-range": {
"version": "5.0.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": {
"is-number": "^7.0.0"
},
@@ -479,18 +532,14 @@
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
@@ -521,58 +570,62 @@
"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",
"license": "MIT",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"version": "0.3.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"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"
},
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"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.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"version": "1.52.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"version": "2.1.35",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
"mime-db": "1.52.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.6"
}
},
"node_modules/ms": {
@@ -580,27 +633,32 @@
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"version": "0.6.3",
"license": "MIT",
"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.exe"
"node": "bin/node"
},
"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",
@@ -619,13 +677,10 @@
}
}
},
"node_modules/node/node_modules/node-bin-setup": {
"version": "1.1.4",
"license": "ISC"
},
"node_modules/object-inspect": {
"version": "1.13.4",
"license": "MIT",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"engines": {
"node": ">= 0.4"
},
@@ -643,14 +698,6 @@
"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",
@@ -661,7 +708,8 @@
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"license": "MIT"
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
"node_modules/picomatch": {
"version": "2.3.1",
@@ -692,10 +740,11 @@
}
},
"node_modules/qs": {
"version": "6.14.1",
"license": "BSD-3-Clause",
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"dependencies": {
"side-channel": "^1.1.0"
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
@@ -713,46 +762,40 @@
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"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==",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.10"
"node": ">= 0.8"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"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/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/safer-buffer": {
"version": "2.1.2",
@@ -760,46 +803,61 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"dependencies": {
"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"
"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"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"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_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.19.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
@@ -808,7 +866,8 @@
},
"node_modules/side-channel": {
"version": "1.1.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
@@ -825,7 +884,8 @@
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
@@ -839,7 +899,8 @@
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -855,7 +916,8 @@
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"license": "MIT",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -871,9 +933,8 @@
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"version": "2.0.1",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
@@ -890,13 +951,11 @@
"license": "MIT"
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"version": "1.6.18",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
@@ -909,6 +968,13 @@
"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",
@@ -927,11 +993,6 @@
"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=="
}
}
}

View File

@@ -11,10 +11,9 @@
"keywords": [],
"author": "Microsoft Corporation",
"dependencies": {
"body-parser": "^2.2.2",
"express": "^5.2.1",
"follow-redirects": "^1.15.6",
"http-proxy-middleware": "^3.0.5",
"body-parser": "^1.20.3",
"express": "^4.21.2",
"http-proxy-middleware": "^3.0.3",
"node": "^20.19.5",
"node-fetch": "^2.6.1",
"path-to-regexp": "^0.1.12"

View File

@@ -1,11 +0,0 @@
import "i18next";
import Resources from "../Localization/en/Resources.json";
declare module "i18next" {
interface CustomTypeOptions {
defaultNS: "Resources";
resources: {
Resources: typeof Resources;
};
}
}

View File

@@ -1,7 +1,5 @@
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";
@@ -9,36 +7,19 @@ import { HttpStatusCodes } from "./Constants";
import { logError } from "./Logger";
import { sendMessage } from "./MessageHandler";
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 => {
export const handleError = (error: string | ARMError | Error, area: string, consoleErrorPrefix?: string): void => {
const errorMessage = getErrorMessage(error);
const errorCode = error instanceof ARMError ? error.code : undefined;
// logs error to data explorer console (always shows original, non-redacted message)
// logs error to data explorer console
const consoleErrorMessage = consoleErrorPrefix ? `${consoleErrorPrefix}:\n ${errorMessage}` : errorMessage;
logConsoleError(consoleErrorMessage);
// 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);
// logs error to both app insight and kusto
logError(errorMessage, 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 => {

View File

@@ -9,7 +9,7 @@ import {
SqlStoredProcedureCreateUpdateParameters,
SqlStoredProcedureResource,
} from "../../Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -20,7 +20,6 @@ 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 &&
@@ -61,16 +60,14 @@ export async function createStoredProcedure(
storedProcedure.id,
createSprocParams,
);
resource = rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
} else {
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.storedProcedures.create(storedProcedure);
resource = response.resource;
return rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
}
logConsoleInfo(`Successfully created stored procedure ${storedProcedure.id}`);
return resource;
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.storedProcedures.create(storedProcedure);
return response?.resource;
} catch (error) {
handleError(error, "CreateStoredProcedure", `Error while creating stored procedure ${storedProcedure.id}`);
throw error;

View File

@@ -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 { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -14,7 +14,6 @@ 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 &&
@@ -53,16 +52,14 @@ export async function createTrigger(
trigger.id,
createTriggerParams,
);
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;
return rpResponse && rpResponse.properties?.resource;
}
logConsoleInfo(`Successfully created trigger ${trigger.id}`);
return 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;
} catch (error) {
handleError(error, "CreateTrigger", `Error while creating trigger ${trigger.id}`);
throw error;

View File

@@ -9,7 +9,7 @@ import {
SqlUserDefinedFunctionCreateUpdateParameters,
SqlUserDefinedFunctionResource,
} from "../../Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -20,7 +20,6 @@ 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 &&
@@ -61,17 +60,14 @@ export async function createUserDefinedFunction(
userDefinedFunction.id,
createUDFParams,
);
resource = rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
} else {
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.userDefinedFunctions.create(userDefinedFunction);
resource = response.resource;
return rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
}
logConsoleInfo(`Successfully created user defined function ${userDefinedFunction.id}`);
return resource;
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.userDefinedFunctions.create(userDefinedFunction);
return response?.resource;
} catch (error) {
handleError(
error,

View File

@@ -44,8 +44,7 @@ export const deleteDocuments = async (
documentIds: DocumentId[],
abortSignal: AbortSignal,
): Promise<IBulkDeleteResult[]> => {
const totalCount = documentIds.length;
const clearMessage = logConsoleProgress(`Deleting ${totalCount} ${getEntityName(true)}`);
const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`);
try {
const v2Container = await client().database(collection.databaseId).container(collection.id());
@@ -84,7 +83,11 @@ export const deleteDocuments = async (
const flatAllResult = Array.prototype.concat.apply([], allResult);
return flatAllResult;
} catch (error) {
handleError(error, "DeleteDocuments", `Error while deleting ${totalCount} ${getEntityName(totalCount > 1)}`);
handleError(
error,
"DeleteDocuments",
`Error while deleting ${documentIds.length} ${getEntityName(documentIds.length > 1)}`,
);
throw error;
} finally {
clearMessage();

View File

@@ -28,7 +28,6 @@ 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;

View File

@@ -24,7 +24,6 @@ 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;

View File

@@ -24,7 +24,6 @@ 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;

View File

@@ -1,171 +0,0 @@
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__");
});
});

View File

@@ -4,51 +4,6 @@ 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,
@@ -63,12 +18,7 @@ export const queryDocumentsPage = async (
logConsoleInfo(`Successfully fetched ${itemCount} ${entityName} for container ${resourceName}`);
return result;
} catch (error) {
// 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,
});
handleError(error, "QueryDocumentsPage", `Failed to query ${entityName} for container ${resourceName}`);
throw error;
} finally {
clearMessage();

View File

@@ -77,12 +77,6 @@ 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/",

View File

@@ -46,10 +46,6 @@ 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";

View File

@@ -275,7 +275,8 @@ export interface DataMaskingPolicy {
startPosition: number;
length: number;
}>;
excludedPaths?: string[];
excludedPaths: string[];
isPolicyEnabled: boolean;
}
export interface MaterializedView {

View File

@@ -25,18 +25,7 @@ export default {
subscriptionDropdownPlaceholder: "Select a subscription",
sourceAccountDropdownLabel: "Account",
sourceAccountDropdownPlaceholder: "Select an account",
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).",
},
},
migrationTypeCheckboxLabel: "Copy container in offline mode",
// Select Source and Target Containers Screen
selectSourceAndTargetContainersDescription:
@@ -184,10 +173,5 @@ export default {
Skipped: "Cancelled",
Cancelled: "Cancelled",
},
dialog: {
heading: "",
confirmButtonText: "Confirm",
cancelButtonText: "Cancel",
},
},
};

View File

@@ -395,14 +395,6 @@ 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);

View File

@@ -142,7 +142,7 @@ export function isEqual(prevJobs: CopyJobType[], newJobs: CopyJobType[]): boolea
if (!newJob) {
return false;
}
return prevJob.Status === newJob.Status && prevJob.CompletionPercentage === newJob.CompletionPercentage;
return prevJob.Status === newJob.Status;
});
}

View File

@@ -1,241 +0,0 @@
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();
});
});
});

View File

@@ -1,77 +0,0 @@
/* 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>
);
});

View File

@@ -0,0 +1,72 @@
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();
});
});
});

View File

@@ -0,0 +1,33 @@
/* 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>
);
});

View File

@@ -1,109 +0,0 @@
// 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>
`;

View File

@@ -0,0 +1,82 @@
// 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>
`;

View File

@@ -1,5 +1,5 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
@@ -18,8 +18,19 @@ jest.mock("./Components/AccountDropdown", () => ({
AccountDropdown: jest.fn(() => <div data-testid="account-dropdown">Account Dropdown</div>),
}));
jest.mock("./Components/MigrationType", () => ({
MigrationType: jest.fn(() => <div data-testid="migration-type">Migration Type</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>
)),
}));
describe("SelectAccount", () => {
@@ -72,7 +83,7 @@ describe("SelectAccount", () => {
expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("account-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
expect(screen.getByTestId("migration-type-checkbox")).toBeInTheDocument();
});
it("should render correctly with snapshot", () => {
@@ -82,20 +93,78 @@ describe("SelectAccount", () => {
});
describe("Migration Type Functionality", () => {
it("should render migration type component", () => {
it("should display migration type checkbox as unchecked when migrationType is Online", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Online,
},
});
render(<SelectAccount />);
const migrationTypeComponent = screen.getByTestId("migration-type");
expect(migrationTypeComponent).toBeInTheDocument();
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,
});
});
});
describe("Performance and Optimization", () => {
it("should render without performance issues", () => {
it("should maintain referential equality of handler functions between renders", async () => {
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 />);
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
const secondRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
expect(firstRenderHandler).toBe(secondRenderHandler);
});
});
});

View File

@@ -1,11 +1,24 @@
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 { MigrationType } from "./Components/MigrationType";
import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
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>
@@ -14,7 +27,7 @@ const SelectAccount = React.memo(() => {
<AccountDropdown />
<MigrationType />
<MigrationTypeCheckbox checked={migrationTypeChecked} onChange={handleMigrationTypeChange} />
</Stack>
);
});

View File

@@ -21,9 +21,14 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot
Account Dropdown
</div>
<div
data-testid="migration-type"
data-testid="migration-type-checkbox"
>
Migration Type
<input
aria-label="Migration Type Checkbox"
data-testid="migration-checkbox-input"
type="checkbox"
/>
Copy container in offline mode
</div>
</div>
`;

View File

@@ -1,4 +1,3 @@
/* eslint-disable jest/no-conditional-expect */
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
@@ -6,20 +5,6 @@ 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: {
@@ -33,11 +18,6 @@ jest.mock("../../ContainerCopyMessages", () => ({
cancel: "Cancel",
complete: "Complete",
},
dialog: {
heading: "Confirm Action",
confirmButtonText: "Confirm",
cancelButtonText: "Cancel",
},
},
},
}));
@@ -70,9 +50,6 @@ describe("CopyJobActionMenu", () => {
beforeEach(() => {
jest.clearAllMocks();
mockShowOkCancelModalDialog.mockClear();
mockCloseDialog.mockClear();
mockOpenDialog.mockClear();
});
describe("Component Rendering", () => {
@@ -289,29 +266,7 @@ describe("CopyJobActionMenu", () => {
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function));
});
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", () => {
it("should call handleClick when cancel action is clicked", () => {
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
@@ -322,9 +277,6 @@ 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));
});
@@ -342,33 +294,7 @@ describe("CopyJobActionMenu", () => {
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.resume, expect.any(Function));
});
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", () => {
it("should call handleClick when complete action is clicked", () => {
const job = createMockJob({
Status: CopyJobStatusType.InProgress,
Mode: CopyJobMigrationType.Online,
@@ -382,87 +308,10 @@ 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;
@@ -490,13 +339,8 @@ describe("CopyJobActionMenu", () => {
const pauseButton = screen.getByText("Pause");
fireEvent.click(pauseButton);
fireEvent.click(actionButton);
const pauseButtonAfterClick = screen.getByText("Pause").closest("button");
const pauseButtonAfterClick = screen.getByText("Pause");
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", () => {
@@ -516,7 +360,23 @@ describe("CopyJobActionMenu", () => {
expect(screen.getByText("Cancel")).toBeInTheDocument();
});
it("should disable complete action when job is being updated", () => {
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", () => {
const job = createMockJob({
Status: CopyJobStatusType.InProgress,
Mode: CopyJobMigrationType.Online,
@@ -530,34 +390,8 @@ 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);
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");
expect(screen.getByText("Complete")).toBeInTheDocument();
});
});
@@ -628,7 +462,6 @@ 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();
@@ -775,129 +608,4 @@ 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();
});
});
});

View File

@@ -1,6 +1,5 @@
import { DirectionalHint, IconButton, IContextualMenuProps, Stack } from "@fluentui/react";
import { IconButton, IContextualMenuProps } 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";
@@ -10,28 +9,6 @@ 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 (
@@ -45,22 +22,9 @@ 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 = [
{
@@ -68,21 +32,21 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
text: ContainerCopyMessages.MonitorJobs.Actions.pause,
iconProps: { iconName: "Pause" },
onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction),
disabled: isThisJobUpdating,
disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause,
},
{
key: CopyJobActions.cancel,
text: ContainerCopyMessages.MonitorJobs.Actions.cancel,
iconProps: { iconName: "Cancel" },
onClick: () => showActionConfirmationDialog(job, CopyJobActions.cancel),
disabled: isThisJobUpdating,
onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel,
},
{
key: CopyJobActions.resume,
text: ContainerCopyMessages.MonitorJobs.Actions.resume,
iconProps: { iconName: "Play" },
onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction),
disabled: isThisJobUpdating,
disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume,
},
];
@@ -103,8 +67,8 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
key: CopyJobActions.complete,
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
iconProps: { iconName: "CheckMark" },
onClick: () => showActionConfirmationDialog(job, CopyJobActions.complete),
disabled: isThisJobUpdating,
onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete,
});
}
return filteredItems;
@@ -122,8 +86,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(), directionalHint: DirectionalHint.leftTopEdge, directionalHintFixed: false }}
menuIconProps={{ iconName: "", className: "hidden" }}
menuProps={{ items: getMenuItems() }}
menuIconProps={{ iconName: "" }}
ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions}
title={ContainerCopyMessages.MonitorJobs.Columns.actions}
/>

View File

@@ -11,17 +11,9 @@ 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: "Job name",
name: "Name",
fieldName: "Name",
minWidth: 140,
maxWidth: 300,
@@ -173,165 +165,6 @@ 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", () => {
@@ -509,7 +342,7 @@ describe("CopyJobsList", () => {
describe("Component Props", () => {
it("uses default page size when not provided", () => {
const manyJobs: CopyJobType[] = Array.from({ length: 20 }, (_, i) => ({
const manyJobs: CopyJobType[] = Array.from({ length: 12 }, (_, i) => ({
...mockJobs[0],
ID: `job-${i + 1}`,
Name: `Test Job ${i + 1}`,
@@ -518,7 +351,7 @@ describe("CopyJobsList", () => {
render(<CopyJobsList jobs={manyJobs} handleActionClick={mockHandleActionClick} />);
expect(screen.getByLabelText("Go to next page")).toBeInTheDocument();
expect(screen.getByText("Showing 1 - 15 of 20 items")).toBeInTheDocument();
expect(screen.getByText("Showing 1 - 10 of 12 items")).toBeInTheDocument();
});
it("passes correct props to getColumns function", async () => {
@@ -607,33 +440,7 @@ describe("CopyJobsList", () => {
render(<CopyJobsList jobs={largeJobsList} handleActionClick={mockHandleActionClick} />);
}).not.toThrow();
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();
});
expect(screen.getByText("Showing 1 - 10 of 1000 items")).toBeInTheDocument();
});
});
});

View File

@@ -12,9 +12,8 @@ import {
Stack,
Sticky,
StickyPositionType,
TextField,
} from "@fluentui/react";
import React, { useEffect, useMemo } from "react";
import React, { useEffect } from "react";
import Pager from "../../../../Common/Pager";
import { useThemeStore } from "../../../../hooks/useTheme";
import { getThemeTokens } from "../../../Theme/ThemeUtil";
@@ -31,15 +30,9 @@ 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 = 15;
// Columns to search across
const searchableFields = ["Name", "Status", "LastUpdatedTime", "Mode"];
const PAGE_SIZE = 10;
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
const isDarkMode = useThemeStore((state) => state.isDarkMode);
@@ -48,23 +41,6 @@ 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);
@@ -88,15 +64,7 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
setStartIndex(0);
};
const sortableColumns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending);
const handleFilterTextChange = (
_event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
newValue?: string,
) => {
setFilterText(newValue || "");
setStartIndex(0);
};
const columns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending);
const _handleRowClick = (job: CopyJobType) => {
openCopyJobDetailsPanel(job);
@@ -113,25 +81,14 @@ 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={sortableColumns}
items={filteredJobs.slice(startIndex, startIndex + pageSize)}
columns={columns}
items={sortedJobs.slice(startIndex, startIndex + pageSize)}
enableShimmer={false}
constrainMode={ConstrainMode.unconstrained}
layoutMode={DetailsListLayoutMode.justified}
@@ -160,12 +117,12 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
/>
</ScrollablePane>
</Stack.Item>
{filteredJobs.length > pageSize && (
{sortedJobs.length > pageSize && (
<Stack.Item>
<Pager
disabled={false}
startIndex={startIndex}
totalCount={filteredJobs.length}
totalCount={sortedJobs.length}
pageSize={pageSize}
onLoadPage={(startIdx /* pageSize */) => {
setStartIndex(startIdx);

View File

@@ -1,27 +1,5 @@
@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);
@@ -141,14 +119,23 @@
filter: invert(1);
}
.themedTextFieldStyles();
.migrationTypeDescription {
p {
color: var(--colorNeutralForeground1);
.ms-TextField {
.ms-TextField-fieldGroup {
background-color: var(--colorNeutralBackground1);
border-color: var(--colorNeutralStroke1);
}
a {
color: var(--colorBrandForeground1);
.ms-TextField-field {
color: var(--colorNeutralForeground1);
background-color: var(--colorNeutralBackground1);
&::placeholder {
color: var(--colorNeutralForeground4);
}
}
.ms-Label {
color: var(--colorNeutralForeground1);
}
}
}
@@ -178,11 +165,6 @@
width: 100%;
max-width: 100%;
margin: 0 auto;
body.isDarkMode & {
.themedTextFieldStyles();
}
.ms-DetailsList {
width: 100%;
@@ -199,9 +181,6 @@
background-color: var(--colorNeutralBackground3);
}
}
.ms-DetailsHeader-cellTitle {
padding-left: 20px;
}
}
.ms-DetailsRow {

View File

@@ -7,9 +7,7 @@ import {
AddGlobalSecondaryIndexPanelProps,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { useDatabases } from "Explorer/useDatabases";
import { Keys } from "Localization/Keys.generated";
import { t } from "Localization/t";
import { isFabric, isFabricNative, openRestoreContainerDialog } from "Platform/Fabric/FabricUtil";
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
import { ReactTabKind, useTabs } from "hooks/useTabs";
@@ -26,7 +24,6 @@ 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";
@@ -59,21 +56,10 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin
{
iconSrc: AddCollectionIcon,
onClick: () => container.onNewCollectionClicked({ databaseId }),
label: t(Keys.contextMenu.newContainer, { containerName: getCollectionName() }),
label: `New ${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,
@@ -82,11 +68,11 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin
useSidePanel
.getState()
.openSidePanel(
t(Keys.contextMenu.deleteDatabase, { databaseName: getDatabaseName() }),
"Delete " + getDatabaseName(),
<DeleteDatabaseConfirmationPanel refreshDatabases={() => container.refreshAllDatabases()} />,
);
},
label: t(Keys.contextMenu.deleteDatabase, { databaseName: getDatabaseName() }),
label: `Delete ${getDatabaseName()}`,
styleClass: "deleteDatabaseMenuItem",
});
}
@@ -102,7 +88,7 @@ export const createCollectionContextMenuButton = (
items.push({
iconSrc: AddSqlQueryIcon,
onClick: () => selectedCollection && selectedCollection.onNewQueryClick(selectedCollection, undefined),
label: t(Keys.contextMenu.newSqlQuery),
label: "New SQL Query",
});
}
@@ -110,7 +96,7 @@ export const createCollectionContextMenuButton = (
items.push({
iconSrc: AddSqlQueryIcon,
onClick: () => selectedCollection && selectedCollection.onNewMongoQueryClick(selectedCollection, undefined),
label: t(Keys.contextMenu.newQuery),
label: "New Query",
});
items.push({
@@ -125,8 +111,8 @@ export const createCollectionContextMenuButton = (
},
label:
useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell
? t(Keys.contextMenu.openMongoShell)
: t(Keys.contextMenu.newShell),
? "Open Mongo Shell"
: "New Shell",
});
}
@@ -139,7 +125,7 @@ export const createCollectionContextMenuButton = (
onClick: () => {
container.openNotebookTerminal(ViewModels.TerminalKind.Cassandra);
},
label: t(Keys.contextMenu.openCassandraShell),
label: "Open Cassandra Shell",
});
}
@@ -152,7 +138,7 @@ export const createCollectionContextMenuButton = (
onClick: () => {
selectedCollection && selectedCollection.onNewStoredProcedureClick(selectedCollection, undefined);
},
label: t(Keys.contextMenu.newStoredProcedure),
label: "New Stored Procedure",
});
items.push({
@@ -160,7 +146,7 @@ export const createCollectionContextMenuButton = (
onClick: () => {
selectedCollection && selectedCollection.onNewUserDefinedFunctionClick(selectedCollection);
},
label: t(Keys.contextMenu.newUdf),
label: "New UDF",
});
items.push({
@@ -168,7 +154,7 @@ export const createCollectionContextMenuButton = (
onClick: () => {
selectedCollection && selectedCollection.onNewTriggerClick(selectedCollection, undefined);
},
label: t(Keys.contextMenu.newTrigger),
label: "New Trigger",
});
}
@@ -181,11 +167,11 @@ export const createCollectionContextMenuButton = (
useSidePanel
.getState()
.openSidePanel(
t(Keys.contextMenu.deleteContainer, { containerName: getCollectionName() }),
"Delete " + getCollectionName(),
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
);
},
label: t(Keys.contextMenu.deleteContainer, { containerName: getCollectionName() }),
label: `Delete ${getCollectionName()}`,
styleClass: "deleteCollectionMenuItem",
});
}
@@ -222,14 +208,14 @@ export const createSampleCollectionContextMenuButton = (): TreeNodeMenuItem[] =>
useTabs.getState().openAndActivateReactTab(ReactTabKind.QueryCopilot);
traceOpen(Action.OpenQueryCopilotFromNewQuery, { apiType: userContext.apiType });
},
label: t(Keys.contextMenu.newSqlQuery),
label: "New SQL Query",
});
} else if (copilotVersion === "v2.0") {
const sampleCollection = useDatabases.getState().sampleDataResourceTokenCollection;
items.push({
iconSrc: AddSqlQueryIcon,
onClick: () => sampleCollection && sampleCollection.onNewQueryClick(sampleCollection, undefined),
label: t(Keys.contextMenu.newSqlQuery),
label: "New SQL Query",
});
}
}
@@ -249,7 +235,7 @@ export const createStoreProcedureContextMenuItems = (
{
iconSrc: DeleteSprocIcon,
onClick: () => storedProcedure.delete(),
label: t(Keys.contextMenu.deleteStoredProcedure),
label: "Delete Stored Procedure",
},
];
};
@@ -263,7 +249,7 @@ export const createTriggerContextMenuItems = (container: Explorer, trigger: Trig
{
iconSrc: DeleteTriggerIcon,
onClick: () => trigger.delete(),
label: t(Keys.contextMenu.deleteTrigger),
label: "Delete Trigger",
},
];
};
@@ -280,7 +266,7 @@ export const createUserDefinedFunctionContextMenuItems = (
{
iconSrc: DeleteUDFIcon,
onClick: () => userDefinedFunction.delete(),
label: t(Keys.contextMenu.deleteUdf),
label: "Delete User Defined Function",
},
];
};

View File

@@ -1,8 +1,5 @@
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";
@@ -30,6 +27,7 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({
dataMaskingPolicy: {
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
},
indexes: [],
}),
@@ -306,10 +304,12 @@ describe("SettingsComponent", () => {
dataMaskingContent: {
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
},
dataMaskingContentBaseline: {
includedPaths: [],
excludedPaths: [],
isPolicyEnabled: false,
},
isDataMaskingDirty: true,
});
@@ -323,6 +323,7 @@ describe("SettingsComponent", () => {
expect(wrapper.state("dataMaskingContentBaseline")).toEqual({
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
});
});
@@ -336,6 +337,7 @@ 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);
@@ -344,6 +346,7 @@ describe("SettingsComponent", () => {
expect(wrapper.state("dataMaskingContent")).toEqual({
includedPaths: "invalid",
excludedPaths: [],
isPolicyEnabled: false,
});
expect(wrapper.state("dataMaskingValidationErrors")).toEqual(["includedPaths must be an array"]);
@@ -358,6 +361,7 @@ describe("SettingsComponent", () => {
},
],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
};
settingsComponentInstance["onDataMaskingContentChange"](validPolicy);
@@ -381,6 +385,7 @@ describe("SettingsComponent", () => {
},
],
excludedPaths: ["/excludedPath1"],
isPolicyEnabled: false,
};
const modifiedPolicy = {
@@ -393,6 +398,7 @@ describe("SettingsComponent", () => {
},
],
excludedPaths: ["/excludedPath2"],
isPolicyEnabled: true,
};
// Set initial state
@@ -438,49 +444,3 @@ 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);
});
});

View File

@@ -13,10 +13,9 @@ 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 { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
import * as React from "react";
import DiscardIcon from "../../../../images/discard.svg";
@@ -70,11 +69,11 @@ import {
getMongoNotification,
getTabTitle,
hasDatabaseSharedThroughput,
isDataMaskingEnabled,
isDirty,
parseConflictResolutionMode,
parseConflictResolutionProcedure,
} from "./SettingsUtils";
interface SettingsV2TabInfo {
tab: SettingsV2TabTypes;
content: JSX.Element;
@@ -183,7 +182,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);
@@ -313,13 +312,6 @@ 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();
@@ -327,11 +319,7 @@ 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());
@@ -687,14 +675,22 @@ 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 (newDataMasking.includedPaths === undefined || newDataMasking.includedPaths === null) {
validationErrors.push("includedPaths is required");
} else if (!Array.isArray(newDataMasking.includedPaths)) {
if (!Array.isArray(newDataMasking.includedPaths)) {
validationErrors.push("includedPaths must be an array");
}
if (newDataMasking.excludedPaths !== undefined && !Array.isArray(newDataMasking.excludedPaths)) {
validationErrors.push("excludedPaths must be an array if provided");
if (!Array.isArray(newDataMasking.excludedPaths)) {
validationErrors.push("excludedPaths must be an array");
}
if (typeof newDataMasking.isPolicyEnabled !== "boolean") {
validationErrors.push("isPolicyEnabled must be a boolean");
}
this.setState({
@@ -835,6 +831,7 @@ 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();
@@ -852,6 +849,7 @@ 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 {
@@ -1011,31 +1009,10 @@ 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 ||
@@ -1065,8 +1042,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
newCollection.fullTextPolicy = this.state.fullTextPolicy;
// Only send data masking policy if it was modified (dirty) and data masking is enabled
if (this.state.isDataMaskingDirty && isDataMaskingEnabled(this.collection.dataMaskingPolicy?.())) {
// Only send data masking policy if it was modified (dirty)
if (this.state.isDataMaskingDirty && isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) {
newCollection.dataMaskingPolicy = this.state.dataMaskingContent;
}
@@ -1275,6 +1252,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onScaleDiscardableChange: this.onScaleDiscardableChange,
throughputError: this.state.throughputError,
};
if (!this.isCollectionSettingsTab) {
return (
<div className="settingsV2MainContainer">
@@ -1455,7 +1433,15 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
});
}
if (isDataMaskingEnabled(this.collection.dataMaskingPolicy?.())) {
// 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()) {
const dataMaskingComponentProps: DataMaskingComponentProps = {
shouldDiscardDataMasking: this.state.shouldDiscardDataMasking,
resetShouldDiscardDataMasking: this.resetShouldDiscardDataMasking,

View File

@@ -155,12 +155,7 @@ export class ComputedPropertiesComponent extends React.Component<
</Link>
&#160; about how to define computed properties and how to use them.
</Text>
<div
className="settingsV2Editor"
tabIndex={0}
ref={this.computedPropertiesDiv}
data-test="computed-properties-editor"
></div>
<div className="settingsV2Editor" tabIndex={0} ref={this.computedPropertiesDiv}></div>
</Stack>
);
}

View File

@@ -53,6 +53,7 @@ describe("DataMaskingComponent", () => {
},
],
excludedPaths: [],
isPolicyEnabled: false,
};
let changeContentCallback: () => void;
@@ -77,7 +78,7 @@ describe("DataMaskingComponent", () => {
<DataMaskingComponent
{...mockProps}
dataMaskingContent={samplePolicy}
dataMaskingContentBaseline={{ ...samplePolicy, excludedPaths: ["/excluded"] }}
dataMaskingContentBaseline={{ ...samplePolicy, isPolicyEnabled: true }}
/>,
);
@@ -122,7 +123,7 @@ describe("DataMaskingComponent", () => {
});
it("resets content when shouldDiscardDataMasking is true", async () => {
const baselinePolicy = { ...samplePolicy, excludedPaths: ["/excluded"] };
const baselinePolicy = { ...samplePolicy, isPolicyEnabled: true };
const wrapper = mount(
<DataMaskingComponent
@@ -158,7 +159,7 @@ describe("DataMaskingComponent", () => {
wrapper.update();
// Update baseline to trigger componentDidUpdate
const newBaseline = { ...samplePolicy, excludedPaths: ["/excluded"] };
const newBaseline = { ...samplePolicy, isPolicyEnabled: true };
wrapper.setProps({ dataMaskingContentBaseline: newBaseline });
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
@@ -173,6 +174,7 @@ describe("DataMaskingComponent", () => {
const invalidPolicy: Record<string, unknown> = {
includedPaths: "not an array",
excludedPaths: [] as string[],
isPolicyEnabled: "not a boolean",
};
mockGetValue.mockReturnValue(JSON.stringify(invalidPolicy));
@@ -195,7 +197,7 @@ describe("DataMaskingComponent", () => {
wrapper.update();
// First change
const modifiedPolicy1 = { ...samplePolicy, excludedPaths: ["/path1"] };
const modifiedPolicy1 = { ...samplePolicy, isPolicyEnabled: true };
mockGetValue.mockReturnValue(JSON.stringify(modifiedPolicy1));
changeContentCallback();
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);

View File

@@ -1,10 +1,12 @@
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, isDataMaskingEnabled } from "../SettingsUtils";
import { isDirty as isContentDirty } from "../SettingsUtils";
export interface DataMaskingComponentProps {
shouldDiscardDataMasking: boolean;
@@ -22,8 +24,16 @@ interface DataMaskingComponentState {
}
const emptyDataMaskingPolicy: DataModels.DataMaskingPolicy = {
includedPaths: [],
includedPaths: [
{
path: "/",
strategy: "Default",
startPosition: 0,
length: -1,
},
],
excludedPaths: [],
isPolicyEnabled: true,
};
export class DataMaskingComponent extends React.Component<DataMaskingComponentProps, DataMaskingComponentState> {
@@ -130,7 +140,7 @@ export class DataMaskingComponent extends React.Component<DataMaskingComponentPr
};
public render(): JSX.Element {
if (!isDataMaskingEnabled(this.props.dataMaskingContent)) {
if (!isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) {
return null;
}

View File

@@ -1,10 +1,10 @@
import { MessageBar, MessageBarType } from "@fluentui/react";
import * as React from "react";
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import { MessageBar, MessageBarType } from "@fluentui/react";
import {
mongoIndexTransformationRefreshingMessage,
renderMongoIndexTransformationRefreshMessage,
} from "../../SettingsRenderUtils";
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import { isIndexTransforming } from "../../SettingsUtils";
export interface IndexingPolicyRefreshComponentProps {

View File

@@ -302,8 +302,8 @@ export class SubSettingsComponent extends React.Component<SubSettingsComponentPr
);
private geoSpatialConfigTypeChoiceGroupOptions: IChoiceGroupOption[] = [
{ key: GeospatialConfigType.Geography, text: "Geography", ariaLabel: "geography-option" },
{ key: GeospatialConfigType.Geometry, text: "Geometry", ariaLabel: "geometry-option" },
{ key: GeospatialConfigType.Geography, text: "Geography" },
{ key: GeospatialConfigType.Geometry, text: "Geometry" },
];
private getGeoSpatialComponent = (): JSX.Element => (

View File

@@ -546,6 +546,9 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
<span style={{ float: "right" }}>
{this.props.softAllowedMaximumThroughput.toLocaleString(ThroughputInputAutoPilotV3Component.LOCALE_EN_US)}
</span>
<span style={{ float: "right" }} data-test="soft-allowed-maximum-throughput">
{this.props.softAllowedMaximumThroughput.toLocaleString(ThroughputInputAutoPilotV3Component.LOCALE_EN_US)}
</span>
</Stack.Item>
</Stack>
<ProgressIndicator

View File

@@ -435,6 +435,16 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
>
1,000,000
</span>
<span
data-test="soft-allowed-maximum-throughput"
style={
{
"float": "right",
}
}
>
1,000,000
</span>
</StackItem>
</Stack>
<StyledProgressIndicatorBase
@@ -1033,6 +1043,16 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
>
1,000,000
</span>
<span
data-test="soft-allowed-maximum-throughput"
style={
{
"float": "right",
}
}
>
1,000,000
</span>
</StackItem>
</Stack>
<StyledProgressIndicatorBase
@@ -1609,6 +1629,16 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
>
1,000,000
</span>
<span
data-test="soft-allowed-maximum-throughput"
style={
{
"float": "right",
}
}
>
1,000,000
</span>
</StackItem>
</Stack>
<StyledProgressIndicatorBase

View File

@@ -31,7 +31,6 @@ exports[`ComputedPropertiesComponent renders 1`] = `
</Text>
<div
className="settingsV2Editor"
data-test="computed-properties-editor"
tabIndex={0}
/>
</Stack>

View File

@@ -167,12 +167,10 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -654,12 +652,10 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -1228,12 +1224,10 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -1766,12 +1760,10 @@ exports[`SubSettingsComponent renders 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -2338,12 +2330,10 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},

View File

@@ -2,8 +2,6 @@ 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;
@@ -90,19 +88,6 @@ 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) {

View File

@@ -68,6 +68,7 @@ export const collection = {
dataMaskingPolicy: ko.observable<DataModels.DataMaskingPolicy>({
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
}),
readSettings: () => {
return;

View File

@@ -153,16 +153,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -274,16 +264,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -496,16 +476,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -604,58 +574,6 @@ 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={
{
@@ -735,16 +653,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{

View File

@@ -113,7 +113,7 @@ export class ContainerSampleGenerator {
? await createMongoDocument(collection.databaseId, collection, shardKey, doc)
: await createDocument(collection, doc);
} catch (error) {
NotificationConsoleUtils.logConsoleError(error instanceof Error ? error.message : String(error));
NotificationConsoleUtils.logConsoleError(error);
}
}),
);

View File

@@ -329,10 +329,7 @@ export class NotificationConsoleComponent extends React.Component<
}
private static extractHeaderStatus(consoleData: ConsoleData) {
if (!consoleData?.message || typeof consoleData.message !== "string") {
return undefined;
}
return consoleData.message.split(":\n")[0];
return consoleData?.message.split(":\n")[0];
}
private onConsoleWasExpanded = (): void => {

View File

@@ -42,8 +42,6 @@ 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";
@@ -179,7 +177,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
messageType="info"
showErrorDetails={false}
link={Constants.Urls.freeTierInformation}
linkText={t(Keys.common.learnMore)}
linkText="Learn more"
/>
)}
@@ -276,17 +274,17 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={t(Keys.panes.addCollection.databaseTooltip, {
collectionName: getCollectionName(true).toLocaleLowerCase(),
})}
content={`A database is analogous to a namespace. It is the unit of management for a set of ${getCollectionName(
true,
).toLocaleLowerCase()}.`}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={t(Keys.panes.addCollection.databaseTooltip, {
collectionName: getCollectionName(true).toLocaleLowerCase(),
})}
ariaLabel={`A database is analogous to a namespace. It is the unit of management for a set of ${getCollectionName(
true,
).toLocaleLowerCase()}.`}
/>
</TooltipHost>
</Stack>
@@ -306,7 +304,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
tabIndex={0}
onChange={this.onCreateNewDatabaseRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.createNew)}</span>
<span className="panelRadioBtnLabel">Create new</span>
<input
className="panelRadioBtn"
@@ -319,7 +317,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
tabIndex={0}
onChange={this.onUseExistingDatabaseRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.useExisting)}</span>
<span className="panelRadioBtnLabel">Use existing</span>
</div>
</Stack>
)}
@@ -349,9 +347,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{!isServerlessAccount() && (
<Stack horizontal>
<Checkbox
label={t(Keys.panes.addCollection.shareThroughput, {
collectionName: getCollectionName(true).toLocaleLowerCase(),
})}
label={`Share throughput across ${getCollectionName(true).toLocaleLowerCase()}`}
checked={this.state.isSharedThroughputChecked}
styles={{
text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
@@ -369,17 +365,17 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
/>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={t(Keys.panes.addCollection.shareThroughputTooltip, {
collectionName: getCollectionName(true).toLocaleLowerCase(),
})}
content={`Throughput configured at the database level will be shared across all ${getCollectionName(
true,
).toLocaleLowerCase()} within the database.`}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={t(Keys.panes.addCollection.shareThroughputTooltip, {
collectionName: getCollectionName(true).toLocaleLowerCase(),
})}
ariaLabel={`Throughput configured at the database level will be shared across all ${getCollectionName(
true,
).toLocaleLowerCase()} within the database.`}
/>
</TooltipHost>
</Stack>
@@ -428,18 +424,14 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={t(Keys.panes.addCollection.collectionIdTooltip, {
collectionName: getCollectionName().toLocaleLowerCase(),
})}
content={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
>
<Icon
role="button"
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={t(Keys.panes.addCollection.collectionIdTooltip, {
collectionName: getCollectionName().toLocaleLowerCase(),
})}
ariaLabel={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
/>
</TooltipHost>
</Stack>
@@ -453,10 +445,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
autoComplete="off"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder={t(Keys.panes.addCollection.collectionIdPlaceholder, { collectionName: getCollectionName() })}
placeholder={`e.g., ${getCollectionName()}1`}
size={40}
className="panelTextField"
aria-label={t(Keys.panes.addCollection.collectionIdAriaLabel, { collectionName: getCollectionName() })}
aria-label={`${getCollectionName()} id, Example ${getCollectionName()}1`}
value={this.state.collectionId}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
this.setState({ collectionId: event.target.value })
@@ -470,7 +462,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<Stack horizontal style={{ marginTop: -4, marginBottom: -5 }}>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
{t(Keys.panes.addCollection.indexing)}
Indexing
</Text>
</Stack>
@@ -478,32 +470,32 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<input
className="panelRadioBtn"
checked={this.state.enableIndexing}
aria-label={t(Keys.panes.addCollection.turnOnIndexing)}
aria-label="Turn on indexing"
aria-checked={this.state.enableIndexing}
type="radio"
role="radio"
tabIndex={0}
onChange={this.onTurnOnIndexing.bind(this)}
/>
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.automatic)}</span>
<span className="panelRadioBtnLabel">Automatic</span>
<input
className="panelRadioBtn"
checked={!this.state.enableIndexing}
aria-label={t(Keys.panes.addCollection.turnOffIndexing)}
aria-label="Turn off indexing"
aria-checked={!this.state.enableIndexing}
type="radio"
role="radio"
tabIndex={0}
onChange={this.onTurnOffIndexing.bind(this)}
/>
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.off)}</span>
<span className="panelRadioBtnLabel">Off</span>
</Stack>
<Text variant="small">
{this.getFreeTierIndexingText()}{" "}
<Link target="_blank" href="https://aka.ms/cosmos-indexing-policy">
{t(Keys.common.learnMore)}
Learn more
</Link>
</Text>
</Stack>
@@ -516,17 +508,21 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<Stack horizontal style={{ marginTop: -5, marginBottom: -4 }}>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
{t(Keys.panes.addCollection.sharding)}
Sharding
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={t(Keys.panes.addCollection.shardingTooltip)}
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."
}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={t(Keys.panes.addCollection.shardingTooltip)}
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."
}
/>
</TooltipHost>
</Stack>
@@ -535,7 +531,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<input
className="panelRadioBtn"
checked={!this.state.isSharded}
aria-label={t(Keys.panes.addCollection.unsharded)}
aria-label="Unsharded"
aria-checked={!this.state.isSharded}
name="unsharded"
type="radio"
@@ -544,12 +540,12 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
tabIndex={0}
onChange={this.onUnshardedRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.unshardedLabel)}</span>
<span className="panelRadioBtnLabel">Unsharded (20GB limit)</span>
<input
className="panelRadioBtn"
checked={this.state.isSharded}
aria-label={t(Keys.panes.addCollection.sharded)}
aria-label="Sharded"
aria-checked={this.state.isSharded}
name="sharded"
type="radio"
@@ -558,7 +554,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
tabIndex={0}
onChange={this.onShardedRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.sharded)}</span>
<span className="panelRadioBtnLabel">Sharded</span>
</Stack>
</Stack>
)}
@@ -683,14 +679,15 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
disabled={this.state.subPartitionKeys.length >= Constants.BackendDefaults.maxNumMultiHashPartition}
onClick={() => this.setState({ subPartitionKeys: [...this.state.subPartitionKeys, ""] })}
>
{t(Keys.panes.addCollection.addPartitionKey)}
Add hierarchical partition key
</DefaultButton>
{this.state.subPartitionKeys.length > 0 && (
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
<Icon iconName="InfoSolid" className="removeIcon" tabIndex={0} />{" "}
{t(Keys.panes.addCollection.hierarchicalPartitionKeyInfo)}{" "}
<Icon iconName="InfoSolid" className="removeIcon" tabIndex={0} /> This feature allows you to
partition your data with up to three levels of keys for better data distribution. Requires .NET
V3, Java V4 SDK, or preview JavaScript V3 SDK.{" "}
<Link href="https://aka.ms/cosmos-hierarchical-partitioning" target="_blank">
{t(Keys.common.learnMore)}
Learn more
</Link>
</Text>
)}
@@ -703,9 +700,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{!isServerlessAccount() && !this.state.createNewDatabase && this.isSelectedDatabaseSharedThroughput() && (
<Stack horizontal verticalAlign="center">
<Checkbox
label={t(Keys.panes.addCollection.provisionDedicatedThroughput, {
collectionName: getCollectionName().toLocaleLowerCase(),
})}
label={`Provision dedicated throughput for this ${getCollectionName().toLocaleLowerCase()}`}
checked={this.state.enableDedicatedThroughput}
styles={{
text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
@@ -723,19 +718,23 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
/>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={t(Keys.panes.addCollection.provisionDedicatedThroughputTooltip, {
collectionName: getCollectionName().toLocaleLowerCase(),
collectionNamePlural: getCollectionName(true).toLocaleLowerCase(),
})}
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.`}
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={t(Keys.panes.addCollection.provisionDedicatedThroughputTooltip, {
collectionName: getCollectionName().toLocaleLowerCase(),
collectionNamePlural: getCollectionName(true).toLocaleLowerCase(),
})}
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.`}
/>
</TooltipHost>
</Stack>
@@ -770,8 +769,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
autoComplete="off"
placeholder={
userContext.apiType === "Mongo"
? t(Keys.panes.addCollection.uniqueKeysPlaceholderMongo)
: t(Keys.panes.addCollection.uniqueKeysPlaceholderSql)
? "Comma separated paths e.g. firstName,address.zipCode"
: "Comma separated paths e.g. /firstName,/address/zipCode"
}
className="panelTextField"
value={uniqueKey}
@@ -803,7 +802,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, ""] })}
>
{t(Keys.panes.addCollection.addUniqueKey)}
Add unique key
</ActionButton>
</Stack>
)}
@@ -824,7 +823,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
className="panelRadioBtn"
checked={this.state.enableAnalyticalStore}
disabled={!isSynapseLinkEnabled()}
aria-label={t(Keys.panes.addCollection.enableAnalyticalStore)}
aria-label="Enable analytical store"
aria-checked={this.state.enableAnalyticalStore}
name="analyticalStore"
type="radio"
@@ -833,13 +832,13 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
tabIndex={0}
onChange={this.onEnableAnalyticalStoreRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.on)}</span>
<span className="panelRadioBtnLabel">On</span>
<input
className="panelRadioBtn"
checked={!this.state.enableAnalyticalStore}
disabled={!isSynapseLinkEnabled()}
aria-label={t(Keys.panes.addCollection.disableAnalyticalStore)}
aria-label="Disable analytical store"
aria-checked={!this.state.enableAnalyticalStore}
name="analyticalStore"
type="radio"
@@ -848,28 +847,26 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
tabIndex={0}
onChange={this.onDisableAnalyticalStoreRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.off)}</span>
<span className="panelRadioBtnLabel">Off</span>
</div>
</Stack>
{!isSynapseLinkEnabled() && (
<Stack className="panelGroupSpacing">
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
{t(Keys.panes.addCollection.analyticalStoreSynapseLinkRequired, {
collectionName: getCollectionName().toLocaleLowerCase(),
})}{" "}
<br />
Azure Synapse Link is required for creating an analytical store{" "}
{getCollectionName().toLocaleLowerCase()}. Enable Synapse Link for this Cosmos DB account. <br />
<Link
href="https://aka.ms/cosmosdb-synapselink"
target="_blank"
aria-label={Constants.ariaLabelForLearnMoreLink.AzureSynapseLink}
className="capacitycalculator-link"
>
{t(Keys.common.learnMore)}
Learn more
</Link>
</Text>
<DefaultButton
text={t(Keys.panes.addCollection.enable)}
text="Enable"
onClick={() => this.props.explorer.openEnableSynapseLinkDialog()}
style={{ height: 27, width: 80 }}
styles={{ label: { fontSize: 12 } }}
@@ -881,7 +878,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{this.shouldShowVectorSearchParameters() && (
<Stack>
<CollapsibleSectionComponent
title={t(Keys.panes.addCollection.containerVectorPolicy)}
title="Container Vector Policy"
isExpandedByDefault={false}
onExpand={() => {
scrollToSection("collapsibleVectorPolicySectionContent");
@@ -909,7 +906,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{this.shouldShowFullTextSearchParameters() && (
<Stack>
<CollapsibleSectionComponent
title={t(Keys.panes.addCollection.containerFullTextSearchPolicy)}
title="Container Full Text Search Policy"
isExpandedByDefault={false}
onExpand={() => {
scrollToSection("collapsibleFullTextPolicySectionContent");
@@ -938,7 +935,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
)}
{!isFabricNative() && userContext.apiType !== "Tables" && (
<CollapsibleSectionComponent
title={t(Keys.panes.addCollection.advanced)}
title="Advanced"
isExpandedByDefault={false}
onExpand={() => {
TelemetryProcessor.traceOpen(Action.ExpandAddCollectionPaneAdvancedSection);
@@ -951,23 +948,23 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
{t(Keys.panes.addCollection.indexing)}
Indexing
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={t(Keys.panes.addCollection.mongoIndexingTooltip)}
content="The _id field is indexed by default. Creating a wildcard index for all fields will optimize queries and is recommended for development."
>
<Icon
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={t(Keys.panes.addCollection.mongoIndexingTooltip)}
ariaLabel="The _id field is indexed by default. Creating a wildcard index for all fields will optimize queries and is recommended for development."
/>
</TooltipHost>
</Stack>
<Checkbox
label={t(Keys.panes.addCollection.createWildcardIndex)}
label="Create a Wildcard Index on all fields"
checked={this.state.createMongoWildCardIndex}
styles={{
text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
@@ -989,7 +986,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{userContext.apiType === "SQL" && (
<Stack className="panelGroupSpacing">
<Checkbox
label={t(Keys.panes.addCollection.legacySdkCheckbox)}
label="My application uses an older Cosmos .NET or Java SDK version (.NET V1 or Java V2)"
checked={this.state.useHashV1}
styles={{
text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
@@ -1006,9 +1003,11 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
/>
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
<Icon iconName="InfoSolid" className="removeIcon" /> {t(Keys.panes.addCollection.legacySdkInfo)}{" "}
<Icon iconName="InfoSolid" className="removeIcon" /> To ensure compatibility with older SDKs, the
created container will use a legacy partitioning scheme that supports partition key values of size
only up to 101 bytes. If this is enabled, you will not be able to use hierarchical partition keys.{" "}
<Link href="https://aka.ms/cosmos-large-pk" target="_blank">
{t(Keys.common.learnMore)}
Learn more
</Link>
</Text>
</Stack>
@@ -1019,7 +1018,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</div>
{!this.props.isCopyJobFlow && (
<PanelFooterComponent buttonLabel={t(Keys.common.ok)} isButtonDisabled={this.state.isThroughputCapExceeded} />
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={this.state.isThroughputCapExceeded} />
)}
{this.state.isExecuting && (
@@ -1045,7 +1044,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
progressTrack: { backgroundColor: "#A6A6A6" },
progressBar: { background: "white" },
}}
label={t(Keys.panes.addCollection.addingSampleDataSet)}
label="Adding sample data set"
/>
</TeachingBubble>
)}
@@ -1151,8 +1150,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
private getFreeTierIndexingText(): string {
return this.state.enableIndexing
? t(Keys.panes.addCollection.indexingOnInfo)
: t(Keys.panes.addCollection.indexingOffInfo);
? "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.";
}
private getPartitionKeySubtext(): string {
@@ -1250,14 +1249,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
? t(Keys.panes.addCollection.acknowledgeSpendErrorMonthly)
: t(Keys.panes.addCollection.acknowledgeSpendErrorDaily);
? "Please acknowledge the estimated monthly spend."
: "Please acknowledge the estimated daily spend.";
this.setState({ errorMessage });
return false;
}
if (throughput > CollectionCreation.MaxRUPerPartition && !this.state.isSharded) {
this.setState({ errorMessage: t(Keys.panes.addCollection.unshardedMaxRuError) });
this.setState({ errorMessage: "Unsharded collections support up to 10,000 RUs" });
return false;
}
@@ -1271,12 +1270,12 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
if (this.shouldShowVectorSearchParameters()) {
if (!this.state.vectorPolicyValidated) {
this.setState({ errorMessage: t(Keys.panes.addCollection.vectorPolicyError) });
this.setState({ errorMessage: "Please fix errors in container vector policy" });
return false;
}
if (!this.state.fullTextPolicyValidated) {
this.setState({ errorMessage: t(Keys.panes.addCollection.fullTextSearchPolicyError) });
this.setState({ errorMessage: "Please fix errors in container full text search polilcy" });
return false;
}
}

View File

@@ -3,33 +3,28 @@ 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 t(Keys.panes.addCollectionUtility.shardKeyTooltip);
return "The shard key (field) is used to split your data across many replica sets (shards) to achieve unlimited scalability. Its critical to choose a field that will evenly distribute your data.";
}
let tooltipText = t(Keys.panes.addCollectionUtility.partitionKeyTooltip, {
partitionKeyName: getPartitionKeyName(true),
});
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.`;
if (userContext.apiType === "SQL") {
tooltipText += t(Keys.panes.addCollectionUtility.partitionKeyTooltipSqlSuffix);
tooltipText += " For small read-heavy workloads or write-heavy workloads of any size, id is often a good choice.";
}
return tooltipText;
}
export function getPartitionKeyName(isLowerCase?: boolean): string {
const partitionKeyName =
userContext.apiType === "Mongo"
? t(Keys.panes.addCollectionUtility.shardKeyLabel)
: t(Keys.panes.addCollectionUtility.partitionKeyLabel);
const partitionKeyName = userContext.apiType === "Mongo" ? "Shard key" : "Partition key";
return isLowerCase ? partitionKeyName.toLocaleLowerCase() : partitionKeyName;
}
@@ -37,19 +32,19 @@ export function getPartitionKeyName(isLowerCase?: boolean): string {
export function getPartitionKeyPlaceHolder(index?: number): string {
switch (userContext.apiType) {
case "Mongo":
return t(Keys.panes.addCollectionUtility.shardKeyPlaceholder);
return "e.g., categoryId";
case "Gremlin":
return t(Keys.panes.addCollectionUtility.partitionKeyPlaceholderDefault);
return "e.g., /address";
case "SQL":
return `${
index === undefined
? t(Keys.panes.addCollectionUtility.partitionKeyPlaceholderFirst)
? "Required - first partition key e.g., /TenantId"
: index === 0
? t(Keys.panes.addCollectionUtility.partitionKeyPlaceholderSecond)
: t(Keys.panes.addCollectionUtility.partitionKeyPlaceholderThird)
? "second partition key e.g., /UserId"
: "third partition key e.g., /SessionId"
}`;
default:
return t(Keys.panes.addCollectionUtility.partitionKeyPlaceholderGraph);
return "e.g., /address/zipCode";
}
}
@@ -74,12 +69,13 @@ export function isFreeTierAccount(): boolean {
}
export function UniqueKeysHeader(): JSX.Element {
const tooltipContent = t(Keys.panes.addCollectionUtility.uniqueKeysTooltip);
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.";
return (
<Stack horizontal style={{ marginBottom: -2 }}>
<Text className="panelTextBold" variant="small">
{t(Keys.panes.addCollectionUtility.uniqueKeysLabel)}
Unique keys
</Text>
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={tooltipContent}>
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} ariaLabel={tooltipContent} />
@@ -103,11 +99,12 @@ export function shouldShowAnalyticalStoreOptions(): boolean {
}
export function AnalyticalStoreHeader(): JSX.Element {
const tooltipContent = t(Keys.panes.addCollectionUtility.analyticalStoreTooltip);
const tooltipContent =
"Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads.";
return (
<Stack horizontal style={{ marginBottom: -2 }}>
<Text className="panelTextBold" variant="small">
{t(Keys.panes.addCollectionUtility.analyticalStoreLabel)}
Analytical Store
</Text>
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={tooltipContent}>
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} ariaLabel={tooltipContent} />
@@ -119,13 +116,14 @@ export function AnalyticalStoreHeader(): JSX.Element {
export function AnalyticalStorageContent(): JSX.Element {
return (
<Text variant="small">
{t(Keys.panes.addCollectionUtility.analyticalStoreDescription)}{" "}
Enable analytical store capability to perform near real-time analytics on your operational data, without impacting
the performance of transactional workloads.{" "}
<Link
aria-label={Constants.ariaLabelForLearnMoreLink.AnalyticalStore}
target="_blank"
href="https://aka.ms/analytical-store-overview"
>
{t(Keys.common.learnMore)}
Learn more
</Link>
</Text>
);
@@ -157,9 +155,10 @@ export function scrollToSection(id: string): void {
export function ContainerVectorPolicyTooltipContent(): JSX.Element {
return (
<Text variant="small">
{t(Keys.panes.addCollectionUtility.vectorPolicyTooltip)}{" "}
Describe any properties in your data that contain vectors, so that they can be made available for similarity
queries.{" "}
<Link target="_blank" href="https://aka.ms/CosmosDBVectorSetup">
{t(Keys.common.learnMore)}
Learn more
</Link>
</Text>
);

View File

@@ -482,8 +482,10 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
}
variant="small"
>
Azure Synapse Link is required for creating an analytical store container. Enable Synapse Link for this Cosmos DB account.
Azure Synapse Link is required for creating an analytical store
container
. Enable Synapse Link for this Cosmos DB account.
<br />
<StyledLinkBase
aria-label="Learn more about Azure Synapse Link."
@@ -606,8 +608,7 @@ 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"

View File

@@ -1,7 +1,5 @@
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";
@@ -42,18 +40,15 @@ 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
? t(Keys.panes.addDatabase.keyspaceIdLabel)
: t(Keys.panes.addDatabase.databaseIdLabel);
const databaseIdPlaceHolder: string = t(Keys.panes.addDatabase.databaseIdPlaceholder, { databaseLabel });
const databaseIdLabel: string = isCassandraAccount ? "Keyspace id" : "Database id";
const databaseIdPlaceHolder: string = isCassandraAccount ? "Type a new keyspace id" : "Type a new database id";
const [databaseId, setDatabaseId] = useState<string>("");
const databaseIdTooltipText = t(Keys.panes.addDatabase.databaseTooltip, { databaseLabel, collectionsLabel });
const databaseIdTooltipText = `A ${
isCassandraAccount ? "keyspace" : "database"
} is a logical container of one or more ${isCassandraAccount ? "tables" : "collections"}`;
const databaseLevelThroughputTooltipText = t(Keys.panes.addDatabase.shareThroughputTooltip, {
databaseLabel,
collectionsLabel,
});
const databaseLevelThroughputTooltipText = `Provisioned throughput at the ${databaseLabel} level will be shared across all ${collectionsLabel} within the ${databaseLabel}.`;
const [databaseCreateNewShared, setDatabaseCreateNewShared] = useState<boolean>(
getNewDatabaseSharedThroughputDefault(),
);
@@ -149,15 +144,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(t(Keys.panes.addDatabase.greaterThanError, { minValue: AutoPilotUtils.autoPilotThroughput1K }));
setFormErrors(
`Please enter a value greater than ${AutoPilotUtils.autoPilotThroughput1K} for autopilot throughput`,
);
return false;
}
}
if (throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) {
setFormErrors(
t(Keys.panes.addDatabase.acknowledgeSpendError, { period: isAutoscaleSelected ? "monthly" : "daily" }),
);
setFormErrors(`Please acknowledge the estimated ${isAutoscaleSelected ? "monthly" : "daily"} spend.`);
return false;
}
@@ -174,7 +169,7 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
const props: RightPaneFormProps = {
formError: formErrors,
isExecuting,
submitButtonText: t(Keys.common.ok),
submitButtonText: "OK",
isSubmitButtonDisabled: isThroughputCapExceeded,
onSubmit,
};
@@ -192,7 +187,7 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
messageType="info"
showErrorDetails={false}
link={Constants.Urls.freeTierInformation}
linkText={t(Keys.common.learnMore)}
linkText="Learn more"
/>
)}
<div className="panelMainContent">

View File

@@ -40,8 +40,6 @@ 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";
@@ -170,19 +168,19 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
}
if (globalSecondaryIndexThroughput > CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) {
const errorMessage: string = t(Keys.panes.addCollection.acknowledgeSpendErrorMonthly);
const errorMessage: string = "Please acknowledge the estimated monthly spend.";
setErrorMessage(errorMessage);
return false;
}
if (showVectorSearchParameters()) {
if (!vectorPolicyValidated) {
setErrorMessage(t(Keys.panes.addCollection.vectorPolicyError));
setErrorMessage("Please fix errors in container vector policy");
return false;
}
if (!fullTextPolicyValidated) {
setErrorMessage(t(Keys.panes.addCollection.fullTextSearchPolicyError));
setErrorMessage("Please fix errors in container full text search policy");
return false;
}
}
@@ -309,7 +307,7 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
{t(Keys.panes.addGlobalSecondaryIndex.globalSecondaryIndexId)}
Global secondary index container id
</Text>
</Stack>
<input
@@ -320,7 +318,7 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
autoComplete="off"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder={t(Keys.panes.addGlobalSecondaryIndex.globalSecondaryIndexIdPlaceholder)}
placeholder={`e.g., indexbyEmailId`}
size={40}
className="panelTextField"
value={globalSecondaryIndexId}
@@ -338,7 +336,7 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
href="https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views#defining-materialized-views"
target="blank"
>
{t(Keys.panes.addGlobalSecondaryIndex.projectionQueryTooltip)}
Learn more about defining global secondary indexes.
</Link>
}
>
@@ -351,7 +349,7 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
aria-required
required
autoComplete="off"
placeholder={t(Keys.panes.addGlobalSecondaryIndex.projectionQueryPlaceholder)}
placeholder={"SELECT c.email, c.accountId FROM c"}
size={40}
className="panelTextField"
value={definition || ""}
@@ -395,7 +393,7 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
<AdvancedComponent {...{ useHashV1, setUseHashV1, setSubPartitionKeys }} />
</Stack>
</div>
<PanelFooterComponent buttonLabel={t(Keys.common.ok)} isButtonDisabled={isThroughputCapExceeded} />
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={isThroughputCapExceeded} />
{isExecuting && <PanelLoadingScreen />}
</form>
);

View File

@@ -2,16 +2,14 @@ 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";
@@ -73,8 +71,8 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
if (throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) {
const errorMessage =
isNewKeySpaceAutoscale || isTableAutoscale
? t(Keys.panes.addCollection.acknowledgeSpendErrorMonthly)
: t(Keys.panes.addCollection.acknowledgeSpendErrorDaily);
? "Please acknowledge the estimated monthly spend."
: "Please acknowledge the estimated daily spend.";
setFormError(errorMessage);
return;
}
@@ -151,7 +149,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
const props: RightPaneFormProps = {
formError,
isExecuting,
submitButtonText: t(Keys.common.ok),
submitButtonText: "OK",
isSubmitButtonDisabled: isThroughputCapExceeded,
onSubmit,
};
@@ -163,8 +161,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
{t(Keys.panes.cassandraAddCollection.keyspaceLabel)}{" "}
<InfoTooltip>{t(Keys.panes.cassandraAddCollection.keyspaceTooltip)}</InfoTooltip>
Keyspace name <InfoTooltip>Select an existing keyspace or enter a new keyspace id.</InfoTooltip>
</Text>
</Stack>
@@ -182,7 +179,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
setExistingKeyspaceId("");
}}
/>
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.createNew)}</span>
<span className="panelRadioBtnLabel">Create new</span>
<input
className="panelRadioBtn"
@@ -196,7 +193,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
setIsKeyspaceShared(false);
}}
/>
<span className="panelRadioBtnLabel">{t(Keys.panes.addCollection.useExisting)}</span>
<span className="panelRadioBtnLabel">Use existing</span>
</Stack>
{keyspaceCreateNew && (
@@ -278,9 +275,9 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
{t(Keys.panes.cassandraAddCollection.tableIdLabel)}{" "}
Enter CQL command to create the table.{" "}
<Link className="underlinedLink" href="https://aka.ms/cassandra-create-table" target="_blank">
{t(Keys.common.learnMore)}
Learn More
</Link>
</Text>
</Stack>
@@ -298,7 +295,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
autoComplete="off"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder={t(Keys.panes.cassandraAddCollection.enterTableId)}
placeholder="Enter table Id"
size={20}
value={tableId}
onChange={(e, newValue) => setTableId(newValue)}
@@ -310,7 +307,7 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
multiline
id="editor-area"
rows={5}
ariaLabel={t(Keys.panes.cassandraAddCollection.tableSchemaAriaLabel)}
ariaLabel="Table schema"
value={userTableQuery}
onChange={(e, newValue) => setUserTableQuery(newValue)}
/>
@@ -321,12 +318,17 @@ export const CassandraAddCollectionPane: FunctionComponent<CassandraAddCollectio
<input
type="checkbox"
id="tableSharedThroughput"
title={t(Keys.panes.cassandraAddCollection.provisionDedicatedThroughput)}
title="Provision dedicated throughput for this table"
checked={dedicateTableThroughput}
onChange={(e) => setDedicateTableThroughput(e.target.checked)}
/>
<span>{t(Keys.panes.cassandraAddCollection.provisionDedicatedThroughput)}</span>
<InfoTooltip>{t(Keys.panes.cassandraAddCollection.provisionDedicatedThroughputTooltip)}</InfoTooltip>
<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>
</Stack>
)}
{!isServerlessAccount() && (!isKeyspaceShared || dedicateTableThroughput) && (

View File

@@ -26,8 +26,6 @@ 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";
@@ -74,7 +72,7 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
await createDataTransferJob();
await onClose();
} catch (error) {
handleError(error, "ChangePartitionKey", t(Keys.panes.changePartitionKey.failedToStartError));
handleError(error, "ChangePartitionKey", "Failed to start data transfer job");
}
setIsExecuting(false);
useSidePanel.getState().closeSidePanel();
@@ -135,21 +133,17 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
};
return (
<RightPaneForm
formError={formError}
isExecuting={isExecuting}
onSubmit={submit}
submitButtonText={t(Keys.common.ok)}
>
<RightPaneForm formError={formError} isExecuting={isExecuting} onSubmit={submit} submitButtonText="OK">
<Stack tokens={{ childrenGap: 10 }} className="panelMainContent">
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
{t(Keys.panes.changePartitionKey.description)}&nbsp;
When changing a containers partition key, you will need to create a destination container with the correct
partition key. You may also select an existing destination container.&nbsp;
<Link
href="https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy#container-copy-within-an-azure-cosmos-db-account"
target="_blank"
underline
>
{t(Keys.common.learnMore)}
Learn more
</Link>
</Text>
<Stack>
@@ -224,18 +218,14 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={t(Keys.panes.changePartitionKey.collectionIdTooltip, {
collectionName: getCollectionName().toLocaleLowerCase(),
})}
content={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
>
<Icon
role="button"
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={t(Keys.panes.changePartitionKey.collectionIdTooltip, {
collectionName: getCollectionName().toLocaleLowerCase(),
})}
ariaLabel={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
/>
</TooltipHost>
</Stack>
@@ -249,14 +239,10 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
autoComplete="off"
pattern={ValidCosmosDbIdInputPattern.source}
title={ValidCosmosDbIdDescription}
placeholder={t(Keys.panes.changePartitionKey.collectionIdPlaceholder, {
collectionName: getCollectionName(),
})}
placeholder={`e.g., ${getCollectionName()}1`}
size={40}
className="panelTextField"
aria-label={t(Keys.panes.changePartitionKey.collectionIdAriaLabel, {
collectionName: getCollectionName(),
})}
aria-label={`${getCollectionName()} id, Example ${getCollectionName()}1`}
value={targetCollectionId}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setTargetCollectionId(event.target.value)}
/>
@@ -363,7 +349,7 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
disabled={subPartitionKeys.length >= Constants.BackendDefaults.maxNumMultiHashPartition}
onClick={() => setSubPartitionKeys([...subPartitionKeys, ""])}
>
{t(Keys.panes.addCollection.addPartitionKey)}
Add hierarchical partition key
</DefaultButton>
{subPartitionKeys.length > 0 && (
<Text
@@ -371,10 +357,11 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
variant="small"
style={{ color: "var(--colorNeutralForeground1)" }}
>
<Icon iconName="InfoSolid" className="removeIcon" tabIndex={0} />{" "}
{t(Keys.panes.addCollection.hierarchicalPartitionKeyInfo)}{" "}
<Icon iconName="InfoSolid" className="removeIcon" tabIndex={0} /> This feature allows you to
partition your data with up to three levels of keys for better data distribution. Requires .NET V3,
Java V4 SDK, or preview JavaScript V3 SDK.{" "}
<Link href="https://aka.ms/cosmos-hierarchical-partitioning" target="_blank">
{t(Keys.common.learnMore)}
Learn more
</Link>
</Text>
)}
@@ -390,18 +377,14 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={t(Keys.panes.changePartitionKey.collectionIdTooltip, {
collectionName: getCollectionName().toLocaleLowerCase(),
})}
content={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
>
<Icon
role="button"
iconName="Info"
className="panelInfoIcon"
tabIndex={0}
ariaLabel={t(Keys.panes.changePartitionKey.collectionIdTooltip, {
collectionName: getCollectionName().toLocaleLowerCase(),
})}
ariaLabel={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
/>
</TooltipHost>
</Stack>
@@ -417,7 +400,7 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
}}
defaultSelectedKey={targetCollectionId}
responsiveMode={999}
ariaLabel={t(Keys.panes.changePartitionKey.existingContainers)}
ariaLabel="Existing Containers"
/>
</Stack>
)}

View File

@@ -4,8 +4,6 @@ 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";
@@ -84,14 +82,14 @@ export const CopyNotebookPane: FunctionComponent<CopyNotebookPanelProps> = ({
const notebookContentItem = await copyNotebook(selectedLocation);
if (!notebookContentItem) {
throw new Error(t(Keys.panes.copyNotebook.uploadFailedError, { name }));
throw new Error(`Failed to upload ${name}`);
}
NotificationConsoleUtils.logConsoleInfo(`Successfully copied ${name} to ${destination}`);
closeSidePanel();
} catch (error) {
const errorMessage = getErrorMessage(error);
setFormError(t(Keys.panes.copyNotebook.copyFailedError, { name, destination }));
setFormError(`Failed to copy ${name} to ${destination}`);
handleError(errorMessage, "CopyNotebookPaneAdapter/submit", formError);
} finally {
clearMessage && clearMessage();
@@ -138,7 +136,7 @@ export const CopyNotebookPane: FunctionComponent<CopyNotebookPanelProps> = ({
const props: RightPaneFormProps = {
formError,
isExecuting: isExecuting,
submitButtonText: t(Keys.common.ok),
submitButtonText: "OK",
onSubmit: () => submit(),
};

View File

@@ -12,8 +12,6 @@ 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";
@@ -98,8 +96,8 @@ export const CopyNotebookPaneComponent: FunctionComponent<CopyNotebookPaneProps>
return options;
};
const dropDownProps: IDropdownProps = {
label: t(Keys.panes.copyNotebook.location),
ariaLabel: t(Keys.panes.copyNotebook.locationAriaLabel),
label: "Location",
ariaLabel: "Location",
placeholder: "Select an option",
onRenderTitle: onRenderDropDownTitle,
onRenderOption: onRenderDropDownOption,
@@ -111,7 +109,7 @@ export const CopyNotebookPaneComponent: FunctionComponent<CopyNotebookPaneProps>
<div className="paneMainContent">
<Stack tokens={{ childrenGap: 10 }}>
<Stack.Item>
<Label htmlFor="notebookName">{t(Keys.panes.copyNotebook.name)}</Label>
<Label htmlFor="notebookName">Name</Label>
<Text id="notebookName">{name}</Text>
</Stack.Item>

View File

@@ -4,8 +4,6 @@ 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";
@@ -36,15 +34,12 @@ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectio
useDatabases.getState().isLastCollection() && !useDatabases.getState().findSelectedDatabase()?.isDatabaseShared();
const collectionName = getCollectionName().toLocaleLowerCase();
const paneTitle = t(Keys.panes.deleteCollection.panelTitle, { collectionName });
const paneTitle = "Delete " + collectionName;
const onSubmit = async (): Promise<void> => {
const collection = useSelectedNode.getState().findSelectedCollection();
if (!collection || inputCollectionName !== collection.id()) {
const errorMessage = t(Keys.panes.deleteCollection.inputMismatch, {
input: inputCollectionName,
selectedId: collection.id(),
});
const errorMessage = "Input id " + inputCollectionName + " does not match the selected " + collection.id();
setFormError(errorMessage);
NotificationConsoleUtils.logConsoleError(
`Error while deleting ${collectionName} ${collection.id()}: ${errorMessage}`,
@@ -111,23 +106,18 @@ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectio
const props: RightPaneFormProps = {
formError: formError,
isExecuting,
submitButtonText: t(Keys.common.ok),
submitButtonText: "OK",
onSubmit,
};
const confirmContainer = t(Keys.panes.deleteCollection.confirmPrompt, {
collectionName: collectionName.toLowerCase(),
});
const reasonInfo =
t(Keys.panes.deleteCollection.feedbackTitle) +
" " +
t(Keys.panes.deleteCollection.feedbackReason, { collectionName });
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}?`;
return (
<RightPaneForm {...props}>
<div className="panelFormWrapper">
<div className="panelMainContent">
<div className="confirmDeleteInput">
<span className="mandatoryStar">* </span>
<Text variant="small">{confirmContainer}</Text>
<Text variant="small">Confirm by typing the {collectionName.toLowerCase()} id</Text>
<TextField
id="confirmCollectionId"
autoFocus
@@ -143,10 +133,10 @@ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectio
{shouldRecordFeedback() && (
<div className="deleteCollectionFeedback">
<Text variant="small" block>
{t(Keys.panes.deleteCollection.feedbackTitle)}
Help us improve Azure Cosmos DB!
</Text>
<Text variant="small" block>
{t(Keys.panes.deleteCollection.feedbackReason, { collectionName })}
What is the reason why you are deleting this {collectionName}?
</Text>
<TextField
id="deleteCollectionFeedbackInput"

View File

@@ -34,7 +34,9 @@ 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

View File

@@ -5,8 +5,6 @@ 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";
@@ -40,19 +38,11 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
const submit = async (): Promise<void> => {
if (selectedDatabase?.id() && databaseInput !== selectedDatabase.id()) {
setFormError(
t(Keys.panes.deleteDatabase.inputMismatch, {
databaseName: getDatabaseName(),
input: databaseInput,
selectedId: selectedDatabase.id(),
}),
`Input ${getDatabaseName()} name "${databaseInput}" does not match the selected ${getDatabaseName()} "${selectedDatabase.id()}"`,
);
logConsoleError(`Error while deleting ${getDatabaseName()} ${selectedDatabase && selectedDatabase.id()}`);
logConsoleError(
t(Keys.panes.deleteDatabase.inputMismatch, {
databaseName: getDatabaseName(),
input: databaseInput,
selectedId: selectedDatabase.id(),
}),
`Input ${getDatabaseName()} name "${databaseInput}" does not match the selected ${getDatabaseName()} "${selectedDatabase.id()}"`,
);
return;
}
@@ -124,20 +114,18 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
const props: RightPaneFormProps = {
formError,
isExecuting: isLoading,
submitButtonText: t(Keys.common.ok),
submitButtonText: "OK",
onSubmit: () => submit(),
};
const errorProps: PanelInfoErrorProps = {
messageType: "warning",
showErrorDetails: false,
message: t(Keys.panes.deleteDatabase.warningMessage),
message:
"Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.",
};
const confirmDatabase = t(Keys.panes.deleteDatabase.confirmPrompt, { databaseName: getDatabaseName() });
const reasonInfo =
t(Keys.panes.deleteDatabase.feedbackTitle) +
" " +
t(Keys.panes.deleteDatabase.feedbackReason, { databaseName: getDatabaseName() });
const confirmDatabase = `Confirm by typing the ${getDatabaseName()} id (name)`;
const reasonInfo = `Help us improve Azure Cosmos DB! What is the reason why you are deleting this ${getDatabaseName()}?`;
return (
<RightPaneForm {...props}>
{!formError && <PanelInfoErrorComponent {...errorProps} />}
@@ -160,10 +148,10 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
{isLastNonEmptyDatabase() && (
<div className="deleteDatabaseFeedback">
<Text variant="small" block>
{t(Keys.panes.deleteDatabase.feedbackTitle)}
Help us improve Azure Cosmos DB!
</Text>
<Text variant="small" block>
{t(Keys.panes.deleteDatabase.feedbackReason, { databaseName: getDatabaseName() })}
What is the reason why you are deleting this {getDatabaseName()}?
</Text>
<TextField
id="deleteDatabaseFeedbackInput"

View File

@@ -3,8 +3,6 @@ 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";
@@ -47,8 +45,8 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
};
const setInvalidParamError = (invalidParam: string): void => {
setFormError(t(Keys.panes.executeStoredProcedure.invalidParamError, { invalidParam }));
logConsoleError(t(Keys.panes.executeStoredProcedure.invalidParamConsoleError, { invalidParam }));
setFormError(`Invalid param specified: ${invalidParam}`);
logConsoleError(`Invalid param specified: ${invalidParam} is not a valid literal value`);
};
const submit = (): void => {
@@ -98,7 +96,7 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
const props: RightPaneFormProps = {
formError: formError,
isExecuting: isLoading,
submitButtonText: t(Keys.common.execute),
submitButtonText: "Execute",
onSubmit: () => submit(),
};
@@ -109,9 +107,9 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
inputParameters.push(
<InputParameter
key={paramKeyValue.text + i}
dropdownLabel={i === 0 ? t(Keys.panes.executeStoredProcedure.key) : ""}
inputParameterTitle={i === 0 ? t(Keys.panes.executeStoredProcedure.enterInputParameters) : ""}
inputLabel={i === 0 ? t(Keys.panes.executeStoredProcedure.param) : ""}
dropdownLabel={i === 0 ? "Key" : ""}
inputParameterTitle={i === 0 ? "Enter input parameters (if any)" : ""}
inputLabel={i === 0 ? "Param" : ""}
isAddRemoveVisible={true}
onDeleteParamKeyPress={() => deleteParamAtIndex(i)}
onAddNewParamKeyPress={() => addNewParamAtIndex(i + 1)}
@@ -132,9 +130,9 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
<RightPaneForm {...props}>
<div className="panelMainContent">
<InputParameter
dropdownLabel={t(Keys.panes.executeStoredProcedure.key)}
inputParameterTitle={t(Keys.panes.executeStoredProcedure.partitionKeyValue)}
inputLabel={t(Keys.panes.executeStoredProcedure.value)}
dropdownLabel="Key"
inputParameterTitle="Partition key value"
inputLabel="Value"
isAddRemoveVisible={false}
onParamValueChange={(_event, newInput?: string) => (partitionValueRef.current = newInput)}
onParamKeyChange={(_event: React.FormEvent<HTMLDivElement>, item: IDropdownOption) =>
@@ -145,8 +143,8 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
/>
{getInputParameterComponent()}
<Stack horizontal onClick={() => addNewParamAtLastIndex()} tabIndex={0}>
<Image {...imageProps} src={AddPropertyIcon} alt={t(Keys.panes.executeStoredProcedure.addParam)} />
<Text className="addNewParamStyle">{t(Keys.panes.executeStoredProcedure.addNewParam)}</Text>
<Image {...imageProps} src={AddPropertyIcon} alt="Add param" />
<Text className="addNewParamStyle">Add New Param</Text>
</Stack>
</div>
</RightPaneForm>

View File

@@ -11,8 +11,6 @@ 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 = [
@@ -76,7 +74,7 @@ export const InputParameter: FunctionComponent<InputParameterProps> = ({
<Image
{...imageProps}
src={EntityCancelIcon}
alt={t(Keys.panes.executeStoredProcedure.deleteParam)}
alt="Delete param"
id="deleteparam"
role="button"
onClick={onDeleteParamKeyPress}
@@ -86,7 +84,7 @@ export const InputParameter: FunctionComponent<InputParameterProps> = ({
<Image
{...imageProps}
src={AddPropertyIcon}
alt={t(Keys.panes.executeStoredProcedure.addParam)}
alt="Add param"
id="addparam"
role="button"
onClick={onAddNewParamKeyPress}

View File

@@ -1,7 +1,5 @@
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";
@@ -19,7 +17,7 @@ export const GraphStylingPanel: FunctionComponent<GraphStylingProps> = ({
}: GraphStylingProps): JSX.Element => {
const closeSidePanel = useSidePanel((state) => state.closeSidePanel);
const buttonLabel = t(Keys.common.ok);
const buttonLabel = "Ok";
const submit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();

View File

@@ -5,8 +5,6 @@ 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";
@@ -35,8 +33,8 @@ export const LoadQueryPane: FunctionComponent = (): JSX.Element => {
const submit = async (): Promise<void> => {
setFormError("");
if (!selectedFiles || selectedFiles.length === 0) {
setFormError(t(Keys.panes.loadQuery.noFileSpecifiedError));
logConsoleError(t(Keys.panes.loadQuery.noFileSpecifiedError));
setFormError("No file specified");
logConsoleError("Could not load query -- No file specified. Please input a file.");
return;
}
@@ -50,7 +48,7 @@ export const LoadQueryPane: FunctionComponent = (): JSX.Element => {
setLoadingFalse();
} catch (error) {
setLoadingFalse();
setFormError(t(Keys.panes.loadQuery.failedToLoadQueryError));
setFormError("Failed to load query");
logConsoleError(`Failed to load query from file ${file.name}: ${error}`);
}
};
@@ -73,7 +71,7 @@ export const LoadQueryPane: FunctionComponent = (): JSX.Element => {
};
reader.onerror = (): void => {
setFormError(t(Keys.panes.loadQuery.failedToLoadQueryFromFileError, { fileName: file.name }));
setFormError("Failed to load query");
logConsoleError(`Failed to load query from file ${file.name}`);
};
return reader.readAsText(file);
@@ -81,7 +79,7 @@ export const LoadQueryPane: FunctionComponent = (): JSX.Element => {
const props: RightPaneFormProps = {
formError: formError,
isExecuting: isLoading,
submitButtonText: t(Keys.common.load),
submitButtonText: "Load",
onSubmit: () => submit(),
};
@@ -92,7 +90,7 @@ export const LoadQueryPane: FunctionComponent = (): JSX.Element => {
<Stack horizontal>
<TextField
id="confirmCollectionId"
label={t(Keys.panes.loadQuery.selectFilesToOpen)}
label="Select a query document"
value={selectedFileName}
autoFocus
readOnly

View File

@@ -1,8 +1,6 @@
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";
@@ -43,7 +41,7 @@ export const NewVertexPanel: FunctionComponent<INewVertexPanelProps> = ({
const props: RightPaneFormProps = {
formError: errorMessage,
isExecuting: isLoading,
submitButtonText: t(Keys.common.ok),
submitButtonText: "OK",
onSubmit: () => submit(),
};

View File

@@ -1,6 +1,4 @@
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";
@@ -22,17 +20,13 @@ 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={t(Keys.panes.panelInfo.information)} />
);
let icon: JSX.Element = <Icon iconName="InfoSolid" className="panelLargeInfoIcon" aria-label="Infomation" />;
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={t(Keys.panes.panelInfo.information)} />
);
icon = <Icon iconName="InfoSolid" className="panelLargeInfoIcon" aria-label="Infomation" />;
}
return (
@@ -49,7 +43,7 @@ export const PanelInfoErrorComponent: React.FunctionComponent<PanelInfoErrorProp
</Text>
{showErrorDetails && (
<a className="paneErrorLink" role="button" onClick={expandConsole} tabIndex={0} onKeyPress={expandConsole}>
{t(Keys.panes.panelInfo.moreDetails)}
More details
</a>
)}
</span>

View File

@@ -5,8 +5,6 @@ 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";
@@ -93,7 +91,7 @@ export const PublishNotebookPane: FunctionComponent<PublishNotebookPaneAProps> =
let startKey: number;
if (!notebookName || !notebookDescription || !author || !imageSrc) {
setFormError(t(Keys.panes.publishNotebook.publishFailedError, { notebookName }));
setFormError(`Failed to publish ${notebookName} to gallery`);
setFormErrorDetail("Name, description, author and cover image are required");
createFormError(formError, formErrorDetail, "PublishNotebookPaneAdapter/submit");
setIsExecuting(false);
@@ -145,11 +143,7 @@ export const PublishNotebookPane: FunctionComponent<PublishNotebookPaneAProps> =
);
const errorMessage = getErrorMessage(error);
setFormError(
t(Keys.panes.publishNotebook.publishFailedError, {
notebookName: FileSystemUtil.stripExtension(notebookName, "ipynb"),
}),
);
setFormError(`Failed to publish ${FileSystemUtil.stripExtension(notebookName, "ipynb")} to gallery`);
setFormErrorDetail(`${errorMessage}`);
handleError(errorMessage, "PublishNotebookPaneAdapter/submit", formError);
return;

View File

@@ -1,8 +1,6 @@
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";
@@ -59,11 +57,13 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
const maxImageSizeInMib = 1.5;
const descriptionPara1 = t(Keys.panes.publishNotebook.publishDescription);
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 descriptionPara2 = t(Keys.panes.publishNotebook.publishPrompt, {
name: FileSystemUtil.stripExtension(notebookName, "ipynb"),
});
const descriptionPara2 = `Would you like to publish and share "${FileSystemUtil.stripExtension(
notebookName,
"ipynb",
)}" to the gallery?`;
const options: ImageTypes[] = [ImageTypes.CustomImage, ImageTypes.Url];
if (onTakeSnapshot) {
@@ -74,9 +74,9 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
}
const thumbnailSelectorProps: IDropdownProps = {
label: t(Keys.panes.publishNotebook.coverImage),
label: "Cover image",
selectedKey: type,
ariaLabel: t(Keys.panes.publishNotebook.coverImage),
ariaLabel: "Cover image",
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(t(Keys.panes.publishNotebook.outputDoesNotExist)));
firstOutputErrorHandler(new Error("Output does not exist for any of the cells."));
}
}
setType(options.text);
@@ -107,8 +107,8 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
};
const thumbnailUrlProps: ITextFieldProps = {
label: t(Keys.panes.publishNotebook.coverImageUrl),
ariaLabel: t(Keys.panes.publishNotebook.coverImageUrl),
label: "Cover image url",
ariaLabel: "Cover image url",
required: true,
onChange: (event, newValue) => {
setImageSrc(newValue);
@@ -116,7 +116,7 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
};
const firstOutputErrorHandler = (error: Error) => {
const formError = t(Keys.panes.publishNotebook.failedToCaptureOutput);
const formError = "Failed to capture first output";
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 = t(Keys.panes.publishNotebook.failedToConvertError, { fileName: file.name });
const formError = `Failed to convert ${file.name} to base64 format`;
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 = t(Keys.panes.publishNotebook.failedToUploadError, { fileName: file.name });
const formError = `Failed to upload ${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={t(Keys.panes.publishNotebook.name)}
ariaLabel={t(Keys.panes.publishNotebook.name)}
label="Name"
ariaLabel="Name"
defaultValue={FileSystemUtil.stripExtension(notebookName, "ipynb")}
required
onChange={(event, newValue) => {
@@ -198,8 +198,8 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
<Stack.Item>
<TextField
label={t(Keys.panes.publishNotebook.description)}
ariaLabel={t(Keys.panes.publishNotebook.description)}
label="Description"
ariaLabel="Description"
multiline
rows={3}
required
@@ -211,9 +211,9 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
<Stack.Item>
<TextField
label={t(Keys.panes.publishNotebook.tags)}
ariaLabel={t(Keys.panes.publishNotebook.tags)}
placeholder={t(Keys.panes.publishNotebook.tagsPlaceholder)}
label="Tags"
ariaLabel="Tags"
placeholder="Optional tag 1, Optional tag 2"
onChange={(event, newValue) => {
setNotebookTags(newValue);
}}
@@ -227,7 +227,7 @@ export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPane
<Stack.Item>{renderThumbnailSelectors(type)}</Stack.Item>
<Stack.Item>
<Text>{t(Keys.panes.publishNotebook.preview)}</Text>
<Text>Preview</Text>
</Stack.Item>
<Stack.Item>
<GalleryCardComponent

View File

@@ -4,8 +4,6 @@ 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";
@@ -30,27 +28,27 @@ export const SaveQueryPane: FunctionComponent<SaveQueryPaneProps> = ({
const [formError, setFormError] = useState<string>("");
const [queryName, setQueryName] = useState<string>("");
const setupSaveQueriesText = t(Keys.panes.saveQuery.setupCostMessage, { databaseName: SavedQueries.DatabaseName });
const title = t(Keys.panes.saveQuery.panelTitle);
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 isSaveQueryEnabled = useDatabases((state) => state.isSaveQueryEnabled);
const submit = async (): Promise<void> => {
setFormError("");
if (!isSaveQueryEnabled()) {
setFormError("Cannot save query");
logConsoleError(t(Keys.panes.saveQuery.accountNotSetupError));
logConsoleError("Failed to save query: account not setup to save queries");
}
const queryTab = useTabs.getState().activeTab as NewQueryTab;
const query: string = queryToSave || queryTab?.iTabAccessor.onSaveClickEvent();
if (!queryName || queryName.length === 0) {
setFormError(t(Keys.panes.saveQuery.noQueryNameError));
logConsoleError(t(Keys.panes.saveQuery.noQueryNameError));
setFormError("No query name specified");
logConsoleError("Could not save query -- No query name specified. Please specify a query name.");
return;
} else if (!query || query.length === 0) {
setFormError(t(Keys.panes.saveQuery.invalidQueryContentError));
logConsoleError(t(Keys.panes.saveQuery.invalidQueryContentError));
setFormError("Invalid query content specified");
logConsoleError("Could not save query -- Invalid query content specified. Please enter query content.");
return;
}
@@ -82,8 +80,8 @@ export const SaveQueryPane: FunctionComponent<SaveQueryPaneProps> = ({
} catch (error) {
setLoadingFalse();
const errorMessage = getErrorMessage(error);
setFormError(t(Keys.panes.saveQuery.failedToSaveQueryError, { queryName }));
logConsoleError(t(Keys.panes.saveQuery.failedToSaveQueryError, { queryName }) + ": " + errorMessage);
setFormError("Failed to save query");
logConsoleError(`Failed to save query: ${errorMessage}`);
traceFailure(
Action.SaveQuery,
{
@@ -128,8 +126,8 @@ export const SaveQueryPane: FunctionComponent<SaveQueryPaneProps> = ({
},
startKey,
);
setFormError(t(Keys.panes.saveQuery.failedToSetupContainerError));
logConsoleError(t(Keys.panes.saveQuery.failedToSetupContainerError) + ": " + errorMessage);
setFormError("Failed to setup a container for saved queries");
logConsoleError(`Failed to setup a container for saved queries: ${errorMessage}`);
} finally {
setLoadingFalse();
}
@@ -138,7 +136,7 @@ export const SaveQueryPane: FunctionComponent<SaveQueryPaneProps> = ({
const props: RightPaneFormProps = {
formError: formError,
isExecuting: isLoading,
submitButtonText: isSaveQueryEnabled() ? t(Keys.common.save) : t(Keys.panes.saveQuery.completeSetup),
submitButtonText: isSaveQueryEnabled() ? "Save" : "Complete setup",
onSubmit: () => {
isSaveQueryEnabled() ? submit() : setupQueries();
},
@@ -162,7 +160,7 @@ export const SaveQueryPane: FunctionComponent<SaveQueryPaneProps> = ({
) : (
<TextField
id="saveQueryInput"
label={t(Keys.panes.saveQuery.name)}
label="Name"
autoFocus
styles={{ fieldGroup: { width: 300 } }}
onChange={(event, newInput?: string) => {

View File

@@ -24,8 +24,6 @@ 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,
@@ -237,7 +235,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
const regionOptions: IDropdownOption[] = [];
regionOptions.push({
key: userContext?.databaseAccount?.properties?.documentEndpoint,
text: t(Keys.panes.settings.globalDefault),
text: `Global (Default)`,
data: {
isGlobal: true,
writeEnabled: true,
@@ -248,7 +246,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
uniqueAccountRegions.add(loc.locationName);
regionOptions.push({
key: loc.documentEndpoint,
text: `${loc.locationName} ${t(Keys.panes.settings.readWrite)}`,
text: `${loc.locationName} (Read/Write)`,
data: {
isGlobal: false,
writeEnabled: true,
@@ -261,7 +259,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
uniqueAccountRegions.add(loc.locationName);
regionOptions.push({
key: loc.documentEndpoint,
text: `${loc.locationName} ${t(Keys.panes.settings.read)}`,
text: `${loc.locationName} (Read)`,
data: {
isGlobal: false,
writeEnabled: false,
@@ -319,9 +317,13 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
authError instanceof msalAuthError &&
authError.errorCode === msalBrowserAuthErrorMessage.popUpWindowError.code
) {
logConsoleError(t(Keys.panes.settings.popupsDisabledError));
logConsoleError(
`We were unable to establish authorization for this account, due to pop-ups being disabled in the browser.\nPlease enable pop-ups for this site and click on "Login for Entra ID" button`,
);
} else {
logConsoleError(t(Keys.panes.settings.failedToAcquireTokenError));
logConsoleError(
`"Failed to acquire authorization token automatically. Please click on "Login for Entra ID" button to enable Entra ID RBAC operations`,
);
}
}
} else {
@@ -483,33 +485,33 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
const genericPaneProps: RightPaneFormProps = {
formError: "",
isExecuting,
submitButtonText: t(Keys.common.apply),
submitButtonText: "Apply",
onSubmit: () => handlerOnSubmit(),
};
const pageOptionList: IChoiceGroupOption[] = [
{ key: Constants.Queries.CustomPageOption, text: t(Keys.panes.settings.custom) },
{ key: Constants.Queries.UnlimitedPageOption, text: t(Keys.panes.settings.unlimited) },
{ key: Constants.Queries.CustomPageOption, text: "Custom" },
{ key: Constants.Queries.UnlimitedPageOption, text: "Unlimited" },
];
const graphAutoOptionList: IChoiceGroupOption[] = [
{ key: "false", text: t(Keys.panes.settings.graph) },
{ key: "true", text: t(Keys.panes.settings.json) },
{ key: "false", text: "Graph" },
{ key: "true", text: "JSON" },
];
const priorityLevelOptionList: IChoiceGroupOption[] = [
{ key: Constants.PriorityLevel.Low, text: t(Keys.panes.settings.low) },
{ key: Constants.PriorityLevel.High, text: t(Keys.panes.settings.high) },
{ key: Constants.PriorityLevel.Low, text: "Low" },
{ key: Constants.PriorityLevel.High, text: "High" },
];
const dataPlaneRBACOptionsList: IChoiceGroupOption[] = [
{ 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"]) },
{ key: Constants.RBACOptions.setAutomaticRBACOption, text: "Automatic" },
{ key: Constants.RBACOptions.setTrueRBACOption, text: "True" },
{ key: Constants.RBACOptions.setFalseRBACOption, text: "False" },
];
const defaultQueryResultsViewOptionList: IChoiceGroupOption[] = [
{ key: SplitterDirection.Vertical, text: t(Keys.tabs.query.vertical) },
{ key: SplitterDirection.Horizontal, text: t(Keys.tabs.query.horizontal) },
{ key: SplitterDirection.Vertical, text: "Vertical" },
{ key: SplitterDirection.Horizontal, text: "Horizontal" },
];
const mongoGuidRepresentationDropdownOptions: IDropdownOption[] = [
@@ -722,12 +724,13 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{shouldShowQueryPageOptions && (
<AccordionItem value="1">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.pageOptions)}</div>
<div className={styles.header}>Page Options</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.pageOptionsDescription)}
Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as
many query results per page.
</div>
<ChoiceGroup
ariaLabelledBy="pageOptions"
@@ -741,14 +744,14 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{isCustomPageOptionSelected() && (
<div className="tabcontent">
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.queryResultsPerPage)}{" "}
Query results per page{" "}
<InfoTooltip className={styles.headerIcon}>
{t(Keys.panes.settings.queryResultsPerPageTooltip)}
Enter the number of query results that should be shown per page.
</InfoTooltip>
</div>
<SpinButton
ariaLabel={t(Keys.panes.settings.customQueryItemsPerPage)}
ariaLabel="Custom query items per page"
value={"" + customItemPerPage}
onIncrement={(newValue) => {
setCustomItemPerPage(parseInt(newValue) + 1 || customItemPerPage);
@@ -758,8 +761,8 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
min={1}
step={1}
className="textfontclr"
incrementButtonAriaLabel={t(Keys.common.increaseValueBy1)}
decrementButtonAriaLabel={t(Keys.common.decreaseValueBy1)}
incrementButtonAriaLabel="Increase value by 1"
decrementButtonAriaLabel="Decrease value by 1"
styles={spinButtonStyles}
/>
</div>
@@ -771,19 +774,20 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{showEnableEntraIdRbac && (
<AccordionItem value="2">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.entraIdRbac)}</div>
<div className={styles.header}>Enable Entra ID RBAC</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.entraIdRbacDescription)}
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra
ID RBAC.
<a
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
target="_blank"
rel="noopener noreferrer"
>
{" "}
{t(Keys.common.learnMore)}{" "}
Learn more{" "}
</a>
</div>
<ChoiceGroup
@@ -800,17 +804,17 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && !isFabric() && (
<AccordionItem value="3">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.regionSelection)}</div>
<div className={styles.header}>Region Selection</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.regionSelectionDescription)}
Changes region the Cosmos Client uses to access account.
</div>
<div>
<span className={styles.subHeader}>{t(Keys.panes.settings.selectRegion)}</span>
<span className={styles.subHeader}>Select Region</span>
<InfoTooltip className={styles.headerIcon}>
{t(Keys.panes.settings.selectRegionTooltip)}
Changes the account endpoint used to perform client operations.
</InfoTooltip>
</div>
<Dropdown
@@ -861,16 +865,17 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
<>
<AccordionItem value="4">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.queryTimeout)}</div>
<div className={styles.header}>Query Timeout</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.queryTimeoutDescription)}
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.
</div>
<Toggle
styles={toggleStyles}
label={t(Keys.panes.settings.enableQueryTimeout)}
label="Enable query timeout"
onChange={handleOnQueryTimeoutToggleChange}
defaultChecked={queryTimeoutEnabled}
/>
@@ -878,18 +883,18 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{queryTimeoutEnabled && (
<div className={styles.settingsSectionContainer}>
<SpinButton
label={t(Keys.panes.settings.queryTimeoutMs)}
label="Query timeout (ms)"
labelPosition={Position.top}
defaultValue={(queryTimeout || 5000).toString()}
min={100}
step={1000}
onChange={handleOnQueryTimeoutSpinButtonChange}
incrementButtonAriaLabel={t(Keys.panes.settings.increaseValueBy1000)}
decrementButtonAriaLabel={t(Keys.panes.settings.decreaseValueBy1000)}
incrementButtonAriaLabel="Increase value by 1000"
decrementButtonAriaLabel="Decrease value by 1000"
styles={spinButtonStyles}
/>
<Toggle
label={t(Keys.panes.settings.automaticallyCancelQuery)}
label="Automatically cancel query after timeout"
styles={toggleStyles}
onChange={handleOnAutomaticallyCancelQueryToggleChange}
defaultChecked={automaticallyCancelQueryAfterTimeout}
@@ -900,16 +905,16 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionItem>
<AccordionItem value="5">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.ruLimit)}</div>
<div className={styles.header}>RU Limit</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.ruLimitDescription)}
If a query exceeds a configured RU limit, the query will be aborted.
</div>
<Toggle
styles={toggleStyles}
label={t(Keys.panes.settings.enableRuLimit)}
label="Enable RU limit"
onChange={handleOnRUThresholdToggleChange}
defaultChecked={ruThresholdEnabled}
/>
@@ -917,14 +922,14 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{ruThresholdEnabled && (
<div className={styles.settingsSectionContainer}>
<SpinButton
label={t(Keys.panes.settings.ruLimitLabel)}
label="RU Limit (RU)"
labelPosition={Position.top}
defaultValue={(ruThreshold || DefaultRUThreshold).toString()}
min={1}
step={1000}
onChange={handleOnRUThresholdSpinButtonChange}
incrementButtonAriaLabel={t(Keys.panes.settings.increaseValueBy1000)}
decrementButtonAriaLabel={t(Keys.panes.settings.decreaseValueBy1000)}
incrementButtonAriaLabel="Increase value by 1000"
decrementButtonAriaLabel="Decrease value by 1000"
styles={spinButtonStyles}
/>
</div>
@@ -934,12 +939,12 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
<AccordionItem value="6">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.defaultQueryResults)}</div>
<div className={styles.header}>Default Query Results View</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.defaultQueryResultsDescription)}
Select the default view to use when displaying query results.
</div>
<ChoiceGroup
ariaLabelledBy="defaultQueryResultsView"
@@ -957,17 +962,17 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{showRetrySettings && (
<AccordionItem value="7">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.retrySettings)}</div>
<div className={styles.header}>Retry Settings</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.retrySettingsDescription)}
Retry policy associated with throttled requests during CosmosDB queries.
</div>
<div>
<span className={styles.subHeader}>{t(Keys.panes.settings.maxRetryAttempts)}</span>
<span className={styles.subHeader}>Max retry attempts</span>
<InfoTooltip className={styles.headerIcon}>
{t(Keys.panes.settings.maxRetryAttemptsTooltip)}
Max number of retries to be performed for a request. Default value 9.
</InfoTooltip>
</div>
<SpinButton
@@ -976,17 +981,18 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
step={1}
value={"" + retryAttempts}
onChange={handleOnQueryRetryAttemptsSpinButtonChange}
incrementButtonAriaLabel={t(Keys.common.increaseValueBy1)}
decrementButtonAriaLabel={t(Keys.common.decreaseValueBy1)}
incrementButtonAriaLabel="Increase value by 1"
decrementButtonAriaLabel="Decrease value by 1"
onIncrement={(newValue) => setRetryAttempts(parseInt(newValue) + 1 || retryAttempts)}
onDecrement={(newValue) => setRetryAttempts(parseInt(newValue) - 1 || retryAttempts)}
onValidate={(newValue) => setRetryAttempts(parseInt(newValue) || retryAttempts)}
styles={spinButtonStyles}
/>
<div>
<span className={styles.subHeader}>{t(Keys.panes.settings.fixedRetryInterval)}</span>
<span className={styles.subHeader}>Fixed retry interval (ms)</span>
<InfoTooltip className={styles.headerIcon}>
{t(Keys.panes.settings.fixedRetryIntervalTooltip)}
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned
as part of the response. Default value is 0 milliseconds.
</InfoTooltip>
</div>
<SpinButton
@@ -995,17 +1001,18 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
step={1000}
value={"" + retryInterval}
onChange={handleOnRetryIntervalSpinButtonChange}
incrementButtonAriaLabel={t(Keys.panes.settings.increaseValueBy1000)}
decrementButtonAriaLabel={t(Keys.panes.settings.decreaseValueBy1000)}
incrementButtonAriaLabel="Increase value by 1000"
decrementButtonAriaLabel="Decrease value by 1000"
onIncrement={(newValue) => setRetryInterval(parseInt(newValue) + 1000 || retryInterval)}
onDecrement={(newValue) => setRetryInterval(parseInt(newValue) - 1000 || retryInterval)}
onValidate={(newValue) => setRetryInterval(parseInt(newValue) || retryInterval)}
styles={spinButtonStyles}
/>
<div>
<span className={styles.subHeader}>{t(Keys.panes.settings.maxWaitTime)}</span>
<span className={styles.subHeader}>Max wait time (s)</span>
<InfoTooltip className={styles.headerIcon}>
{t(Keys.panes.settings.maxWaitTimeTooltip)}
Max wait time in seconds to wait for a request while the retries are happening. Default value 30
seconds.
</InfoTooltip>
</div>
<SpinButton
@@ -1014,8 +1021,8 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
step={1}
value={"" + MaxWaitTimeInSeconds}
onChange={handleOnMaxWaitTimeSpinButtonChange}
incrementButtonAriaLabel={t(Keys.common.increaseValueBy1)}
decrementButtonAriaLabel={t(Keys.common.decreaseValueBy1)}
incrementButtonAriaLabel="Increase value by 1"
decrementButtonAriaLabel="Decrease value by 1"
onIncrement={(newValue) =>
setMaxWaitTimeInSeconds(parseInt(newValue) + 1 || MaxWaitTimeInSeconds)
}
@@ -1032,26 +1039,24 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{!isEmulator && (
<AccordionItem value="8">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.enableContainerPagination)}</div>
<div className={styles.header}>Enable container pagination</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.enableContainerPaginationDescription)}
Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order.
</div>
<Checkbox
styles={{
label: { padding: 0 },
}}
className="padding"
ariaLabel={t(Keys.panes.settings.enableContainerPagination)}
ariaLabel="Enable container pagination"
checked={containerPaginationEnabled}
onChange={() => setContainerPaginationEnabled(!containerPaginationEnabled)}
label={t(Keys.panes.settings.enableContainerPagination)}
label="Enable container pagination"
onRenderLabel={() => (
<span style={{ color: "var(--colorNeutralForeground1)" }}>
{t(Keys.panes.settings.enableContainerPagination)}
</span>
<span style={{ color: "var(--colorNeutralForeground1)" }}>Enable container pagination</span>
)}
/>
</div>
@@ -1061,25 +1066,24 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{shouldShowCrossPartitionOption && (
<AccordionItem value="9">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.enableCrossPartitionQuery)}</div>
<div className={styles.header}>Enable cross-partition query</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.enableCrossPartitionQueryDescription)}
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>
<Checkbox
styles={{
label: { padding: 0 },
}}
className="padding"
ariaLabel={t(Keys.panes.settings.enableCrossPartitionQuery)}
ariaLabel="Enable cross partition query"
checked={crossPartitionQueryEnabled}
onChange={() => setCrossPartitionQueryEnabled(!crossPartitionQueryEnabled)}
onRenderLabel={() => (
<span style={{ color: "var(--colorNeutralForeground1)" }}>
{t(Keys.panes.settings.enableCrossPartitionQuery)}
</span>
<span style={{ color: "var(--colorNeutralForeground1)" }}>Enable cross-partition query</span>
)}
/>
</div>
@@ -1089,19 +1093,19 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{shouldShowEnhancedQueryControl && (
<AccordionItem value="10">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.enhancedQueryControl)}</div>
<div className={styles.header}>Enhanced query control</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.maxDegreeOfParallelismQuery)}
Query up to the max degree of parallelism.
<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"
>
{" "}
{t(Keys.common.learnMore)}{" "}
Learn more{" "}
</a>
</div>
<Checkbox
@@ -1109,13 +1113,11 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
label: { padding: 0 },
}}
className="padding"
ariaLabel={t(Keys.panes.settings.enableQueryControl)}
ariaLabel="EnableQueryControl"
checked={queryControlEnabled}
onChange={() => setQueryControlEnabled(!queryControlEnabled)}
onRenderLabel={() => (
<span style={{ color: "var(--colorNeutralForeground1)" }}>
{t(Keys.panes.settings.enableQueryControl)}
</span>
<span style={{ color: "var(--colorNeutralForeground1)" }}>Enable query control</span>
)}
/>
</div>
@@ -1125,12 +1127,14 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{shouldShowParallelismOption && (
<AccordionItem value="10">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.maxDegreeOfParallelism)}</div>
<div className={styles.header}>Max degree of parallelism</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.maxDegreeOfParallelismDescription)}
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.
</div>
<SpinButton
min={-1}
@@ -1146,8 +1150,8 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
setMaxDegreeOfParallelism(parseInt(newValue) - 1 || maxDegreeOfParallelism)
}
onValidate={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) || maxDegreeOfParallelism)}
ariaLabel={t(Keys.panes.settings.maxDegreeOfParallelism)}
label={t(Keys.panes.settings.maxDegreeOfParallelism)}
ariaLabel="Max degree of parallelism"
label="Max degree of parallelism"
styles={spinButtonStyles}
/>
</div>
@@ -1157,12 +1161,14 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{shouldShowPriorityLevelOption && (
<AccordionItem value="11">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.priorityLevel)}</div>
<div className={styles.header}>Priority Level</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.priorityLevelDescription)}
Sets the priority level for data-plane requests from Data Explorer when using Priority-Based
Execution. If &quot;None&quot; is selected, Data Explorer will not specify priority level, and the
server-side default priority level will be used.
</div>
<ChoiceGroup
ariaLabelledBy="priorityLevel"
@@ -1178,18 +1184,19 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{shouldShowGraphAutoVizOption && (
<AccordionItem value="12">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.displayGremlinQueryResults)}&nbsp;</div>
<div className={styles.header}>Display Gremlin query results as:&nbsp;</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.displayGremlinQueryResultsDescription)}
Select Graph to automatically visualize the query results as a Graph or JSON to display the
results as JSON.
</div>
<ChoiceGroup
selectedKey={graphAutoVizDisabled}
options={graphAutoOptionList}
onChange={handleOnGremlinChange}
aria-label={t(Keys.panes.settings.graphAutoVisualization)}
aria-label="Graph Auto-visualization"
/>
</div>
</AccordionPanel>
@@ -1198,25 +1205,25 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{shouldShowCopilotSampleDBOption && (
<AccordionItem value="13">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.enableSampleDatabase)}</div>
<div className={styles.header}>Enable sample database</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.enableSampleDatabaseDescription)}
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.
</div>
<Checkbox
styles={{
label: { padding: 0 },
}}
className="padding"
ariaLabel={t(Keys.panes.settings.enableSampleDbAriaLabel)}
ariaLabel="Enable sample db for query exploration"
checked={copilotSampleDBEnabled}
onChange={handleSampleDatabaseChange}
onRenderLabel={() => (
<span style={{ color: "var(--colorNeutralForeground1)" }}>
{t(Keys.panes.settings.enableSampleDatabase)}
</span>
<span style={{ color: "var(--colorNeutralForeground1)" }}>Enable sample database</span>
)}
/>
</div>
@@ -1226,12 +1233,13 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{shouldShowMongoGuidRepresentationOption && (
<AccordionItem value="14">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.guidRepresentation)}</div>
<div className={styles.header}>Guid Representation</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
{t(Keys.panes.settings.guidRepresentationDescription)}
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.
</div>
<Dropdown
aria-labelledby="mongoGuidRepresentation"
@@ -1245,7 +1253,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
)}
<AccordionItem value="15">
<AccordionHeader>
<div className={styles.header}>{t(Keys.panes.settings.advancedSettings)}</div>
<div className={styles.header}>Advanced Settings</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
@@ -1275,13 +1283,14 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
},
}}
className="padding"
ariaLabel={t(Keys.panes.settings.ignorePartitionKey)}
ariaLabel="Ignore partition key on document update"
checked={ignorePartitionKeyOnDocumentUpdate}
onChange={handleOnIgnorePartitionKeyOnDocumentUpdateChange}
label={t(Keys.panes.settings.ignorePartitionKey)}
label="Ignore partition key on document update"
/>
<InfoTooltip className={styles.headerIcon}>
{t(Keys.panes.settings.ignorePartitionKeyTooltip)}
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.
</InfoTooltip>
</Stack>
</div>
@@ -1311,9 +1320,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
}}
onClick={() => {
useDialog.getState().showOkCancelModalDialog(
t(Keys.panes.settings.clearHistory),
"Clear History",
undefined,
t(Keys.panes.settings.clearHistoryConfirm),
"Are you sure you want to proceed?",
() => {
deleteAllStates();
updateUserContext({
@@ -1323,33 +1332,35 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
});
useClientWriteEnabled.setState({ clientWriteEnabled: true });
},
t(Keys.common.cancel),
"Cancel",
undefined,
<>
<span>{t(Keys.panes.settings.clearHistoryDescription)}</span>
<span>
This action will clear the all customizations for this account in this browser, including:
</span>
<ul className={styles.bulletList}>
<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>
<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>
</ul>
</>,
);
}}
>
{t(Keys.panes.settings.clearHistory)}
Clear History
</DefaultButton>
</div>
</div>
<div className="settingsSection">
<div className={`settingsSectionPart ${styles.settingsSectionContainer}`}>
<div className="settingsSectionLabel">{t(Keys.panes.settings.explorerVersion)}</div>
<div className="settingsSectionLabel">Explorer Version</div>
<div>{explorerVersion}</div>
</div>
</div>
<div className="settingsSection">
<div className="settingsSectionPart">
<div className="settingsSectionLabel">{t(Keys.panes.settings.sessionId)}</div>
<div className="settingsSectionLabel">Session ID</div>
<div>{sessionId}</div>
</div>
</div>

View File

@@ -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="Enable query control"
ariaLabel="EnableQueryControl"
checked={false}
className="padding"
onChange={[Function]}
@@ -1190,8 +1190,7 @@ 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>

View File

@@ -11,8 +11,6 @@ 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";
@@ -115,13 +113,13 @@ export const TableColumnSelectionPane: React.FC<TableColumnSelectionPaneProps> =
<CosmosFluentProvider>
<div className="panelFormWrapper">
<div className="panelMainContent" style={{ display: "flex", flexDirection: "column" }}>
<Text>{t(Keys.panes.tableColumnSelection.selectColumns)}</Text>
<Text>Select which columns to display in your view of items in your container.</Text>
<div /* Wrap <SearchBox> to avoid margin-bottom set by panelMainContent css */>
<SearchBox
className={styles.searchBox}
value={columnSearchText}
onChange={onSearchChange}
placeholder={t(Keys.panes.tableColumnSelection.searchFields)}
placeholder="Search fields"
/>
</div>
@@ -132,9 +130,7 @@ export const TableColumnSelectionPane: React.FC<TableColumnSelectionPaneProps> =
key={columnDefinition.id}
label={{
className: styles.checkboxLabel,
children: `${columnDefinition.label}${
columnDefinition.isPartitionKey ? t(Keys.panes.tableColumnSelection.partitionKeySuffix) : ""
}`,
children: `${columnDefinition.label}${columnDefinition.isPartitionKey ? " (partition key)" : ""}`,
}}
checked={selectedColumnIdsSet.has(columnDefinition.id)}
onChange={(_, data) => onCheckedValueChange(columnDefinition.id, data)}
@@ -142,15 +138,15 @@ export const TableColumnSelectionPane: React.FC<TableColumnSelectionPaneProps> =
))}
</div>
<Button appearance="secondary" size="small" onClick={() => setNewSelectedColumnIds(defaultSelection)}>
{t(Keys.panes.tableColumnSelection.reset)}
Reset
</Button>
</div>
<div className="panelFooter" style={{ display: "flex", gap: theme.spacingHorizontalS }}>
<Button appearance="primary" onClick={onSave}>
{t(Keys.common.save)}
Save
</Button>
<Button appearance="secondary" onClick={closeSidePanel}>
{t(Keys.common.cancel)}
Cancel
</Button>
</div>
</div>

View File

@@ -1,7 +1,5 @@
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";
@@ -102,8 +100,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(t(Keys.panes.tables.propertyEmptyError, { property }));
setFormError(t(Keys.panes.tables.propertyEmptyError, { property }));
logConsoleError(`${property} cannot be empty. Please input a value for ${property}`);
setFormError(`${property} cannot be empty. Please input a value for ${property}`);
return;
}
@@ -111,13 +109,13 @@ export const AddTableEntityPanel: FunctionComponent<AddTableEntityPanelProps> =
(property === "PartitionKey" && containsAnyWhiteSpace(value) === true) ||
(property === "RowKey" && containsAnyWhiteSpace(value) === true)
) {
logConsoleError(t(Keys.panes.tables.whitespaceError, { property }));
setFormError(t(Keys.panes.tables.whitespaceError, { property }));
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`);
return;
}
if (!type) {
setFormError(t(Keys.panes.tables.propertyTypeEmptyError, { property }));
setFormError(`Property type cannot be empty. Please select a type from the dropdown for property ${property}`);
return;
}

View File

@@ -1,7 +1,5 @@
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";
@@ -200,7 +198,7 @@ export const EditTableEntityPanel: FunctionComponent<EditTableEntityPanelProps>
}
if (!type) {
setFormError(t(Keys.panes.tables.propertyTypeEmptyError, { property }));
setFormError(`Property type cannot be empty. Please select a type from the dropdown for property ${property}`);
return;
}
@@ -210,8 +208,8 @@ export const EditTableEntityPanel: FunctionComponent<EditTableEntityPanelProps>
(property === "RowKey" && value === "") ||
(property === "RowKey" && value === undefined)
) {
logConsoleError(t(Keys.panes.tables.propertyEmptyError, { property }));
setFormError(t(Keys.panes.tables.propertyEmptyError, { property }));
logConsoleError(`${property} cannot be empty. Please input a value for ${property}`);
setFormError(`${property} cannot be empty. Please input a value for ${property}`);
return;
}
}
@@ -405,7 +403,7 @@ export const EditTableEntityPanel: FunctionComponent<EditTableEntityPanelProps>
)}
</div>
<div className="panelNullWarning" style={{ padding: "20px", color: "red" }}>
{t(Keys.panes.tables.nullFieldsWarning)}
Warning: Null fields will not be displayed for editing.
</div>
</RightPaneForm>
);

View File

@@ -1,6 +1,4 @@
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";
@@ -37,7 +35,7 @@ export const TableQuerySelectPanel: FunctionComponent<TableQuerySelectPanelProps
const props: RightPaneFormProps = {
formError: "",
isExecuting: false,
submitButtonText: t(Keys.common.ok),
submitButtonText: "OK",
onSubmit,
};
@@ -123,11 +121,11 @@ export const TableQuerySelectPanel: FunctionComponent<TableQuerySelectPanelProps
<RightPaneForm {...props}>
<div className="panelFormWrapper">
<div className="panelMainContent">
<Text>{t(Keys.panes.tableQuerySelect.selectColumns)}</Text>
<Text>Select the columns that you want to query.</Text>
<div className="column-select-view">
<Checkbox
id="availableCheckbox"
label={t(Keys.panes.tableQuerySelect.availableColumns)}
label="Available Columns"
checked={isAvailableColumnChecked}
onChange={availableColumnsCheckboxClick}
/>

View File

@@ -13,8 +13,6 @@ 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";
@@ -65,8 +63,8 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
const onSubmit = () => {
setFormError("");
if (!files || files.length === 0) {
setFormError(t(Keys.panes.uploadItems.noFilesSpecifiedError));
logConsoleError(t(Keys.panes.uploadItems.noFilesSpecifiedError));
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.");
return;
}
@@ -152,7 +150,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
},
{
key: "fileName",
name: t(Keys.panes.uploadItems.fileNameColumn),
name: "FILE NAME",
fieldName: "fileName",
minWidth: 120,
maxWidth: 140,
@@ -171,7 +169,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
},
{
key: "status",
name: t(Keys.panes.uploadItems.statusColumn),
name: "STATUS",
fieldName: "numSucceeded",
minWidth: 120,
maxWidth: 140,
@@ -180,11 +178,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
data: "string",
isPadded: true,
onRender: (item: UploadDetailsRecord, index: number, column: IColumn) => {
const fieldContent = t(Keys.panes.uploadItems.uploadStatus, {
numSucceeded: item.numSucceeded,
numThrottled: item.numThrottled,
numFailed: item.numFailed,
});
const fieldContent = `${item.numSucceeded} created, ${item.numThrottled} throttled, ${item.numFailed} errors`;
return (
<TooltipHost
content={fieldContent}
@@ -203,15 +197,15 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
<div className="paneMainContent">
<Upload
key={reducer} // Force re-render on state change
label={t(Keys.panes.uploadItems.selectJsonFiles)}
label="Select JSON Files"
onUpload={updateSelectedFiles}
accept="application/json"
multiple
tabIndex={0}
tooltip={t(Keys.panes.uploadItems.selectJsonFilesTooltip)}
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."
/>
{uploadFileData?.length > 0 && (
<div className="fileUploadSummaryContainer" data-test="file-upload-status">
<div className="fileUploadSummaryContainer">
<b style={{ color: "var(--colorNeutralForeground1)" }}>File upload status</b>
<DetailsList
items={uploadFileData}

View File

@@ -714,7 +714,9 @@ 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

View File

@@ -6,8 +6,6 @@ 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";
@@ -161,8 +159,8 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
const getSplashScreenButtons = (): JSX.Element => {
const buttons: FabricHomeScreenButtonProps[] = [
{
title: t(Keys.splashScreen.fabric.newContainer.title),
description: t(Keys.splashScreen.fabric.newContainer.description),
title: "New container",
description: "Create a destination container to store your data",
icon: <DocumentAddRegular />,
onClick: () => {
const databaseId = isFabricNative() ? userContext.fabricContext?.databaseName : undefined;
@@ -170,8 +168,8 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
},
},
{
title: t(Keys.splashScreen.fabric.sampleData.title),
description: t(Keys.splashScreen.fabric.sampleData.description),
title: "Sample Data",
description: "Load sample data in your database",
icon: <img src={CosmosDbBlackIcon} alt={"Azure Cosmos DB icon"} aria-hidden="true" />,
onClick: () => {
setSelectedSampleDataConfiguration({
@@ -183,8 +181,8 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
},
},
{
title: t(Keys.splashScreen.fabric.sampleVectorData.title),
description: t(Keys.splashScreen.fabric.sampleVectorData.description),
title: "Sample Vector Data",
description: "Load sample vector data with text-embedding-ada-002",
icon: <img src={AzureOpenAiIcon} alt={"Azure Open AI icon"} aria-hidden="true" />,
onClick: () => {
setSelectedSampleDataConfiguration({
@@ -196,14 +194,14 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
},
},
{
title: t(Keys.splashScreen.fabric.appDevelopment.title),
description: t(Keys.splashScreen.fabric.appDevelopment.description),
title: "App development",
description: "Start here to use an SDK to build your apps",
icon: <LinkMultipleRegular />,
onClick: () => window.open("https://aka.ms/cosmosdbfabricsdk", "_blank"),
},
{
title: t(Keys.splashScreen.fabric.sampleGallery.title),
description: t(Keys.splashScreen.fabric.sampleGallery.description),
title: "Sample Gallery",
description: "Get real-world end-to-end samples",
icon: <img src={GithubIcon} alt={"GitHub icon"} aria-hidden="true" />,
onClick: () => window.open("https://aka.ms/CosmosFabricSamplesGallery", "_blank"),
},
@@ -224,9 +222,7 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
);
};
const title = isFabricNativeReadOnly()
? t(Keys.splashScreen.fabric.useTitle)
: t(Keys.splashScreen.fabric.buildTitle);
const title = isFabricNativeReadOnly() ? "Use your database" : "Build your database";
return (
<>
<CosmosFluentProvider className={styles.homeContainer}>
@@ -242,9 +238,9 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
{getSplashScreenButtons()}
{
<div className={styles.footer}>
{t(Keys.splashScreen.sections.needHelp)}{" "}
Need help?{" "}
<Link href="https://learn.microsoft.com/fabric/database/cosmos-db/overview" target="_blank">
{t(Keys.common.learnMore)} <OpenRegular />
Learn more <OpenRegular />
</Link>
</div>
}

View File

@@ -12,8 +12,6 @@ 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";
@@ -61,7 +59,7 @@ export const SampleDataImportDialog: React.FC<{
setStatus("creating");
const databaseName = props.sampleDataConfiguration.databaseName;
if (checkContainerExists(databaseName, containerName)) {
const msg = t(Keys.splashScreen.sampleDataDialog.errorContainerExists, { containerName, databaseName });
const msg = `The container "${containerName}" in database "${databaseName}" already exists. Please delete it and retry.`;
setStatus("error");
setErrorMessage(msg);
return;
@@ -77,11 +75,7 @@ export const SampleDataImportDialog: React.FC<{
);
} catch (error) {
setStatus("error");
setErrorMessage(
t(Keys.splashScreen.sampleDataDialog.errorCreateContainer, {
error: error instanceof Error ? error.message : String(error),
}),
);
setErrorMessage(`Failed to create container: ${error instanceof Error ? error.message : String(error)}`);
return;
}
@@ -92,11 +86,7 @@ export const SampleDataImportDialog: React.FC<{
setStatus("completed");
} catch (error) {
setStatus("error");
setErrorMessage(
t(Keys.splashScreen.sampleDataDialog.errorImportData, {
error: error instanceof Error ? error.message : String(error),
}),
);
setErrorMessage(`Failed to import data: ${error instanceof Error ? error.message : String(error)}`);
}
};
@@ -122,26 +112,14 @@ export const SampleDataImportDialog: React.FC<{
const renderContent = () => {
switch (status) {
case "idle":
return t(Keys.splashScreen.sampleDataDialog.createPrompt, { containerName });
return `Create a container "${containerName}" and import sample data into it. This may take a few minutes.`;
case "creating":
return (
<Spinner
size="small"
labelPosition="above"
label={t(Keys.splashScreen.sampleDataDialog.creatingContainer, { containerName })}
/>
);
return <Spinner size="small" labelPosition="above" label={`Creating container "${containerName}"...`} />;
case "importing":
return (
<Spinner
size="small"
labelPosition="above"
label={t(Keys.splashScreen.sampleDataDialog.importingData, { containerName })}
/>
);
return <Spinner size="small" labelPosition="above" label={`Importing data into "${containerName}"...`} />;
case "completed":
return t(Keys.splashScreen.sampleDataDialog.success, { containerName });
return `Successfully created "${containerName}" with sample data.`;
case "error":
return (
<div style={{ color: "red" }}>
@@ -154,14 +132,14 @@ export const SampleDataImportDialog: React.FC<{
const getButtonLabel = () => {
switch (status) {
case "idle":
return t(Keys.splashScreen.sampleDataDialog.startButton);
return "Start";
case "creating":
case "importing":
return t(Keys.common.close);
return "Close";
case "completed":
return t(Keys.common.close);
return "Close";
case "error":
return t(Keys.common.close);
return "Close";
}
};
@@ -169,7 +147,7 @@ export const SampleDataImportDialog: React.FC<{
<Dialog open={props.open} onOpenChange={(event, data) => props.setOpen(data.open)}>
<DialogSurface>
<DialogBody>
<DialogTitle>{t(Keys.splashScreen.sampleDataDialog.title)}</DialogTitle>
<DialogTitle>Sample Data</DialogTitle>
<DialogContent>
<div className={styles.dialogContent}>{renderContent()}</div>
</DialogContent>

View File

@@ -54,6 +54,6 @@
.mainButtonsContainer {
display: flex;
gap: 0 16px;
margin: 40px auto
margin-bottom: 10px
}

View File

@@ -16,8 +16,6 @@ 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";
@@ -166,23 +164,6 @@ 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(
{
@@ -251,8 +232,8 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
<Stack className="splashStackRow" horizontal>
<SplashScreenButton
imgSrc={QuickStartIcon}
title={t(Keys.splashScreen.quickStart.title)}
description={t(Keys.splashScreen.quickStart.description)}
title={"Launch quick start"}
description={"Launch a quick start tutorial to get started with sample data"}
onClick={() => {
container.onNewCollectionClicked({ isQuickstart: true });
traceOpen(Action.LaunchQuickstart, { apiType: userContext.apiType });
@@ -260,8 +241,8 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
/>
<SplashScreenButton
imgSrc={ContainersIcon}
title={t(Keys.splashScreen.newCollection.title, { collectionName: getCollectionName() })}
description={t(Keys.splashScreen.newCollection.description)}
title={`New ${getCollectionName()}`}
description={"Create a new container for storage and throughput"}
onClick={() => {
container.onNewCollectionClicked();
traceOpen(Action.NewContainerHomepage, { apiType: userContext.apiType });
@@ -272,8 +253,10 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
<SplashScreenButton
imgSrc={CosmosDBIcon}
imgSize={35}
title={t(Keys.splashScreen.samplesGallery.title)}
description={t(Keys.splashScreen.samplesGallery.description)}
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"
}
onClick={() => {
window.open("https://azurecosmosdb.github.io/gallery/?tags=example", "_blank");
traceOpen(Action.LearningResourcesClicked, { apiType: userContext.apiType });
@@ -281,8 +264,8 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
/>
<SplashScreenButton
imgSrc={ConnectIcon}
title={t(Keys.splashScreen.connectCard.title)}
description={t(Keys.splashScreen.connectCard.description)}
title={"Connect"}
description={"Prefer using your own choice of tooling? Find the connection string you need to connect"}
onClick={() => useTabs.getState().openAndActivateReactTab(ReactTabKind.Connect)}
/>
</Stack>
@@ -297,7 +280,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
usePostgres.getState().showPostgreTeachingBubble &&
!usePostgres.getState().showResetPasswordBubble && (
<TeachingBubble
headline={t(Keys.splashScreen.teachingBubble.newToPostgres.headline)}
headline="New to Cosmos DB PGSQL?"
target={"#mainButton-quickstartDescription"}
hasCloseButton
onDismiss={() => usePostgres.getState().setShowPostgreTeachingBubble(false)}
@@ -309,14 +292,15 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
preventDismissOnScroll: true,
}}
primaryButtonProps={{
text: t(Keys.common.getStarted),
text: "Get started",
onClick: () => {
useTabs.getState().openAndActivateReactTab(ReactTabKind.Quickstart);
usePostgres.getState().setShowPostgreTeachingBubble(false);
},
}}
>
{t(Keys.splashScreen.teachingBubble.newToPostgres.body)}
Welcome! If you are new to Cosmos DB PGSQL and need help with getting started, here is where you can find
sample data, query.
</TeachingBubble>
)}
{/*TODO: convert below to use SplashScreenButton */}
@@ -348,7 +332,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
))}
{userContext.apiType === "Postgres" && usePostgres.getState().showResetPasswordBubble && (
<TeachingBubble
headline={t(Keys.splashScreen.teachingBubble.resetPassword.headline)}
headline="Create your password"
target={"#mainButton-quickstartDescription"}
hasCloseButton
onDismiss={() => {
@@ -363,7 +347,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
preventDismissOnScroll: true,
}}
primaryButtonProps={{
text: t(Keys.common.create),
text: "Create",
onClick: () => {
localStorage.setItem(userContext.databaseAccount.id, "true");
sendMessage({
@@ -373,7 +357,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
},
}}
>
{t(Keys.splashScreen.teachingBubble.resetPassword.body)}
If you haven&apos;t changed your password yet, change it now.
</TeachingBubble>
)}
</div>
@@ -392,8 +376,8 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
const launchQuickstartBtn = {
id: "quickstartDescription",
iconSrc: QuickStartIcon,
title: t(Keys.splashScreen.quickStart.title),
description: t(Keys.splashScreen.quickStart.description),
title: "Launch quick start",
description: "Launch a quick start tutorial to get started with sample data",
onClick: () => {
if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") {
useTabs.getState().openAndActivateReactTab(ReactTabKind.Quickstart);
@@ -415,8 +399,8 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
if (userContext.apiType === "Postgres") {
return {
iconSrc: PowerShellIcon,
title: t(Keys.splashScreen.shell.postgres.title),
description: t(Keys.splashScreen.shell.postgres.description),
title: "PostgreSQL Shell",
description: "Create table and interact with data using PostgreSQL's shell interface",
onClick: () => container.openNotebookTerminal(TerminalKind.Postgres),
};
}
@@ -424,16 +408,16 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
if (userContext.apiType === "VCoreMongo") {
return {
iconSrc: PowerShellIcon,
title: t(Keys.splashScreen.shell.vcoreMongo.title),
description: t(Keys.splashScreen.shell.vcoreMongo.description),
title: "Mongo Shell",
description: "Create a collection and interact with data using MongoDB's shell interface",
onClick: () => container.openNotebookTerminal(TerminalKind.VCoreMongo),
};
}
return {
iconSrc: ContainersIcon,
title: t(Keys.splashScreen.newCollection.title, { collectionName: getCollectionName() }),
description: t(Keys.splashScreen.newCollection.description),
title: `New ${getCollectionName()}`,
description: "Create a new container for storage and throughput",
onClick: () => {
container.onNewCollectionClicked();
traceOpen(Action.NewContainerHomepage, { apiType: userContext.apiType });
@@ -443,19 +427,19 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
const getThirdCard = (): SplashScreenItem => {
let icon = ConnectIcon;
let title = t(Keys.splashScreen.connectCard.title);
let description = t(Keys.splashScreen.connectCard.description);
let title = "Connect";
let description = "Prefer using your own choice of tooling? Find the connection string you need to connect";
let onClick = () => useTabs.getState().openAndActivateReactTab(ReactTabKind.Connect);
if (userContext.apiType === "Postgres") {
title = t(Keys.splashScreen.connectCard.pgAdmin.title);
description = t(Keys.splashScreen.connectCard.pgAdmin.description);
title = "Connect with pgAdmin";
description = "Prefer pgAdmin? Find your connection strings here";
}
if (userContext.apiType === "VCoreMongo") {
icon = VisualStudioIcon;
title = t(Keys.splashScreen.connectCard.vsCode.title);
description = t(Keys.splashScreen.connectCard.vsCode.description);
title = "Connect with VS Code";
description = "Query and Manage your MongoDB and DocumentDB clusters in Visual Studio Code";
onClick = () => container?.openInVsCode && container.openInVsCode();
}
@@ -484,7 +468,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
info: activity.path,
iconSrc: NotebookIcon,
title: activity.name,
description: t(Keys.splashScreen.sections.notebook),
description: "Notebook",
onClick: () => {
const notebookItem = container.createNotebookContentItemFile(activity.name, activity.path);
notebookItem && container.openNotebook(notebookItem);
@@ -523,18 +507,18 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
items = [
{
link: "https://aka.ms/msl-modeling-partitioning-2",
title: t(Keys.splashScreen.top3Items.sql.advancedModeling.title),
description: t(Keys.splashScreen.top3Items.sql.advancedModeling.description),
title: "Advanced Modeling Patterns",
description: "Learn advanced strategies to optimize your database.",
},
{
link: "https://aka.ms/msl-modeling-partitioning-1",
title: t(Keys.splashScreen.top3Items.sql.partitioning.title),
description: t(Keys.splashScreen.top3Items.sql.partitioning.description),
title: "Partitioning Best Practices",
description: "Learn to apply data model and partitioning strategies.",
},
{
link: "https://aka.ms/msl-resource-planning",
title: t(Keys.splashScreen.top3Items.sql.resourcePlanning.title),
description: t(Keys.splashScreen.top3Items.sql.resourcePlanning.description),
title: "Plan Your Resource Requirements",
description: "Get to know the different configuration choices.",
},
];
break;
@@ -542,18 +526,18 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
items = [
{
link: "https://aka.ms/mongodbintro",
title: t(Keys.splashScreen.top3Items.mongo.whatIsMongo.title),
description: t(Keys.splashScreen.top3Items.mongo.whatIsMongo.description),
title: "What is the MongoDB API?",
description: "Understand Azure Cosmos DB for MongoDB and its features.",
},
{
link: "https://aka.ms/mongodbfeaturesupport",
title: t(Keys.splashScreen.top3Items.mongo.features.title),
description: t(Keys.splashScreen.top3Items.mongo.features.description),
title: "Features and Syntax",
description: "Discover the advantages and features",
},
{
link: "https://aka.ms/mongodbpremigration",
title: t(Keys.splashScreen.top3Items.mongo.migrate.title),
description: t(Keys.splashScreen.top3Items.mongo.migrate.description),
title: "Migrate Your Data",
description: "Pre-migration steps for moving data",
},
];
break;
@@ -561,18 +545,18 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
items = [
{
link: "https://aka.ms/cassandrajava",
title: t(Keys.splashScreen.top3Items.cassandra.buildJavaApp.title),
description: t(Keys.splashScreen.top3Items.cassandra.buildJavaApp.description),
title: "Build a Java App",
description: "Create a Java app using an SDK.",
},
{
link: "https://aka.ms/cassandrapartitioning",
title: t(Keys.splashScreen.top3Items.cassandra.partitioning.title),
description: t(Keys.splashScreen.top3Items.cassandra.partitioning.description),
title: "Partitioning Best Practices",
description: "Learn how partitioning works.",
},
{
link: "https://aka.ms/cassandraRu",
title: t(Keys.splashScreen.top3Items.cassandra.requestUnits.title),
description: t(Keys.splashScreen.top3Items.cassandra.requestUnits.description),
title: "Request Units (RUs)",
description: "Understand RU charges.",
},
];
break;
@@ -580,18 +564,18 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
items = [
{
link: "https://aka.ms/Graphdatamodeling",
title: t(Keys.splashScreen.top3Items.gremlin.dataModeling.title),
description: t(Keys.splashScreen.top3Items.gremlin.dataModeling.description),
title: "Data Modeling",
description: "Graph data modeling recommendations",
},
{
link: "https://aka.ms/graphpartitioning",
title: t(Keys.splashScreen.top3Items.gremlin.partitioning.title),
description: t(Keys.splashScreen.top3Items.gremlin.partitioning.description),
title: "Partitioning Best Practices",
description: "Learn how partitioning works",
},
{
link: "https://aka.ms/graphapiquery",
title: t(Keys.splashScreen.top3Items.gremlin.queryData.title),
description: t(Keys.splashScreen.top3Items.gremlin.queryData.description),
title: "Query Data",
description: "Querying data with Gremlin",
},
];
break;
@@ -599,18 +583,18 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
items = [
{
link: "https://aka.ms/tableintro",
title: t(Keys.splashScreen.top3Items.tables.whatIsTable.title),
description: t(Keys.splashScreen.top3Items.tables.whatIsTable.description),
title: "What is the Table API?",
description: "Understand Azure Cosmos DB for Table and its features",
},
{
link: "https://aka.ms/tableimport",
title: t(Keys.splashScreen.top3Items.tables.migrate.title),
description: t(Keys.splashScreen.top3Items.tables.migrate.description),
title: "Migrate your data",
description: "Learn how to migrate your data",
},
{
link: "https://aka.ms/tablefaq",
title: t(Keys.splashScreen.top3Items.tables.faq.title),
description: t(Keys.splashScreen.top3Items.tables.faq.description),
title: "Azure Cosmos DB for Table FAQs",
description: "Common questions about Azure Cosmos DB for Table",
},
];
break;
@@ -667,7 +651,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
</ul>
{recentItems.length > 0 && (
<Link onClick={() => clearMostRecent()} className={styles.listItemTitle}>
{t(Keys.splashScreen.sections.clearRecents)}
Clear Recents
</Link>
)}
</Stack>
@@ -682,15 +666,15 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
}
const cdbLiveTv: item = {
link: "https://developer.azurecosmosdb.com/tv",
title: t(Keys.splashScreen.learningResources.liveTv.title),
description: t(Keys.splashScreen.learningResources.liveTv.description),
title: "Learn the Fundamentals",
description: "Watch Azure Cosmos DB Live TV show introductory and how to videos.",
};
const commonItems: item[] = [
{
link: "https://learn.microsoft.com/azure/cosmos-db/data-explorer-shortcuts",
title: t(Keys.splashScreen.learningResources.shortcuts.title),
description: t(Keys.splashScreen.learningResources.shortcuts.description),
title: "Data Explorer keyboard shortcuts",
description: "Learn keyboard shortcuts to navigate Data Explorer.",
},
];
@@ -701,14 +685,14 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
apiItems = [
{
link: "https://aka.ms/msl-sdk-connect",
title: t(Keys.splashScreen.learningResources.sql.sdk.title),
description: t(Keys.splashScreen.learningResources.sql.sdk.description),
title: "Get Started using an SDK",
description: "Learn about the Azure Cosmos DB SDK.",
},
cdbLiveTv,
{
link: "https://aka.ms/msl-move-data",
title: t(Keys.splashScreen.learningResources.sql.migrate.title),
description: t(Keys.splashScreen.learningResources.sql.migrate.description),
title: "Migrate Your Data",
description: "Migrate data using Azure services and open-source solutions.",
},
];
break;
@@ -716,13 +700,13 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
apiItems = [
{
link: "https://aka.ms/mongonodejs",
title: t(Keys.splashScreen.learningResources.mongo.nodejs.title),
description: t(Keys.splashScreen.learningResources.mongo.nodejs.description),
title: "Build an app with Node.js",
description: "Create a Node.js app.",
},
{
link: "https://aka.ms/mongopython",
title: t(Keys.splashScreen.learningResources.mongo.gettingStarted.title),
description: t(Keys.splashScreen.learningResources.mongo.gettingStarted.description),
title: "Getting Started Guide",
description: "Learn the basics to get started.",
},
cdbLiveTv,
];
@@ -731,14 +715,14 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
apiItems = [
{
link: "https://aka.ms/cassandracontainer",
title: t(Keys.splashScreen.learningResources.cassandra.createContainer.title),
description: t(Keys.splashScreen.learningResources.cassandra.createContainer.description),
title: "Create a Container",
description: "Get to know the create a container options.",
},
cdbLiveTv,
{
link: "https://aka.ms/Cassandrathroughput",
title: t(Keys.splashScreen.learningResources.cassandra.throughput.title),
description: t(Keys.splashScreen.learningResources.cassandra.throughput.description),
title: "Provision Throughput",
description: "Learn how to configure throughput.",
},
];
break;
@@ -746,13 +730,13 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
apiItems = [
{
link: "https://aka.ms/graphquickstart",
title: t(Keys.splashScreen.learningResources.gremlin.getStarted.title),
description: t(Keys.splashScreen.learningResources.gremlin.getStarted.description),
title: "Get Started ",
description: "Create, query, and traverse using the Gremlin console",
},
{
link: "https://aka.ms/graphimport",
title: t(Keys.splashScreen.learningResources.gremlin.importData.title),
description: t(Keys.splashScreen.learningResources.gremlin.importData.description),
title: "Import Graph Data",
description: "Learn Bulk ingestion data using BulkExecutor",
},
cdbLiveTv,
];
@@ -761,13 +745,13 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
apiItems = [
{
link: "https://aka.ms/tabledotnet",
title: t(Keys.splashScreen.learningResources.tables.dotnet.title),
description: t(Keys.splashScreen.learningResources.tables.dotnet.description),
title: "Build a .NET App",
description: "How to access Azure Cosmos DB for Table from a .NET app.",
},
{
link: "https://aka.ms/Tablejava",
title: t(Keys.splashScreen.learningResources.tables.java.title),
description: t(Keys.splashScreen.learningResources.tables.java.description),
title: "Build a Java App",
description: "Create a Azure Cosmos DB for Table app with Java SDK ",
},
cdbLiveTv,
];
@@ -806,17 +790,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: t(Keys.splashScreen.nextStepItems.postgres.dataModeling),
title: "Data Modeling",
description: "",
},
{
link: " https://go.microsoft.com/fwlink/?linkid=2206941 ",
title: t(Keys.splashScreen.nextStepItems.postgres.distributionColumn),
title: "How to choose a Distribution Column",
description: "",
},
{
link: "https://go.microsoft.com/fwlink/?linkid=2207425",
title: t(Keys.splashScreen.nextStepItems.postgres.buildApps),
title: "Build Apps with Python/Java/Django",
description: "",
},
];
@@ -824,17 +808,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: t(Keys.splashScreen.nextStepItems.vcoreMongo.migrateData),
title: "Migrate Data",
description: "",
},
{
link: "https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/vector-search-ai",
title: t(Keys.splashScreen.nextStepItems.vcoreMongo.vectorSearch),
title: "Build AI apps with Vector Search",
description: "",
},
{
link: "https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/tutorial-nodejs-web-app?tabs=github-codespaces",
title: t(Keys.splashScreen.nextStepItems.vcoreMongo.buildApps),
title: "Build Apps with Nodejs",
description: "",
},
];
@@ -862,17 +846,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: t(Keys.splashScreen.learnMoreItems.postgres.performanceTuning),
title: "Performance Tuning",
description: "",
},
{
link: "https://go.microsoft.com/fwlink/?linkid=2208037",
title: t(Keys.splashScreen.learnMoreItems.postgres.diagnosticQueries),
title: "Useful Diagnostic Queries",
description: "",
},
{
link: "https://go.microsoft.com/fwlink/?linkid=2205270",
title: t(Keys.splashScreen.learnMoreItems.postgres.sqlReference),
title: "Distributed SQL Reference",
description: "",
},
];
@@ -880,17 +864,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: t(Keys.splashScreen.learnMoreItems.vcoreMongo.vectorSearch),
title: "Vector Search",
description: "",
},
{
link: "https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/how-to-create-text-index",
title: t(Keys.splashScreen.learnMoreItems.vcoreMongo.textIndexing),
title: "Text Indexing",
description: "",
},
{
link: "https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/vcore/troubleshoot-common-issues",
title: t(Keys.splashScreen.learnMoreItems.vcoreMongo.troubleshoot),
title: "Troubleshoot common issues",
description: "",
},
];
@@ -918,11 +902,10 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
return (
<div className={styles.splashScreenContainer}>
<div className={styles.splashScreen}>
<h2 className={styles.title} role="heading" aria-label={title}>
{title}
<span className="activePatch"></span>
<h2 className={styles.title} role="heading" aria-label="Welcome to Azure Cosmos DB">
Welcome to Azure Cosmos DB<span className="activePatch"></span>
</h2>
<div className={styles.subtitle}>{subtitle}</div>
<div className={styles.subtitle}>Globally distributed, multi-model database service for any scale</div>
{getSplashScreenButtons()}
{useCarousel.getState().showCoachMark && (
<Coachmark
@@ -931,25 +914,24 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
persistentBeak
>
<TeachingBubbleContent
headline={t(Keys.splashScreen.teachingBubble.coachMark.headline, {
collectionName: getCollectionName().toLocaleLowerCase(),
})}
headline={`Start with sample ${getCollectionName().toLocaleLowerCase()}`}
hasCloseButton
closeButtonAriaLabel={t(Keys.common.close)}
closeButtonAriaLabel="Close"
primaryButtonProps={{
text: t(Keys.common.getStarted),
text: "Get started",
onClick: () => {
useCarousel.getState().setShowCoachMark(false);
container.onNewCollectionClicked({ isQuickstart: true });
},
}}
secondaryButtonProps={{
text: t(Keys.common.cancel),
text: "Cancel",
onClick: () => useCarousel.getState().setShowCoachMark(false),
}}
onDismiss={() => useCarousel.getState().setShowCoachMark(false)}
>
{t(Keys.splashScreen.teachingBubble.coachMark.body)}
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
</TeachingBubbleContent>
</Coachmark>
)}
@@ -963,7 +945,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
fontFamily: '"Segoe UI Semibold", "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif',
}}
>
{t(Keys.splashScreen.sections.nextSteps)}
Next steps
</Text>
{getNextStepItems()}
</Stack>
@@ -975,7 +957,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
fontFamily: '"Segoe UI Semibold", "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif',
}}
>
{t(Keys.splashScreen.sections.tipsAndLearnMore)}
Tips & learn more
</Text>
{getTipsAndLearnMoreItems()}
</Stack>
@@ -984,15 +966,15 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
) : (
<div className={styles.moreStuffContainer}>
<div className={styles.moreStuffColumn}>
<h2 className={styles.columnTitle}>{t(Keys.splashScreen.sections.recents)}</h2>
<h2 className={styles.columnTitle}>Recents</h2>
{getRecentItems()}
</div>
<div className={styles.moreStuffColumn}>
<h2 className={styles.columnTitle}>{t(Keys.splashScreen.sections.top3)}</h2>
<h2 className={styles.columnTitle}>Top 3 things you need to know</h2>
{top3Items()}
</div>
<div className={styles.moreStuffColumn}>
<h2 className={styles.columnTitle}>{t(Keys.splashScreen.sections.learningResources)}</h2>
<h2 className={styles.columnTitle}>Learning Resources</h2>
{getLearningResourceItems()}
</div>
</div>

View File

@@ -18,8 +18,6 @@ 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";
@@ -59,7 +57,7 @@ export default class ConflictsTab extends TabsBase {
private _documentsIterator: MinimalQueryIterator;
private _container: Explorer;
private _acceptButtonLabel: ko.Observable<string> = ko.observable(t(Keys.common.save));
private _acceptButtonLabel: ko.Observable<string> = ko.observable("Save");
constructor(options: ViewModels.ConflictsTabOptions) {
super(options);
@@ -215,9 +213,9 @@ export default class ConflictsTab extends TabsBase {
this.selectedConflictContent.subscribe((newContent: string) => this._onEditorContentChange(newContent));
this.conflictOperation.subscribe((newOperationType: string) => {
let operationLabel = t(Keys.common.save);
let operationLabel = "Save";
if (newOperationType === Constants.ConflictOperationType.Replace) {
operationLabel = t(Keys.common.update);
operationLabel = "Update";
}
this._acceptButtonLabel(operationLabel);
@@ -231,7 +229,7 @@ export default class ConflictsTab extends TabsBase {
this._documentsIterator = this.createIterator();
await this.loadNextPage();
} catch (error) {
useDialog.getState().showOkModalDialog(t(Keys.tabs.conflicts.refreshGridFailed), getErrorMessage(error));
useDialog.getState().showOkModalDialog("Refresh documents grid failed", getErrorMessage(error));
}
}
@@ -259,11 +257,11 @@ export default class ConflictsTab extends TabsBase {
useDialog
.getState()
.showOkCancelModalDialog(
t(Keys.tabs.conflicts.unsavedChanges),
t(Keys.tabs.conflicts.changesWillBeLost),
t(Keys.common.ok),
"Unsaved changes",
"Changes will be lost. Do you want to continue?",
"OK",
async () => await this.resolveConflict(),
t(Keys.common.cancel),
"Cancel",
undefined,
);
} else {
@@ -334,7 +332,7 @@ export default class ConflictsTab extends TabsBase {
} catch (error) {
this.isExecutionError(true);
const errorMessage = getErrorMessage(error);
useDialog.getState().showOkModalDialog(t(Keys.tabs.conflicts.resolveConflictFailed), errorMessage);
useDialog.getState().showOkModalDialog("Resolve conflict failed", errorMessage);
TelemetryProcessor.traceFailure(
Action.ResolveConflict,
{
@@ -388,7 +386,7 @@ export default class ConflictsTab extends TabsBase {
} catch (error) {
this.isExecutionError(true);
const errorMessage = getErrorMessage(error);
useDialog.getState().showOkModalDialog(t(Keys.tabs.conflicts.deleteConflictFailed), errorMessage);
useDialog.getState().showOkModalDialog("Delete conflict failed", errorMessage);
TelemetryProcessor.traceFailure(
Action.DeleteConflict,
{
@@ -619,7 +617,7 @@ export default class ConflictsTab extends TabsBase {
}
if (this.discardButton.visible()) {
const label = t(Keys.common.discard);
const label = "Discard";
buttons.push({
iconSrc: DiscardIcon,
iconAlt: label,
@@ -632,7 +630,7 @@ export default class ConflictsTab extends TabsBase {
}
if (this.deleteButton.visible()) {
const label = t(Keys.common.delete);
const label = "Delete";
buttons.push({
iconSrc: DeleteIcon,
iconAlt: label,

View File

@@ -41,8 +41,6 @@ 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";
@@ -351,7 +349,7 @@ export const getTabsButtons = ({
}
const buttons: CommandButtonComponentProps[] = [];
const label = !isPreferredApiMongoDB ? t(Keys.tabs.documents.newItem) : t(Keys.tabs.documents.newDocument);
const label = !isPreferredApiMongoDB ? "New Item" : "New Document";
if (getNewDocumentButtonState(editorState).visible) {
buttons.push({
iconSrc: NewDocumentIcon,
@@ -370,7 +368,7 @@ export const getTabsButtons = ({
}
if (getSaveNewDocumentButtonState(editorState).visible) {
const label = t(Keys.common.save);
const label = "Save";
buttons.push({
iconSrc: SaveIcon,
iconAlt: label,
@@ -388,7 +386,7 @@ export const getTabsButtons = ({
}
if (getDiscardNewDocumentChangesButtonState(editorState).visible) {
const label = t(Keys.common.discard);
const label = "Discard";
buttons.push({
iconSrc: DiscardIcon,
iconAlt: label,
@@ -405,7 +403,7 @@ export const getTabsButtons = ({
}
if (getSaveExistingDocumentButtonState(editorState).visible) {
const label = t(Keys.common.update);
const label = "Update";
buttons.push({
iconSrc: SaveIcon,
iconAlt: label,
@@ -423,7 +421,7 @@ export const getTabsButtons = ({
}
if (getDiscardExistingDocumentChangesButtonState(editorState).visible) {
const label = t(Keys.common.discard);
const label = "Discard";
buttons.push({
iconSrc: DiscardIcon,
iconAlt: label,
@@ -440,7 +438,7 @@ export const getTabsButtons = ({
}
if (selectedRows.size > 0) {
const label = t(Keys.common.delete);
const label = "Delete";
buttons.push({
iconSrc: DeleteDocumentIcon,
iconAlt: label,
@@ -455,7 +453,7 @@ export const getTabsButtons = ({
}
if (!isPreferredApiMongoDB) {
const label = t(Keys.tabs.documents.uploadItem);
const label = "Upload Item";
buttons.push({
id: UPLOAD_BUTTON_ID,
iconSrc: UploadIcon,
@@ -739,18 +737,17 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
} else if (result.statusCode >= 400) {
newFailed.push(result.documentId);
logConsoleError(
t(Keys.tabs.documents.deleteDocumentFailedLog, {
documentId: result.documentId.id(),
statusCode: result.statusCode,
}),
`Failed to delete document ${result.documentId.id()} with status code ${result.statusCode}`,
);
}
});
logConsoleInfo(t(Keys.tabs.documents.deleteSuccessLog, { count: newSuccessful.length }));
logConsoleInfo(`Successfully deleted ${newSuccessful.length} document(s)`);
if (newThrottled.length > 0) {
logConsoleError(t(Keys.tabs.documents.deleteThrottledLog, { count: newThrottled.length }));
logConsoleError(
`Failed to delete ${newThrottled.length} document(s) due to "Request too large" (429) error. Retrying...`,
);
}
// Update result of the bulk delete: method is called again, because the state variables changed
@@ -792,7 +789,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
);
let partitionKeyProperties = useMemo(() => {
return partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) =>
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, "").replace(/["]+/g, ""),
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""),
);
}, [partitionKeyPropertyHeaders]);
@@ -920,11 +917,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
useDialog
.getState()
.showOkCancelModalDialog(
t(Keys.tabs.documents.unsavedChanges),
t(Keys.tabs.documents.unsavedChangesMessage),
t(Keys.common.ok),
"Unsaved changes",
"Your unsaved changes will be lost. Do you want to continue?",
"OK",
onDiscard,
t(Keys.common.cancel),
"Cancel",
onCancelDiscard,
);
} else {
@@ -1014,7 +1011,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(error) => {
onExecutionErrorChange(true);
const errorMessage = getErrorMessage(error);
useDialog.getState().showOkModalDialog(t(Keys.tabs.documents.createDocumentFailed), errorMessage);
useDialog.getState().showOkModalDialog("Create document failed", errorMessage);
TelemetryProcessor.traceFailure(
Action.CreateDocument,
{
@@ -1100,7 +1097,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
selectedDocumentId.partitionKeyValue = originalPartitionKeyValue;
onExecutionErrorChange(true);
const errorMessage = getErrorMessage(error);
useDialog.getState().showOkModalDialog(t(Keys.tabs.documents.updateDocumentFailed), errorMessage);
useDialog.getState().showOkModalDialog("Update document failed", errorMessage);
TelemetryProcessor.traceFailure(
Action.UpdateDocument,
{
@@ -1177,12 +1174,7 @@ 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(
t(Keys.tabs.documents.deleteDocumentDialogTitle),
t(Keys.tabs.documents.documentDeleted),
);
useDialog.getState().showOkModalDialog("Delete document", "Document successfully deleted.");
return [toDeleteDocumentIds[0]];
});
// ----------------------------------------------------------------------------------------------------
@@ -1259,20 +1251,17 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
useDialog
.getState()
.showOkModalDialog(
t(Keys.tabs.documents.deleteDocumentsDialogTitle),
t(Keys.tabs.documents.throttlingError),
"Delete documents",
`Some documents failed to delete due to a rate limiting error. Please try again later. To prevent this in the future, consider increasing the throughput on your container or database.`,
{
linkText: t(Keys.common.learnMore),
linkText: "Learn More",
linkUrl: MONGO_THROTTLING_DOC_URL,
},
);
} else {
useDialog
.getState()
.showOkModalDialog(
t(Keys.tabs.documents.deleteDocumentsDialogTitle),
t(Keys.tabs.documents.deleteFailed, { error: error.message }),
);
.showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`);
}
},
)
@@ -1286,21 +1275,21 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const isPlural = selectedRows.size > 1;
const documentName = !isPreferredApiMongoDB
? isPlural
? t(Keys.tabs.documents.selectedItems, { count: selectedRows.size })
: t(Keys.tabs.documents.selectedItem)
? `the selected ${selectedRows.size} items`
: "the selected item"
: isPlural
? t(Keys.tabs.documents.selectedDocuments, { count: selectedRows.size })
: t(Keys.tabs.documents.selectedDocument);
const msg = t(Keys.tabs.documents.confirmDelete, { documentName });
? `the selected ${selectedRows.size} documents`
: "the selected document";
const msg = `Are you sure you want to delete ${documentName}?`;
useDialog
.getState()
.showOkCancelModalDialog(
t(Keys.tabs.documents.confirmDeleteTitle),
"Confirm delete",
msg,
t(Keys.common.delete),
"Delete",
() => deleteDocuments(Array.from(selectedRows).map((index) => documentIds[index as number])),
t(Keys.common.cancel),
"Cancel",
undefined,
);
}, [deleteDocuments, documentIds, isPreferredApiMongoDB, selectedRows]);
@@ -1481,11 +1470,7 @@ 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, "")
.replace(/["]+/g, ""),
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""),
);
return newDocumentId(rawDocument, partitionKeyProperties, partitionKeyValue);
@@ -1834,8 +1819,8 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const partitionKeyProperty = partitionKeyProperties?.[0];
if (partitionKeyProperty !== "_id" && !_hasShardKeySpecified(documentContent)) {
const message = t(Keys.tabs.documents.missingShardProperty, { partitionKeyProperty });
useDialog.getState().showOkModalDialog(t(Keys.tabs.documents.createDocumentFailed), message);
const message = `The document is lacking the shard property: ${partitionKeyProperty}`;
useDialog.getState().showOkModalDialog("Create document failed", message);
onExecutionErrorChange(true);
TelemetryProcessor.traceFailure(
Action.CreateDocument,
@@ -1846,7 +1831,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
},
startKey,
);
Logger.logError(t(Keys.tabs.documents.missingShardKeyLog), "MongoDocumentsTab");
Logger.logError("Failed to save new document: Document shard key not defined", "MongoDocumentsTab");
throw new Error("Document without shard key");
}
@@ -1889,7 +1874,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(error) => {
onExecutionErrorChange(true);
const errorMessage = getErrorMessage(error);
useDialog.getState().showOkModalDialog(t(Keys.tabs.documents.createDocumentFailed), errorMessage);
useDialog.getState().showOkModalDialog("Create document failed", errorMessage);
TelemetryProcessor.traceFailure(
Action.CreateDocument,
{
@@ -1960,7 +1945,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(error) => {
onExecutionErrorChange(true);
const errorMessage = getErrorMessage(error);
useDialog.getState().showOkModalDialog(t(Keys.tabs.documents.updateDocumentFailed), errorMessage);
useDialog.getState().showOkModalDialog("Update document failed", errorMessage);
TelemetryProcessor.traceFailure(
Action.UpdateDocument,
{
@@ -2069,7 +2054,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}
} catch (error) {
console.error(error);
useDialog.getState().showOkModalDialog(t(Keys.tabs.documents.refreshGridFailed), getErrorMessage(error));
useDialog.getState().showOkModalDialog("Refresh documents grid failed", getErrorMessage(error));
}
},
[createIterator, filterContent],
@@ -2081,17 +2066,18 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
* @returns 429 warning message
*/
const get429WarningMessageNoSql = (): string => {
let message = t(Keys.tabs.documents.requestTooLargeBase);
let message = 'Some delete requests failed due to a "Request too large" exception (429)';
if (bulkDeleteOperation.count === bulkDeleteProcess.successfulIds.length) {
message += ", " + t(Keys.tabs.documents.retriedSuccessfully);
message += ", but were successfully retried.";
} else if (bulkDeleteMode === "inProgress" || bulkDeleteMode === "aborting") {
message += ". " + t(Keys.tabs.documents.retryingNow);
message += ". Retrying now.";
} else {
message += ".";
}
return (message += " " + t(Keys.tabs.documents.increaseThroughputTip));
return (message +=
" To prevent this in the future, consider increasing the throughput on your container or database.");
};
const onColumnSelectionChange = (newSelectedColumnIds: string[]): void => {
@@ -2138,7 +2124,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const nonBlankLastFilters = lastFilterContents.filter((filter) => filter.trim() !== "");
if (nonBlankLastFilters.length > 0) {
options.push({
label: t(Keys.tabs.documents.savedFilters),
label: "Saved filters",
options: nonBlankLastFilters,
});
}
@@ -2167,14 +2153,14 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
dropdownOptions={getFilterChoices()}
placeholder={
isPreferredApiMongoDB
? t(Keys.tabs.documents.mongoFilterPlaceholder)
: t(Keys.tabs.documents.sqlFilterPlaceholder)
? "Type a query predicate (e.g., {´a´:´foo´}), or choose one from the drop down list, or leave empty to query all documents."
: "Type a query predicate (e.g., WHERE c.id=´1´), or choose one from the drop down list, or leave empty to query all documents."
}
title={t(Keys.tabs.documents.filterTooltip)}
title="Type a query predicate or choose one from the list."
value={filterContent}
onChange={updateFilterContent}
onKeyDown={onFilterKeyDown}
bottomLink={{ text: t(Keys.common.learnMore), url: DATA_EXPLORER_DOC_URL }}
bottomLink={{ text: "Learn more", url: DATA_EXPLORER_DOC_URL }}
/>
<Button
appearance="primary"
@@ -2190,12 +2176,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}
}}
disabled={isExecuting && isPreferredApiMongoDB}
aria-label={
!isExecuting || isPreferredApiMongoDB ? t(Keys.tabs.documents.applyFilter) : t(Keys.common.cancel)
}
aria-label={!isExecuting || isPreferredApiMongoDB ? "Apply filter" : "Cancel"}
tabIndex={0}
>
{!isExecuting || isPreferredApiMongoDB ? t(Keys.tabs.documents.applyFilter) : t(Keys.common.cancel)}
{!isExecuting || isPreferredApiMongoDB ? "Apply Filter" : "Cancel"}
</Button>
</div>
<Allotment
@@ -2239,14 +2223,14 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
</div>
{tableContainerSizePx?.width >= calculateOffset(selectedColumnIds.length) + 200 && (
<div
title={t(Keys.common.refresh)}
title="Refresh"
className={styles.refreshBtn}
role="button"
onClick={() => refreshDocumentsGrid(false)}
aria-label={t(Keys.common.refresh)}
aria-label="Refresh"
tabIndex={0}
>
<img src={RefreshIcon} alt={t(Keys.common.refresh)} />
<img src={RefreshIcon} alt="Refresh" />
</div>
)}
</div>
@@ -2259,7 +2243,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
onClick={() => loadNextPage(documentsIterator.iterator, false)}
onKeyDown={onLoadMoreKeyInput}
>
{t(Keys.tabs.documents.loadMore)}
Load more
</a>
)}
</div>
@@ -2271,7 +2255,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
language={"json"}
content={selectedDocumentContent}
isReadOnly={false}
ariaLabel={t(Keys.tabs.documents.documentEditor)}
ariaLabel={"Document editor"}
lineNumbers={"on"}
theme={"_theme"}
onContentChanged={_onEditorContentChange}
@@ -2279,9 +2263,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
/>
)}
{selectedRows.size > 1 && (
<span style={{ margin: 10 }}>
{t(Keys.tabs.documents.numberOfSelectedDocuments, { count: selectedRows.size })}
</span>
<span style={{ margin: 10 }}>Number of selected documents: {selectedRows.size}</span>
)}
</div>
</Allotment.Pane>
@@ -2290,43 +2272,42 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
{bulkDeleteOperation && (
<ProgressModalDialog
isOpen={isBulkDeleteDialogOpen}
dismissText={t(Keys.tabs.documents.abort)}
dismissText="Abort"
onDismiss={() => {
setIsBulkDeleteDialogOpen(false);
setBulkDeleteOperation(undefined);
}}
onCancel={() => setBulkDeleteMode("aborting")}
title={t(Keys.tabs.documents.deletingDocuments, { count: bulkDeleteOperation.count })}
message={t(Keys.tabs.documents.deletedDocumentsSuccess, { count: bulkDeleteProcess.successfulIds.length })}
title={`Deleting ${bulkDeleteOperation.count} document(s)`}
message={`Successfully deleted ${bulkDeleteProcess.successfulIds.length} document(s).`}
maxValue={bulkDeleteOperation.count}
value={bulkDeleteProcess.successfulIds.length}
mode={bulkDeleteMode}
>
<div className={styles.deleteProgressContent}>
{(bulkDeleteMode === "aborting" || bulkDeleteMode === "aborted") && (
<div style={{ paddingBottom: tokens.spacingVerticalL }}>{t(Keys.tabs.documents.deleteAborted)}</div>
<div style={{ paddingBottom: tokens.spacingVerticalL }}>Deleting document(s) was aborted.</div>
)}
{(bulkDeleteProcess.failedIds.length > 0 ||
(bulkDeleteProcess.throttledIds.length > 0 && bulkDeleteMode !== "inProgress")) && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalL }}>
<MessageBarBody>
<MessageBarTitle>{t(Keys.tabs.documents.error)}</MessageBarTitle>
{t(Keys.tabs.documents.failedToDeleteDocuments, {
count:
bulkDeleteMode === "inProgress"
? bulkDeleteProcess.failedIds.length
: bulkDeleteProcess.failedIds.length + bulkDeleteProcess.throttledIds.length,
})}
<MessageBarTitle>Error</MessageBarTitle>
Failed to delete{" "}
{bulkDeleteMode === "inProgress"
? bulkDeleteProcess.failedIds.length
: bulkDeleteProcess.failedIds.length + bulkDeleteProcess.throttledIds.length}{" "}
document(s).
</MessageBarBody>
</MessageBar>
)}
{bulkDeleteProcess.hasBeenThrottled && (
<MessageBar intent="warning">
<MessageBarBody>
<MessageBarTitle>{t(Keys.tabs.documents.warning)}</MessageBarTitle>
<MessageBarTitle>Warning</MessageBarTitle>
{get429WarningMessageNoSql()}{" "}
<Link href={NO_SQL_THROTTLING_DOC_URL} target="_blank">
{t(Keys.common.learnMore)}
Learn More
</Link>
</MessageBarBody>
</MessageBar>

View File

@@ -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]}

View File

@@ -1,8 +1,6 @@
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";
@@ -214,7 +212,7 @@ export default class MongoShellTabComponent extends Component<
src={this.state.url}
id={this.props.tabsBaseInstance.tabId}
onLoad={(event) => this.setContentFocus(event)}
title={t(Keys.tabs.mongoShell.title)}
title="Mongo Shell"
role="tabpanel"
></iframe>
);

View File

@@ -1,107 +0,0 @@
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>
);
};

View File

@@ -3,21 +3,18 @@ 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 {
@@ -52,8 +49,6 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults,
executeQueryDocumentsPage,
isExecuting,
databaseId,
containerId,
}: QueryResultProps): JSX.Element => {
const styles = useQueryTabStyles();
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
@@ -96,9 +91,6 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults={queryResults}
executeQueryDocumentsPage={executeQueryDocumentsPage}
isMongoDB={isMongoDB}
queryEditorContent={queryEditorContent}
databaseId={databaseId}
containerId={containerId}
/>
) : (
<ExecuteQueryCallToAction />

View File

@@ -52,9 +52,8 @@ describe("QueryTabComponent", () => {
copilotVersion: "v3.0",
},
});
const propsMock: Readonly<IQueryTabComponentProps> = {
collection: { databaseId: "CopilotSampleDB", id: () => "CopilotContainer" },
collection: { databaseId: "CopilotSampleDB" },
onTabAccessor: () => jest.fn(),
isExecutionError: false,
tabId: "mockTabId",

View File

@@ -17,8 +17,6 @@ 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";
@@ -30,7 +28,6 @@ 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";
@@ -60,20 +57,6 @@ 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,
@@ -281,10 +264,6 @@ 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 () => {
@@ -317,9 +296,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
};
public onSaveQueryClick = (): void => {
useSidePanel
.getState()
.openSidePanel(t(Keys.tabs.query.saveQuery), <SaveQueryPane explorer={this.props.collection.container} />);
useSidePanel.getState().openSidePanel("Save Query", <SaveQueryPane explorer={this.props.collection.container} />);
};
public launchQueryCopilotChat = (): void => {
@@ -329,10 +306,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
public onSavedQueriesClick = (): void => {
useSidePanel
.getState()
.openSidePanel(
t(Keys.tabs.query.openSavedQueries),
<BrowseQueriesPane explorer={this.props.collection.container} />,
);
.openSidePanel("Open Saved Queries", <BrowseQueriesPane explorer={this.props.collection.container} />);
};
public toggleResult(): void {
@@ -480,8 +454,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
protected getTabsButtons(): CommandButtonComponentProps[] {
const buttons: CommandButtonComponentProps[] = [];
if (this.executeQueryButton.visible) {
const label =
this.state.selectedContent?.length > 0 ? t(Keys.tabs.query.executeSelection) : t(Keys.tabs.query.executeQuery);
const label = this.state.selectedContent?.length > 0 ? "Execute Selection" : "Execute Query";
buttons.push({
iconSrc: ExecuteQueryIcon,
iconAlt: label,
@@ -498,7 +471,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
if (this.saveQueryButton.visible) {
if (configContext.platform !== Platform.Fabric) {
const label = t(Keys.tabs.query.saveQuery);
const label = "Save Query";
buttons.push({
iconSrc: SaveQueryIcon,
iconAlt: label,
@@ -515,11 +488,11 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
buttons.push({
iconSrc: DownloadQueryIcon,
iconAlt: t(Keys.tabs.query.downloadQuery),
iconAlt: "Download Query",
keyboardAction: KeyboardAction.DOWNLOAD_ITEM,
onCommandClick: this.onDownloadQueryClick,
commandButtonLabel: t(Keys.tabs.query.downloadQuery),
ariaLabel: t(Keys.tabs.query.downloadQuery),
commandButtonLabel: "Download Query",
ariaLabel: "Download Query",
hasPopup: false,
disabled: !this.saveQueryButton.enabled,
});
@@ -576,7 +549,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
// }
if (!this.props.isPreferredApiMongoDB && this.state.isExecuting) {
const label = t(Keys.tabs.query.cancelQuery);
const label = "Cancel query";
buttons.push({
iconSrc: CancelQueryIcon,
iconAlt: label,
@@ -597,23 +570,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: t(Keys.tabs.query.vertical),
ariaLabel: t(Keys.tabs.query.vertical),
commandButtonLabel: "Vertical",
ariaLabel: "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: t(Keys.tabs.query.horizontal),
ariaLabel: t(Keys.tabs.query.horizontal),
commandButtonLabel: "Horizontal",
ariaLabel: "Horizontal",
onCommandClick: () => this._setViewLayout(SplitterDirection.Horizontal),
hasPopup: false,
};
return {
commandButtonLabel: t(Keys.tabs.query.view),
ariaLabel: t(Keys.tabs.query.view),
commandButtonLabel: "View",
ariaLabel: "View",
hasPopup: true,
children: [verticalButton, horizontalButton],
};
@@ -790,7 +763,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
modelMarkers={this.state.modelMarkers}
isReadOnly={false}
wordWrap={"on"}
ariaLabel={t(Keys.tabs.query.editingQuery)}
ariaLabel={"Editing Query"}
lineNumbers={"on"}
theme={this.props.monacoTheme}
onContentChanged={(newContent: string) => this.onChangeContent(newContent)}
@@ -807,8 +780,6 @@ 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,
@@ -824,8 +795,6 @@ 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)
}

View File

@@ -1,170 +0,0 @@
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();
});
});

View File

@@ -1,8 +1,5 @@
import type { CompositePath, IndexingPolicy } from "@azure/cosmos";
import { FontIcon } from "@fluentui/react";
import {
Button,
Checkbox,
DataGrid,
DataGridBody,
DataGridCell,
@@ -11,45 +8,28 @@ import {
DataGridRow,
SelectTabData,
SelectTabEvent,
Spinner,
Tab,
TabList,
Table,
TableBody,
TableCell,
TableColumnDefinition,
TableHeader,
TableRow,
createTableColumn,
} from "@fluentui/react-components";
import { ArrowDownloadRegular, ChevronDown20Regular, ChevronRight20Regular, CopyRegular } from "@fluentui/react-icons";
import copy from "clipboard-copy";
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
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 { logConsoleProgress } from "Utils/NotificationConsoleUtils";
import create from "zustand";
import { client } from "../../../Common/CosmosClient";
import { handleError } from "../../../Common/ErrorHandlingUtils";
import { sampleDataClient } from "../../../Common/SampleDataClient";
import copy from "clipboard-copy";
import React, { useCallback, useState } from "react";
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 */
@@ -543,331 +523,14 @@ const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ query
);
};
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,
}) => {
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
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}>
@@ -885,13 +548,6 @@ export const ResultsView: React.FC<ResultsViewProps> = ({
>
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 && (
@@ -902,30 +558,7 @@ export const ResultsView: React.FC<ResultsViewProps> = ({
/>
)}
{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 },
},
})),
}));

View File

@@ -1,95 +0,0 @@
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",
},
},
});

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