From 36736882ee45346751b11d5995b9004586627559 Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Wed, 29 May 2024 09:09:13 +0200 Subject: [PATCH] Migrate DocumentsTab to React and add bulk delete and column resize (#1770) * Document page now loads list of docs and displays selection * DocumentsTabV2 now properly loads documents, show partition keys and display first doc with proper selection behavior. Move it to its own folder. * Extract table in a separate component * Resizable columns on the document table * Fix selection behavior and some layout issue * Adding table scrolling * Fix NaN height issue * Fix NaN height issue * Fix column sizing + cell selection * Improvement in width size. Add Load More * Add react editor and pass column headers * Dynamic columns for pk * Fix initial columns size * Add nav buttons * Editing content updates buttons state * Discard and save buttons working * Fix save new document. Implement delete. * Remove debug display * Fix unexpand filter and reformat * Fix compil issues * Add refresh button * Update column header placeholder style * Implement delete multiple docs * Fix multi delete * Fix show/hide delete button * Fix selection behavior * Fix UX with buttons behavior and editor display * Fix UX issue with not discarding edit changes * Add some TODO's * Remove debugging info and reformat * Add mongo support * Fix build issues * Fix table header. Remove debug statement * Restore broken nosql * Fix mongo save new document/update document * Fix bugs with clicking on newly created documents * Fix comment * Fix double fetch issue when clicking on an item * Auto-select last document when saving new document * Fix resourceTokenPartitionKey code * Fix format * Fix isQueryCopilotSampleContainer flag * Fix unused code * Call tab when updating error flag * Destructure props to make useEffect dependencies work * Fix loadStartKey * minor update * Fix format * Add title to table * Fix table coming off its container with unwanted horizontal scrollbar * Increase table width. Fix eslint issue. * Move refresh documents button from table back to DocumentsTabV2 * Fix load more text centering * Don't show Load More if nothing to show * Fix columns min width * Add keyboard shortcuts * Add keyboard handlers to load more and refresh button * Add keyboard support to select table row * Disable eslint issue from fluent library * Connect cancel query button * Add Fluent V9 theme for Fabric (#1821) * Clean up dependencies and memoize internal functions and object. Move methods and object that don't depend on state outside of component. * Fix filter disappearing when clicking Apply Filter * Fix typo and format * Implement bulk delete for nosql * Replace filter ui components with fluent ui * Remove jquery calls * Migrate unit test to DocumentsTabV2 * Remove DocumentsTab and MongoDocumentsTab. Fix build issues. * Properly handle activetab * Remove comments and unused code * Port keyboard shortcuts from commitId 1f4d0f2 * Port item editor shortcuts to improved Items tab branch (#1831) * set filter focus on Ctrl+Shift+F * implement filter enter/esc keybinds * remove debugging * Collapse filter when query is executed * Fix monaco editor not happy when parent is null * Fix how bulk delete operation gets called when no partition key * Fix update id list after delete * Fix deleteDocuments * Fix build issue * Fix bug in mongo delete * Fix mongo delete flow * Proper error handling in mongo * Handle >100 bulk delete operations * Add unit tests for DocumentsTableComponent * More improvements to table unit tests * Fix import. Disable selection test for now * Add more DocumentsTab unit react tests * Remove selection test * Add more unit tests. Add lcov coverage report to display in vscode * Move unit tests to correct file * Add unit test on command bar * Fix build issues * Add more unit tests * Remove unneeded call * Add DocumentsTab for Mongo API * Fix linting errors * Update fluent ui v9 dependency. Color columns separation. Fix refresh button placement to not interfere with header cell width. * Revert @fluentui/react-components to a safe version that compiles * Add confirmation window when documents have been deleted * Fix mongo unit tests * Fix format * Update src/Common/dataAccess/deleteDocument.ts Co-authored-by: Ashley Stanton-Nurse * Update src/Common/dataAccess/deleteDocument.ts Co-authored-by: Ashley Stanton-Nurse * Update src/Common/dataAccess/deleteDocument.ts Co-authored-by: Ashley Stanton-Nurse * Fix bug with markup. Simplify code. * Protect against creating React editor without parent node * Replace rendering tests with snapshot match * Add test screenshot to troubleshoot e2e test * Revert "Add test screenshot to troubleshoot e2e test" This reverts commit 1b8138ade00571972c0e9bce77846e8fe4a3446a. * Attempt 2 at troubleshooting failing test * Revert "Attempt 2 at troubleshooting failing test" This reverts commit 3e51a593bf2001d44b6e5316d4865e6e861c21c3. * Delete button now shows if one or more rows are selected --------- Co-authored-by: Vsevolod Kukol Co-authored-by: Ashley Stanton-Nurse --- .eslintignore | 3 - jest.config.js | 2 +- less/documentDB.less | 71 +- package-lock.json | 653 ++++-- package.json | 13 +- patches/@uiw+react-split+5.9.3.patch | 11 + src/Common/DocumentUtility.ts | 6 +- src/Common/QueriesClient.ts | 7 +- src/Common/dataAccess/deleteDocument.ts | 56 + src/Explorer/Controls/Editor/EditorReact.tsx | 8 +- src/Explorer/Tabs/DocumentsTab.html | 239 --- src/Explorer/Tabs/DocumentsTab.test.ts | 152 -- src/Explorer/Tabs/DocumentsTab.ts | 1080 ---------- .../DocumentsTabV2/DocumentsTabV2.test.tsx | 476 +++++ .../Tabs/DocumentsTabV2/DocumentsTabV2.tsx | 1816 +++++++++++++++++ .../DocumentsTabV2Mongo.test.tsx | 195 ++ .../DocumentsTableComponent.test.tsx | 34 + .../DocumentsTableComponent.tsx | 271 +++ .../DocumentsTabV2.test.tsx.snap | 558 +++++ .../DocumentsTableComponent.test.tsx.snap | 1140 +++++++++++ src/Explorer/Tabs/MongoDocumentsTab.ts | 320 --- src/Explorer/Tabs/useTabs.test.ts | 8 +- src/Explorer/Theme/ThemeUtil.ts | 31 + src/Explorer/Tree/Collection.ts | 19 +- src/Explorer/Tree/DocumentId.ts | 14 +- src/Explorer/Tree/ObjectId.ts | 5 +- src/Explorer/Tree/ResourceTokenCollection.ts | 10 +- src/Explorer/Tree2/ResourceTree.tsx | 30 +- src/Platform/Fabric/FabricTheme.tsx | 22 + src/Shared/Telemetry/TelemetryConstants.ts | 1 + src/setupTests.ts | 6 + 31 files changed, 5143 insertions(+), 2114 deletions(-) create mode 100644 patches/@uiw+react-split+5.9.3.patch delete mode 100644 src/Explorer/Tabs/DocumentsTab.html delete mode 100644 src/Explorer/Tabs/DocumentsTab.test.ts delete mode 100644 src/Explorer/Tabs/DocumentsTab.ts create mode 100644 src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx create mode 100644 src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx create mode 100644 src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx create mode 100644 src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.test.tsx create mode 100644 src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx create mode 100644 src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap create mode 100644 src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap delete mode 100644 src/Explorer/Tabs/MongoDocumentsTab.ts create mode 100644 src/Explorer/Theme/ThemeUtil.ts diff --git a/.eslintignore b/.eslintignore index 78e9b517e..20cdeb4c3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -89,10 +89,7 @@ src/Explorer/Tables/TableEntityProcessor.ts src/Explorer/Tables/Utilities.ts src/Explorer/Tabs/ConflictsTab.ts src/Explorer/Tabs/DatabaseSettingsTab.ts -src/Explorer/Tabs/DocumentsTab.test.ts -src/Explorer/Tabs/DocumentsTab.ts src/Explorer/Tabs/GraphTab.ts -src/Explorer/Tabs/MongoDocumentsTab.ts src/Explorer/Tabs/NotebookV2Tab.ts src/Explorer/Tabs/ScriptTabBase.ts src/Explorer/Tabs/TabComponents.ts diff --git a/jest.config.js b/jest.config.js index b4f660063..1c1f48e6d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -31,7 +31,7 @@ module.exports = { coveragePathIgnorePatterns: ["/node_modules/"], // A list of reporter names that Jest uses when writing coverage reports - coverageReporters: ["json", "text", "cobertura"], + coverageReporters: ["json", "text", "cobertura", "lcov"], // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { diff --git a/less/documentDB.less b/less/documentDB.less index 357d159b3..05ad22760 100644 --- a/less/documentDB.less +++ b/less/documentDB.less @@ -2264,33 +2264,33 @@ a:link { width: 82px; } -.tabdocuments .scrollable { - height: 100%; - overflow-y: auto; - overflow-x: hidden; - padding-left: 5px; - padding-right: 5px; - width: 100%; -} +// .tabdocuments .scrollable { +// height: 100%; +// overflow-y: auto; +// overflow-x: hidden; +// padding-left: 5px; +// padding-right: 5px; +// width: 100%; +// } -.tabdocuments > .tabdocumentsGridElement { - width: 50%; -} +// .tabdocuments > .tabdocumentsGridElement { +// width: 50%; +// } -.tabdocuments > .evenlySpacedHeader { - width: 30%; -} +// .tabdocuments > .evenlySpacedHeader { +// width: 30%; +// } -.tabdocuments.scrollable:focus, -.tabdocuments.scrollable:active { - outline: 1px dotted; -} +// .tabdocuments.scrollable:focus, +// .tabdocuments.scrollable:active { +// outline: 1px dotted; +// } -.tabdocuments .scrollable table td { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} +// .tabdocuments .scrollable table td { +// white-space: nowrap; +// overflow: hidden; +// text-overflow: ellipsis; +// } .mongoDocumentEditor .monaco-editor.vs .redsquiggly { display: none !important; @@ -2316,10 +2316,9 @@ td a:hover { } .loadMore { + display: block; width: 100%; - padding-left: 30%; - padding-top: 2px; - cursor: pointer; + text-align: center; } .loadMore > a:focus { @@ -2558,10 +2557,12 @@ a:link { } .filterdivs { - padding-top: 15px; - height: 45px; - margin-bottom: 8px; + margin: 10px 0px; white-space: nowrap; + input { + line-height: 14px; // To correct vertical position of the down arrow of the input + outline: none; // Remove the dotted border on focus, because fluent has its own focus style (underlined) + } } .editFilterContainer { @@ -2578,6 +2579,18 @@ a:link { cursor: pointer; } +.documentsTab { + .documentsTable { + .documentsTableCell { + border-left: 1px solid @BaseMedium; + height: 100%; + } + .documentsTableHeader { + border-bottom: 1px solid @BaseMedium; + } + } +} + .querydropdown { border: 1px solid @BaseMedium; font-style: normal; diff --git a/package-lock.json b/package-lock.json index 26c513d6f..31e3d37db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "@types/lodash": "4.14.171", "@types/mkdirp": "1.0.1", "@types/node-fetch": "2.5.7", + "@uiw/react-split": "5.9.3", "@xmldom/xmldom": "0.7.13", "applicationinsights": "1.8.0", "bootstrap": "3.4.1", @@ -102,6 +103,7 @@ "react-redux": "7.1.3", "react-splitter-layout": "4.0.0", "react-string-format": "1.0.1", + "react-window": "1.8.10", "react-youtube": "9.0.1", "reflect-metadata": "0.1.13", "rx-jupyter": "5.5.12", @@ -128,8 +130,8 @@ "@types/datatables.net": "1.10.28", "@types/datatables.net-colreorder": "1.4.5", "@types/dom-to-image": "2.6.2", - "@types/enzyme": "3.10.7", - "@types/enzyme-adapter-react-16": "1.0.6", + "@types/enzyme": "3.10.12", + "@types/enzyme-adapter-react-16": "1.0.9", "@types/hasher": "0.0.31", "@types/jest": "26.0.20", "@types/jquery": "3.5.29", @@ -141,6 +143,7 @@ "@types/react-notification-system": "0.2.39", "@types/react-redux": "7.1.7", "@types/react-splitter-layout": "3.0.1", + "@types/react-window": "1.8.8", "@types/sanitize-html": "1.27.2", "@types/sinon": "2.3.3", "@types/styled-components": "5.1.1", @@ -156,8 +159,8 @@ "create-file-webpack": "1.0.2", "css-loader": "6.8.1", "enzyme": "3.11.0", - "enzyme-adapter-react-16": "1.15.5", - "enzyme-to-json": "3.6.1", + "enzyme-adapter-react-16": "1.15.8", + "enzyme-to-json": "3.6.2", "eslint": "8.50.0", "eslint-cli": "1.1.1", "eslint-plugin-no-null": "1.0.2", @@ -12481,9 +12484,9 @@ "integrity": "sha512-PD+wqNhrjWFjAlSVd18jvChZvOXB2SOwAILBmuYev5zswBats5qmzs/QFoooLKd2omj9BT05a8MeSeRmXLGY+Q==" }, "node_modules/@types/enzyme": { - "version": "3.10.7", - "resolved": "https://registry.npmjs.org/@types/enzyme/-/enzyme-3.10.7.tgz", - "integrity": "sha512-J+0wduPGAkzOvW7sr6hshGv1gBI3WXLRTczkRKzVPxLP3xAkYxZmvvagSBPw8Z452fZ8TGUxCmAXcb44yLQksw==", + "version": "3.10.12", + "resolved": "https://registry.npmjs.org/@types/enzyme/-/enzyme-3.10.12.tgz", + "integrity": "sha512-xryQlOEIe1TduDWAOphR0ihfebKFSWOXpIsk+70JskCfRfW+xALdnJ0r1ZOTo85F9Qsjk6vtlU7edTYHbls9tA==", "dev": true, "dependencies": { "@types/cheerio": "*", @@ -12491,9 +12494,9 @@ } }, "node_modules/@types/enzyme-adapter-react-16": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.6.tgz", - "integrity": "sha512-VonDkZ15jzqDWL8mPFIQnnLtjwebuL9YnDkqeCDYnB4IVgwUm0mwKkqhrxLL6mb05xm7qqa3IE95m8CZE9imCg==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.9.tgz", + "integrity": "sha512-z24MMxGtUL8HhXdye3tWzjp+19QTsABqLaX2oOZpxMPHRJgLfahQmOeTTrEBQd9ogW20+UmPBXD9j+XOasFHvw==", "dev": true, "dependencies": { "@types/enzyme": "*" @@ -13426,6 +13429,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -13903,6 +13915,18 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@uiw/react-split": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/@uiw/react-split/-/react-split-5.9.3.tgz", + "integrity": "sha512-HgwETU+kRhzZAp+YiE4Yu8bNJm3jxxnGgGPfkadUl8jA1wsMD3aMMskuhQF5akiUUUadiLUvAc8e1RH9Y/SKDw==", + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@ungap/url-search-params": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@ungap/url-search-params/-/url-search-params-0.2.2.tgz", @@ -14608,12 +14632,15 @@ } }, "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -14701,15 +14728,19 @@ } }, "node_modules/array.prototype.find": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.2.tgz", - "integrity": "sha512-DRumkfW97iZGOfn+lIXbkVrXL04sfYKX+EfOodo8XboR5sxPDVvOjZTF/rysusa9lmhmSOeD6Vp6RKQP+eP4Tg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.3.tgz", + "integrity": "sha512-fO/ORdOELvjbbeIfZfzrXFMhYHGofRGqd+am9zm3tZ4GlJINj/pA2eITyfd65Vg6+ZbHd/Cys7stpoRSWtQFdA==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -14780,16 +14811,17 @@ } }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", - "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "is-array-buffer": "^3.0.2", + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", "is-shared-array-buffer": "^1.0.2" }, "engines": { @@ -14914,9 +14946,12 @@ } }, "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -15731,13 +15766,18 @@ } }, "node_modules/call-bind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -17879,6 +17919,54 @@ "webidl-conversions": "^4.0.2" } }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/datatables.net": { "version": "1.13.8", "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-1.13.8.tgz", @@ -18142,16 +18230,19 @@ } }, "node_modules/define-data-property": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dependencies": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-lazy-prop": { @@ -18778,20 +18869,20 @@ } }, "node_modules/enzyme-adapter-react-16": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.5.tgz", - "integrity": "sha512-33yUJGT1nHFQlbVI5qdo5Pfqvu/h4qPwi1o0a6ZZsjpiqq92a3HjynDhwd1IeED+Su60HDWV8mxJqkTnLYdGkw==", + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.8.tgz", + "integrity": "sha512-uYGC31eGZBp5nGsr4nKhZKvxGQjyHGjS06BJsUlWgE29/hvnpgCsT1BJvnnyny7N3GIIVyxZ4O9GChr6hy2WQA==", "dev": true, "dependencies": { - "enzyme-adapter-utils": "^1.13.1", - "enzyme-shallow-equal": "^1.0.4", - "has": "^1.0.3", - "object.assign": "^4.1.0", - "object.values": "^1.1.1", - "prop-types": "^15.7.2", + "enzyme-adapter-utils": "^1.14.2", + "enzyme-shallow-equal": "^1.0.7", + "hasown": "^2.0.0", + "object.assign": "^4.1.5", + "object.values": "^1.1.7", + "prop-types": "^15.8.1", "react-is": "^16.13.1", "react-test-renderer": "^16.0.0-0", - "semver": "^5.7.0" + "semver": "^5.7.2" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -18809,18 +18900,18 @@ "dev": true }, "node_modules/enzyme-adapter-utils": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.1.tgz", - "integrity": "sha512-JZgMPF1QOI7IzBj24EZoDpaeG/p8Os7WeBZWTJydpsH7JRStc7jYbHE4CmNQaLqazaGFyLM8ALWA3IIZvxW3PQ==", + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.2.tgz", + "integrity": "sha512-1ZC++RlsYRaiOWE5NRaF5OgsMt7F5rn/VuaJIgc7eW/fmgg8eS1/Ut7EugSPPi7VMdWMLcymRnMF+mJUJ4B8KA==", "dev": true, "dependencies": { "airbnb-prop-types": "^2.16.0", - "function.prototype.name": "^1.1.5", - "has": "^1.0.3", - "object.assign": "^4.1.4", - "object.fromentries": "^2.0.5", + "function.prototype.name": "^1.1.6", + "hasown": "^2.0.0", + "object.assign": "^4.1.5", + "object.fromentries": "^2.0.7", "prop-types": "^15.8.1", - "semver": "^5.7.1" + "semver": "^6.3.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -18829,13 +18920,22 @@ "react": "0.13.x || 0.14.x || ^15.0.0-0 || ^16.0.0-0" } }, + "node_modules/enzyme-adapter-utils/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/enzyme-shallow-equal": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.5.tgz", - "integrity": "sha512-i6cwm7hN630JXenxxJFBKzgLC3hMTafFQXflvzHgPmDhOBhxUWDe8AeRv1qp2/uWJ2Y8z5yLWMzmAfkTOiOCZg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.7.tgz", + "integrity": "sha512-/um0GFqUXnpM9SvKtje+9Tjoz3f1fpBC3eXRFrNs8kpYn69JljciYP7KZTqM/YQbUY9KUjvKB4jo/q+L6WGGvg==", "dev": true, "dependencies": { - "has": "^1.0.3", + "hasown": "^2.0.0", "object-is": "^1.1.5" }, "funding": { @@ -18843,13 +18943,13 @@ } }, "node_modules/enzyme-to-json": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/enzyme-to-json/-/enzyme-to-json-3.6.1.tgz", - "integrity": "sha512-15tXuONeq5ORoZjV/bUo2gbtZrN2IH+Z6DvL35QmZyKHgbY1ahn6wcnLd9Xv9OjiwbAXiiP8MRZwbZrCv1wYNg==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/enzyme-to-json/-/enzyme-to-json-3.6.2.tgz", + "integrity": "sha512-Ynm6Z6R6iwQ0g2g1YToz6DWhxVnt8Dy1ijR2zynRKxTyBGA8rCDXU3rs2Qc4OKvUvc2Qoe1bcFK6bnPs20TrTg==", "dev": true, "dependencies": { "@types/cheerio": "^0.22.22", - "lodash": "^4.17.15", + "lodash": "^4.17.21", "react-is": "^16.12.0" }, "engines": { @@ -18911,49 +19011,56 @@ } }, "node_modules/es-abstract": { - "version": "1.22.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", - "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "arraybuffer.prototype.slice": "^1.0.2", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.5", - "es-set-tostringtag": "^2.0.1", + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", "es-to-primitive": "^1.2.1", "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.2", - "get-symbol-description": "^1.0.0", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", "has-symbols": "^1.0.3", - "hasown": "^2.0.0", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", + "is-shared-array-buffer": "^1.0.3", "is-string": "^1.0.7", - "is-typed-array": "^1.1.12", + "is-typed-array": "^1.1.13", "is-weakref": "^1.0.2", "object-inspect": "^1.13.1", "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "safe-array-concat": "^1.0.1", - "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.8", - "string.prototype.trimend": "^1.0.7", - "string.prototype.trimstart": "^1.0.7", - "typed-array-buffer": "^1.0.0", - "typed-array-byte-length": "^1.0.0", - "typed-array-byte-offset": "^1.0.0", - "typed-array-length": "^1.0.4", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.13" + "which-typed-array": "^1.1.15" }, "engines": { "node": ">= 0.4" @@ -18967,6 +19074,25 @@ "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==" }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-iterator-helpers": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", @@ -18993,14 +19119,25 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==" }, - "node_modules/es-set-tostringtag": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", - "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", "dependencies": { - "get-intrinsic": "^1.2.2", - "has-tostringtag": "^1.0.0", - "hasown": "^2.0.0" + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dependencies": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -21334,15 +21471,19 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dependencies": { + "es-errors": "^1.3.0", "function-bind": "^1.1.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", "hasown": "^2.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -21372,12 +21513,13 @@ } }, "node_modules/get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" }, "engines": { "node": ">= 0.4" @@ -21735,20 +21877,20 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", - "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dependencies": { - "get-intrinsic": "^1.2.2" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", "engines": { "node": ">= 0.4" }, @@ -21768,11 +21910,11 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -21859,9 +22001,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dependencies": { "function-bind": "^1.1.2" }, @@ -22826,11 +22968,11 @@ "devOptional": true }, "node_modules/internal-slot": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", - "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", "dependencies": { - "get-intrinsic": "^1.2.2", + "es-errors": "^1.3.0", "hasown": "^2.0.0", "side-channel": "^1.0.4" }, @@ -22916,13 +23058,15 @@ } }, "node_modules/is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", "dependencies": { "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -23034,6 +23178,20 @@ "node": ">= 0.4" } }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-date-object": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", @@ -23209,9 +23367,9 @@ } }, "node_modules/is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "engines": { "node": ">= 0.4" }, @@ -23348,11 +23506,14 @@ } }, "node_modules/is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", "dependencies": { - "call-bind": "^1.0.2" + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -23405,11 +23566,11 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -32745,12 +32906,12 @@ } }, "node_modules/object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", "has-symbols": "^1.0.3", "object-keys": "^1.1.1" }, @@ -33769,6 +33930,14 @@ "node": ">=0.10.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/post-robot": { "version": "10.0.42", "resolved": "https://registry.npmjs.org/post-robot/-/post-robot-10.0.42.tgz", @@ -35185,6 +35354,22 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-window": { + "version": "1.8.10", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.10.tgz", + "integrity": "sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-youtube": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/react-youtube/-/react-youtube-9.0.1.tgz", @@ -35553,13 +35738,14 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", - "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "set-function-name": "^2.0.0" + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -36190,12 +36376,12 @@ } }, "node_modules/safe-array-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", - "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", "has-symbols": "^1.0.3", "isarray": "^2.0.5" }, @@ -36239,14 +36425,17 @@ } }, "node_modules/safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", "is-regex": "^1.1.4" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -36726,14 +36915,16 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, "node_modules/set-function-length": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", - "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dependencies": { - "define-data-property": "^1.1.1", - "get-intrinsic": "^1.2.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -37587,13 +37778,14 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", - "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -37603,26 +37795,29 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", - "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/string.prototype.trimstart": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", - "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -38708,27 +38903,28 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", - "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" } }, "node_modules/typed-array-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", - "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -38738,15 +38934,16 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", - "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -38756,13 +38953,19 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", - "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -40265,15 +40468,15 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" }, "node_modules/which-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", - "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index e1d544d1d..d27273af9 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@types/lodash": "4.14.171", "@types/mkdirp": "1.0.1", "@types/node-fetch": "2.5.7", + "@uiw/react-split": "5.9.3", "@xmldom/xmldom": "0.7.13", "applicationinsights": "1.8.0", "bootstrap": "3.4.1", @@ -98,6 +99,7 @@ "react-splitter-layout": "4.0.0", "react-string-format": "1.0.1", "react-youtube": "9.0.1", + "react-window": "1.8.10", "reflect-metadata": "0.1.13", "rx-jupyter": "5.5.12", "sanitize-html": "2.3.3", @@ -123,8 +125,8 @@ "@types/datatables.net": "1.10.28", "@types/datatables.net-colreorder": "1.4.5", "@types/dom-to-image": "2.6.2", - "@types/enzyme": "3.10.7", - "@types/enzyme-adapter-react-16": "1.0.6", + "@types/enzyme": "3.10.12", + "@types/enzyme-adapter-react-16": "1.0.9", "@types/hasher": "0.0.31", "@types/jest": "26.0.20", "@types/jquery": "3.5.29", @@ -136,6 +138,7 @@ "@types/react-notification-system": "0.2.39", "@types/react-redux": "7.1.7", "@types/react-splitter-layout": "3.0.1", + "@types/react-window": "1.8.8", "@types/sanitize-html": "1.27.2", "@types/sinon": "2.3.3", "@types/styled-components": "5.1.1", @@ -151,8 +154,8 @@ "create-file-webpack": "1.0.2", "css-loader": "6.8.1", "enzyme": "3.11.0", - "enzyme-adapter-react-16": "1.15.5", - "enzyme-to-json": "3.6.1", + "enzyme-adapter-react-16": "1.15.8", + "enzyme-to-json": "3.6.2", "eslint": "8.50.0", "eslint-cli": "1.1.1", "eslint-plugin-no-null": "1.0.2", @@ -243,4 +246,4 @@ "printWidth": 120, "endOfLine": "auto" } -} +} \ No newline at end of file diff --git a/patches/@uiw+react-split+5.9.3.patch b/patches/@uiw+react-split+5.9.3.patch new file mode 100644 index 000000000..3e5307463 --- /dev/null +++ b/patches/@uiw+react-split+5.9.3.patch @@ -0,0 +1,11 @@ +diff --git a/node_modules/@uiw/react-split/cjs/index.d.ts b/node_modules/@uiw/react-split/cjs/index.d.ts +index 644bcc3..f794760 100644 +--- a/node_modules/@uiw/react-split/cjs/index.d.ts ++++ b/node_modules/@uiw/react-split/cjs/index.d.ts +@@ -56,5 +56,5 @@ export default class Split extends React.Component { + onMouseDown(paneNumber: number, env: React.MouseEvent): void; + onDragging(env: Event): void; + onDragEnd(): void; +- render(): import("react/jsx-runtime").JSX.Element; ++ render(): JSX.Element; + } diff --git a/src/Common/DocumentUtility.ts b/src/Common/DocumentUtility.ts index 99cdefc5a..322d883b0 100644 --- a/src/Common/DocumentUtility.ts +++ b/src/Common/DocumentUtility.ts @@ -1,9 +1,9 @@ import { userContext } from "../UserContext"; -export const getEntityName = (): string => { +export const getEntityName = (multiple?: boolean): string => { if (userContext.apiType === "Mongo") { - return "document"; + return multiple ? "documents" : "document"; } - return "item"; + return multiple ? "items" : "item"; }; diff --git a/src/Common/QueriesClient.ts b/src/Common/QueriesClient.ts index a4834b128..b56b78bb8 100644 --- a/src/Common/QueriesClient.ts +++ b/src/Common/QueriesClient.ts @@ -3,8 +3,7 @@ import * as _ from "underscore"; import * as DataModels from "../Contracts/DataModels"; import * as ViewModels from "../Contracts/ViewModels"; import Explorer from "../Explorer/Explorer"; -import DocumentsTab from "../Explorer/Tabs/DocumentsTab"; -import DocumentId from "../Explorer/Tree/DocumentId"; +import DocumentId, { IDocumentIdContainer } from "../Explorer/Tree/DocumentId"; import { useDatabases } from "../Explorer/useDatabases"; import { userContext } from "../UserContext"; import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils"; @@ -162,10 +161,10 @@ export class QueriesClient { { partitionKey: QueriesClient.PartitionKey, partitionKeyProperties: ["id"], - } as DocumentsTab, + } as IDocumentIdContainer, query, [query.queryName], - ); // TODO: Remove DocumentId's dependency on DocumentsTab + ); const options: any = { partitionKey: query.resourceId }; return deleteDocument(queriesCollection, documentId) .then( diff --git a/src/Common/dataAccess/deleteDocument.ts b/src/Common/dataAccess/deleteDocument.ts index 5caef9e0e..f20dc9cc8 100644 --- a/src/Common/dataAccess/deleteDocument.ts +++ b/src/Common/dataAccess/deleteDocument.ts @@ -1,3 +1,4 @@ +import { BulkOperationType, OperationInput } from "@azure/cosmos"; import { CollectionBase } from "../../Contracts/ViewModels"; import DocumentId from "../../Explorer/Tree/DocumentId"; import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; @@ -24,3 +25,58 @@ export const deleteDocument = async (collection: CollectionBase, documentId: Doc clearMessage(); } }; + +/** + * Bulk delete documents + * @param collection + * @param documentId + * @returns array of ids that were successfully deleted + */ +export const deleteDocuments = async (collection: CollectionBase, documentIds: DocumentId[]): Promise => { + const nbDocuments = documentIds.length; + const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`); + try { + const v2Container = await client().database(collection.databaseId).container(collection.id()); + + // Bulk can only delete 100 documents at a time + const BULK_DELETE_LIMIT = 100; + const promiseArray = []; + + while (documentIds.length > 0) { + const documentIdsChunk = documentIds.splice(0, BULK_DELETE_LIMIT); + const operations: OperationInput[] = documentIdsChunk.map((documentId) => ({ + id: documentId.id(), + // bulk delete: if not partition key is specified, do not pass empty array, but undefined + partitionKey: + documentId.partitionKeyValue && + Array.isArray(documentId.partitionKeyValue) && + documentId.partitionKeyValue.length === 0 + ? undefined + : documentId.partitionKeyValue, + operationType: BulkOperationType.Delete, + })); + + const promise = v2Container.items.bulk(operations).then((bulkResult) => { + return documentIdsChunk.filter((_, index) => bulkResult[index].statusCode === 204); + }); + promiseArray.push(promise); + } + + const allResult = await Promise.all(promiseArray); + const flatAllResult = Array.prototype.concat.apply([], allResult); + logConsoleInfo( + `Successfully deleted ${getEntityName(flatAllResult.length > 1)}: ${flatAllResult.length} out of ${nbDocuments}`, + ); + // TODO: handle case result.length != nbDocuments + return flatAllResult; + } catch (error) { + handleError( + error, + "DeleteDocuments", + `Error while deleting ${documentIds.length} ${getEntityName(documentIds.length > 1)}`, + ); + throw error; + } finally { + clearMessage(); + } +}; diff --git a/src/Explorer/Controls/Editor/EditorReact.tsx b/src/Explorer/Controls/Editor/EditorReact.tsx index a6f487ba5..f2274d7dc 100644 --- a/src/Explorer/Controls/Editor/EditorReact.tsx +++ b/src/Explorer/Controls/Editor/EditorReact.tsx @@ -137,7 +137,13 @@ export class EditorReact extends React.Component - - -
-
-

Title

-
Text
-
-
- - -
-
-
-
- - - - -
- -
- SELECT * FROM c - - -
-
- Filter : - - No filter applied - - -
- - - -
-
-
- SELECT * FROM c - - - - - - - - - - - - - - Hide filter - -
-
-
- -
- - - -
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - -
- Refresh documents -
- -
-
-
- Load more -
- - -
-
-
-
-

Document WaterMark

-

Create new or work with existing document(s).

-
- - - -
- - diff --git a/src/Explorer/Tabs/DocumentsTab.test.ts b/src/Explorer/Tabs/DocumentsTab.test.ts deleted file mode 100644 index c8d84e716..000000000 --- a/src/Explorer/Tabs/DocumentsTab.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import * as ko from "knockout"; -import { DatabaseAccount } from "../../Contracts/DataModels"; -import * as ViewModels from "../../Contracts/ViewModels"; -import { updateUserContext } from "../../UserContext"; -import Explorer from "../Explorer"; -import DocumentId from "../Tree/DocumentId"; -import DocumentsTab from "./DocumentsTab"; - -describe("Documents tab", () => { - describe("buildQuery", () => { - it("should generate the right select query for SQL API", () => { - const documentsTab = new DocumentsTab({ - partitionKey: null, - documentIds: ko.observableArray(), - tabKind: ViewModels.CollectionTabKind.Documents, - title: "", - tabPath: "", - }); - - expect(documentsTab.buildQuery("")).toContain("select"); - }); - }); - - describe("showPartitionKey", () => { - const explorer = new Explorer(); - const mongoExplorer = new Explorer(); - updateUserContext({ - databaseAccount: { - properties: { - capabilities: [{ name: "EnableGremlin" }], - }, - } as DatabaseAccount, - }); - - const collectionWithoutPartitionKey = ({ - id: ko.observable("foo"), - database: { - id: ko.observable("foo"), - }, - container: explorer, - }); - - const collectionWithSystemPartitionKey = ({ - id: ko.observable("foo"), - database: { - id: ko.observable("foo"), - }, - partitionKey: { - paths: ["/foo"], - kind: "Hash", - version: 2, - systemKey: true, - }, - container: explorer, - }); - - const collectionWithNonSystemPartitionKey = ({ - id: ko.observable("foo"), - database: { - id: ko.observable("foo"), - }, - partitionKey: { - paths: ["/foo"], - kind: "Hash", - version: 2, - systemKey: false, - }, - container: explorer, - }); - - const mongoCollectionWithSystemPartitionKey = ({ - id: ko.observable("foo"), - database: { - id: ko.observable("foo"), - }, - partitionKey: { - paths: ["/foo"], - kind: "Hash", - version: 2, - systemKey: true, - }, - container: mongoExplorer, - }); - - it("should be false for null or undefined collection", () => { - const documentsTab = new DocumentsTab({ - partitionKey: null, - documentIds: ko.observableArray(), - tabKind: ViewModels.CollectionTabKind.Documents, - title: "", - tabPath: "", - }); - - expect(documentsTab.showPartitionKey).toBe(false); - }); - - it("should be false for null or undefined partitionKey", () => { - const documentsTab = new DocumentsTab({ - collection: collectionWithoutPartitionKey, - partitionKey: null, - documentIds: ko.observableArray(), - tabKind: ViewModels.CollectionTabKind.Documents, - title: "", - tabPath: "", - }); - - expect(documentsTab.showPartitionKey).toBe(false); - }); - - it("should be true for non-Mongo accounts with system partitionKey", () => { - const documentsTab = new DocumentsTab({ - collection: collectionWithSystemPartitionKey, - partitionKey: null, - documentIds: ko.observableArray(), - tabKind: ViewModels.CollectionTabKind.Documents, - title: "", - tabPath: "", - }); - - expect(documentsTab.showPartitionKey).toBe(true); - }); - - it("should be false for Mongo accounts with system partitionKey", () => { - updateUserContext({ - apiType: "Mongo", - }); - const documentsTab = new DocumentsTab({ - collection: mongoCollectionWithSystemPartitionKey, - partitionKey: null, - documentIds: ko.observableArray(), - tabKind: ViewModels.CollectionTabKind.Documents, - title: "", - tabPath: "", - }); - - expect(documentsTab.showPartitionKey).toBe(false); - }); - - it("should be true for non-system partitionKey", () => { - const documentsTab = new DocumentsTab({ - collection: collectionWithNonSystemPartitionKey, - partitionKey: null, - documentIds: ko.observableArray(), - tabKind: ViewModels.CollectionTabKind.Documents, - title: "", - tabPath: "", - }); - - expect(documentsTab.showPartitionKey).toBe(true); - }); - }); -}); diff --git a/src/Explorer/Tabs/DocumentsTab.ts b/src/Explorer/Tabs/DocumentsTab.ts deleted file mode 100644 index 949b13697..000000000 --- a/src/Explorer/Tabs/DocumentsTab.ts +++ /dev/null @@ -1,1080 +0,0 @@ -import { ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos"; -import { Platform, configContext } from "ConfigContext"; -import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities"; -import { KeyboardAction, KeyboardActionGroup, KeyboardHandlerSetter, useKeyboardActionGroup } from "KeyboardShortcuts"; -import { QueryConstants } from "Shared/Constants"; -import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; -import * as ko from "knockout"; -import Q from "q"; -import { format } from "react-string-format"; -import DeleteDocumentIcon from "../../../images/DeleteDocument.svg"; -import NewDocumentIcon from "../../../images/NewDocument.svg"; -import UploadIcon from "../../../images/Upload_16x16.svg"; -import DiscardIcon from "../../../images/discard.svg"; -import SaveIcon from "../../../images/save-cosmos.svg"; -import * as Constants from "../../Common/Constants"; -import { - DocumentsGridMetrics, - KeyCodes, - QueryCopilotSampleContainerId, - QueryCopilotSampleDatabaseId, -} from "../../Common/Constants"; -import editable from "../../Common/EditableUtility"; -import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; -import * as HeadersUtility from "../../Common/HeadersUtility"; -import { Splitter, SplitterBounds, SplitterDirection } from "../../Common/Splitter"; -import { createDocument } from "../../Common/dataAccess/createDocument"; -import { deleteDocument } from "../../Common/dataAccess/deleteDocument"; -import { queryDocuments } from "../../Common/dataAccess/queryDocuments"; -import { readDocument } from "../../Common/dataAccess/readDocument"; -import { updateDocument } from "../../Common/dataAccess/updateDocument"; -import * as DataModels from "../../Contracts/DataModels"; -import * as ViewModels from "../../Contracts/ViewModels"; -import { Action } from "../../Shared/Telemetry/TelemetryConstants"; -import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; -import { userContext } from "../../UserContext"; -import { logConsoleError } from "../../Utils/NotificationConsoleUtils"; -import * as QueryUtils from "../../Utils/QueryUtils"; -import { extractPartitionKeyValues } from "../../Utils/QueryUtils"; -import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent"; -import { useDialog } from "../Controls/Dialog"; -import Explorer from "../Explorer"; -import { AccessibleVerticalList } from "../Tree/AccessibleVerticalList"; -import DocumentId from "../Tree/DocumentId"; -import { useSelectedNode } from "../useSelectedNode"; -import template from "./DocumentsTab.html"; -import TabsBase from "./TabsBase"; - -export default class DocumentsTab extends TabsBase { - public readonly html = template; - public selectedDocumentId: ko.Observable; - public selectedDocumentContent: ViewModels.Editable; - public initialDocumentContent: ko.Observable; - public documentContentsGridId: string; - public documentContentsContainerId: string; - public filterContent: ko.Observable; - public appliedFilter: ko.Observable; - public lastFilterContents: ko.ObservableArray; - public isFilterExpanded: ko.Observable; - public isFilterCreated: ko.Observable; - public applyFilterButton: ViewModels.Button; - public isEditorDirty: ko.Computed; - public editorState: ko.Observable; - public newDocumentButton: ViewModels.Button; - public saveNewDocumentButton: ViewModels.Button; - public saveExistingDocumentButton: ViewModels.Button; - public discardNewDocumentChangesButton: ViewModels.Button; - public discardExisitingDocumentChangesButton: ViewModels.Button; - public deleteExisitingDocumentButton: ViewModels.Button; - public displayedError: ko.Observable; - public accessibleDocumentList: AccessibleVerticalList; - public dataContentsGridScrollHeight: ko.Observable; - public isPreferredApiMongoDB: boolean; - public shouldShowEditor: ko.Computed; - public splitter: Splitter; - public showPartitionKey: boolean; - public idHeader: string; - - // TODO need to refactor - public partitionKey: DataModels.PartitionKey; - public partitionKeyPropertyHeaders: string[]; - public partitionKeyProperties: string[]; - public documentIds: ko.ObservableArray; - - private _documentsIterator: QueryIterator; - private _resourceTokenPartitionKey: string; - private _isQueryCopilotSampleContainer: boolean; - private queryAbortController: AbortController; - private cancelQueryTimeoutID: NodeJS.Timeout; - private setKeyboardActions: KeyboardHandlerSetter; - - constructor(options: ViewModels.DocumentsTabOptions) { - super(options); - this.setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB); - this.isPreferredApiMongoDB = userContext.apiType === "Mongo" || options.isPreferredApiMongoDB; - - this.idHeader = this.isPreferredApiMongoDB ? "_id" : "id"; - - this.documentContentsGridId = `documentContentsGrid${this.tabId}`; - this.documentContentsContainerId = `documentContentsContainer${this.tabId}`; - this.editorState = ko.observable( - ViewModels.DocumentExplorerState.noDocumentSelected, - ); - this.selectedDocumentId = ko.observable(); - this.selectedDocumentContent = editable.observable(""); - this.initialDocumentContent = ko.observable(""); - this.partitionKey = options.partitionKey || (this.collection && this.collection.partitionKey); - this._resourceTokenPartitionKey = options.resourceTokenPartitionKey; - this.documentIds = options.documentIds; - - this.partitionKeyPropertyHeaders = this.collection?.partitionKeyPropertyHeaders || this.partitionKey?.paths; - this.partitionKeyProperties = this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) => - partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""), - ); - - this.isFilterExpanded = ko.observable(false); - this.isFilterCreated = ko.observable(true); - this.filterContent = ko.observable(""); - this.appliedFilter = ko.observable(""); - this.displayedError = ko.observable(""); - this.lastFilterContents = ko.observableArray([ - 'WHERE c.id = "foo"', - "ORDER BY c._ts DESC", - 'WHERE c.id = "foo" ORDER BY c._ts DESC', - ]); - - this.dataContentsGridScrollHeight = ko.observable(null); - - // initialize splitter only after template has been loaded so dom elements are accessible - super.onTemplateReady((isTemplateReady: boolean) => { - if (isTemplateReady) { - const tabContainer: HTMLElement = document.getElementById("content"); - const splitterBounds: SplitterBounds = { - min: Constants.DocumentsGridMetrics.DocumentEditorMinWidthRatio * tabContainer.clientWidth, - max: Constants.DocumentsGridMetrics.DocumentEditorMaxWidthRatio * tabContainer.clientWidth, - }; - this.splitter = new Splitter({ - splitterId: "h_splitter2", - leftId: this.documentContentsContainerId, - bounds: splitterBounds, - direction: SplitterDirection.Vertical, - }); - } - }); - - this.accessibleDocumentList = new AccessibleVerticalList(this.documentIds()); - this.accessibleDocumentList.setOnSelect( - (selectedDocument: DocumentId) => selectedDocument && selectedDocument.click(), - ); - this.selectedDocumentId.subscribe((newSelectedDocumentId: DocumentId) => - this.accessibleDocumentList.updateCurrentItem(newSelectedDocumentId), - ); - this.documentIds.subscribe((newDocuments: DocumentId[]) => { - this.accessibleDocumentList.updateItemList(newDocuments); - if (newDocuments.length > 0) { - this.dataContentsGridScrollHeight( - newDocuments.length * DocumentsGridMetrics.IndividualRowHeight + DocumentsGridMetrics.BufferHeight + "px", - ); - } else { - this.dataContentsGridScrollHeight( - DocumentsGridMetrics.IndividualRowHeight + DocumentsGridMetrics.BufferHeight + "px", - ); - } - }); - - this.isEditorDirty = ko.computed(() => { - switch (this.editorState()) { - case ViewModels.DocumentExplorerState.noDocumentSelected: - case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: - return false; - - case ViewModels.DocumentExplorerState.newDocumentValid: - case ViewModels.DocumentExplorerState.newDocumentInvalid: - case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: - return true; - - case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: - return ( - this.selectedDocumentContent.getEditableOriginalValue() !== - this.selectedDocumentContent.getEditableCurrentValue() - ); - - default: - return false; - } - }); - - this.newDocumentButton = { - enabled: ko.computed(() => { - switch (this.editorState()) { - case ViewModels.DocumentExplorerState.noDocumentSelected: - case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: - return true; - } - - return false; - }), - - visible: ko.computed(() => { - return true; - }), - }; - - this.saveNewDocumentButton = { - enabled: ko.computed(() => { - switch (this.editorState()) { - case ViewModels.DocumentExplorerState.newDocumentValid: - return true; - } - - return false; - }), - - visible: ko.computed(() => { - switch (this.editorState()) { - case ViewModels.DocumentExplorerState.newDocumentValid: - case ViewModels.DocumentExplorerState.newDocumentInvalid: - return true; - } - - return false; - }), - }; - - this.discardNewDocumentChangesButton = { - enabled: ko.computed(() => { - switch (this.editorState()) { - case ViewModels.DocumentExplorerState.newDocumentValid: - case ViewModels.DocumentExplorerState.newDocumentInvalid: - return true; - } - - return false; - }), - - visible: ko.computed(() => { - switch (this.editorState()) { - case ViewModels.DocumentExplorerState.newDocumentValid: - case ViewModels.DocumentExplorerState.newDocumentInvalid: - return true; - } - - return false; - }), - }; - - this.saveExistingDocumentButton = { - enabled: ko.computed(() => { - switch (this.editorState()) { - case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: - return true; - } - - return false; - }), - - visible: ko.computed(() => { - switch (this.editorState()) { - case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: - case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: - case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: - return true; - } - - return false; - }), - }; - - this.discardExisitingDocumentChangesButton = { - enabled: ko.computed(() => { - switch (this.editorState()) { - case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: - case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: - return true; - } - - return false; - }), - - visible: ko.computed(() => { - switch (this.editorState()) { - case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: - case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: - case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: - return true; - } - - return false; - }), - }; - - this.deleteExisitingDocumentButton = { - enabled: ko.computed(() => { - switch (this.editorState()) { - case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: - case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: - case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: - return true; - } - - return false; - }), - - visible: ko.computed(() => { - switch (this.editorState()) { - case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: - case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: - case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: - return true; - } - - return false; - }), - }; - - this.applyFilterButton = { - enabled: ko.computed(() => { - return true; - }), - - visible: ko.computed(() => { - return true; - }), - }; - this.buildCommandBarOptions(); - this.shouldShowEditor = ko.computed(() => { - const documentHasContent: boolean = - this.selectedDocumentContent() != null && this.selectedDocumentContent().length > 0; - const isNewDocument: boolean = - this.editorState() === ViewModels.DocumentExplorerState.newDocumentValid || - this.editorState() === ViewModels.DocumentExplorerState.newDocumentInvalid; - - return documentHasContent || isNewDocument; - }); - this.selectedDocumentContent.subscribe((newContent: string) => this._onEditorContentChange(newContent)); - - this.showPartitionKey = this._shouldShowPartitionKey(); - this._isQueryCopilotSampleContainer = - this.collection?.isSampleCollection && - this.collection?.databaseId === QueryCopilotSampleDatabaseId && - this.collection?.id() === QueryCopilotSampleContainerId; - } - - private _shouldShowPartitionKey(): boolean { - if (!this.collection) { - return false; - } - - if (!this.collection.partitionKey) { - return false; - } - - if (this.collection.partitionKey.systemKey && this.isPreferredApiMongoDB) { - return false; - } - - return true; - } - - /** - * Query first page of documents - * Select and query first document and display content - */ - private async autoPopulateContent(applyFilterButtonPressed?: boolean) { - // reset iterator - this._documentsIterator = this.createIterator(); - // load documents - await this.loadNextPage(applyFilterButtonPressed); - - // Select first document and load content - if (this.documentIds().length > 0) { - this.documentIds()[0].click(); - } - } - - public onShowFilterClick(): Q.Promise { - this.isFilterCreated(true); - this.isFilterExpanded(true); - - $(".filterDocExpanded").addClass("active"); - $("#content").addClass("active"); - $(".querydropdown").focus(); - - return Q(); - } - - public onHideFilterClick(): Q.Promise { - this.isFilterExpanded(false); - - $(".filterDocExpanded").removeClass("active"); - $("#content").removeClass("active"); - $(".queryButton").focus(); - return Q(); - } - - public onCloseButtonKeyDown = (source: any, event: KeyboardEvent): boolean => { - if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) { - this.onHideFilterClick(); - event.stopPropagation(); - return false; - } - return true; - }; - - public async refreshDocumentsGrid(applyFilterButtonPressed?: boolean): Promise { - // clear documents grid - this.documentIds([]); - try { - // reset iterator - this._documentsIterator = this.createIterator(); - // load documents - await this.autoPopulateContent(applyFilterButtonPressed); - // collapse filter - this.appliedFilter(this.filterContent()); - this.isFilterExpanded(false); - document.getElementById("errorStatusIcon")?.focus(); - } catch (error) { - useDialog.getState().showOkModalDialog("Refresh documents grid failed", getErrorMessage(error)); - } - } - - public onRefreshButtonKeyDown = (source: any, event: KeyboardEvent): boolean => { - if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) { - this.refreshDocumentsGrid(); - event.stopPropagation(); - return false; - } - return true; - }; - - public onAbortQueryClick(): void { - this.queryAbortController.abort(); - } - - /** - * TODO Doesn't seem to be used: remove? - * @param clickedDocumentId - * @returns - */ - public onDocumentIdClick(clickedDocumentId: DocumentId): Q.Promise { - if (this.editorState() !== ViewModels.DocumentExplorerState.noDocumentSelected) { - return Q(); - } - - this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); - - return Q(); - } - - public onNewDocumentClick = (): void => { - if (this.isEditorDirty()) { - useDialog - .getState() - .showOkCancelModalDialog( - "Unsaved changes", - "Changes will be lost. Do you want to continue?", - "OK", - () => this.initializeNewDocument(), - "Cancel", - undefined, - ); - } else { - this.initializeNewDocument(); - } - }; - - private initializeNewDocument = (): void => { - this.selectedDocumentId(null); - const newDocument: any = { - id: "replace_with_new_document_id", - }; - this.partitionKeyProperties.forEach((partitionKeyProperty) => { - let target = newDocument; - const keySegments = partitionKeyProperty.split("."); - const finalSegment = keySegments.pop(); - - // Initialize nested objects as needed - keySegments.forEach((segment) => { - target = target[segment] = target[segment] || {}; - }); - - target[finalSegment] = "replace_with_new_partition_key_value"; - }); - const defaultDocument: string = this.renderObjectForEditor(newDocument, null, 4); - this.initialDocumentContent(defaultDocument); - this.selectedDocumentContent.setBaseline(defaultDocument); - this.editorState(ViewModels.DocumentExplorerState.newDocumentValid); - }; - - public onSaveNewDocumentClick = (): Promise => { - this.isExecutionError(false); - const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - }); - const document = JSON.parse(this.selectedDocumentContent()); - this.isExecuting(true); - return createDocument(this.collection, document) - .then( - (savedDocument: any) => { - const value: string = this.renderObjectForEditor(savedDocument || {}, null, 4); - this.selectedDocumentContent.setBaseline(value); - this.initialDocumentContent(value); - const partitionKeyValueArray: PartitionKey[] = extractPartitionKeyValues( - savedDocument, - this.partitionKey as PartitionKeyDefinition, - ); - let id = new DocumentId(this, savedDocument, partitionKeyValueArray); - let ids = this.documentIds(); - ids.push(id); - - this.selectedDocumentId(id); - this.documentIds(ids); - this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); - TelemetryProcessor.traceSuccess( - Action.CreateDocument, - { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - }, - startKey, - ); - }, - (error) => { - this.isExecutionError(true); - const errorMessage = getErrorMessage(error); - useDialog.getState().showOkModalDialog("Create document failed", errorMessage); - TelemetryProcessor.traceFailure( - Action.CreateDocument, - { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - error: errorMessage, - errorStack: getErrorStack(error), - }, - startKey, - ); - }, - ) - .finally(() => this.isExecuting(false)); - }; - - public onRevertNewDocumentClick = (): Q.Promise => { - this.initialDocumentContent(""); - this.selectedDocumentContent(""); - this.editorState(ViewModels.DocumentExplorerState.noDocumentSelected); - - return Q(); - }; - - public onSaveExistingDocumentClick = (): Promise => { - const selectedDocumentId = this.selectedDocumentId(); - const documentContent = JSON.parse(this.selectedDocumentContent()); - - const partitionKeyValueArray: PartitionKey[] = extractPartitionKeyValues( - documentContent, - this.partitionKey as PartitionKeyDefinition, - ); - selectedDocumentId.partitionKeyValue = partitionKeyValueArray; - - this.isExecutionError(false); - const startKey: number = TelemetryProcessor.traceStart(Action.UpdateDocument, { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - }); - this.isExecuting(true); - return updateDocument(this.collection, selectedDocumentId, documentContent) - .then( - (updatedDocument: any) => { - const value: string = this.renderObjectForEditor(updatedDocument || {}, null, 4); - this.selectedDocumentContent.setBaseline(value); - this.initialDocumentContent(value); - this.documentIds().forEach((documentId: DocumentId) => { - if (documentId.rid === updatedDocument._rid) { - documentId.id(updatedDocument.id); - } - }); - this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); - TelemetryProcessor.traceSuccess( - Action.UpdateDocument, - { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - }, - startKey, - ); - }, - (error) => { - this.isExecutionError(true); - const errorMessage = getErrorMessage(error); - useDialog.getState().showOkModalDialog("Update document failed", errorMessage); - TelemetryProcessor.traceFailure( - Action.UpdateDocument, - { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - error: errorMessage, - errorStack: getErrorStack(error), - }, - startKey, - ); - }, - ) - .finally(() => this.isExecuting(false)); - }; - - public onRevertExisitingDocumentClick = (): Q.Promise => { - this.selectedDocumentContent.setBaseline(this.initialDocumentContent()); - this.initialDocumentContent.valueHasMutated(); - this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); - - return Q(); - }; - - public onDeleteExisitingDocumentClick = async (): Promise => { - const selectedDocumentId = this.selectedDocumentId(); - const msg = !this.isPreferredApiMongoDB - ? "Are you sure you want to delete the selected item ?" - : "Are you sure you want to delete the selected document ?"; - - useDialog - .getState() - .showOkCancelModalDialog( - "Confirm delete", - msg, - "Delete", - async () => await this._deleteDocument(selectedDocumentId), - "Cancel", - undefined, - ); - }; - - public onValidDocumentEdit(): Q.Promise { - if ( - this.editorState() === ViewModels.DocumentExplorerState.newDocumentInvalid || - this.editorState() === ViewModels.DocumentExplorerState.newDocumentValid - ) { - this.editorState(ViewModels.DocumentExplorerState.newDocumentValid); - return Q(); - } - - this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid); - return Q(); - } - - public onInvalidDocumentEdit(): Q.Promise { - if ( - this.editorState() === ViewModels.DocumentExplorerState.newDocumentInvalid || - this.editorState() === ViewModels.DocumentExplorerState.newDocumentValid - ) { - this.editorState(ViewModels.DocumentExplorerState.newDocumentInvalid); - return Q(); - } - - if ( - this.editorState() === ViewModels.DocumentExplorerState.exisitingDocumentNoEdits || - this.editorState() === ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid - ) { - this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid); - return Q(); - } - - return Q(); - } - - public onTabClick(): void { - super.onTabClick(); - this.collection && this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Documents); - } - - public onFilterKeyDown(model: unknown, e: KeyboardEvent): boolean { - if (e.key === "Enter") { - this.refreshDocumentsGrid(true); - - // Suppress the default behavior of the key - return false; - } else if (e.key === "Escape") { - this.onHideFilterClick(); - - // Suppress the default behavior of the key - return false; - } else { - // Allow the default behavior of the key - return true; - } - } - - public async onActivate(): Promise { - super.onActivate(); - - this.setKeyboardActions({ - [KeyboardAction.SEARCH]: () => { - this.onShowFilterClick(); - return true; - }, - [KeyboardAction.CLEAR_SEARCH]: () => { - this.filterContent(""); - this.refreshDocumentsGrid(true); - return true; - }, - }); - - if (!this._documentsIterator) { - try { - await this.autoPopulateContent(); - } catch (error) { - if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) { - TelemetryProcessor.traceFailure( - Action.Tab, - { - databaseName: this.collection.databaseId, - collectionName: this.collection.id(), - - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - error: getErrorMessage(error), - errorStack: getErrorStack(error), - }, - this.onLoadStartKey, - ); - this.onLoadStartKey = null; - } - } - } - } - - protected __deleteDocument(documentId: DocumentId): Promise { - return deleteDocument(this.collection, documentId); - } - - private _deleteDocument(selectedDocumentId: DocumentId): Promise { - this.isExecutionError(false); - const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocument, { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - }); - this.isExecuting(true); - return this.__deleteDocument(selectedDocumentId) - .then( - () => { - this.documentIds.remove((documentId: DocumentId) => documentId.rid === selectedDocumentId.rid); - this.selectedDocumentContent(""); - this.selectedDocumentId(null); - this.editorState(ViewModels.DocumentExplorerState.noDocumentSelected); - TelemetryProcessor.traceSuccess( - Action.DeleteDocument, - { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - }, - startKey, - ); - }, - (error) => { - this.isExecutionError(true); - console.error(error); - TelemetryProcessor.traceFailure( - Action.DeleteDocument, - { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - error: getErrorMessage(error), - errorStack: getErrorStack(error), - }, - startKey, - ); - }, - ) - .finally(() => this.isExecuting(false)); - } - - public createIterator(): QueryIterator { - this.queryAbortController = new AbortController(); - const filter: string = this.filterContent().trim(); - const query: string = this.buildQuery(filter); - let options: any = {}; - options.enableCrossPartitionQuery = HeadersUtility.shouldEnableCrossPartitionKey(); - - if (this._resourceTokenPartitionKey) { - options.partitionKey = this._resourceTokenPartitionKey; - } - options.abortSignal = this.queryAbortController.signal; - return this._isQueryCopilotSampleContainer - ? querySampleDocuments(query, options) - : queryDocuments(this.collection.databaseId, this.collection.id(), query, options); - } - - public async selectDocument(documentId: DocumentId): Promise { - this.selectedDocumentId(documentId); - const content = await (this._isQueryCopilotSampleContainer - ? readSampleDocument(documentId) - : readDocument(this.collection, documentId)); - this.initDocumentEditor(documentId, content); - } - - public loadNextPage(applyFilterButtonClicked?: boolean): Q.Promise { - this.isExecuting(true); - this.isExecutionError(false); - let automaticallyCancelQueryAfterTimeout: boolean; - if (applyFilterButtonClicked && this.queryTimeoutEnabled()) { - const queryTimeout: number = LocalStorageUtility.getEntryNumber(StorageKey.QueryTimeout); - automaticallyCancelQueryAfterTimeout = LocalStorageUtility.getEntryBoolean( - StorageKey.AutomaticallyCancelQueryAfterTimeout, - ); - const cancelQueryTimeoutID: NodeJS.Timeout = setTimeout(() => { - if (this.isExecuting()) { - if (automaticallyCancelQueryAfterTimeout) { - this.queryAbortController.abort(); - } else { - useDialog - .getState() - .showOkCancelModalDialog( - QueryConstants.CancelQueryTitle, - format(QueryConstants.CancelQuerySubTextTemplate, QueryConstants.CancelQueryTimeoutThresholdReached), - "Yes", - () => this.queryAbortController.abort(), - "No", - undefined, - ); - } - } - }, queryTimeout); - this.cancelQueryTimeoutID = cancelQueryTimeoutID; - } - return this._loadNextPageInternal() - .then( - (documentsIdsResponse = []) => { - const currentDocuments = this.documentIds(); - const currentDocumentsRids = currentDocuments.map((currentDocument) => currentDocument.rid); - const nextDocumentIds = documentsIdsResponse - // filter documents already loaded in observable - .filter((d: any) => { - return currentDocumentsRids.indexOf(d._rid) < 0; - }) - // map raw response to view model - .map((rawDocument: any) => { - const partitionKeyValue = rawDocument._partitionKeyValue; - return new DocumentId(this, rawDocument, partitionKeyValue); - }); - - const merged = currentDocuments.concat(nextDocumentIds); - this.documentIds(merged); - if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) { - TelemetryProcessor.traceSuccess( - Action.Tab, - { - databaseName: this.collection.databaseId, - collectionName: this.collection.id(), - - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - }, - this.onLoadStartKey, - ); - this.onLoadStartKey = null; - } - }, - (error) => { - this.isExecutionError(true); - const errorMessage = getErrorMessage(error); - logConsoleError(errorMessage); - if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) { - TelemetryProcessor.traceFailure( - Action.Tab, - { - databaseName: this.collection.databaseId, - collectionName: this.collection.id(), - - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - error: errorMessage, - errorStack: getErrorStack(error), - }, - this.onLoadStartKey, - ); - this.onLoadStartKey = null; - } - }, - ) - .finally(() => { - this.isExecuting(false); - if (applyFilterButtonClicked && this.queryTimeoutEnabled()) { - clearTimeout(this.cancelQueryTimeoutID); - if (!automaticallyCancelQueryAfterTimeout) { - useDialog.getState().closeDialog(); - } - } - }); - } - - public onLoadMoreKeyInput = (source: any, event: KeyboardEvent): void => { - if (event.key === " " || event.key === "Enter") { - const focusElement = document.getElementById(this.documentContentsGridId); - this.loadNextPage(); - focusElement && focusElement.focus(); - event.stopPropagation(); - event.preventDefault(); - } - }; - - protected _loadNextPageInternal(): Q.Promise { - return Q(this._documentsIterator.fetchNext().then((response) => response.resources)); - } - - protected _onEditorContentChange(newContent: string) { - try { - let parsed: any = JSON.parse(newContent); - this.onValidDocumentEdit(); - } catch (e) { - this.onInvalidDocumentEdit(); - } - } - - public initDocumentEditor(documentId: DocumentId, documentContent: any): Q.Promise { - if (documentId) { - const content: string = this.renderObjectForEditor(documentContent, null, 4); - this.selectedDocumentContent.setBaseline(content); - this.initialDocumentContent(content); - const newState = documentId - ? ViewModels.DocumentExplorerState.exisitingDocumentNoEdits - : ViewModels.DocumentExplorerState.newDocumentValid; - this.editorState(newState); - } - - return Q(); - } - - public buildQuery(filter: string): string { - return QueryUtils.buildDocumentsQuery(filter, this.partitionKeyProperties, this.partitionKey); - } - - protected getTabsButtons(): CommandButtonComponentProps[] { - if (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) { - // All the following buttons require write access - return []; - } - - const buttons: CommandButtonComponentProps[] = []; - const label = !this.isPreferredApiMongoDB ? "New Item" : "New Document"; - if (this.newDocumentButton.visible()) { - buttons.push({ - iconSrc: NewDocumentIcon, - iconAlt: label, - keyboardAction: KeyboardAction.NEW_ITEM, - onCommandClick: this.onNewDocumentClick, - commandButtonLabel: label, - ariaLabel: label, - hasPopup: false, - disabled: !this.newDocumentButton.enabled() || useSelectedNode.getState().isQueryCopilotCollectionSelected(), - id: "mongoNewDocumentBtn", - }); - } - - if (this.saveNewDocumentButton.visible()) { - const label = "Save"; - buttons.push({ - iconSrc: SaveIcon, - iconAlt: label, - keyboardAction: KeyboardAction.SAVE_ITEM, - onCommandClick: this.onSaveNewDocumentClick, - commandButtonLabel: label, - ariaLabel: label, - hasPopup: false, - disabled: - !this.saveNewDocumentButton.enabled() || useSelectedNode.getState().isQueryCopilotCollectionSelected(), - }); - } - - if (this.discardNewDocumentChangesButton.visible()) { - const label = "Discard"; - buttons.push({ - iconSrc: DiscardIcon, - iconAlt: label, - keyboardAction: KeyboardAction.CANCEL_OR_DISCARD, - onCommandClick: this.onRevertNewDocumentClick, - commandButtonLabel: label, - ariaLabel: label, - hasPopup: false, - disabled: - !this.discardNewDocumentChangesButton.enabled() || - useSelectedNode.getState().isQueryCopilotCollectionSelected(), - }); - } - - if (this.saveExistingDocumentButton.visible()) { - const label = "Update"; - buttons.push({ - iconSrc: SaveIcon, - iconAlt: label, - keyboardAction: KeyboardAction.SAVE_ITEM, - onCommandClick: this.onSaveExistingDocumentClick, - commandButtonLabel: label, - ariaLabel: label, - hasPopup: false, - disabled: - !this.saveExistingDocumentButton.enabled() || useSelectedNode.getState().isQueryCopilotCollectionSelected(), - }); - } - - if (this.discardExisitingDocumentChangesButton.visible()) { - const label = "Discard"; - buttons.push({ - iconSrc: DiscardIcon, - iconAlt: label, - keyboardAction: KeyboardAction.CANCEL_OR_DISCARD, - onCommandClick: this.onRevertExisitingDocumentClick, - commandButtonLabel: label, - ariaLabel: label, - hasPopup: false, - disabled: - !this.discardExisitingDocumentChangesButton.enabled() || - useSelectedNode.getState().isQueryCopilotCollectionSelected(), - }); - } - - if (this.deleteExisitingDocumentButton.visible()) { - const label = "Delete"; - buttons.push({ - iconSrc: DeleteDocumentIcon, - iconAlt: label, - keyboardAction: KeyboardAction.DELETE_ITEM, - onCommandClick: this.onDeleteExisitingDocumentClick, - commandButtonLabel: label, - ariaLabel: label, - hasPopup: false, - disabled: - !this.deleteExisitingDocumentButton.enabled() || - useSelectedNode.getState().isQueryCopilotCollectionSelected(), - }); - } - - if (!this.isPreferredApiMongoDB) { - buttons.push(DocumentsTab._createUploadButton(this.collection.container)); - } - - return buttons; - } - - protected buildCommandBarOptions(): void { - ko.computed(() => - ko.toJSON([ - this.newDocumentButton.visible, - this.newDocumentButton.enabled, - this.saveNewDocumentButton.visible, - this.saveNewDocumentButton.enabled, - this.discardNewDocumentChangesButton.visible, - this.discardNewDocumentChangesButton.enabled, - this.saveExistingDocumentButton.visible, - this.saveExistingDocumentButton.enabled, - this.discardExisitingDocumentChangesButton.visible, - this.discardExisitingDocumentChangesButton.enabled, - this.deleteExisitingDocumentButton.visible, - this.deleteExisitingDocumentButton.enabled, - ]), - ).subscribe(() => this.updateNavbarWithTabsButtons()); - this.updateNavbarWithTabsButtons(); - } - - public static _createUploadButton(container: Explorer): CommandButtonComponentProps { - const label = "Upload Item"; - return { - id: "uploadItemBtn", - iconSrc: UploadIcon, - iconAlt: label, - onCommandClick: () => { - const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection(); - selectedCollection && container.openUploadItemsPanePane(); - }, - commandButtonLabel: label, - ariaLabel: label, - hasPopup: true, - disabled: - useSelectedNode.getState().isDatabaseNodeOrNoneSelected() || - useSelectedNode.getState().isQueryCopilotCollectionSelected(), - }; - } - - private queryTimeoutEnabled(): boolean { - return !this.isPreferredApiMongoDB && LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled); - } -} diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx new file mode 100644 index 000000000..d224c2f9a --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx @@ -0,0 +1,476 @@ +import { FeedResponse, ItemDefinition, Resource } from "@azure/cosmos"; +import { deleteDocuments } from "Common/dataAccess/deleteDocument"; +import { Platform, updateConfigContext } from "ConfigContext"; +import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact"; +import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; +import { + ButtonsDependencies, + DELETE_BUTTON_ID, + DISCARD_BUTTON_ID, + DocumentsTabComponent, + IDocumentsTabComponentProps, + NEW_DOCUMENT_BUTTON_ID, + SAVE_BUTTON_ID, + UPDATE_BUTTON_ID, + UPLOAD_BUTTON_ID, + buildQuery, + getDiscardExistingDocumentChangesButtonState, + getDiscardNewDocumentChangesButtonState, + getSaveExistingDocumentButtonState, + getSaveNewDocumentButtonState, + getTabsButtons, + showPartitionKey, +} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; +import { ReactWrapper, ShallowWrapper, mount, shallow } from "enzyme"; +import * as ko from "knockout"; +import React from "react"; +import { act } from "react-dom/test-utils"; +import { DatabaseAccount, DocumentId } from "../../../Contracts/DataModels"; +import * as ViewModels from "../../../Contracts/ViewModels"; +import { updateUserContext } from "../../../UserContext"; +import Explorer from "../../Explorer"; + +jest.mock("Common/dataAccess/queryDocuments", () => ({ + queryDocuments: jest.fn(() => ({ + // Omit headers, because we can't mock a private field and we don't need to test it + fetchNext: (): Promise, "headers">> => + Promise.resolve({ + resources: [{ id: "id", _rid: "rid", _self: "self", _etag: "etag", _ts: 123 }], + hasMoreResults: false, + diagnostics: undefined, + + continuation: undefined, + continuationToken: undefined, + queryMetrics: "queryMetrics", + requestCharge: 1, + activityId: "activityId", + indexMetrics: "indexMetrics", + }), + })), +})); + +const PROPERTY_VALUE = "__SOME_PROPERTY_VALUE__"; +jest.mock("Common/dataAccess/readDocument", () => ({ + readDocument: jest.fn(() => + Promise.resolve({ + container: undefined, + id: "id", + property: PROPERTY_VALUE, + }), + ), +})); + +jest.mock("Explorer/Controls/Editor/EditorReact", () => ({ + EditorReact: (props: EditorReactProps) => <>{props.content}, +})); + +jest.mock("Explorer/Controls/Dialog", () => ({ + useDialog: { + getState: jest.fn(() => ({ + showOkCancelModalDialog: (title: string, subText: string, okLabel: string, onOk: () => void) => onOk(), + showOkModalDialog: () => {}, + })), + }, +})); + +jest.mock("Common/dataAccess/deleteDocument", () => ({ + deleteDocuments: jest.fn((collection: ViewModels.CollectionBase, documentIds: DocumentId[]) => + Promise.resolve(documentIds), + ), +})); + +async function waitForComponentToPaint

(wrapper: ReactWrapper

| ShallowWrapper

, amount = 0) { + let newWrapper; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, amount)); + newWrapper = wrapper.update(); + }); + return newWrapper; +} + +describe("Documents tab (noSql API)", () => { + describe("buildQuery", () => { + it("should generate the right select query for SQL API", () => { + expect(buildQuery(false, "")).toContain("select"); + }); + }); + + describe("showPartitionKey", () => { + const explorer = new Explorer(); + const mongoExplorer = new Explorer(); + updateUserContext({ + databaseAccount: { + properties: { + capabilities: [{ name: "EnableGremlin" }], + }, + } as DatabaseAccount, + }); + + const collectionWithoutPartitionKey: ViewModels.Collection = { + id: ko.observable("foo"), + databaseId: "foo", + container: explorer, + } as ViewModels.Collection; + + const collectionWithSystemPartitionKey: ViewModels.Collection = { + id: ko.observable("foo"), + databaseId: "foo", + partitionKey: { + paths: ["/foo"], + kind: "Hash", + version: 2, + systemKey: true, + }, + container: explorer, + } as ViewModels.Collection; + + const collectionWithNonSystemPartitionKey: ViewModels.Collection = { + id: ko.observable("foo"), + databaseId: "foo", + partitionKey: { + paths: ["/foo"], + kind: "Hash", + version: 2, + systemKey: false, + }, + container: explorer, + } as ViewModels.Collection; + + const mongoCollectionWithSystemPartitionKey: ViewModels.Collection = { + id: ko.observable("foo"), + databaseId: "foo", + partitionKey: { + paths: ["/foo"], + kind: "Hash", + version: 2, + systemKey: true, + }, + container: mongoExplorer, + } as ViewModels.Collection; + + it("should be false for null or undefined collection", () => { + expect(showPartitionKey(undefined, false)).toBe(false); + expect(showPartitionKey(null, false)).toBe(false); + expect(showPartitionKey(undefined, true)).toBe(false); + expect(showPartitionKey(null, true)).toBe(false); + }); + + it("should be false for null or undefined partitionKey", () => { + expect(showPartitionKey(collectionWithoutPartitionKey, false)).toBe(false); + }); + + it("should be true for non-Mongo accounts with system partitionKey", () => { + expect(showPartitionKey(collectionWithSystemPartitionKey, false)).toBe(true); + }); + + it("should be false for Mongo accounts with system partitionKey", () => { + expect(showPartitionKey(mongoCollectionWithSystemPartitionKey, true)).toBe(false); + }); + + it("should be true for non-system partitionKey", () => { + expect(showPartitionKey(collectionWithNonSystemPartitionKey, false)).toBe(true); + }); + }); + + describe("when getting command bar button state", () => { + describe("should set Save New Document state", () => { + const testCases = new Set<{ state: ViewModels.DocumentExplorerState; enabled: boolean; visible: boolean }>(); + testCases.add({ state: ViewModels.DocumentExplorerState.noDocumentSelected, enabled: false, visible: false }); + testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentValid, enabled: true, visible: true }); + testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentInvalid, enabled: false, visible: true }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentNoEdits, + enabled: false, + visible: false, + }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid, + enabled: false, + visible: false, + }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid, + enabled: false, + visible: false, + }); + + testCases.forEach((testCase) => { + const state = getSaveNewDocumentButtonState(testCase.state); + it(`enable for ${testCase.state}`, () => { + expect(state.enabled).toBe(testCase.enabled); + }); + it(`visible for ${testCase.state}`, () => { + expect(state.visible).toBe(testCase.visible); + }); + }); + }); + + describe("should set Discard New Document state", () => { + const testCases = new Set<{ state: ViewModels.DocumentExplorerState; enabled: boolean; visible: boolean }>(); + testCases.add({ state: ViewModels.DocumentExplorerState.noDocumentSelected, enabled: false, visible: false }); + testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentValid, enabled: true, visible: true }); + testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentInvalid, enabled: true, visible: true }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentNoEdits, + enabled: false, + visible: false, + }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid, + enabled: false, + visible: false, + }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid, + enabled: false, + visible: false, + }); + + testCases.forEach((testCase) => { + const state = getDiscardNewDocumentChangesButtonState(testCase.state); + it(`enable for ${testCase.state}`, () => { + expect(state.enabled).toBe(testCase.enabled); + }); + it(`visible for ${testCase.state}`, () => { + expect(state.visible).toBe(testCase.visible); + }); + }); + }); + + describe("should set Save Existing Document state", () => { + const testCases = new Set<{ state: ViewModels.DocumentExplorerState; enabled: boolean; visible: boolean }>(); + testCases.add({ state: ViewModels.DocumentExplorerState.noDocumentSelected, enabled: false, visible: false }); + testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentValid, enabled: false, visible: false }); + testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentInvalid, enabled: false, visible: false }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentNoEdits, + enabled: false, + visible: true, + }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid, + enabled: true, + visible: true, + }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid, + enabled: false, + visible: true, + }); + + testCases.forEach((testCase) => { + const state = getSaveExistingDocumentButtonState(testCase.state); + it(`enable for ${testCase.state}`, () => { + expect(state.enabled).toBe(testCase.enabled); + }); + it(`visible for ${testCase.state}`, () => { + expect(state.visible).toBe(testCase.visible); + }); + }); + }); + + describe("should set Discard Existing Document state", () => { + const testCases = new Set<{ state: ViewModels.DocumentExplorerState; enabled: boolean; visible: boolean }>(); + testCases.add({ state: ViewModels.DocumentExplorerState.noDocumentSelected, enabled: false, visible: false }); + testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentValid, enabled: false, visible: false }); + testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentInvalid, enabled: false, visible: false }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentNoEdits, + enabled: false, + visible: true, + }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid, + enabled: true, + visible: true, + }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid, + enabled: true, + visible: true, + }); + + testCases.forEach((testCase) => { + const state = getDiscardExistingDocumentChangesButtonState(testCase.state); + it(`enable for ${testCase.state}`, () => { + expect(state.enabled).toBe(testCase.enabled); + }); + it(`visible for ${testCase.state}`, () => { + expect(state.visible).toBe(testCase.visible); + }); + }); + }); + + describe("should set Delete Existing Document state", () => { + const testCases = new Set<{ state: ViewModels.DocumentExplorerState; enabled: boolean; visible: boolean }>(); + testCases.add({ state: ViewModels.DocumentExplorerState.noDocumentSelected, enabled: false, visible: false }); + testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentValid, enabled: false, visible: false }); + testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentInvalid, enabled: false, visible: false }); + testCases.add({ state: ViewModels.DocumentExplorerState.exisitingDocumentNoEdits, enabled: true, visible: true }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid, + enabled: true, + visible: true, + }); + testCases.add({ + state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid, + enabled: true, + visible: true, + }); + }); + }); + + it("Do not get tabs button for Fabric readonly", () => { + updateConfigContext({ platform: Platform.Fabric }); + updateUserContext({ + fabricContext: { + connectionId: "test", + databaseConnectionInfo: undefined, + isReadOnly: true, + isVisible: true, + }, + }); + + const buttons = getTabsButtons({} as ButtonsDependencies); + expect(buttons.length).toBe(0); + }); + + describe("when rendered", () => { + const createMockProps = (): IDocumentsTabComponentProps => ({ + isPreferredApiMongoDB: false, + documentIds: [], + collection: undefined, + partitionKey: undefined, + onLoadStartKey: 0, + tabTitle: "", + onExecutionErrorChange: (isExecutionError: boolean): void => { + isExecutionError; + }, + onIsExecutingChange: (isExecuting: boolean): void => { + isExecuting; + }, + isTabActive: true, + }); + + let wrapper: ShallowWrapper; + + beforeEach(async () => { + const props: IDocumentsTabComponentProps = createMockProps(); + wrapper = shallow(); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + it("should render the page", () => { + expect(wrapper).toMatchSnapshot(); + }); + + it("clicking on Edit filter should render the Apply Filter button", () => { + wrapper + .findWhere((node) => node.text() === "Edit Filter") + .at(0) + .simulate("click"); + expect(wrapper.findWhere((node) => node.text() === "Apply Filter").exists()).toBeTruthy(); + }); + + it("clicking on Edit filter should render input for filter", () => { + wrapper + .findWhere((node) => node.text() === "Edit Filter") + .at(0) + .simulate("click"); + expect(wrapper.find("#filterInput").exists()).toBeTruthy(); + }); + }); + + describe("Command bar buttons", () => { + const createMockProps = (): IDocumentsTabComponentProps => ({ + isPreferredApiMongoDB: false, + documentIds: [], + collection: { + id: ko.observable("foo"), + container: new Explorer(), + partitionKey: { + kind: "MultiHash", + paths: ["/pkey1", "/pkey2", "/pkey3"], + version: 2, + }, + partitionKeyProperties: ["pkey1", "pkey2", "pkey3"], + partitionKeyPropertyHeaders: ["/pkey1", "/pkey2", "/pkey3"], + } as ViewModels.CollectionBase, + partitionKey: undefined, + onLoadStartKey: 0, + tabTitle: "", + onExecutionErrorChange: (isExecutionError: boolean): void => { + isExecutionError; + }, + onIsExecutingChange: (isExecuting: boolean): void => { + isExecuting; + }, + isTabActive: true, + }); + + let wrapper: ReactWrapper; + + beforeEach(async () => { + updateConfigContext({ platform: Platform.Hosted }); + + const props: IDocumentsTabComponentProps = createMockProps(); + + wrapper = mount(); + wrapper = await waitForComponentToPaint(wrapper); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + it("renders by default the first document", async () => { + expect(wrapper.findWhere((node) => node.text().includes(PROPERTY_VALUE)).exists()).toBeTruthy(); + }); + + it("default buttons", async () => { + expect(useCommandBar.getState().contextButtons.find((button) => button.id === UPDATE_BUTTON_ID)).toBeDefined(); + expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined(); + expect(useCommandBar.getState().contextButtons.find((button) => button.id === DELETE_BUTTON_ID)).toBeDefined(); + expect(useCommandBar.getState().contextButtons.find((button) => button.id === UPLOAD_BUTTON_ID)).toBeDefined(); + }); + + it("clicking on New Document should show editor with new document", () => { + act(() => { + useCommandBar + .getState() + .contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID) + .onCommandClick(undefined); + }); + expect(wrapper.findWhere((node) => node.text().includes("replace_with_new_document_id")).exists()).toBeTruthy(); + }); + + it("clicking on New Document should show Save and Discard buttons", () => { + act(() => { + useCommandBar + .getState() + .contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID) + .onCommandClick(undefined); + }); + + expect(useCommandBar.getState().contextButtons.find((button) => button.id === SAVE_BUTTON_ID)).toBeDefined(); + expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined(); + }); + + it("clicking Delete Document asks for confirmation", () => { + const mockDeleteDocuments = deleteDocuments as jest.Mock; + mockDeleteDocuments.mockClear(); + + act(() => { + useCommandBar + .getState() + .contextButtons.find((button) => button.id === DELETE_BUTTON_ID) + .onCommandClick(undefined); + }); + + expect(mockDeleteDocuments).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx new file mode 100644 index 000000000..1fb6ed9db --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -0,0 +1,1816 @@ +import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos"; +import { Button, FluentProvider, Input, TableRowId } from "@fluentui/react-components"; +import { ArrowClockwise16Filled, Dismiss16Filled } from "@fluentui/react-icons"; +import Split from "@uiw/react-split"; +import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants"; +import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; +import MongoUtility from "Common/MongoUtility"; +import { StyleConstants } from "Common/StyleConstants"; +import { createDocument } from "Common/dataAccess/createDocument"; +import { deleteDocuments as deleteNoSqlDocuments } from "Common/dataAccess/deleteDocument"; +import { queryDocuments } from "Common/dataAccess/queryDocuments"; +import { readDocument } from "Common/dataAccess/readDocument"; +import { updateDocument } from "Common/dataAccess/updateDocument"; +import { Platform, configContext } from "ConfigContext"; +import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent"; +import { useDialog } from "Explorer/Controls/Dialog"; +import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; +import Explorer from "Explorer/Explorer"; +import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; +import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities"; +import { getPlatformTheme } from "Explorer/Theme/ThemeUtil"; +import { useSelectedNode } from "Explorer/useSelectedNode"; +import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; +import { QueryConstants } from "Shared/Constants"; +import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; +import { Action } from "Shared/Telemetry/TelemetryConstants"; +import { userContext } from "UserContext"; +import { logConsoleError } from "Utils/NotificationConsoleUtils"; +import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { format } from "react-string-format"; +import { CSSProperties } from "styled-components"; +import DeleteDocumentIcon from "../../../../images/DeleteDocument.svg"; +import NewDocumentIcon from "../../../../images/NewDocument.svg"; +import UploadIcon from "../../../../images/Upload_16x16.svg"; +import DiscardIcon from "../../../../images/discard.svg"; +import SaveIcon from "../../../../images/save-cosmos.svg"; +import * as Constants from "../../../Common/Constants"; +import * as HeadersUtility from "../../../Common/HeadersUtility"; +import * as Logger from "../../../Common/Logger"; +import * as MongoProxyClient from "../../../Common/MongoProxyClient"; +import * as DataModels from "../../../Contracts/DataModels"; +import * as ViewModels from "../../../Contracts/ViewModels"; +import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; +import * as QueryUtils from "../../../Utils/QueryUtils"; +import { extractPartitionKeyValues } from "../../../Utils/QueryUtils"; +import DocumentId from "../../Tree/DocumentId"; +import ObjectId from "../../Tree/ObjectId"; +import TabsBase from "../TabsBase"; +import { DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent"; + +export class DocumentsTabV2 extends TabsBase { + public partitionKey: DataModels.PartitionKey; + private documentIds: DocumentId[]; + private title: string; + private resourceTokenPartitionKey: string; + + constructor(options: ViewModels.DocumentsTabOptions) { + super(options); + + this.documentIds = options.documentIds(); + this.title = options.title; + this.partitionKey = options.partitionKey; + this.resourceTokenPartitionKey = options.resourceTokenPartitionKey; + } + + public render(): JSX.Element { + return ( + this.isExecutionError(isExecutionError)} + onIsExecutingChange={(isExecuting: boolean) => this.isExecuting(isExecuting)} + isTabActive={this.isActive()} + /> + ); + } + + public onActivate(): void { + super.onActivate(); + this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Documents); + } +} + +const filterButtonStyle: CSSProperties = { + marginLeft: 8, +}; + +// From TabsBase.renderObjectForEditor() +let renderObjectForEditor = ( + value: unknown, + replacer: (this: unknown, key: string, value: unknown) => unknown, + space: string | number, +): string => JSON.stringify(value, replacer, space); + +// Export to expose to unit tests +export const getSaveNewDocumentButtonState = (editorState: ViewModels.DocumentExplorerState) => ({ + enabled: (() => { + switch (editorState) { + case ViewModels.DocumentExplorerState.newDocumentValid: + return true; + default: + return false; + } + })(), + + visible: (() => { + switch (editorState) { + case ViewModels.DocumentExplorerState.newDocumentValid: + case ViewModels.DocumentExplorerState.newDocumentInvalid: + return true; + default: + return false; + } + })(), +}); + +// Export to expose to unit tests +export const getDiscardNewDocumentChangesButtonState = (editorState: ViewModels.DocumentExplorerState) => ({ + enabled: (() => { + switch (editorState) { + case ViewModels.DocumentExplorerState.newDocumentValid: + case ViewModels.DocumentExplorerState.newDocumentInvalid: + return true; + default: + return false; + } + })(), + + visible: (() => { + switch (editorState) { + case ViewModels.DocumentExplorerState.newDocumentValid: + case ViewModels.DocumentExplorerState.newDocumentInvalid: + return true; + default: + return false; + } + })(), +}); + +// Export to expose to unit tests +export const getSaveExistingDocumentButtonState = (editorState: ViewModels.DocumentExplorerState) => ({ + enabled: (() => { + switch (editorState) { + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: + return true; + default: + return false; + } + })(), + + visible: (() => { + switch (editorState) { + case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: + return true; + default: + return false; + } + })(), +}); + +// Export to expose to unit tests +export const getDiscardExistingDocumentChangesButtonState = (editorState: ViewModels.DocumentExplorerState) => ({ + enabled: (() => { + switch (editorState) { + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: + return true; + default: + return false; + } + })(), + + visible: (() => { + switch (editorState) { + case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: + return true; + default: + return false; + } + })(), +}); + +type UiKeyboardEvent = (e: KeyboardEvent | React.SyntheticEvent) => void; + +// Export to expose to unit tests +export type ButtonsDependencies = { + _collection: ViewModels.CollectionBase; + selectedRows: Set; + editorState: ViewModels.DocumentExplorerState; + isPreferredApiMongoDB: boolean; + onNewDocumentClick: UiKeyboardEvent; + onSaveNewDocumentClick: UiKeyboardEvent; + onRevertNewDocumentClick: UiKeyboardEvent; + onSaveExistingDocumentClick: UiKeyboardEvent; + onRevertExistingDocumentClick: UiKeyboardEvent; + onDeleteExistingDocumentsClick: UiKeyboardEvent; +}; + +const createUploadButton = (container: Explorer): CommandButtonComponentProps => { + const label = "Upload Item"; + return { + id: UPLOAD_BUTTON_ID, + iconSrc: UploadIcon, + iconAlt: label, + onCommandClick: () => { + const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection(); + selectedCollection && container.openUploadItemsPanePane(); + }, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: true, + disabled: + useSelectedNode.getState().isDatabaseNodeOrNoneSelected() || + useSelectedNode.getState().isQueryCopilotCollectionSelected(), + }; +}; + +// Export to expose to unit tests +export const NEW_DOCUMENT_BUTTON_ID = "mongoNewDocumentBtn"; +export const SAVE_BUTTON_ID = "saveBtn"; +export const UPDATE_BUTTON_ID = "updateBtn"; +export const DISCARD_BUTTON_ID = "discardBtn"; +export const DELETE_BUTTON_ID = "deleteBtn"; +export const UPLOAD_BUTTON_ID = "uploadItemBtn"; + +// Export to expose in unit tests +export const getTabsButtons = ({ + _collection, + selectedRows, + editorState, + isPreferredApiMongoDB, + onNewDocumentClick, + onSaveNewDocumentClick, + onRevertNewDocumentClick, + onSaveExistingDocumentClick, + onRevertExistingDocumentClick, + onDeleteExistingDocumentsClick, +}: ButtonsDependencies): CommandButtonComponentProps[] => { + if (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) { + // All the following buttons require write access + return []; + } + + const buttons: CommandButtonComponentProps[] = []; + const label = !isPreferredApiMongoDB ? "New Item" : "New Document"; + if (getNewDocumentButtonState(editorState).visible) { + buttons.push({ + iconSrc: NewDocumentIcon, + iconAlt: label, + keyboardAction: KeyboardAction.NEW_ITEM, + onCommandClick: onNewDocumentClick, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: false, + disabled: + !getNewDocumentButtonState(editorState).enabled || + useSelectedNode.getState().isQueryCopilotCollectionSelected(), + id: NEW_DOCUMENT_BUTTON_ID, + }); + } + + if (getSaveNewDocumentButtonState(editorState).visible) { + const label = "Save"; + buttons.push({ + iconSrc: SaveIcon, + iconAlt: label, + keyboardAction: KeyboardAction.SAVE_ITEM, + onCommandClick: onSaveNewDocumentClick, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: false, + disabled: + !getSaveNewDocumentButtonState(editorState).enabled || + useSelectedNode.getState().isQueryCopilotCollectionSelected(), + id: SAVE_BUTTON_ID, + }); + } + + if (getDiscardNewDocumentChangesButtonState(editorState).visible) { + const label = "Discard"; + buttons.push({ + iconSrc: DiscardIcon, + iconAlt: label, + keyboardAction: KeyboardAction.CANCEL_OR_DISCARD, + onCommandClick: onRevertNewDocumentClick, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: false, + disabled: + !getDiscardNewDocumentChangesButtonState(editorState).enabled || + useSelectedNode.getState().isQueryCopilotCollectionSelected(), + id: DISCARD_BUTTON_ID, + }); + } + + if (getSaveExistingDocumentButtonState(editorState).visible) { + const label = "Update"; + buttons.push({ + iconSrc: SaveIcon, + iconAlt: label, + keyboardAction: KeyboardAction.SAVE_ITEM, + onCommandClick: onSaveExistingDocumentClick, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: false, + disabled: + !getSaveExistingDocumentButtonState(editorState).enabled || + useSelectedNode.getState().isQueryCopilotCollectionSelected(), + id: UPDATE_BUTTON_ID, + }); + } + + if (getDiscardExistingDocumentChangesButtonState(editorState).visible) { + const label = "Discard"; + buttons.push({ + iconSrc: DiscardIcon, + iconAlt: label, + keyboardAction: KeyboardAction.CANCEL_OR_DISCARD, + onCommandClick: onRevertExistingDocumentClick, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: false, + disabled: + !getDiscardExistingDocumentChangesButtonState(editorState).enabled || + useSelectedNode.getState().isQueryCopilotCollectionSelected(), + id: DISCARD_BUTTON_ID, + }); + } + + if (selectedRows.size > 0) { + const label = "Delete"; + buttons.push({ + iconSrc: DeleteDocumentIcon, + iconAlt: label, + keyboardAction: KeyboardAction.DELETE_ITEM, + onCommandClick: onDeleteExistingDocumentsClick, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: false, + disabled: useSelectedNode.getState().isQueryCopilotCollectionSelected(), + id: DELETE_BUTTON_ID, + }); + } + + if (!isPreferredApiMongoDB) { + buttons.push(createUploadButton(_collection.container)); + } + + return buttons; +}; + +const updateNavbarWithTabsButtons = (isTabActive: boolean, dependencies: ButtonsDependencies): void => { + if (isTabActive) { + useCommandBar.getState().setContextButtons(getTabsButtons(dependencies)); + } +}; + +const getNewDocumentButtonState = (editorState: ViewModels.DocumentExplorerState) => ({ + enabled: (() => { + switch (editorState) { + case ViewModels.DocumentExplorerState.noDocumentSelected: + case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: + return true; + default: + return false; + } + })(), + visible: true, +}); + +const _loadNextPageInternal = ( + iterator: QueryIterator, +): Promise => { + return iterator.fetchNext().then((response) => response.resources); +}; + +// Export to expose to unit tests +export const showPartitionKey = (collection: ViewModels.CollectionBase, isPreferredApiMongoDB: boolean) => { + if (!collection) { + return false; + } + + if (!collection.partitionKey) { + return false; + } + + if (collection.partitionKey.systemKey && isPreferredApiMongoDB) { + return false; + } + + return true; +}; + +// Export to expose to unit tests +export const buildQuery = ( + isMongo: boolean, + filter: string, + partitionKeyProperties?: string[], + partitionKey?: DataModels.PartitionKey, +): string => { + if (isMongo) { + return filter || "{}"; + } + + return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey); +}; + +// Export to expose to unit tests +export interface IDocumentsTabComponentProps { + isPreferredApiMongoDB: boolean; + documentIds: DocumentId[]; // TODO: this contains ko observables. We need to convert them to React state. + collection: ViewModels.CollectionBase; + partitionKey: DataModels.PartitionKey; + onLoadStartKey: number; + tabTitle: string; + resourceTokenPartitionKey?: string; + onExecutionErrorChange: (isExecutionError: boolean) => void; + onIsExecutingChange: (isExecuting: boolean) => void; + isTabActive: boolean; +} + +// Export to expose to unit tests +export const DocumentsTabComponent: React.FunctionComponent = ({ + isPreferredApiMongoDB, + documentIds: _documentIds, + collection: _collection, + partitionKey: _partitionKey, + onLoadStartKey: _onLoadStartKey, + tabTitle, + resourceTokenPartitionKey, + onExecutionErrorChange, + onIsExecutingChange, + isTabActive, +}): JSX.Element => { + const [isFilterCreated, setIsFilterCreated] = useState(true); + const [isFilterExpanded, setIsFilterExpanded] = useState(false); + const [isFilterFocused, setIsFilterFocused] = useState(false); + const [appliedFilter, setAppliedFilter] = useState(""); + const [filterContent, setFilterContent] = useState(""); + const [documentIds, setDocumentIds] = useState([]); + const [isExecuting, setIsExecuting] = useState(false); + const filterInput = useRef(null); + + // Query + const [documentsIterator, setDocumentsIterator] = useState<{ + iterator: QueryIterator; + applyFilterButtonPressed: boolean; + }>(undefined); + const [queryAbortController, setQueryAbortController] = useState(undefined); + const [cancelQueryTimeoutID, setCancelQueryTimeoutID] = useState(undefined); + + const [onLoadStartKey, setOnLoadStartKey] = useState(_onLoadStartKey); + + const [initialDocumentContent, setInitialDocumentContent] = useState(undefined); + const [selectedDocumentContent, setSelectedDocumentContent] = useState(undefined); + const [selectedDocumentContentBaseline, setSelectedDocumentContentBaseline] = useState(undefined); + + // Table user clicked on this row + const [clickedRow, setClickedRow] = useState(undefined); + // Table multiple selection + const [selectedRows, setSelectedRows] = React.useState>(() => new Set([0])); + + // Command buttons + const [editorState, setEditorState] = useState( + ViewModels.DocumentExplorerState.noDocumentSelected, + ); + + const isQueryCopilotSampleContainer = + _collection?.isSampleCollection && + _collection?.databaseId === QueryCopilotSampleDatabaseId && + _collection?.id() === QueryCopilotSampleContainerId; + + // For Mongo only + const [continuationToken, setContinuationToken] = useState(undefined); + + const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB); + + useEffect(() => { + if (isFilterFocused) { + filterInput.current?.focus(); + } + }, [isFilterFocused]); + + let lastFilterContents = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC']; + + const applyFilterButton = { + enabled: true, + visible: true, + }; + + const partitionKey: DataModels.PartitionKey = useMemo( + () => _partitionKey || (_collection && _collection.partitionKey), + [_collection, _partitionKey], + ); + const partitionKeyPropertyHeaders: string[] = useMemo( + () => _collection?.partitionKeyPropertyHeaders || partitionKey?.paths, + [_collection?.partitionKeyPropertyHeaders, partitionKey?.paths], + ); + let partitionKeyProperties = useMemo( + () => + partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) => + partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""), + ), + [partitionKeyPropertyHeaders], + ); + + // new DocumentId() requires a DocumentTab which we mock with only the required properties + const newDocumentId = useCallback( + (rawDocument: DataModels.DocumentId, partitionKeyProperties: string[], partitionKeyValue: string[]) => + new DocumentId( + { + partitionKey, + partitionKeyProperties, + // Fake unused mocks + isEditorDirty: () => false, + selectDocument: () => Promise.reject(), + }, + rawDocument, + partitionKeyValue, + ), + [partitionKey], + ); + + useEffect(() => { + setDocumentIds(_documentIds); + }, [_documentIds]); + + // This is executed in onActivate() in the original code. + useEffect(() => { + setKeyboardActions({ + [KeyboardAction.SEARCH]: () => { + onShowFilterClick(); + return true; + }, + [KeyboardAction.CLEAR_SEARCH]: () => { + setFilterContent(""); + refreshDocumentsGrid(true); + return true; + }, + }); + + if (!documentsIterator) { + try { + refreshDocumentsGrid(); + } catch (error) { + if (onLoadStartKey !== null && onLoadStartKey !== undefined) { + TelemetryProcessor.traceFailure( + Action.Tab, + { + databaseName: _collection.databaseId, + collectionName: _collection.id(), + + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + onLoadStartKey, + ); + setOnLoadStartKey(undefined); + } + } + } + + updateNavbarWithTabsButtons(isTabActive, { + _collection, + selectedRows, + editorState, + isPreferredApiMongoDB, + onNewDocumentClick, + onSaveNewDocumentClick, + onRevertNewDocumentClick, + onSaveExistingDocumentClick, + onRevertExistingDocumentClick, + onDeleteExistingDocumentsClick, + }); + }, []); + + const isEditorDirty = useCallback((): boolean => { + switch (editorState) { + case ViewModels.DocumentExplorerState.noDocumentSelected: + case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: + return false; + + case ViewModels.DocumentExplorerState.newDocumentValid: + case ViewModels.DocumentExplorerState.newDocumentInvalid: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: + return true; + + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: + return true; + + default: + return false; + } + }, [editorState]); + + const confirmDiscardingChange = useCallback( + (onDiscard: () => void, onCancelDiscard?: () => void): void => { + if (isEditorDirty()) { + useDialog + .getState() + .showOkCancelModalDialog( + "Unsaved changes", + "Your unsaved changes will be lost. Do you want to continue?", + "OK", + onDiscard, + "Cancel", + onCancelDiscard, + ); + } else { + onDiscard(); + } + }, + [isEditorDirty], + ); + + // Update parent (tab) if isExecuting has changed + useEffect(() => { + onIsExecutingChange(isExecuting); + }, [onIsExecutingChange, isExecuting]); + + const onNewDocumentClick = useCallback( + (): void => confirmDiscardingChange(() => initializeNewDocument()), + [confirmDiscardingChange], + ); + + const initializeNewDocument = (): void => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newDocument: any = { + id: "replace_with_new_document_id", + }; + partitionKeyProperties.forEach((partitionKeyProperty) => { + let target = newDocument; + const keySegments = partitionKeyProperty.split("."); + const finalSegment = keySegments.pop(); + + // Initialize nested objects as needed + keySegments.forEach((segment) => { + target = target[segment] = target[segment] || {}; + }); + + target[finalSegment] = "replace_with_new_partition_key_value"; + }); + const defaultDocument: string = renderObjectForEditor(newDocument, null, 4); + + setInitialDocumentContent(defaultDocument); + setSelectedDocumentContent(defaultDocument); + setSelectedDocumentContentBaseline(defaultDocument); + setSelectedRows(new Set()); + setClickedRow(undefined); + setEditorState(ViewModels.DocumentExplorerState.newDocumentValid); + }; + + let onSaveNewDocumentClick = useCallback((): Promise => { + onExecutionErrorChange(false); + const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }); + const sanitizedContent = selectedDocumentContent.replace("\n", ""); + const document = JSON.parse(sanitizedContent); + setIsExecuting(true); + return createDocument(_collection, document) + .then( + (savedDocument: DataModels.DocumentId) => { + const value: string = renderObjectForEditor(savedDocument || {}, null, 4); + setSelectedDocumentContentBaseline(value); + setInitialDocumentContent(value); + const partitionKeyValueArray: PartitionKey[] = extractPartitionKeyValues( + savedDocument, + partitionKey as PartitionKeyDefinition, + ); + const id = newDocumentId(savedDocument, partitionKeyProperties, partitionKeyValueArray as string[]); + const ids = documentIds; + ids.push(id); + + setDocumentIds(ids); + setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); + TelemetryProcessor.traceSuccess( + Action.CreateDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }, + startKey, + ); + }, + (error) => { + onExecutionErrorChange(true); + const errorMessage = getErrorMessage(error); + useDialog.getState().showOkModalDialog("Create document failed", errorMessage); + TelemetryProcessor.traceFailure( + Action.CreateDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + error: errorMessage, + errorStack: getErrorStack(error), + }, + startKey, + ); + }, + ) + .then(() => setSelectedRows(new Set([documentIds.length - 1]))) + .finally(() => setIsExecuting(false)); + }, [ + onExecutionErrorChange, + tabTitle, + selectedDocumentContent, + _collection, + partitionKey, + newDocumentId, + partitionKeyProperties, + documentIds, + ]); + + const onRevertNewDocumentClick = useCallback((): void => { + setInitialDocumentContent(""); + setSelectedDocumentContent(""); + setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected); + }, [setInitialDocumentContent, setSelectedDocumentContent, setEditorState]); + + let onSaveExistingDocumentClick = useCallback((): Promise => { + const documentContent = JSON.parse(selectedDocumentContent); + + const partitionKeyValueArray: PartitionKey[] = extractPartitionKeyValues( + documentContent, + partitionKey as PartitionKeyDefinition, + ); + + const selectedDocumentId = documentIds[clickedRow as number]; + selectedDocumentId.partitionKeyValue = partitionKeyValueArray; + + onExecutionErrorChange(false); + const startKey: number = TelemetryProcessor.traceStart(Action.UpdateDocument, { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }); + setIsExecuting(true); + return updateDocument(_collection, selectedDocumentId, documentContent) + .then( + (updatedDocument: Item & { _rid: string }) => { + const value: string = renderObjectForEditor(updatedDocument || {}, null, 4); + setSelectedDocumentContentBaseline(value); + setInitialDocumentContent(value); + setSelectedDocumentContent(value); + documentIds.forEach((documentId: DocumentId) => { + if (documentId.rid === updatedDocument._rid) { + documentId.id(updatedDocument.id); + } + }); + setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); + TelemetryProcessor.traceSuccess( + Action.UpdateDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }, + startKey, + ); + }, + (error) => { + onExecutionErrorChange(true); + const errorMessage = getErrorMessage(error); + useDialog.getState().showOkModalDialog("Update document failed", errorMessage); + TelemetryProcessor.traceFailure( + Action.UpdateDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + error: errorMessage, + errorStack: getErrorStack(error), + }, + startKey, + ); + }, + ) + .finally(() => setIsExecuting(false)); + }, [onExecutionErrorChange, tabTitle, selectedDocumentContent, _collection, partitionKey, documentIds, clickedRow]); + + const onRevertExistingDocumentClick = useCallback((): void => { + setSelectedDocumentContentBaseline(initialDocumentContent); + setSelectedDocumentContent(selectedDocumentContentBaseline); + }, [initialDocumentContent, selectedDocumentContentBaseline, setSelectedDocumentContent]); + + /** + * Implementation using bulk delete + */ + let _deleteDocuments = useCallback( + async (toDeleteDocumentIds: DocumentId[]): Promise => { + onExecutionErrorChange(false); + const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocuments, { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }); + setIsExecuting(true); + return deleteNoSqlDocuments(_collection, toDeleteDocumentIds) + .then( + (deletedIds) => { + TelemetryProcessor.traceSuccess( + Action.DeleteDocuments, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }, + startKey, + ); + return deletedIds; + }, + (error) => { + onExecutionErrorChange(true); + console.error(error); + TelemetryProcessor.traceFailure( + Action.DeleteDocuments, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey, + ); + throw error; + }, + ) + .finally(() => setIsExecuting(false)); + }, + [_collection, onExecutionErrorChange, tabTitle], + ); + + const deleteDocuments = useCallback( + (toDeleteDocumentIds: DocumentId[]): void => { + onExecutionErrorChange(false); + setIsExecuting(true); + _deleteDocuments(toDeleteDocumentIds) + .then( + (deletedIds: DocumentId[]) => { + const deletedRids = new Set(deletedIds.map((documentId) => documentId.rid)); + const newDocumentIds = [...documentIds.filter((documentId) => !deletedRids.has(documentId.rid))]; + setDocumentIds(newDocumentIds); + + setSelectedDocumentContent(undefined); + setClickedRow(undefined); + setSelectedRows(new Set()); + setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected); + useDialog + .getState() + .showOkModalDialog("Delete documents", `${deletedIds.length} document(s) successfully deleted.`); + }, + (error: Error) => + useDialog + .getState() + .showOkModalDialog("Delete documents", `Document(s) deleted failed (${JSON.stringify(error)})`), + ) + .finally(() => setIsExecuting(false)); + }, + [onExecutionErrorChange, _deleteDocuments, documentIds], + ); + + const onDeleteExistingDocumentsClick = useCallback(async (): Promise => { + // TODO: Rework this for localization + const isPlural = selectedRows.size > 1; + const documentName = !isPreferredApiMongoDB + ? isPlural + ? `the selected ${selectedRows.size} items` + : "the selected item" + : isPlural + ? `the selected ${selectedRows.size} documents` + : "the selected document"; + const msg = `Are you sure you want to delete ${documentName}?`; + + useDialog + .getState() + .showOkCancelModalDialog( + "Confirm delete", + msg, + "Delete", + () => deleteDocuments(Array.from(selectedRows).map((index) => documentIds[index as number])), + "Cancel", + undefined, + ); + }, [deleteDocuments, documentIds, isPreferredApiMongoDB, selectedRows]); + + // If editor state changes, update the nav + useEffect( + () => + updateNavbarWithTabsButtons(isTabActive, { + _collection, + selectedRows, + editorState, + isPreferredApiMongoDB, + onNewDocumentClick, + onSaveNewDocumentClick, + onRevertNewDocumentClick, + onSaveExistingDocumentClick, + onRevertExistingDocumentClick: onRevertExistingDocumentClick, + onDeleteExistingDocumentsClick: onDeleteExistingDocumentsClick, + }), + [ + _collection, + selectedRows, + editorState, + isPreferredApiMongoDB, + onNewDocumentClick, + onSaveNewDocumentClick, + onRevertNewDocumentClick, + onSaveExistingDocumentClick, + onRevertExistingDocumentClick, + onDeleteExistingDocumentsClick, + isTabActive, + ], + ); + + const onShowFilterClick = () => { + setIsFilterCreated(true); + setIsFilterExpanded(true); + setIsFilterFocused(true); + }; + + const queryTimeoutEnabled = useCallback( + (): boolean => !isPreferredApiMongoDB && LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled), + [isPreferredApiMongoDB], + ); + + const createIterator = useCallback((): QueryIterator => { + const _queryAbortController = new AbortController(); + setQueryAbortController(_queryAbortController); + const filter: string = filterContent.trim(); + const query: string = buildQuery(isPreferredApiMongoDB, filter, partitionKeyProperties, partitionKey); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options: any = {}; + // TODO: Property 'enableCrossPartitionQuery' does not exist on type 'FeedOptions'. + options.enableCrossPartitionQuery = HeadersUtility.shouldEnableCrossPartitionKey(); + + if (resourceTokenPartitionKey) { + options.partitionKey = resourceTokenPartitionKey; + } + // Fixes compile error error TS2741: Property 'throwIfAborted' is missing in type 'AbortSignal' but required in type 'import("/home/runner/work/cosmos-explorer/cosmos-explorer/node_modules/node-abort-controller/index").AbortSignal'. + options.abortSignal = _queryAbortController.signal; + + return isQueryCopilotSampleContainer + ? querySampleDocuments(query, options) + : queryDocuments(_collection.databaseId, _collection.id(), query, options); + }, [ + filterContent, + isPreferredApiMongoDB, + partitionKeyProperties, + partitionKey, + resourceTokenPartitionKey, + isQueryCopilotSampleContainer, + _collection, + ]); + + const onHideFilterClick = (): void => { + setIsFilterExpanded(false); + }; + + const onCloseButtonKeyDown: KeyboardEventHandler = (event) => { + if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) { + onHideFilterClick(); + event.stopPropagation(); + return false; + } + return true; + }; + + let loadNextPage = useCallback( + (iterator: QueryIterator, applyFilterButtonClicked?: boolean): Promise => { + setIsExecuting(true); + onExecutionErrorChange(false); + let automaticallyCancelQueryAfterTimeout: boolean; + if (applyFilterButtonClicked && queryTimeoutEnabled()) { + const queryTimeout: number = LocalStorageUtility.getEntryNumber(StorageKey.QueryTimeout); + automaticallyCancelQueryAfterTimeout = LocalStorageUtility.getEntryBoolean( + StorageKey.AutomaticallyCancelQueryAfterTimeout, + ); + const cancelQueryTimeoutID: NodeJS.Timeout = setTimeout(() => { + if (isExecuting) { + if (automaticallyCancelQueryAfterTimeout) { + queryAbortController.abort(); + } else { + useDialog + .getState() + .showOkCancelModalDialog( + QueryConstants.CancelQueryTitle, + format(QueryConstants.CancelQuerySubTextTemplate, QueryConstants.CancelQueryTimeoutThresholdReached), + "Yes", + () => queryAbortController.abort(), + "No", + undefined, + ); + } + } + }, queryTimeout); + setCancelQueryTimeoutID(cancelQueryTimeoutID); + } + return _loadNextPageInternal(iterator) + .then( + (documentsIdsResponse = []) => { + const currentDocuments = documentIds; + const currentDocumentsRids = currentDocuments.map((currentDocument) => currentDocument.rid); + const nextDocumentIds = documentsIdsResponse + // filter documents already loaded in observable + .filter((d: DataModels.DocumentId) => { + return currentDocumentsRids.indexOf(d._rid) < 0; + }) + // map raw response to view model + .map((rawDocument: DataModels.DocumentId & { _partitionKeyValue: string[] }) => { + const partitionKeyValue = rawDocument._partitionKeyValue; + + const partitionKey = _partitionKey || (_collection && _collection.partitionKey); + const partitionKeyPropertyHeaders = _collection?.partitionKeyPropertyHeaders || partitionKey?.paths; + const partitionKeyProperties = partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) => + partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""), + ); + + return newDocumentId(rawDocument, partitionKeyProperties, partitionKeyValue); + }); + + const merged = currentDocuments.concat(nextDocumentIds); + setDocumentIds(merged); + if (onLoadStartKey !== null && onLoadStartKey !== undefined) { + TelemetryProcessor.traceSuccess( + Action.Tab, + { + databaseName: _collection.databaseId, + collectionName: _collection.id(), + + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }, + onLoadStartKey, + ); + setOnLoadStartKey(undefined); + } + }, + (error) => { + onExecutionErrorChange(true); + const errorMessage = getErrorMessage(error); + logConsoleError(errorMessage); + if (onLoadStartKey !== null && onLoadStartKey !== undefined) { + TelemetryProcessor.traceFailure( + Action.Tab, + { + databaseName: _collection.databaseId, + collectionName: _collection.id(), + + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + error: errorMessage, + errorStack: getErrorStack(error), + }, + onLoadStartKey, + ); + setOnLoadStartKey(undefined); + } + }, + ) + .finally(() => { + setIsExecuting(false); + if (applyFilterButtonClicked && queryTimeoutEnabled()) { + clearTimeout(cancelQueryTimeoutID); + if (!automaticallyCancelQueryAfterTimeout) { + useDialog.getState().closeDialog(); + } + } + }); + }, + [ + onExecutionErrorChange, + queryTimeoutEnabled, + isExecuting, + queryAbortController, + documentIds, + onLoadStartKey, + _partitionKey, + _collection, + newDocumentId, + tabTitle, + cancelQueryTimeoutID, + ], + ); + + useEffect(() => { + if (documentsIterator) { + loadNextPage(documentsIterator.iterator, documentsIterator.applyFilterButtonPressed); + } + }, [ + documentsIterator, // loadNextPage: disabled as it will trigger a circular dependency and infinite loop + ]); + + const onRefreshKeyInput: KeyboardEventHandler = (event) => { + if (event.key === " " || event.key === "Enter") { + const focusElement = event.target as HTMLElement; + refreshDocumentsGrid(false); + focusElement && focusElement.focus(); + event.stopPropagation(); + event.preventDefault(); + } + }; + + const onLoadMoreKeyInput: KeyboardEventHandler = (event) => { + if (event.key === " " || event.key === "Enter") { + const focusElement = event.target as HTMLElement; + loadNextPage(documentsIterator.iterator); + focusElement && focusElement.focus(); + event.stopPropagation(); + event.preventDefault(); + } + }; + + const onFilterKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === "Enter") { + refreshDocumentsGrid(true); + + // Suppress the default behavior of the key + e.preventDefault(); + } else if (e.key === "Escape") { + onHideFilterClick(); + + // Suppress the default behavior of the key + e.preventDefault(); + } + }; + + const _isQueryCopilotSampleContainer = + _collection?.isSampleCollection && + _collection?.databaseId === QueryCopilotSampleDatabaseId && + _collection?.id() === QueryCopilotSampleContainerId; + + // Table config here + const tableItems: DocumentsTableComponentItem[] = documentIds.map((documentId) => { + const item: Record & { id: string } = { + id: documentId.id(), + }; + + if (partitionKeyPropertyHeaders && documentId.stringPartitionKeyValues) { + for (let i = 0; i < partitionKeyPropertyHeaders.length; i++) { + item[partitionKeyPropertyHeaders[i]] = documentId.stringPartitionKeyValues[i]; + } + } + + return item; + }); + + /** + * replicate logic of selectedDocument.click(); + * Document has been clicked on in table + * @param tabRowId + */ + const onDocumentClicked = (tabRowId: TableRowId) => { + const index = tabRowId as number; + setClickedRow(index); + loadDocument(documentIds[index]); + }; + + let loadDocument = (documentId: DocumentId) => + (_isQueryCopilotSampleContainer ? readSampleDocument(documentId) : readDocument(_collection, documentId)).then( + (content) => { + initDocumentEditor(documentId, content); + }, + ); + + const initDocumentEditor = (documentId: DocumentId, documentContent: unknown): void => { + if (documentId) { + const content: string = renderObjectForEditor(documentContent, null, 4); + setSelectedDocumentContentBaseline(content); + setSelectedDocumentContent(content); + setInitialDocumentContent(content); + + const newState = documentId + ? ViewModels.DocumentExplorerState.exisitingDocumentNoEdits + : ViewModels.DocumentExplorerState.newDocumentValid; + setEditorState(newState); + } + }; + + const _onEditorContentChange = (newContent: string): void => { + setSelectedDocumentContent(newContent); + + if ( + selectedDocumentContentBaseline === initialDocumentContent && + newContent === initialDocumentContent && + editorState !== ViewModels.DocumentExplorerState.newDocumentValid + ) { + setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); + return; + } + + // Mongo uses BSON format for _id, trying to parse it as JSON blocks normal flow in an edit + // Bypass validation for mongo + if (isPreferredApiMongoDB) { + onValidDocumentEdit(); + return; + } + + try { + JSON.parse(newContent); + onValidDocumentEdit(); + } catch (e) { + onInvalidDocumentEdit(); + } + }; + + const onValidDocumentEdit = (): void => { + if ( + editorState === ViewModels.DocumentExplorerState.newDocumentInvalid || + editorState === ViewModels.DocumentExplorerState.newDocumentValid + ) { + setEditorState(ViewModels.DocumentExplorerState.newDocumentValid); + return; + } + + setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid); + }; + + const onInvalidDocumentEdit = (): void => { + if ( + editorState === ViewModels.DocumentExplorerState.newDocumentInvalid || + editorState === ViewModels.DocumentExplorerState.newDocumentValid + ) { + setEditorState(ViewModels.DocumentExplorerState.newDocumentInvalid); + return; + } + + if ( + editorState === ViewModels.DocumentExplorerState.exisitingDocumentNoEdits || + editorState === ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid + ) { + setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid); + return; + } + }; + + const tableContainerRef = useRef(null); + const [tableContainerSizePx, setTableContainerSizePx] = useState<{ height: number; width: number }>(undefined); + useEffect(() => { + if (!tableContainerRef.current) { + return undefined; + } + const resizeObserver = new ResizeObserver(() => + setTableContainerSizePx({ + height: tableContainerRef.current.offsetHeight, + width: tableContainerRef.current.offsetWidth, + }), + ); + resizeObserver.observe(tableContainerRef.current); + return () => resizeObserver.disconnect(); // clean up + }, []); + + const columnHeaders = { + idHeader: isPreferredApiMongoDB ? "_id" : "id", + partitionKeyHeaders: (showPartitionKey(_collection, isPreferredApiMongoDB) && partitionKeyPropertyHeaders) || [], + }; + + const onSelectedRowsChange = (selectedRows: Set) => { + confirmDiscardingChange(() => { + if (selectedRows.size === 0) { + setSelectedDocumentContent(undefined); + setClickedRow(undefined); + setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected); + } + + // Find if clickedRow is in selectedRows.If not, clear clickedRow and content + if (clickedRow !== undefined && !selectedRows.has(clickedRow)) { + setClickedRow(undefined); + setSelectedDocumentContent(undefined); + setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected); + } + + // If only one selection, we consider as a click + if (selectedRows.size === 1) { + setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); + } + + setSelectedRows(selectedRows); + }); + }; + + // ********* Override here for mongo (from MongoDocumentsTab) ********** + if (isPreferredApiMongoDB) { + loadDocument = (documentId: DocumentId) => + MongoProxyClient.readDocument(_collection.databaseId, _collection as ViewModels.Collection, documentId).then( + (content) => { + initDocumentEditor(documentId, content); + }, + ); + + renderObjectForEditor = (value: unknown): string => MongoUtility.tojson(value, null, false); + + const _hasShardKeySpecified = (document: unknown): boolean => { + return Boolean(extractPartitionKeyValues(document, _getPartitionKeyDefinition() as PartitionKeyDefinition)); + }; + + const _getPartitionKeyDefinition = (): DataModels.PartitionKey => { + let partitionKey: DataModels.PartitionKey = _partitionKey; + + if ( + _partitionKey && + _partitionKey.paths && + _partitionKey.paths.length && + _partitionKey.paths.length > 0 && + _partitionKey.paths[0].indexOf("$v") > -1 + ) { + // Convert BsonSchema2 to /path format + partitionKey = { + kind: partitionKey.kind, + paths: ["/" + partitionKeyProperties?.[0].replace(/\./g, "/")], + version: partitionKey.version, + }; + } + + return partitionKey; + }; + + lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"]; + partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => { + if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) { + partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, ""); + } + + if (partitionKeyProperty && partitionKeyProperty.indexOf("$v") > -1) { + // From $v.shard.$v.key.$v > shard.key + partitionKeyProperty = partitionKeyProperty.replace(/.\$v/g, "").replace(/\$v./g, ""); + partitionKeyPropertyHeaders[i] = "/" + partitionKeyProperty; + } + + return partitionKeyProperty; + }); + + /** + * Mongo implementation + * TODO: update proxy to use mongo driver deleteMany + */ + _deleteDocuments = (toDeleteDocumentIds: DocumentId[]): Promise => { + const promises = toDeleteDocumentIds.map((documentId) => _deleteDocument(documentId)); + return Promise.all(promises); + }; + + const __deleteDocument = async (documentId: DocumentId): Promise => { + await MongoProxyClient.deleteDocument(_collection.databaseId, _collection as ViewModels.Collection, documentId); + return documentId; + }; + + const _deleteDocument = useCallback( + (documentId: DocumentId): Promise => { + onExecutionErrorChange(false); + const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocument, { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }); + setIsExecuting(true); + return __deleteDocument(documentId) + .then( + (deletedDocumentId) => { + TelemetryProcessor.traceSuccess( + Action.DeleteDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }, + startKey, + ); + return deletedDocumentId; + }, + (error) => { + onExecutionErrorChange(true); + console.error(error); + TelemetryProcessor.traceFailure( + Action.DeleteDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey, + ); + return undefined; + }, + ) + .finally(() => setIsExecuting(false)); + }, + [__deleteDocument, onExecutionErrorChange, tabTitle], + ); + + onSaveNewDocumentClick = useCallback((): Promise => { + const documentContent = JSON.parse(selectedDocumentContent); + const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }); + + const partitionKeyProperty = partitionKeyProperties?.[0]; + if (partitionKeyProperty !== "_id" && !_hasShardKeySpecified(documentContent)) { + const message = `The document is lacking the shard property: ${partitionKeyProperty}`; + useDialog.getState().showOkModalDialog("Create document failed", message); + onExecutionErrorChange(true); + TelemetryProcessor.traceFailure( + Action.CreateDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + error: message, + }, + startKey, + ); + Logger.logError("Failed to save new document: Document shard key not defined", "MongoDocumentsTab"); + throw new Error("Document without shard key"); + } + + onExecutionErrorChange(false); + setIsExecuting(true); + return MongoProxyClient.createDocument( + _collection.databaseId, + _collection as ViewModels.Collection, + partitionKeyProperties?.[0], + documentContent, + ) + .then( + (savedDocument: { _self: unknown }) => { + const partitionKeyArray: PartitionKey[] = extractPartitionKeyValues( + savedDocument, + _getPartitionKeyDefinition() as PartitionKeyDefinition, + ); + + const id = new ObjectId(this, savedDocument, partitionKeyArray); + const ids = documentIds; + ids.push(id); + delete savedDocument._self; + + const value: string = renderObjectForEditor(savedDocument || {}, null, 4); + setSelectedDocumentContentBaseline(value); + + setDocumentIds(ids); + setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); + TelemetryProcessor.traceSuccess( + Action.CreateDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }, + startKey, + ); + }, + (error) => { + onExecutionErrorChange(true); + const errorMessage = getErrorMessage(error); + useDialog.getState().showOkModalDialog("Create document failed", errorMessage); + TelemetryProcessor.traceFailure( + Action.CreateDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + error: errorMessage, + errorStack: getErrorStack(error), + }, + startKey, + ); + }, + ) + .then(() => setSelectedRows(new Set([documentIds.length - 1]))) + .finally(() => setIsExecuting(false)); + }, [ + selectedDocumentContent, + tabTitle, + partitionKeyProperties, + _hasShardKeySpecified, + onExecutionErrorChange, + _collection, + _getPartitionKeyDefinition, + documentIds, + ]); + + onSaveExistingDocumentClick = (): Promise => { + const documentContent = selectedDocumentContent; + onExecutionErrorChange(false); + setIsExecuting(true); + const startKey: number = TelemetryProcessor.traceStart(Action.UpdateDocument, { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }); + + const selectedDocumentId = documentIds[clickedRow as number]; + return MongoProxyClient.updateDocument( + _collection.databaseId, + _collection as ViewModels.Collection, + selectedDocumentId, + documentContent, + ) + .then( + (updatedDocument: { _rid: string }) => { + const value: string = renderObjectForEditor(updatedDocument || {}, null, 4); + setSelectedDocumentContentBaseline(value); + + documentIds.forEach((documentId: DocumentId) => { + if (documentId.rid === updatedDocument._rid) { + const partitionKeyArray: PartitionKey[] = extractPartitionKeyValues( + updatedDocument, + _getPartitionKeyDefinition() as PartitionKeyDefinition, + ); + + const id = new ObjectId(this, updatedDocument, partitionKeyArray); + documentId.id(id.id()); + } + }); + setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); + TelemetryProcessor.traceSuccess( + Action.UpdateDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }, + startKey, + ); + }, + (error) => { + onExecutionErrorChange(true); + const errorMessage = getErrorMessage(error); + useDialog.getState().showOkModalDialog("Update document failed", errorMessage); + TelemetryProcessor.traceFailure( + Action.UpdateDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + error: errorMessage, + errorStack: getErrorStack(error), + }, + startKey, + ); + }, + ) + .finally(() => setIsExecuting(false)); + }; + + loadNextPage = (): Promise => { + setIsExecuting(true); + onExecutionErrorChange(false); + const filter: string = filterContent.trim(); + const query: string = buildQuery(isPreferredApiMongoDB, filter); + + return MongoProxyClient.queryDocuments( + _collection.databaseId, + _collection as ViewModels.Collection, + true, + query, + continuationToken, + ) + .then( + ({ continuationToken: newContinuationToken, documents }) => { + setContinuationToken(newContinuationToken); + let currentDocuments = documentIds; + const currentDocumentsRids = currentDocuments.map((currentDocument) => currentDocument.rid); + const nextDocumentIds = documents + .filter((d: { _rid: string }) => { + return currentDocumentsRids.indexOf(d._rid) < 0; + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((rawDocument: any) => { + const partitionKeyValue = rawDocument._partitionKeyValue; + return newDocumentId(rawDocument, partitionKeyProperties, [partitionKeyValue]); + // return new DocumentId(this, rawDocument, [partitionKeyValue]); + }); + + const merged = currentDocuments.concat(nextDocumentIds); + + setDocumentIds(merged); + currentDocuments = merged; + + if (filterContent.length > 0 && currentDocuments.length > 0) { + currentDocuments[0].click(); + } else { + setSelectedDocumentContent(""); + setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected); + } + if (_onLoadStartKey !== null && _onLoadStartKey !== undefined) { + TelemetryProcessor.traceSuccess( + Action.Tab, + { + databaseName: _collection.databaseId, + collectionName: _collection.id(), + + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + }, + _onLoadStartKey, + ); + setOnLoadStartKey(undefined); + } + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (error: any) => { + if (onLoadStartKey !== null && onLoadStartKey !== undefined) { + TelemetryProcessor.traceFailure( + Action.Tab, + { + databaseName: _collection.databaseId, + collectionName: _collection.id(), + + dataExplorerArea: Constants.Areas.Tab, + tabTitle, + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + _onLoadStartKey, + ); + setOnLoadStartKey(undefined); + } + }, + ) + .finally(() => setIsExecuting(false)); + }; + } + // ***************** Mongo *************************** + + const refreshDocumentsGrid = useCallback( + async (applyFilterButtonPressed?: boolean): Promise => { + // clear documents grid + setDocumentIds([]); + try { + // reset iterator which will autoload documents (in useEffect) + setDocumentsIterator({ + iterator: createIterator(), + applyFilterButtonPressed, + }); + + // collapse filter + setAppliedFilter(filterContent); + setIsFilterExpanded(false); + } catch (error) { + console.error(error); + useDialog.getState().showOkModalDialog("Refresh documents grid failed", getErrorMessage(error)); + } + }, + [createIterator, filterContent], + ); + + return ( + +

+ {isFilterCreated && ( +
+ {!isFilterExpanded && !isPreferredApiMongoDB && ( +
+ SELECT * FROM c + {appliedFilter} + +
+ )} + {!isFilterExpanded && isPreferredApiMongoDB && ( +
+ {appliedFilter.length > 0 && Filter :} + {!(appliedFilter.length > 0) && No filter applied} + {appliedFilter} + +
+ )} + {isFilterExpanded && ( +
+
+
+ {!isPreferredApiMongoDB && SELECT * FROM c } + setFilterContent(e.target.value)} + onBlur={() => setIsFilterFocused(false)} + /> + + + {lastFilterContents.map((filter) => ( + + + + + + + {!isPreferredApiMongoDB && isExecuting && ( + + )} + +
+
+
+ )} +
+ )} + {/* doesn't like to be a flex child */} +
+ +
+
+
+ {isTabActive && selectedDocumentContent && selectedRows.size <= 1 && ( + + )} + {selectedRows.size > 1 && ( + Number of selected documents: {selectedRows.size} + )} +
+
+
+
+ + ); +}; diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx new file mode 100644 index 000000000..79d5e2343 --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx @@ -0,0 +1,195 @@ +import { deleteDocument } from "Common/MongoProxyClient"; +import { Platform, updateConfigContext } from "ConfigContext"; +import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact"; +import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; +import { + DELETE_BUTTON_ID, + DISCARD_BUTTON_ID, + DocumentsTabComponent, + IDocumentsTabComponentProps, + NEW_DOCUMENT_BUTTON_ID, + SAVE_BUTTON_ID, + UPDATE_BUTTON_ID, + buildQuery, +} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; +import { ReactWrapper, ShallowWrapper, mount } from "enzyme"; +import * as ko from "knockout"; +import React from "react"; +import { act } from "react-dom/test-utils"; +import * as ViewModels from "../../../Contracts/ViewModels"; +import Explorer from "../../Explorer"; + +jest.requireActual("Explorer/Controls/Editor/EditorReact"); + +const PROPERTY_VALUE = "__SOME_PROPERTY_VALUE__"; + +jest.mock("Common/MongoProxyClient", () => ({ + queryDocuments: jest.fn(() => + Promise.resolve({ + continuationToken: "", + documents: [ + { + _rid: "_rid", + _self: "_self", + _etag: "etag", + _ts: 1234, + id: "id", + }, + ], + headers: {}, + }), + ), + readDocument: jest.fn(() => + Promise.resolve({ + _rid: "_rid1", + _self: "_self1", + _etag: "etag1", + property: PROPERTY_VALUE, + _ts: 5678, + id: "id1", + }), + ), + deleteDocument: jest.fn(() => Promise.resolve()), +})); + +jest.mock("Explorer/Controls/Editor/EditorReact", () => ({ + EditorReact: (props: EditorReactProps) => <>{props.content}, +})); + +jest.mock("Explorer/Controls/Dialog", () => ({ + useDialog: { + getState: jest.fn(() => ({ + showOkCancelModalDialog: (title: string, subText: string, okLabel: string, onOk: () => void) => onOk(), + showOkModalDialog: () => {}, + })), + }, +})); + +async function waitForComponentToPaint

(wrapper: ReactWrapper

| ShallowWrapper

, amount = 0) { + let newWrapper; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, amount)); + newWrapper = wrapper.update(); + }); + return newWrapper; +} + +describe("Documents tab (Mongo API)", () => { + describe("buildQuery", () => { + it("should generate the right select query for SQL API", () => { + expect(buildQuery(true, "")).toContain("{}"); + }); + }); + + describe("Command bar buttons", () => { + const createMockProps = (): IDocumentsTabComponentProps => ({ + isPreferredApiMongoDB: true, + documentIds: [], + collection: { + id: ko.observable("foo"), + container: new Explorer(), + partitionKey: { + kind: "Hash", + paths: ["/pkey"], + version: 2, + }, + partitionKeyProperties: ["pkey"], + partitionKeyPropertyHeaders: ["/pkey"], + databaseId: "databaseId", + self: "self", + rawDataModel: undefined, + selectedSubnodeKind: undefined, + children: undefined, + isCollectionExpanded: undefined, + onDocumentDBDocumentsClick: (): void => { + throw new Error("Function not implemented."); + }, + onNewQueryClick: (): void => { + throw new Error("Function not implemented."); + }, + expandCollection: (): void => { + throw new Error("Function not implemented."); + }, + collapseCollection: (): void => { + throw new Error("Function not implemented."); + }, + getDatabase: (): ViewModels.Database => { + throw new Error("Function not implemented."); + }, + nodeKind: "nodeKind", + rid: "rid", + }, + partitionKey: undefined, + onLoadStartKey: 0, + tabTitle: "", + onExecutionErrorChange: (isExecutionError: boolean): void => { + isExecutionError; + }, + onIsExecutingChange: (isExecuting: boolean): void => { + isExecuting; + }, + isTabActive: true, + }); + + let wrapper: ReactWrapper; + + beforeEach(async () => { + updateConfigContext({ platform: Platform.Hosted }); + + const props: IDocumentsTabComponentProps = createMockProps(); + + wrapper = mount(); + wrapper = await waitForComponentToPaint(wrapper); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + it("renders by default the first document", async () => { + expect(wrapper.findWhere((node) => node.text().includes(PROPERTY_VALUE)).exists()).toBeTruthy(); + }); + + it("default buttons", async () => { + expect(useCommandBar.getState().contextButtons.find((button) => button.id === UPDATE_BUTTON_ID)).toBeDefined(); + expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined(); + expect(useCommandBar.getState().contextButtons.find((button) => button.id === DELETE_BUTTON_ID)).toBeDefined(); + }); + + it("clicking on New Document should show editor with new document", () => { + act(() => { + useCommandBar + .getState() + .contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID) + .onCommandClick(undefined); + }); + expect(wrapper.findWhere((node) => node.text().includes("replace_with_new_document_id")).exists()).toBeTruthy(); + }); + + it("clicking on New Document should show Save and Discard buttons", () => { + act(() => { + useCommandBar + .getState() + .contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID) + .onCommandClick(undefined); + }); + + expect(useCommandBar.getState().contextButtons.find((button) => button.id === SAVE_BUTTON_ID)).toBeDefined(); + expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined(); + }); + + it("clicking Delete Document asks for confirmation", () => { + const mockDeleteDocument = deleteDocument as jest.Mock; + mockDeleteDocument.mockClear(); + + act(() => { + useCommandBar + .getState() + .contextButtons.find((button) => button.id === DELETE_BUTTON_ID) + .onCommandClick(undefined); + }); + + expect(mockDeleteDocument).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.test.tsx new file mode 100644 index 000000000..e20084648 --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.test.tsx @@ -0,0 +1,34 @@ +import { TableRowId } from "@fluentui/react-components"; +import { mount } from "enzyme"; +import React from "react"; +import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent"; + +const PARTITION_KEY_HEADER = "partitionKey"; +const ID_HEADER = "id"; + +describe("DocumentsTableComponent", () => { + const createMockProps = (): IDocumentsTableComponentProps => ({ + items: [ + { [ID_HEADER]: "1", [PARTITION_KEY_HEADER]: "pk1" }, + { [ID_HEADER]: "2", [PARTITION_KEY_HEADER]: "pk2" }, + { [ID_HEADER]: "3", [PARTITION_KEY_HEADER]: "pk3" }, + ], + onItemClicked: (): void => {}, + onSelectedRowsChange: (): void => {}, + selectedRows: new Set(), + size: { + height: 0, + width: 0, + }, + columnHeaders: { + idHeader: ID_HEADER, + partitionKeyHeaders: [PARTITION_KEY_HEADER], + }, + }); + + it("should render documents and partition keys in header", () => { + const props: IDocumentsTableComponentProps = createMockProps(); + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx new file mode 100644 index 000000000..4089b12bd --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx @@ -0,0 +1,271 @@ +import { + Menu, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + TableRowData as RowStateBase, + Table, + TableBody, + TableCell, + TableCellLayout, + TableColumnDefinition, + TableColumnSizingOptions, + TableHeader, + TableHeaderCell, + TableRow, + TableRowId, + TableSelectionCell, + createTableColumn, + useArrowNavigationGroup, + useTableColumnSizing_unstable, + useTableFeatures, + useTableSelection, +} from "@fluentui/react-components"; +import React, { useCallback, useEffect, useMemo } from "react"; +import { FixedSizeList as List, ListChildComponentProps } from "react-window"; + +export type DocumentsTableComponentItem = { + id: string; +} & Record; + +export type ColumnHeaders = { + idHeader: string; + partitionKeyHeaders: string[]; +}; +export interface IDocumentsTableComponentProps { + items: DocumentsTableComponentItem[]; + onItemClicked: (index: number) => void; + onSelectedRowsChange: (selectedItemsIndices: Set) => void; + selectedRows: Set; + size: { height: number; width: number }; + columnHeaders: ColumnHeaders; + style?: React.CSSProperties; +} + +interface TableRowData extends RowStateBase { + onClick: (e: React.MouseEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + selected: boolean; + appearance: "brand" | "none"; +} +interface ReactWindowRenderFnProps extends ListChildComponentProps { + data: TableRowData[]; +} + +export const DocumentsTableComponent: React.FC = ({ + items, + onItemClicked, + onSelectedRowsChange, + selectedRows, + style, + size, + columnHeaders, +}: IDocumentsTableComponentProps) => { + const [activeItemIndex, setActiveItemIndex] = React.useState(undefined); + + const initialSizingOptions: TableColumnSizingOptions = { + id: { + idealWidth: 280, + minWidth: 50, + }, + }; + columnHeaders.partitionKeyHeaders.forEach((pkHeader) => { + initialSizingOptions[pkHeader] = { + idealWidth: 200, + minWidth: 50, + }; + }); + + const [columnSizingOptions, setColumnSizingOptions] = React.useState(initialSizingOptions); + + const onColumnResize = React.useCallback((_, { columnId, width }) => { + setColumnSizingOptions((state) => ({ + ...state, + [columnId]: { + ...state[columnId], + idealWidth: width, + }, + })); + }, []); + + // Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes + const columns: TableColumnDefinition[] = useMemo( + () => + [ + createTableColumn({ + columnId: "id", + compare: (a, b) => a.id.localeCompare(b.id), + renderHeaderCell: () => columnHeaders.idHeader, + renderCell: (item) => ( + + {item.id} + + ), + }), + ].concat( + columnHeaders.partitionKeyHeaders.map((pkHeader) => + createTableColumn({ + columnId: pkHeader, + compare: (a, b) => a[pkHeader].localeCompare(b[pkHeader]), + // Show Refresh button on last column + renderHeaderCell: () => {pkHeader}, + renderCell: (item) => ( + + {item[pkHeader]} + + ), + }), + ), + ), + [columnHeaders], + ); + + const onIdClicked = useCallback((index: number) => onSelectedRowsChange(new Set([index])), [onSelectedRowsChange]); + + const RenderRow = ({ index, style, data }: ReactWindowRenderFnProps) => { + const { item, selected, appearance, onClick, onKeyDown } = data[index]; + return ( + + + {columns.map((column) => ( + onSelectedRowsChange(new Set([index]))} + onKeyDown={() => onIdClicked(index)} + {...columnSizing.getTableCellProps(column.columnId)} + tabIndex={column.columnId === "id" ? 0 : -1} + > + {column.renderCell(item)} + + ))} + + ); + }; + + const { + getRows, + columnSizing_unstable: columnSizing, + tableRef, + selection: { allRowsSelected, someRowsSelected, toggleAllRows, toggleRow, isRowSelected }, + } = useTableFeatures( + { + columns, + items, + }, + [ + useTableColumnSizing_unstable({ columnSizingOptions, onColumnResize }), + useTableSelection({ + selectionMode: "multiselect", + selectedItems: selectedRows, + // eslint-disable-next-line react/prop-types + onSelectionChange: (e, data) => onSelectedRowsChange(data.selectedItems), + }), + ], + ); + + const rows: TableRowData[] = getRows((row) => { + const selected = isRowSelected(row.rowId); + return { + ...row, + onClick: (e: React.MouseEvent) => toggleRow(e, row.rowId), + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === " ") { + e.preventDefault(); + toggleRow(e, row.rowId); + } + }, + selected, + appearance: selected ? ("brand" as const) : ("none" as const), + }; + }); + + const toggleAllKeydown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === " ") { + toggleAllRows(e); + e.preventDefault(); + } + }, + [toggleAllRows], + ); + + // Load document depending on selection + useEffect(() => { + if (selectedRows.size === 1 && items.length > 0) { + const newActiveItemIndex = selectedRows.values().next().value; + if (newActiveItemIndex !== activeItemIndex) { + onItemClicked(newActiveItemIndex); + setActiveItemIndex(newActiveItemIndex); + } + } + }, [selectedRows, items]); + + // Cell keyboard navigation + const keyboardNavAttr = useArrowNavigationGroup({ axis: "grid" }); + + // TODO: Bug in fluent UI typings that requires any here + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tableProps: any = { + "aria-label": "Filtered documents table", + role: "grid", + ...columnSizing.getTableProps(), + ...keyboardNavAttr, + size: "extra-small", + ref: tableRef, + ...style, + }; + + return ( + + + + + {columns.map((column /* index */) => ( + + + + {column.renderHeaderCell()} + + + + + + Keyboard Column Resizing + + + + + ))} + + + + + {RenderRow} + + +
+ ); +}; diff --git a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap new file mode 100644 index 000000000..0b4c7578e --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap @@ -0,0 +1,558 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Documents tab (noSql API) when rendered should render the page 1`] = ` + +

+
+
+ + SELECT * FROM c + + + +
+
+
+ +
+
+
+ +
+
+ +`; diff --git a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap new file mode 100644 index 000000000..6e80eac9f --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap @@ -0,0 +1,1140 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = ` + + +
+ +
+ +
+ +
+ + + +
+ + +
+ + + + + + , + }, + } + } + > + + + } + className="documentsTableCell" + data-tabster="{\\"restorer\\":{\\"type\\":1}}" + id="menu3" + key="id" + onContextMenu={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + onMouseMove={[Function]} + style={ + Object { + "maxWidth": 50, + "minWidth": 50, + "width": 50, + } + } + > + + + + +
, + }, + } + } + > + + + + + + + +
+
+
+
+ +
+ +
+
+ + +
+ +
+ + + +
+ + +
+ + +
+ +
+
+ + 1 + +
+
+
+
+
+ +
+ +
+
+ + pk1 + +
+
+
+
+
+
+ + + + +
+ +
+ + + +
+ + +
+ + +
+ +
+
+ + 2 + +
+
+
+
+
+ +
+ +
+
+ + pk2 + +
+
+
+
+
+
+ + + + +
+ +
+ + + +
+ + +
+ + +
+ +
+
+ + 3 + +
+
+
+
+
+ +
+ +
+
+ + pk3 + +
+
+
+
+
+
+ + +
+
+ +
+ +
+
+
+`; diff --git a/src/Explorer/Tabs/MongoDocumentsTab.ts b/src/Explorer/Tabs/MongoDocumentsTab.ts deleted file mode 100644 index d3bf31a54..000000000 --- a/src/Explorer/Tabs/MongoDocumentsTab.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { PartitionKey, PartitionKeyDefinition } from "@azure/cosmos"; -import { extractPartitionKeyValues } from "Utils/QueryUtils"; -import * as ko from "knockout"; -import Q from "q"; -import * as Constants from "../../Common/Constants"; -import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; -import * as Logger from "../../Common/Logger"; -import { - createDocument, - deleteDocument, - queryDocuments, - readDocument, - updateDocument, -} from "../../Common/MongoProxyClient"; -import MongoUtility from "../../Common/MongoUtility"; -import * as DataModels from "../../Contracts/DataModels"; -import * as ViewModels from "../../Contracts/ViewModels"; -import { Action } from "../../Shared/Telemetry/TelemetryConstants"; -import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; -import { useDialog } from "../Controls/Dialog"; -import DocumentId from "../Tree/DocumentId"; -import ObjectId from "../Tree/ObjectId"; -import DocumentsTab from "./DocumentsTab"; - -export default class MongoDocumentsTab extends DocumentsTab { - public collection: ViewModels.Collection; - private continuationToken: string; - - constructor(options: ViewModels.DocumentsTabOptions) { - super(options); - this.lastFilterContents = ko.observableArray(['{"id":"foo"}', "{ qty: { $gte: 20 } }"]); - - this.partitionKeyProperties = this.partitionKeyProperties?.map((partitionKeyProperty, i) => { - if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) { - partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, ""); - } - - if (partitionKeyProperty && partitionKeyProperty.indexOf("$v") > -1) { - // From $v.shard.$v.key.$v > shard.key - partitionKeyProperty = partitionKeyProperty.replace(/.\$v/g, "").replace(/\$v./g, ""); - this.partitionKeyPropertyHeaders[i] = "/" + partitionKeyProperty; - } - - return partitionKeyProperty; - }); - - this.isFilterExpanded = ko.observable(true); - super.buildCommandBarOptions.bind(this); - super.buildCommandBarOptions(); - } - - public onSaveNewDocumentClick = (): Promise => { - const documentContent = JSON.parse(this.selectedDocumentContent()); - this.displayedError(""); - const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - }); - - const partitionKeyProperty = this.partitionKeyProperties?.[0]; - if (partitionKeyProperty !== "_id" && !this._hasShardKeySpecified(documentContent)) { - const message = `The document is lacking the shard property: ${partitionKeyProperty}`; - this.displayedError(message); - let that = this; - setTimeout(() => { - that.displayedError(""); - }, Constants.ClientDefaults.errorNotificationTimeoutMs); - this.isExecutionError(true); - TelemetryProcessor.traceFailure( - Action.CreateDocument, - { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - error: message, - }, - startKey, - ); - Logger.logError("Failed to save new document: Document shard key not defined", "MongoDocumentsTab"); - throw new Error("Document without shard key"); - } - - this.isExecutionError(false); - this.isExecuting(true); - return createDocument( - this.collection.databaseId, - this.collection, - this.partitionKeyProperties?.[0], - documentContent, - ) - .then( - (savedDocument: any) => { - const partitionKeyArray: PartitionKey[] = extractPartitionKeyValues( - savedDocument, - this._getPartitionKeyDefinition() as PartitionKeyDefinition, - ); - - let id = new ObjectId(this, savedDocument, partitionKeyArray); - let ids = this.documentIds(); - ids.push(id); - delete savedDocument._self; - - let value: string = this.renderObjectForEditor(savedDocument || {}, null, 4); - this.selectedDocumentContent.setBaseline(value); - - this.selectedDocumentId(id); - this.documentIds(ids); - this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); - TelemetryProcessor.traceSuccess( - Action.CreateDocument, - { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - }, - startKey, - ); - }, - (error) => { - this.isExecutionError(true); - const errorMessage = getErrorMessage(error); - useDialog.getState().showOkModalDialog("Create document failed", errorMessage); - TelemetryProcessor.traceFailure( - Action.CreateDocument, - { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - error: errorMessage, - errorStack: getErrorStack(error), - }, - startKey, - ); - }, - ) - .finally(() => this.isExecuting(false)); - }; - - public onSaveExistingDocumentClick = (): Promise => { - const selectedDocumentId = this.selectedDocumentId(); - const documentContent = this.selectedDocumentContent(); - this.isExecutionError(false); - this.isExecuting(true); - const startKey: number = TelemetryProcessor.traceStart(Action.UpdateDocument, { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - }); - - return updateDocument(this.collection.databaseId, this.collection, selectedDocumentId, documentContent) - .then( - (updatedDocument: any) => { - let value: string = this.renderObjectForEditor(updatedDocument || {}, null, 4); - this.selectedDocumentContent.setBaseline(value); - - this.documentIds().forEach((documentId: DocumentId) => { - if (documentId.rid === updatedDocument._rid) { - const partitionKeyArray: PartitionKey[] = extractPartitionKeyValues( - updatedDocument, - this._getPartitionKeyDefinition() as PartitionKeyDefinition, - ); - - const id = new ObjectId(this, updatedDocument, partitionKeyArray); - documentId.id(id.id()); - } - }); - this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); - TelemetryProcessor.traceSuccess( - Action.UpdateDocument, - { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - }, - startKey, - ); - }, - (error) => { - this.isExecutionError(true); - const errorMessage = getErrorMessage(error); - useDialog.getState().showOkModalDialog("Update document failed", errorMessage); - TelemetryProcessor.traceFailure( - Action.UpdateDocument, - { - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - error: errorMessage, - errorStack: getErrorStack(error), - }, - startKey, - ); - }, - ) - .finally(() => this.isExecuting(false)); - }; - - public buildQuery(filter: string): string { - return filter || "{}"; - } - - public async selectDocument(documentId: DocumentId): Promise { - this.selectedDocumentId(documentId); - const content = await readDocument(this.collection.databaseId, this.collection, documentId); - this.initDocumentEditor(documentId, content); - } - - public loadNextPage(): Q.Promise { - this.isExecuting(true); - this.isExecutionError(false); - const filter: string = this.filterContent().trim(); - const query: string = this.buildQuery(filter); - - return Q(queryDocuments(this.collection.databaseId, this.collection, true, query, this.continuationToken)) - .then( - ({ continuationToken, documents }) => { - this.continuationToken = continuationToken; - let currentDocuments = this.documentIds(); - const currentDocumentsRids = currentDocuments.map((currentDocument) => currentDocument.rid); - const nextDocumentIds = documents - .filter((d: any) => { - return currentDocumentsRids.indexOf(d._rid) < 0; - }) - .map((rawDocument: any) => { - const partitionKeyValue = rawDocument._partitionKeyValue; - return new DocumentId(this, rawDocument, [partitionKeyValue]); - }); - - const merged = currentDocuments.concat(nextDocumentIds); - - this.documentIds(merged); - currentDocuments = this.documentIds(); - if (this.filterContent().length > 0 && currentDocuments.length > 0) { - currentDocuments[0].click(); - } else { - this.selectedDocumentContent(""); - this.selectedDocumentId(null); - this.editorState(ViewModels.DocumentExplorerState.noDocumentSelected); - } - if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) { - TelemetryProcessor.traceSuccess( - Action.Tab, - { - databaseName: this.collection.databaseId, - collectionName: this.collection.id(), - - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - }, - this.onLoadStartKey, - ); - this.onLoadStartKey = null; - } - }, - (error: any) => { - if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) { - TelemetryProcessor.traceFailure( - Action.Tab, - { - databaseName: this.collection.databaseId, - collectionName: this.collection.id(), - - dataExplorerArea: Constants.Areas.Tab, - tabTitle: this.tabTitle(), - error: getErrorMessage(error), - errorStack: getErrorStack(error), - }, - this.onLoadStartKey, - ); - this.onLoadStartKey = null; - } - }, - ) - .finally(() => this.isExecuting(false)); - } - - protected _onEditorContentChange(newContent: string) { - try { - if ( - this.editorState() === ViewModels.DocumentExplorerState.newDocumentValid || - this.editorState() === ViewModels.DocumentExplorerState.newDocumentInvalid - ) { - let parsed: any = JSON.parse(newContent); - } - - // Mongo uses BSON format for _id, trying to parse it as JSON blocks normal flow in an edit - this.onValidDocumentEdit(); - } catch (e) { - this.onInvalidDocumentEdit(); - } - } - - /** Renders a Javascript object to be displayed inside Monaco Editor */ - public renderObjectForEditor(value: any, replacer: any, space: string | number): string { - return MongoUtility.tojson(value, null, false); - } - - private _hasShardKeySpecified(document: any): boolean { - return Boolean(extractPartitionKeyValues(document, this._getPartitionKeyDefinition() as PartitionKeyDefinition)); - } - - private _getPartitionKeyDefinition(): DataModels.PartitionKey { - let partitionKey: DataModels.PartitionKey = this.partitionKey; - - if ( - this.partitionKey && - this.partitionKey.paths && - this.partitionKey.paths.length && - this.partitionKey.paths.length > 0 && - this.partitionKey.paths[0].indexOf("$v") > -1 - ) { - // Convert BsonSchema2 to /path format - partitionKey = { - kind: partitionKey.kind, - paths: ["/" + this.partitionKeyProperties?.[0].replace(/\./g, "/")], - version: partitionKey.version, - }; - } - - return partitionKey; - } - - protected __deleteDocument(documentId: DocumentId): Promise { - return deleteDocument(this.collection.databaseId, this.collection, documentId); - } -} diff --git a/src/Explorer/Tabs/useTabs.test.ts b/src/Explorer/Tabs/useTabs.test.ts index 90a13f827..9d4925a2c 100644 --- a/src/Explorer/Tabs/useTabs.test.ts +++ b/src/Explorer/Tabs/useTabs.test.ts @@ -1,17 +1,17 @@ +import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import * as ko from "knockout"; import * as ViewModels from "../../Contracts/ViewModels"; -import { useTabs } from "../../hooks/useTabs"; import { updateUserContext } from "../../UserContext"; +import { useTabs } from "../../hooks/useTabs"; import { container } from "../Controls/Settings/TestUtils"; import DocumentId from "../Tree/DocumentId"; -import DocumentsTab from "./DocumentsTab"; import { NewQueryTab } from "./QueryTab/QueryTab"; describe("useTabs tests", () => { let database: ViewModels.Database; let collection: ViewModels.Collection; let queryTab: NewQueryTab; - let documentsTab: DocumentsTab; + let documentsTab: DocumentsTabV2; beforeEach(() => { updateUserContext({ @@ -56,7 +56,7 @@ describe("useTabs tests", () => { }, ); - documentsTab = new DocumentsTab({ + documentsTab = new DocumentsTabV2({ partitionKey: undefined, documentIds: ko.observableArray(), tabKind: ViewModels.CollectionTabKind.Documents, diff --git a/src/Explorer/Theme/ThemeUtil.ts b/src/Explorer/Theme/ThemeUtil.ts new file mode 100644 index 000000000..fe4075c69 --- /dev/null +++ b/src/Explorer/Theme/ThemeUtil.ts @@ -0,0 +1,31 @@ +import { BrandVariants, Theme, createLightTheme } from "@fluentui/react-components"; +import { Platform } from "ConfigContext"; +import { appThemeFabricTealBrandRamp } from "../../Platform/Fabric/FabricTheme"; + +// These are the theme colors for Fluent UI 9 React components +const appThemePortalBrandRamp: BrandVariants = { + 10: "#020305", + 20: "#111723", + 30: "#16263D", + 40: "#193253", + 50: "#1B3F6A", + 60: "#1B4C82", + 70: "#18599B", + 80: "#1267B4", + 90: "#3174C2", + 100: "#4F82C8", + 110: "#6790CF", + 120: "#7D9ED5", + 130: "#92ACDC", + 140: "#A6BAE2", + 150: "#BAC9E9", + 160: "#CDD8EF", +}; + +export function getPlatformTheme(platform: Platform): Theme { + if (platform === Platform.Fabric) { + return createLightTheme(appThemeFabricTealBrandRamp); + } else { + return createLightTheme(appThemePortalBrandRamp); + } +} diff --git a/src/Explorer/Tree/Collection.ts b/src/Explorer/Tree/Collection.ts index d7c673620..fffacb134 100644 --- a/src/Explorer/Tree/Collection.ts +++ b/src/Explorer/Tree/Collection.ts @@ -1,5 +1,6 @@ import { Resource, StoredProcedureDefinition, TriggerDefinition, UserDefinedFunctionDefinition } from "@azure/cosmos"; import { useNotebook } from "Explorer/Notebook/useNotebook"; +import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import * as ko from "knockout"; import * as _ from "underscore"; import * as Constants from "../../Common/Constants"; @@ -27,9 +28,7 @@ import Explorer from "../Explorer"; import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter"; import { CassandraAPIDataClient, CassandraTableKey, CassandraTableKeys } from "../Tables/TableDataClient"; import ConflictsTab from "../Tabs/ConflictsTab"; -import DocumentsTab from "../Tabs/DocumentsTab"; import GraphTab from "../Tabs/GraphTab"; -import MongoDocumentsTab from "../Tabs/MongoDocumentsTab"; import { NewMongoQueryTab } from "../Tabs/MongoQueryTab/MongoQueryTab"; import { NewMongoShellTab } from "../Tabs/MongoShellTab/MongoShellTab"; import { NewQueryTab } from "../Tabs/QueryTab/QueryTab"; @@ -292,13 +291,13 @@ export default class Collection implements ViewModels.Collection { dataExplorerArea: Constants.Areas.ResourceTree, }); - const documentsTabs: DocumentsTab[] = useTabs + const documentsTabs: DocumentsTabV2[] = useTabs .getState() .getTabs( ViewModels.CollectionTabKind.Documents, (tab) => tab.collection && tab.collection.databaseId === this.databaseId && tab.collection.id() === this.id(), - ) as DocumentsTab[]; - let documentsTab: DocumentsTab = documentsTabs && documentsTabs[0]; + ) as DocumentsTabV2[]; + let documentsTab: DocumentsTabV2 = documentsTabs && documentsTabs[0]; if (documentsTab) { useTabs.getState().activateTab(documentsTab); @@ -312,7 +311,7 @@ export default class Collection implements ViewModels.Collection { }); this.documentIds([]); - documentsTab = new DocumentsTab({ + documentsTab = new DocumentsTabV2({ partitionKey: this.partitionKey, documentIds: ko.observableArray([]), tabKind: ViewModels.CollectionTabKind.Documents, @@ -494,13 +493,13 @@ export default class Collection implements ViewModels.Collection { dataExplorerArea: Constants.Areas.ResourceTree, }); - const mongoDocumentsTabs: MongoDocumentsTab[] = useTabs + const mongoDocumentsTabs: DocumentsTabV2[] = useTabs .getState() .getTabs( ViewModels.CollectionTabKind.Documents, (tab) => tab.collection && tab.collection.databaseId === this.databaseId && tab.collection.id() === this.id(), - ) as MongoDocumentsTab[]; - let mongoDocumentsTab: MongoDocumentsTab = mongoDocumentsTabs && mongoDocumentsTabs[0]; + ) as DocumentsTabV2[]; + let mongoDocumentsTab: DocumentsTabV2 = mongoDocumentsTabs && mongoDocumentsTabs[0]; if (mongoDocumentsTab) { useTabs.getState().activateTab(mongoDocumentsTab); @@ -514,7 +513,7 @@ export default class Collection implements ViewModels.Collection { }); this.documentIds([]); - mongoDocumentsTab = new MongoDocumentsTab({ + mongoDocumentsTab = new DocumentsTabV2({ partitionKey: this.partitionKey, documentIds: this.documentIds, tabKind: ViewModels.CollectionTabKind.Documents, diff --git a/src/Explorer/Tree/DocumentId.ts b/src/Explorer/Tree/DocumentId.ts index 4ccf71e40..c54931c75 100644 --- a/src/Explorer/Tree/DocumentId.ts +++ b/src/Explorer/Tree/DocumentId.ts @@ -1,10 +1,18 @@ import * as ko from "knockout"; import * as DataModels from "../../Contracts/DataModels"; import { useDialog } from "../Controls/Dialog"; -import DocumentsTab from "../Tabs/DocumentsTab"; +/** + * Replaces DocumentsTab so we can plug any object + */ +export interface IDocumentIdContainer { + partitionKeyProperties?: string[]; + partitionKey: DataModels.PartitionKey; + isEditorDirty: () => boolean; + selectDocument: (documentId: DocumentId) => Promise; +} export default class DocumentId { - public container: DocumentsTab; + public container: IDocumentIdContainer; public rid: string; public self: string; public ts: string; @@ -15,7 +23,7 @@ export default class DocumentId { public stringPartitionKeyValues: string[]; public isDirty: ko.Observable; - constructor(container: DocumentsTab, data: any, partitionKeyValue: any[]) { + constructor(container: IDocumentIdContainer, data: any, partitionKeyValue: any[]) { this.container = container; this.self = data._self; this.rid = data._rid; diff --git a/src/Explorer/Tree/ObjectId.ts b/src/Explorer/Tree/ObjectId.ts index fc53a6a37..314a4cd7e 100644 --- a/src/Explorer/Tree/ObjectId.ts +++ b/src/Explorer/Tree/ObjectId.ts @@ -1,9 +1,8 @@ import * as ko from "knockout"; -import DocumentId from "./DocumentId"; -import DocumentsTab from "../Tabs/DocumentsTab"; +import DocumentId, { IDocumentIdContainer } from "./DocumentId"; export default class ObjectId extends DocumentId { - constructor(container: DocumentsTab, data: any, partitionKeyValue: any) { + constructor(container: IDocumentIdContainer, data: any, partitionKeyValue: any) { super(container, data, partitionKeyValue); if (typeof data._id === "object") { this.id = ko.observable(data._id[Object.keys(data._id)[0]]); diff --git a/src/Explorer/Tree/ResourceTokenCollection.ts b/src/Explorer/Tree/ResourceTokenCollection.ts index a5e6ab07f..6212c14a8 100644 --- a/src/Explorer/Tree/ResourceTokenCollection.ts +++ b/src/Explorer/Tree/ResourceTokenCollection.ts @@ -1,3 +1,4 @@ +import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import * as ko from "knockout"; import * as Constants from "../../Common/Constants"; import * as DataModels from "../../Contracts/DataModels"; @@ -7,7 +8,6 @@ import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import { userContext } from "../../UserContext"; import { useTabs } from "../../hooks/useTabs"; import Explorer from "../Explorer"; -import DocumentsTab from "../Tabs/DocumentsTab"; import { NewQueryTab } from "../Tabs/QueryTab/QueryTab"; import TabsBase from "../Tabs/TabsBase"; import { useDatabases } from "../useDatabases"; @@ -118,15 +118,15 @@ export default class ResourceTokenCollection implements ViewModels.CollectionBas dataExplorerArea: Constants.Areas.ResourceTree, }); - const documentsTabs: DocumentsTab[] = useTabs + const documentsTabs: DocumentsTabV2[] = useTabs .getState() .getTabs( ViewModels.CollectionTabKind.Documents, (tab: TabsBase) => tab.collection?.id() === this.id() && (tab.collection as ViewModels.CollectionBase).databaseId === this.databaseId, - ) as DocumentsTab[]; - let documentsTab: DocumentsTab = documentsTabs && documentsTabs[0]; + ) as DocumentsTabV2[]; + let documentsTab: DocumentsTabV2 = documentsTabs && documentsTabs[0]; if (documentsTab) { useTabs.getState().activateTab(documentsTab); @@ -139,7 +139,7 @@ export default class ResourceTokenCollection implements ViewModels.CollectionBas tabTitle: "Items", }); - documentsTab = new DocumentsTab({ + documentsTab = new DocumentsTabV2({ partitionKey: this.partitionKey, resourceTokenPartitionKey: userContext.parsedResourceToken?.partitionKey, documentIds: ko.observableArray([]), diff --git a/src/Explorer/Tree2/ResourceTree.tsx b/src/Explorer/Tree2/ResourceTree.tsx index 761cda2c6..45e3ba5d4 100644 --- a/src/Explorer/Tree2/ResourceTree.tsx +++ b/src/Explorer/Tree2/ResourceTree.tsx @@ -1,14 +1,13 @@ import { - BrandVariants, FluentProvider, - Theme, Tree, TreeItemValue, TreeOpenChangeData, TreeOpenChangeEvent, - createLightTheme, } from "@fluentui/react-components"; +import { configContext } from "ConfigContext"; import { TreeNode2, TreeNode2Component } from "Explorer/Controls/TreeComponent2/TreeNode2Component"; +import { getPlatformTheme } from "Explorer/Theme/ThemeUtil"; import { useDatabaseTreeNodes } from "Explorer/Tree2/useDatabaseTreeNodes"; import * as React from "react"; import shallow from "zustand/shallow"; @@ -22,29 +21,6 @@ interface ResourceTreeProps { container: Explorer; } -const cosmosdb: BrandVariants = { - 10: "#020305", - 20: "#111723", - 30: "#16263D", - 40: "#193253", - 50: "#1B3F6A", - 60: "#1B4C82", - 70: "#18599B", - 80: "#1267B4", - 90: "#3174C2", - 100: "#4F82C8", - 110: "#6790CF", - 120: "#7D9ED5", - 130: "#92ACDC", - 140: "#A6BAE2", - 150: "#BAC9E9", - 160: "#CDD8EF", -}; - -const lightTheme: Theme = { - ...createLightTheme(cosmosdb), -}; - export const DATA_TREE_LABEL = "DATA"; /** @@ -113,7 +89,7 @@ export const ResourceTree2: React.FC = ({ container }: Resour return ( <> - + global).TextEncoder = TextEncoder; (global).TextDecoder = TextDecoder; + +(global).ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +}));