diff --git a/.evergreen/build-dev-release-info.sh b/.evergreen/build-dev-release-info.sh new file mode 100755 index 00000000000..3b0d2a7bac5 --- /dev/null +++ b/.evergreen/build-dev-release-info.sh @@ -0,0 +1,34 @@ +#! /usr/bin/env bash + +set -e +set -x + +if [[ "${EVERGREEN_PROJECT}" != "10gen-compass-main" ]]; then + echo "Trying to publish main compass (dev build) from ${EVERGREEN_PROJECT} project. Skipping..."; + exit 0; +fi + +if [[ "${EVERGREEN_BRANCH_NAME}" != "main" ]]; then + echo "Trying to publish main compass (dev build) from ${EVERGREEN_BRANCH_NAME} branch. Skipping..."; + exit 0; +fi + +JSON_CONTENT=$( jq -n \ + --arg id "$DEV_VERSION_IDENTIFIER" \ + --arg key "${EVERGREEN_REVISION}_${EVERGREEN_REVISION_ORDER_ID}" \ + '{version: $id, bucket_key_prefix: $key}' +) + +URL="https://mciuploads.s3.amazonaws.com/${EVERGREEN_PROJECT}/compass/dev/$1" +DATA=$(curl -sf "${URL}" || echo "$JSON_CONTENT") +CURRENT_VERSION=$(echo "$DATA" | jq -r '.version') + +echo "Comparing versions: $CURRENT_VERSION and $DEV_VERSION_IDENTIFIER" +LATEST_VERSION=$(npx semver "$CURRENT_VERSION" "$DEV_VERSION_IDENTIFIER" | tail -n1 | xargs) + +if [[ "$LATEST_VERSION" == "$CURRENT_VERSION" ]]; then + echo "Skipping publishing dev release, version $DEV_VERSION_IDENTIFIER is not newer than $CURRENT_VERSION" + exit 0 +fi + +echo "$JSON_CONTENT" > "$1" \ No newline at end of file diff --git a/.evergreen/buildvariants.in.yml b/.evergreen/buildvariants.in.yml index 725a194408d..3f7c8e64e50 100644 --- a/.evergreen/buildvariants.in.yml +++ b/.evergreen/buildvariants.in.yml @@ -17,7 +17,7 @@ buildvariants: tasks: - name: publish - name: publish-packages-next - - name: publish-dev-release + - name: publish-dev-release-info - name: ubuntu_connectivity_tests display_name: Connectivity Tests diff --git a/.evergreen/buildvariants.yml b/.evergreen/buildvariants.yml index 25d585fcb4b..a38bc219335 100644 --- a/.evergreen/buildvariants.yml +++ b/.evergreen/buildvariants.yml @@ -17,7 +17,7 @@ buildvariants: tasks: - name: publish - name: publish-packages-next - - name: publish-dev-release + - name: publish-dev-release-info - name: ubuntu_connectivity_tests display_name: Connectivity Tests diff --git a/.evergreen/functions.yml b/.evergreen/functions.yml index 556e8a45805..f6c2dcb4c91 100644 --- a/.evergreen/functions.yml +++ b/.evergreen/functions.yml @@ -33,6 +33,7 @@ variables: EVERGREEN_IS_PATCH: ${is_patch} EVERGREEN_PROJECT: ${project} EVERGREEN_REVISION: ${revision} + EVERGREEN_REVISION_ORDER_ID: ${revision_order_id} EVERGREEN_TASK_ID: ${task_id} EVERGREEN_TASK_NAME: ${task_name} EVERGREEN_TASK_URL: https://evergreen.mongodb.com/task/${task_id} @@ -443,7 +444,7 @@ functions: echo "Uploading release assets to S3 and GitHub if needed..." npm run --workspace mongodb-compass upload - publish-dev-release: + publish-dev-release-info: - command: shell.exec params: working_dir: src @@ -452,14 +453,14 @@ functions: <<: *compass-env script: | eval $(.evergreen/print-compass-env.sh) - .evergreen/publish-dev-release.sh LATEST + .evergreen/build-dev-release-info.sh release.json - command: s3.put params: <<: *save-artifact-params-public - local_file: src/LATEST - remote_file: ${project}/compass/dev/LATEST - content_type: text/plain - display_name: LATEST + local_file: src/release.json + remote_file: ${project}/compass/dev/release.json + content_type: application/json + display_name: release.json optional: true get-packaged-app: diff --git a/.evergreen/print-compass-env.js b/.evergreen/print-compass-env.js index 383c1d01b0a..494a08d3d02 100755 --- a/.evergreen/print-compass-env.js +++ b/.evergreen/print-compass-env.js @@ -105,6 +105,8 @@ function printCompassEnv() { printVar('DEBUG', process.env.DEBUG); printVar('MONGODB_VERSION', process.env.MONGODB_VERSION || process.env.MONGODB_DEFAULT_VERSION); printVar('DEV_VERSION_IDENTIFIER', process.env.DEV_VERSION_IDENTIFIER); + printVar('EVERGREEN_REVISION', process.env.EVERGREEN_REVISION); + printVar('EVERGREEN_REVISION_ORDER_ID', process.env.EVERGREEN_REVISION_ORDER_ID); } printCompassEnv(); diff --git a/.evergreen/publish-dev-release.sh b/.evergreen/publish-dev-release.sh deleted file mode 100755 index 8820bb27e7f..00000000000 --- a/.evergreen/publish-dev-release.sh +++ /dev/null @@ -1,21 +0,0 @@ -#! /usr/bin/env bash - -set -e -set -x - -if [[ "${EVERGREEN_PROJECT}" != "10gen-compass-main" ]]; then - echo "Trying to publish main compass (dev build) from ${EVERGREEN_PROJECT} project. Skipping..."; - exit 0; -fi - -if [[ "${EVERGREEN_BRANCH_NAME}" != "main" ]]; then - echo "Trying to publish main compass (dev build) from ${EVERGREEN_BRANCH_NAME} branch. Skipping..."; - exit 0; -fi - -URL="https://mciuploads.s3.amazonaws.com/${EVERGREEN_PROJECT}/compass/dev/$1" -CURRENT_VERSION=$(curl -sf "${URL}" || echo "0.0.0-dev.0") - -echo "Comparing versions: $CURRENT_VERSION and $DEV_VERSION_IDENTIFIER" -PUBLISH_VERSION=$(npx semver "$CURRENT_VERSION" "$DEV_VERSION_IDENTIFIER" | tail -n1 | xargs) -echo "$PUBLISH_VERSION" > "$1" \ No newline at end of file diff --git a/.evergreen/tasks.in.yml b/.evergreen/tasks.in.yml index eb5915cab90..aec2d50cf01 100644 --- a/.evergreen/tasks.in.yml +++ b/.evergreen/tasks.in.yml @@ -93,14 +93,14 @@ tasks: - func: install - func: bootstrap - func: publish-packages-next - - name: publish-dev-release + - name: publish-dev-release-info tags: [] depends_on: - name: 'publish' variant: '*' commands: - func: prepare - - func: publish-dev-release + - func: publish-dev-release-info <% for (const packageTask of tasks.package) { %> - name: <% out(packageTask.name) %> tags: ['required-for-publish', 'run-on-pr'] diff --git a/.evergreen/tasks.yml b/.evergreen/tasks.yml index db0137fb5ae..76d34367e8b 100644 --- a/.evergreen/tasks.yml +++ b/.evergreen/tasks.yml @@ -93,14 +93,14 @@ tasks: - func: install - func: bootstrap - func: publish-packages-next - - name: publish-dev-release + - name: publish-dev-release-info tags: [] depends_on: - name: 'publish' variant: '*' commands: - func: prepare - - func: publish-dev-release + - func: publish-dev-release-info - name: package-compass tags: ['required-for-publish', 'run-on-pr'] diff --git a/AUTHORS b/AUTHORS index 0cbfec211d8..c1cf30a30e8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -87,3 +87,4 @@ Alena Khineika Mingjun Yin Ben Radcliffe <928675+bsradcliffe@users.noreply.github.com> Betsy Button <36177962+betsybutton@users.noreply.github.com> +Vivian Xiao <57568527+VivianTNT@users.noreply.github.com> diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index d9fe75ad0e4..4f92be401df 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -1,5 +1,5 @@ The following third-party software is used by and included in **Mongodb Compass**. -This document was automatically generated on Wed Jun 05 2024. +This document was automatically generated on Mon Jun 10 2024. ## List of dependencies @@ -459,7 +459,7 @@ This document was automatically generated on Wed Jun 05 2024. | **[path-key](#e1a2a032096ace66b422351e00b11b0229e42e4b49c2146f439f8fe442218451)** | 3.1.1 | MIT | | **[path-key](#8e0734b8abb76579df2174822606e692914e985fc58363a78e6ad4b2a4a5831f)** | 4.0.0 | MIT | | **[path-to-regexp](#4db5679c99a0dc8ff8ccdf6366c06b318a3e81ee0a4c2f4958ac2327f96d0d44)** | 0.1.7 | MIT | -| **[picocolors](#990b3f27d922745f1d3503b8f3cf9cb5d20f553c2f4c1168abe2a411b8cd5da0)** | 1.0.0 | ISC | +| **[picocolors](#87994c8f4d800603a2cf449baca40fcaf984088237be5cebbfbc79de0d95da98)** | 1.0.1 | ISC | | **[polished](#a7a5d1244e48a082dbc54de31b5309caf950b12aa6bd9fefbba39e362e705f06)** | 4.2.2 | MIT | | **[prebuild-install](#b3a047e51af19ed4c091ca34a3d59939490120cbc75e67f511fc02d31379c55d)** | 7.1.1 | MIT | | **[prettier](#2e1e2077936be4bb5f075fd4d279f9ece641322ccd12a8116edb3f99f08f7411)** | 2.7.1 | MIT | @@ -491,7 +491,6 @@ This document was automatically generated on Wed Jun 05 2024. | **[redux-thunk](#7eabcce4f7274e0c876829cb939804a9704770a9a60419d514c11e3e97c01623)** | 2.4.2 | MIT | | **[redux](#98b5d53f97fab4eea98fb5f423cad33400855b69ac662f1fdf55f0fb9e33f2ab)** | 4.2.1 | MIT | | **[reflux-core](#7af6ea33b0ed18717d672b44743ae53dcef843ae464690bb9e10eb1df048e9ea)** | 0.3.0 | BSD-3-Clause | -| **[reflux-state-mixin](#b550b09e44c1263378a50688b4e60d7b4ea29394abcaf0c93aba1078ab93f973)** | 0.7.0 | ISC | | **[reflux](#f892193924d403a4dd1a73a5861913838f1a9d704055d9d098eb0d40f752e053)** | 0.4.1 | BSD-3-Clause | | **[reservoir](#84f8998f94ad5bd85b50458378edf3815fff553cdcabf8ced3db418f05e85ff6)** | 0.1.2 | MIT | | **[resolve-mongodb-srv](#2ae8b0c9dbe8e8c900bfaf5567bcf2af917e62fb0a24121b4d667dffbeaffa99)** | 1.1.5 | Apache-2.0 | @@ -29187,9 +29186,9 @@ License files: OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - + -### [picocolors](https://www.npmjs.com/package/picocolors) (version 1.0.0) +### [picocolors](https://www.npmjs.com/package/picocolors) (version 1.0.1) License tags: ISC @@ -36340,218 +36339,6 @@ License files: THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - -### [reflux-state-mixin](https://www.npmjs.com/package/reflux-state-mixin) (version 0.7.0) - -License tags: ISC - -License files: - -- LICENSE: - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - ### [reflux](https://www.npmjs.com/package/reflux) (version 0.4.1) diff --git a/configs/webpack-config-compass/package.json b/configs/webpack-config-compass/package.json index f31aa43ce8b..305e786ce6f 100644 --- a/configs/webpack-config-compass/package.json +++ b/configs/webpack-config-compass/package.json @@ -69,7 +69,7 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.5", "babel-loader": "^8.2.5", "babel-plugin-istanbul": "^5.2.0", - "browserslist": "^4.23.0", + "browserslist": "^4.23.1", "chalk": "^4.1.2", "cli-progress": "^3.9.1", "core-js": "^3.17.3", diff --git a/package-lock.json b/package-lock.json index 72b28271217..620dcf7fc6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -182,7 +182,7 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.5", "babel-loader": "^8.2.5", "babel-plugin-istanbul": "^5.2.0", - "browserslist": "^4.23.0", + "browserslist": "^4.23.1", "chalk": "^4.1.2", "cli-progress": "^3.9.1", "core-js": "^3.17.3", @@ -8009,6 +8009,10 @@ "prettier": "^2.3.2" } }, + "node_modules/@mongodb-js/reflux-state-mixin": { + "resolved": "packages/reflux-state-mixin", + "link": true + }, "node_modules/@mongodb-js/saslprep": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.7.tgz", @@ -16637,9 +16641,9 @@ "dev": true }, "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", + "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", "funding": [ { "type": "opencollective", @@ -16655,10 +16659,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", + "caniuse-lite": "^1.0.30001629", + "electron-to-chromium": "^1.4.796", "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "update-browserslist-db": "^1.0.16" }, "bin": { "browserslist": "cli.js" @@ -16966,9 +16970,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001591", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001591.tgz", - "integrity": "sha512-PCzRMei/vXjJyL5mJtzNiUCKP59dm8Apqc3PH8gJkMnMXZGox93RbE76jHsmLwmIo6/3nsYIpJtx0O7u5PqFuQ==", + "version": "1.0.30001629", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001629.tgz", + "integrity": "sha512-c3dl911slnQhmxUIT4HhYzT7wnBK/XYpGnYLOj4nJBaRiw52Ibe7YxlDaAeRECvA786zCuExhxIUJ2K7nHMrBw==", "funding": [ { "type": "opencollective", @@ -20769,9 +20773,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.687", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.687.tgz", - "integrity": "sha512-Ic85cOuXSP6h7KM0AIJ2hpJ98Bo4hyTUjc4yjMbkvD+8yTxEhfK9+8exT2KKYsSjnCn2tGsKVSZwE7ZgTORQCw==" + "version": "1.4.796", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.796.tgz", + "integrity": "sha512-NglN/xprcM+SHD2XCli4oC6bWe6kHoytcyLKCWXmRL854F0qhPhaYgUswUsglnPxYaNQIg2uMY4BvaomIf3kLA==" }, "node_modules/electron-window": { "version": "0.8.1", @@ -21446,9 +21450,9 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "engines": { "node": ">=6" } @@ -34845,9 +34849,9 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" }, "node_modules/picomatch": { "version": "2.3.0", @@ -38081,14 +38085,6 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.2.0.tgz", "integrity": "sha1-HIaZHYFq0eUEdQ5zh0Ik7PO+xQg=" }, - "node_modules/reflux-state-mixin": { - "version": "0.7.0", - "resolved": "git+ssh://git@github.com/mongodb-js/reflux-state-mixin.git#e050454cb3be029c3e7fd2ee6a08111e4d15161f", - "license": "ISC", - "peerDependencies": { - "reflux": ">=0.2.5 <=0.4.x" - } - }, "node_modules/reflux/node_modules/eventemitter3": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.2.0.tgz", @@ -41901,9 +41897,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", + "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", "funding": [ { "type": "opencollective", @@ -41919,8 +41915,8 @@ } ], "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -44420,6 +44416,7 @@ "@mongodb-js/compass-workspaces": "^0.10.0", "@mongodb-js/explain-plan-helper": "^1.1.12", "@mongodb-js/my-queries-storage": "^0.8.1", + "@mongodb-js/reflux-state-mixin": "^1.0.0", "ag-grid-community": "^20.2.0", "ag-grid-react": "^20.2.0", "bson": "^6.7.0", @@ -44436,7 +44433,6 @@ "prop-types": "^15.7.2", "react": "^17.0.2", "reflux": "^0.4.1", - "reflux-state-mixin": "github:mongodb-js/reflux-state-mixin", "semver": "^7.6.2" }, "devDependencies": { @@ -46010,6 +46006,7 @@ "@mongodb-js/compass-logging": "^1.2.18", "@mongodb-js/compass-query-bar": "^8.30.0", "@mongodb-js/connection-storage": "^0.12.0", + "@mongodb-js/reflux-state-mixin": "^1.0.0", "bson": "^6.7.0", "compass-preferences-model": "^2.21.0", "d3": "^3.5.17", @@ -46028,8 +46025,7 @@ "react": "^17.0.2", "react-leaflet": "^2.4.0", "react-leaflet-draw": "^0.19.0", - "reflux": "^0.4.1", - "reflux-state-mixin": "github:mongodb-js/reflux-state-mixin" + "reflux": "^0.4.1" }, "devDependencies": { "@mongodb-js/eslint-config-compass": "^1.1.1", @@ -46260,6 +46256,7 @@ "@mongodb-js/compass-logging": "^1.2.18", "@mongodb-js/compass-user-data": "^0.1.21", "@mongodb-js/compass-utils": "^0.6.4", + "@mongodb-js/compass-workspaces": "^0.10.0", "@mongosh/browser-repl": "^2.2.6", "@mongosh/logging": "^2.2.6", "@mongosh/node-runtime-worker-thread": "^2.2.6", @@ -49143,6 +49140,28 @@ "mocha": "^10.2.0" } }, + "packages/reflux-state-mixin": { + "name": "@mongodb-js/reflux-state-mixin", + "version": "1.0.0", + "license": "SSPL", + "dependencies": { + "reflux": "^0.4.1" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.1.1", + "@mongodb-js/mocha-config-compass": "^1.3.9", + "@mongodb-js/prettier-config-compass": "^1.0.2", + "@mongodb-js/tsconfig-compass": "^1.0.4", + "@types/mocha": "^9.0.0", + "depcheck": "^1.4.1", + "eslint": "^7.25.0", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "prettier": "^2.7.1", + "typescript": "^5.0.4" + } + }, "packages/reflux-store/node_modules/acorn": { "version": "5.7.4", "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", @@ -55600,6 +55619,7 @@ "@mongodb-js/mocha-config-compass": "^1.3.9", "@mongodb-js/my-queries-storage": "^0.8.1", "@mongodb-js/prettier-config-compass": "^1.0.2", + "@mongodb-js/reflux-state-mixin": "^1.0.0", "@mongodb-js/tsconfig-compass": "^1.0.4", "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^13.5.0", @@ -55631,7 +55651,6 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "reflux": "^0.4.1", - "reflux-state-mixin": "github:mongodb-js/reflux-state-mixin", "semver": "^7.6.2", "sinon": "^8.1.1", "typescript": "^5.0.4" @@ -56618,6 +56637,7 @@ "@mongodb-js/mocha-config-compass": "^1.3.9", "@mongodb-js/my-queries-storage": "^0.8.1", "@mongodb-js/prettier-config-compass": "^1.0.2", + "@mongodb-js/reflux-state-mixin": "^1.0.0", "@mongodb-js/tsconfig-compass": "^1.0.4", "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^13.5.0", @@ -56653,7 +56673,6 @@ "react-leaflet": "^2.4.0", "react-leaflet-draw": "^0.19.0", "reflux": "^0.4.1", - "reflux-state-mixin": "github:mongodb-js/reflux-state-mixin", "sinon": "^9.2.3", "typescript": "^5.0.4", "xvfb-maybe": "^0.2.1" @@ -57043,6 +57062,7 @@ "@mongodb-js/compass-logging": "^1.2.18", "@mongodb-js/compass-user-data": "^0.1.21", "@mongodb-js/compass-utils": "^0.6.4", + "@mongodb-js/compass-workspaces": "^0.10.0", "@mongodb-js/connection-storage": "^0.12.0", "@mongodb-js/eslint-config-compass": "^1.1.1", "@mongodb-js/mocha-config-compass": "^1.3.9", @@ -58757,6 +58777,24 @@ "integrity": "sha512-Zaw/H/QUzwnIpThiD8IYxTurC7sv7OLwVXx9msgMkBIB6ebYXLeSNVZ25Q+gDah/t8mRFtBbDhq/Uledg7dPSQ==", "dev": true }, + "@mongodb-js/reflux-state-mixin": { + "version": "file:packages/reflux-state-mixin", + "requires": { + "@mongodb-js/eslint-config-compass": "^1.1.1", + "@mongodb-js/mocha-config-compass": "^1.3.9", + "@mongodb-js/prettier-config-compass": "^1.0.2", + "@mongodb-js/tsconfig-compass": "^1.0.4", + "@types/mocha": "^9.0.0", + "depcheck": "^1.4.1", + "eslint": "^7.25.0", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "prettier": "^2.7.1", + "reflux": "^0.4.1", + "typescript": "^5.0.4" + } + }, "@mongodb-js/saslprep": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.7.tgz", @@ -58934,7 +58972,7 @@ "@types/webpack-bundle-analyzer": "^4.4.1", "babel-loader": "^8.2.5", "babel-plugin-istanbul": "^5.2.0", - "browserslist": "^4.23.0", + "browserslist": "^4.23.1", "chalk": "^4.1.2", "cli-progress": "^3.9.1", "core-js": "^3.17.3", @@ -66242,14 +66280,14 @@ } }, "browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", + "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", "requires": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", + "caniuse-lite": "^1.0.30001629", + "electron-to-chromium": "^1.4.796", "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "update-browserslist-db": "^1.0.16" } }, "bson": { @@ -66476,9 +66514,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001591", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001591.tgz", - "integrity": "sha512-PCzRMei/vXjJyL5mJtzNiUCKP59dm8Apqc3PH8gJkMnMXZGox93RbE76jHsmLwmIo6/3nsYIpJtx0O7u5PqFuQ==" + "version": "1.0.30001629", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001629.tgz", + "integrity": "sha512-c3dl911slnQhmxUIT4HhYzT7wnBK/XYpGnYLOj4nJBaRiw52Ibe7YxlDaAeRECvA786zCuExhxIUJ2K7nHMrBw==" }, "caseless": { "version": "0.12.0", @@ -69783,9 +69821,9 @@ } }, "electron-to-chromium": { - "version": "1.4.687", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.687.tgz", - "integrity": "sha512-Ic85cOuXSP6h7KM0AIJ2hpJ98Bo4hyTUjc4yjMbkvD+8yTxEhfK9+8exT2KKYsSjnCn2tGsKVSZwE7ZgTORQCw==" + "version": "1.4.796", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.796.tgz", + "integrity": "sha512-NglN/xprcM+SHD2XCli4oC6bWe6kHoytcyLKCWXmRL854F0qhPhaYgUswUsglnPxYaNQIg2uMY4BvaomIf3kLA==" }, "electron-window": { "version": "0.8.1", @@ -70265,9 +70303,9 @@ } }, "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==" }, "escape-goat": { "version": "2.1.1", @@ -81819,9 +81857,9 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" }, "picomatch": { "version": "2.3.0", @@ -84259,10 +84297,6 @@ } } }, - "reflux-state-mixin": { - "version": "git+ssh://git@github.com/mongodb-js/reflux-state-mixin.git#e050454cb3be029c3e7fd2ee6a08111e4d15161f", - "from": "reflux-state-mixin@github:mongodb-js/reflux-state-mixin" - }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -87228,12 +87262,12 @@ "dev": true }, "update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", + "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", "requires": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" } }, "uri-js": { diff --git a/packages/compass-app-stores/src/stores/instance-store.ts b/packages/compass-app-stores/src/stores/instance-store.ts index 835c808410a..9f4e8a91884 100644 --- a/packages/compass-app-stores/src/stores/instance-store.ts +++ b/packages/compass-app-stores/src/stores/instance-store.ts @@ -233,9 +233,18 @@ export function createInstancesStore( onTopologyDescriptionChanged ); - on(globalAppRegistry, 'sidebar-expand-database', (dbName: string) => { - void instance.databases.get(dbName)?.fetchCollections({ dataService }); - }); + on( + globalAppRegistry, + 'sidebar-expand-database', + (connectionId: string, databaseId: string) => { + if (connectionId !== instanceConnectionId) { + return; + } + void instance.databases + .get(databaseId) + ?.fetchCollections({ dataService }); + } + ); on( globalAppRegistry, diff --git a/packages/compass-connections-navigation/src/base-navigation-item.tsx b/packages/compass-connections-navigation/src/base-navigation-item.tsx new file mode 100644 index 00000000000..b14633cdf97 --- /dev/null +++ b/packages/compass-connections-navigation/src/base-navigation-item.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { + useHoverState, + spacing, + css, + ItemActionControls, +} from '@mongodb-js/compass-components'; +import { ROW_HEIGHT, type Actions } from './constants'; +import { + ItemContainer, + ItemLabel, + ItemWrapper, + ItemButtonWrapper, + ExpandButton, +} from './tree-item'; +import { type NavigationItemActions } from './item-actions'; + +type NavigationBaseItemProps = { + isActive: boolean; + style: React.CSSProperties; + + name: string; + icon: React.ReactNode; + + dataAttributes?: Record; + + canExpand: boolean; + isExpanded: boolean; + onExpand: (toggle: boolean) => void; + + actionProps: { + collapseAfter?: number; + collapseToMenuThreshold?: number; + actions: NavigationItemActions; + onAction: (action: Actions) => void; + }; +}; + +const baseItemContainerStyles = css({ + height: ROW_HEIGHT, +}); + +const baseItemButtonWrapperStyles = css({ + height: ROW_HEIGHT, + paddingRight: spacing[100], +}); + +const baseItemLabelStyles = css({ + marginLeft: spacing[200], +}); + +export const NavigationBaseItem = ({ + isActive, + actionProps, + name, + style, + icon, + dataAttributes, + canExpand, + isExpanded, + onExpand, +}: NavigationBaseItemProps) => { + const [hoverProps, isHovered] = useHoverState(); + return ( + + + + {canExpand && ( + { + evt.stopPropagation(); + onExpand(!isExpanded); + }} + isExpanded={isExpanded} + > + )} + {icon} + + {name} + + + + isVisible={isActive || isHovered} + data-testid="sidebar-navigation-item-actions" + iconSize="small" + {...actionProps} + > + + + ); +}; diff --git a/packages/compass-connections-navigation/src/collection-item.tsx b/packages/compass-connections-navigation/src/collection-item.tsx deleted file mode 100644 index 85b8fe4ec41..00000000000 --- a/packages/compass-connections-navigation/src/collection-item.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { - useHoverState, - spacing, - css, - ItemActionControls, - Icon, -} from '@mongodb-js/compass-components'; -import type { ItemAction } from '@mongodb-js/compass-components'; -import { ROW_HEIGHT } from './constants'; -import { - ItemContainer, - ItemLabel, - ItemWrapper, - ItemButtonWrapper, -} from './tree-item'; -import type { - VirtualListItemProps, - TreeItemProps, - NamespaceItemProps, -} from './tree-item'; -import type { Actions } from './constants'; -import { usePreference } from 'compass-preferences-model/provider'; -import { getItemPaddingStyles } from './utils'; - -const CollectionIcon: React.FunctionComponent<{ - type: string; -}> = ({ type }) => { - const glyph = useMemo(() => { - return type === 'timeseries' - ? 'TimeSeries' - : type === 'view' - ? 'Visibility' - : 'Folder'; - }, [type]); - - return ; -}; - -const collectionItem = css({ - height: ROW_HEIGHT, -}); - -const itemButtonWrapper = css({ - height: ROW_HEIGHT, - paddingRight: spacing[1], -}); - -const collectionItemLabel = css({ - marginLeft: spacing[2], -}); - -export const CollectionItem: React.FunctionComponent< - VirtualListItemProps & TreeItemProps & NamespaceItemProps -> = ({ - connectionId, - id, - name, - level, - type, - posInSet, - setSize, - isActive, - isReadOnly, - isSingleConnection, - isTabbable, - style, - onNamespaceAction, -}) => { - const isRenameCollectionEnabled = usePreference( - 'enableRenameCollectionModal' - ); - const [hoverProps, isHovered] = useHoverState(); - - const itemPaddingStyles = useMemo( - () => getItemPaddingStyles({ level, isSingleConnection }), - [level, isSingleConnection] - ); - - const onDefaultAction = useCallback( - (evt) => { - if (evt.metaKey || evt.ctrlKey) { - onNamespaceAction( - connectionId, - evt.currentTarget.dataset.id as string, - 'open-in-new-tab' - ); - } else { - onNamespaceAction( - connectionId, - evt.currentTarget.dataset.id as string, - 'select-collection' - ); - } - }, - [connectionId, onNamespaceAction] - ); - - const onAction = useCallback( - (action: Actions) => { - onNamespaceAction(connectionId, id, action); - }, - [connectionId, id, onNamespaceAction] - ); - - const actions = useMemo(() => { - const actions: ItemAction[] = [ - { - action: 'open-in-new-tab', - label: 'Open in new tab', - icon: 'OpenNewTab', - }, - ]; - - if (isReadOnly) { - return actions; - } - - if (type === 'view') { - actions.push( - { - action: 'drop-collection', - label: 'Drop view', - icon: 'Trash', - }, - { - action: 'duplicate-view', - label: 'Duplicate view', - icon: 'Copy', - }, - { - action: 'modify-view', - label: 'Modify view', - icon: 'Edit', - } - ); - - return actions; - } - - if (type !== 'timeseries' && isRenameCollectionEnabled) { - actions.push({ - action: 'rename-collection', - label: 'Rename collection', - icon: 'Edit', - }); - } - - actions.push({ - action: 'drop-collection', - label: 'Drop collection', - icon: 'Trash', - }); - - return actions; - }, [type, isReadOnly, isRenameCollectionEnabled]); - - return ( - - - - - - {name} - - - - onAction={onAction} - data-testid="sidebar-collection-item-actions" - iconSize="small" - isVisible={isActive || isHovered} - actions={actions} - > - - - ); -}; diff --git a/packages/compass-connections-navigation/src/connection-item.tsx b/packages/compass-connections-navigation/src/connection-item.tsx deleted file mode 100644 index 354d237fe4d..00000000000 --- a/packages/compass-connections-navigation/src/connection-item.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { - useHoverState, - spacing, - css, - ItemActionControls, - Icon, - ServerIcon, -} from '@mongodb-js/compass-components'; -import type { ItemAction } from '@mongodb-js/compass-components'; -import { ROW_HEIGHT } from './constants'; -import { - ItemContainer, - ItemLabel, - ItemWrapper, - ItemButtonWrapper, - ExpandButton, -} from './tree-item'; -import type { - VirtualListItemProps, - TreeItemProps, - NamespaceItemProps, -} from './tree-item'; -import type { Actions } from './constants'; -import type { ConnectionInfo } from '@mongodb-js/connection-info'; -import { getItemPaddingStyles } from './utils'; - -const iconStyles = css({ - flex: 'none', -}); - -const connectionItem = css({ - height: ROW_HEIGHT, -}); - -const itemButtonWrapper = css({ - height: ROW_HEIGHT, - paddingRight: spacing[1], -}); - -const connectionItemLabel = css({ - marginLeft: spacing[2], -}); - -const actionMenu = css({ - width: '240px', -}); - -export const ConnectionItem: React.FunctionComponent< - VirtualListItemProps & - TreeItemProps & - NamespaceItemProps & { - isExpanded: boolean; - onConnectionExpand(id: string, isExpanded: boolean): void; - onConnectionSelect(id: string): void; - } & { - connectionInfo: ConnectionInfo; - isPerformanceTabSupported: boolean; - } -> = ({ - id, - name, - level, - posInSet, - setSize, - isExpanded, - isActive, - isReadOnly, - isSingleConnection, - isTabbable, - style, - connectionInfo, - isPerformanceTabSupported, - onNamespaceAction, - onConnectionExpand, - onConnectionSelect, -}) => { - const [hoverProps, isHovered] = useHoverState(); - - const isLocalhost = - connectionInfo.connectionOptions.connectionString.startsWith( - 'mongodb://localhost' - ); // TODO(COMPASS-7832) - const isFavorite = connectionInfo.savedConnectionType === 'favorite'; - - const itemPaddingStyles = useMemo( - () => getItemPaddingStyles({ level, isSingleConnection }), - [level, isSingleConnection] - ); - - const onExpandButtonClick = useCallback( - (evt: React.MouseEvent) => { - evt.stopPropagation(); - onConnectionExpand(connectionInfo.id, !isExpanded); - }, - [onConnectionExpand, connectionInfo.id, isExpanded] - ); - - const onDefaultAction = useCallback( - () => onConnectionSelect(connectionInfo.id), - [onConnectionSelect, connectionInfo.id] - ); - - const onAction = useCallback( - (action: Actions) => { - onNamespaceAction(id, id, action); - }, - [id, onNamespaceAction] - ); - - const actions: ItemAction[] = useMemo(() => { - const isFavorite = connectionInfo.savedConnectionType === 'favorite'; - - const actions: ItemAction[] = [ - { - action: 'create-database', - icon: 'Plus', - label: 'Create database', - }, - { - action: 'connection-performance-metrics', - icon: 'Gauge', - label: 'View performance metrics', - isDisabled: !isPerformanceTabSupported, - disabledDescription: 'Not supported', - }, - { - action: 'open-connection-info', - icon: 'InfoWithCircle', - label: 'Show connection info', - }, - { - action: 'copy-connection-string', - icon: 'Copy', - label: 'Copy connection string', - }, - { - action: 'connection-toggle-favorite', - icon: 'Favorite', - label: isFavorite ? 'Unfavorite' : 'Favorite', - }, - { - action: 'connection-disconnect', - icon: 'Disconnect', - label: 'Disconnect', - variant: 'destructive', - }, - ]; - - return actions; - }, [connectionInfo.savedConnectionType, isPerformanceTabSupported]); - - const connectionIcon = isLocalhost ? ( - - ) : isFavorite ? ( - - ) : ( - - ); - - return ( - - - - - {connectionIcon} - - {name} - - - {!isReadOnly && ( - - onAction={onAction} - isVisible={isActive || isHovered} - data-testid="sidebar-connection-item-actions" - iconSize="small" - actions={actions} - collapseAfter={1} - menuClassName={actionMenu} - > - )} - - - ); -}; diff --git a/packages/compass-connections-navigation/src/connections-navigation-tree.spec.tsx b/packages/compass-connections-navigation/src/connections-navigation-tree.spec.tsx index 333983d2966..183c09641b8 100644 --- a/packages/compass-connections-navigation/src/connections-navigation-tree.spec.tsx +++ b/packages/compass-connections-navigation/src/connections-navigation-tree.spec.tsx @@ -10,10 +10,8 @@ import { import userEvent from '@testing-library/user-event'; import { expect } from 'chai'; import Sinon from 'sinon'; -import { - type Connection, - ConnectionsNavigationTree, -} from './connections-navigation-tree'; +import { ConnectionsNavigationTree } from './connections-navigation-tree'; +import type { Connection } from './tree-data'; import type { PreferencesAccess } from 'compass-preferences-model'; import { createSandboxFromDefaultPreferences } from 'compass-preferences-model'; import { PreferencesProvider } from 'compass-preferences-model/provider'; @@ -57,6 +55,7 @@ const connections: Connection[] = [ isReady: true, isDataLake: false, isWritable: false, + isPerformanceTabSupported: true, }, { connectionInfo: { @@ -76,6 +75,7 @@ const connections: Connection[] = [ isReady: true, isDataLake: false, isWritable: false, + isPerformanceTabSupported: false, }, ]; @@ -116,7 +116,7 @@ describe('ConnectionsNavigationTree', function () { expanded: { connection_ready: { db_ready: true } }, }); - const collection = screen.getByTestId('sidebar-collection-db_ready.meow'); + const collection = screen.getByTestId('connection_ready.db_ready.meow'); const showActionsButton = within(collection).getByTitle('Show actions'); expect(within(collection).getByTitle('Show actions')).to.exist; @@ -133,7 +133,7 @@ describe('ConnectionsNavigationTree', function () { onNamespaceAction: spy, }); - const collection = screen.getByTestId('sidebar-collection-db_ready.meow'); + const collection = screen.getByTestId('connection_ready.db_ready.meow'); userEvent.click(within(collection).getByTitle('Show actions')); userEvent.click(screen.getByText('Rename collection')); @@ -213,9 +213,9 @@ describe('ConnectionsNavigationTree', function () { // Virtual list will be the one to grab the focus first, but will // immediately forward it to the element and mocking raf here breaks // virtual list implementatin, waitFor is to accomodate for that - expect(document.querySelector('[data-id="db_ready"]')).to.eq( - document.activeElement - ); + expect( + document.querySelector('[data-id="connection_ready.db_ready"]') + ).to.eq(document.activeElement); return true; }); }); @@ -228,7 +228,7 @@ describe('ConnectionsNavigationTree', function () { userEvent.hover(screen.getByText('foo')); - const database = screen.getByTestId('sidebar-database-db_initial'); + const database = screen.getByTestId('connection_ready.db_initial'); expect(within(database).getByTitle('Create collection')).to.exist; expect(within(database).getByTitle('Drop database')).to.exist; @@ -246,7 +246,7 @@ describe('ConnectionsNavigationTree', function () { } as WorkspaceTab, }); - const database = screen.getByTestId('sidebar-database-db_ready'); + const database = screen.getByTestId('connection_ready.db_ready'); expect(within(database).getByTitle('Create collection')).to.exist; expect(within(database).getByTitle('Drop database')).to.exist; @@ -259,7 +259,7 @@ describe('ConnectionsNavigationTree', function () { }, }); - const collection = screen.getByTestId('sidebar-collection-db_ready.meow'); + const collection = screen.getByTestId('connection_ready.db_ready.meow'); const showActionsButton = within(collection).getByTitle('Show actions'); expect(within(collection).getByTitle('Show actions')).to.exist; @@ -282,7 +282,7 @@ describe('ConnectionsNavigationTree', function () { } as WorkspaceTab, }); - const collection = screen.getByTestId('sidebar-collection-db_ready.bwok'); + const collection = screen.getByTestId('connection_ready.db_ready.bwok'); const showActionsButton = within(collection).getByTitle('Show actions'); expect(within(collection).getByTitle('Show actions')).to.exist; @@ -313,7 +313,7 @@ describe('ConnectionsNavigationTree', function () { isReadOnly: true, }); - const database = screen.getByTestId('sidebar-database-db_ready'); + const database = screen.getByTestId('connection_ready.db_ready'); expect(() => within(database).getByTitle('Create collection')).to.throw; expect(() => within(database).getByTitle('Drop database')).to.throw; @@ -332,7 +332,7 @@ describe('ConnectionsNavigationTree', function () { isReadOnly: true, }); - const collection = screen.getByTestId('sidebar-collection-db_ready.bwok'); + const collection = screen.getByTestId('connection_ready.db_ready.bwok'); expect(within(collection).getByTitle('Open in new tab')).to.exist; }); @@ -348,65 +348,69 @@ describe('ConnectionsNavigationTree', function () { }); }); - it('should activate callback with `create-database` when add database is clicked', async function () { - const spy = Sinon.spy(); - await renderConnectionsNavigationTree({ - expanded: { connection_ready: { db_ready: true } }, - onNamespaceAction: spy, - }); - - userEvent.hover(screen.getByText('turtles')); - - userEvent.click(screen.getByLabelText('Create database')); + describe('when selecting a tree item', function () { + it('should activate callback with `select-connection` when a connection is clicked', async function () { + const spy = Sinon.spy(); + await renderConnectionsNavigationTree({ + onConnectionSelect: spy, + }); - expect(spy).to.be.calledOnceWithExactly( - 'connection_ready', - 'connection_ready', - 'create-database' - ); - }); + userEvent.click(screen.getByText('turtles')); - it('should activate callback with `select-connection` when a connection is clicked', async function () { - const spy = Sinon.spy(); - await renderConnectionsNavigationTree({ - onConnectionSelect: spy, + expect(spy).to.be.calledOnceWithExactly('connection_ready'); }); - userEvent.click(screen.getByText('turtles')); + it('should activate callback with `select-database` when database is clicked', async function () { + const spy = Sinon.spy(); + await renderConnectionsNavigationTree({ + expanded: { connection_ready: {} }, + onNamespaceAction: spy, + }); - expect(spy).to.be.calledOnceWithExactly('connection_ready'); - }); + userEvent.click(screen.getByText('foo')); - it('should activate callback with `select-database` when database is clicked', async function () { - const spy = Sinon.spy(); - await renderConnectionsNavigationTree({ - expanded: { connection_ready: {} }, - onNamespaceAction: spy, + expect(spy).to.be.calledOnceWithExactly( + 'connection_ready', + 'db_initial', + 'select-database' + ); }); - userEvent.click(screen.getByText('foo')); + it('should activate callback with `select-collection` when collection is clicked', async function () { + const spy = Sinon.spy(); + await renderConnectionsNavigationTree({ + expanded: { connection_ready: { db_ready: true } }, + onNamespaceAction: spy, + }); - expect(spy).to.be.calledOnceWithExactly( - 'connection_ready', - 'db_initial', - 'select-database' - ); - }); + userEvent.click(screen.getByText('meow')); - it('should activate callback with `select-collection` when collection is clicked', async function () { - const spy = Sinon.spy(); - await renderConnectionsNavigationTree({ - expanded: { connection_ready: { db_ready: true } }, - onNamespaceAction: spy, + expect(spy).to.be.calledOnceWithExactly( + 'connection_ready', + 'db_ready.meow', + 'select-collection' + ); }); + }); + + describe('connection actions', function () { + it('should activate callback with `create-database` when add database is clicked', async function () { + const spy = Sinon.spy(); + await renderConnectionsNavigationTree({ + expanded: { connection_ready: { db_ready: true } }, + onNamespaceAction: spy, + }); - userEvent.click(screen.getByText('meow')); + userEvent.hover(screen.getByText('turtles')); - expect(spy).to.be.calledOnceWithExactly( - 'connection_ready', - 'db_ready.meow', - 'select-collection' - ); + userEvent.click(screen.getByLabelText('Create database')); + + expect(spy).to.be.calledOnceWithExactly( + 'connection_ready', + 'connection_ready', + 'create-database' + ); + }); }); describe('database actions', function () { @@ -461,9 +465,7 @@ describe('ConnectionsNavigationTree', function () { onNamespaceAction: spy, }); - const collection = screen.getByTestId( - 'sidebar-collection-db_ready.meow' - ); + const collection = screen.getByTestId('connection_ready.db_ready.meow'); userEvent.click(within(collection).getByTitle('Show actions')); userEvent.click(screen.getByText('Open in new tab')); @@ -482,9 +484,7 @@ describe('ConnectionsNavigationTree', function () { onNamespaceAction: spy, }); - const collection = screen.getByTestId( - 'sidebar-collection-db_ready.meow' - ); + const collection = screen.getByTestId('connection_ready.db_ready.meow'); userEvent.click(within(collection).getByTitle('Show actions')); userEvent.click(screen.getByText('Drop collection')); @@ -509,7 +509,7 @@ describe('ConnectionsNavigationTree', function () { onNamespaceAction: spy, }); - const view = screen.getByTestId('sidebar-collection-db_ready.bwok'); + const view = screen.getByTestId('connection_ready.db_ready.bwok'); userEvent.click(within(view).getByTitle('Show actions')); userEvent.click(screen.getByText('Duplicate view')); @@ -532,7 +532,7 @@ describe('ConnectionsNavigationTree', function () { onNamespaceAction: spy, }); - const view = screen.getByTestId('sidebar-collection-db_ready.bwok'); + const view = screen.getByTestId('connection_ready.db_ready.bwok'); userEvent.click(within(view).getByTitle('Show actions')); userEvent.click(screen.getByText('Modify view')); diff --git a/packages/compass-connections-navigation/src/connections-navigation-tree.tsx b/packages/compass-connections-navigation/src/connections-navigation-tree.tsx index b4fb3ebe297..29fd6ec289a 100644 --- a/packages/compass-connections-navigation/src/connections-navigation-tree.tsx +++ b/packages/compass-connections-navigation/src/connections-navigation-tree.tsx @@ -1,402 +1,32 @@ -import React, { useCallback, useMemo, memo, useRef } from 'react'; -import { FixedSizeList as List, areEqual } from 'react-window'; +import React, { useCallback, useMemo } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; +import { getVirtualTreeItems, type Connection } from './tree-data'; +import { ROW_HEIGHT } from './constants'; +import type { Actions } from './constants'; +import { VirtualTree } from './virtual-list/virtual-list'; +import type { + OnDefaultAction, + OnExpandedChange, +} from './virtual-list/virtual-list'; +import { NavigationItem } from './navigation-item'; +import type { SidebarTreeItem, SidebarActionableItem } from './tree-data'; import { FadeInPlaceholder, - css, - useId, VisuallyHidden, + css, spacing, + useId, } from '@mongodb-js/compass-components'; -import { PlaceholderItem } from './placeholder-item'; -import { - MAX_COLLECTION_PLACEHOLDER_ITEMS, - MAX_DATABASE_PLACEHOLDER_ITEMS, - MIN_DATABASE_PLACEHOLDER_ITEMS, - ROW_HEIGHT, -} from './constants'; -import { DatabaseItem } from './database-item'; -import { CollectionItem } from './collection-item'; -import type { Actions } from './constants'; -import { useVirtualNavigationTree } from './use-virtual-navigation-tree'; -import type { NavigationTreeData } from './use-virtual-navigation-tree'; -import { TopPlaceholder } from './top-placeholder'; -import { ConnectionItem } from './connection-item'; -import { type ConnectionInfo } from '@mongodb-js/connection-info'; -import StyledNavigationItem from './styled-navigation-item'; +import type { WorkspaceTab } from '@mongodb-js/compass-workspaces'; import { usePreference } from 'compass-preferences-model/provider'; -import { type WorkspaceTab } from '@mongodb-js/compass-workspaces'; - -type Collection = { - _id: string; - name: string; - type: string; -}; - -type Status = 'initial' | 'fetching' | 'refreshing' | 'ready' | 'error'; - -type Database = { - _id: string; - name: string; - collectionsStatus: Status; - collectionsLength: number; - collections: Collection[]; -}; - -export type Connection = { - connectionInfo: ConnectionInfo; - name: string; - databasesStatus: Status; - databasesLength: number; - databases: Database[]; - isReady: boolean; - isDataLake: boolean; - isWritable: boolean; - isPerformanceTabSupported: boolean; -}; - -type PlaceholderTreeItem = { - key: string; - type: 'placeholder'; - level: number; - id?: string; - colorCode?: string; -}; - -type ConnectionTreeItem = { - key: string; - type: 'connection'; - level: number; - id: string; - name: string; - isExpanded: boolean; - setSize: number; - posInSet: number; - colorCode?: string; - connectionInfo: ConnectionInfo; - isPerformanceTabSupported: boolean; -}; - -type DatabaseTreeItem = { - connectionId: string; - key: string; - type: 'database'; - level: number; - id: string; - name: string; - isExpanded: boolean; - setSize: number; - posInSet: number; - colorCode?: string; -}; - -type CollectionTreeItem = { - connectionId: string; - key: string; - type: 'collection' | 'view' | 'timeseries'; - level: number; - id: string; - name: string; - setSize: number; - posInSet: number; - colorCode?: string; -}; - -export type TreeItem = - | PlaceholderTreeItem - | ConnectionTreeItem - | DatabaseTreeItem - | CollectionTreeItem; - -type ListItemData = { - items: TreeItem[]; - isReadOnly: boolean; - isSingleConnection?: boolean; - activeWorkspace?: WorkspaceTab; - currentTabbable?: string; - onConnectionExpand(this: void, id: string, isExpanded: boolean): void; - onConnectionSelect(this: void, id: string): void; - onDatabaseExpand( - this: void, - connectionId: string, - id: string, - isExpanded: boolean - ): void; - onNamespaceAction( - this: void, - connectionId: string, - namespace: string, - action: Actions - ): void; -}; - -const collectionItemContainer = css({ - position: 'relative', -}); - -const connectionToItems = ({ - connection: { - connectionInfo, - name, - databases, - databasesStatus, - databasesLength, - isPerformanceTabSupported, - }, - connectionIndex, - connectionsLength, - expanded, -}: { - connection: Connection; - connectionIndex: number; - connectionsLength: number; - expanded?: Record>; -}): TreeItem[] => { - const isExpanded = !!(expanded && expanded[connectionInfo.id]); - - const areDatabasesReady = ['ready', 'refreshing', 'error'].includes( - databasesStatus - ); - - const placeholdersLength = Math.max( - Math.min(databasesLength, MAX_DATABASE_PLACEHOLDER_ITEMS), - // we are connecting and we don't have metadata on how many databases are in this cluster - MIN_DATABASE_PLACEHOLDER_ITEMS - ); - - const colorCode = connectionInfo.favorite?.color; - - const connectionTI: ConnectionTreeItem = { - key: String(connectionIndex), - level: 1, - id: connectionInfo.id, - name, - type: 'connection' as const, - isExpanded, - setSize: connectionsLength, - posInSet: connectionIndex + 1, - connectionInfo, - colorCode, - isPerformanceTabSupported, - }; - - if (!isExpanded) { - return [connectionTI]; - } - - return ([connectionTI] as TreeItem[]).concat( - areDatabasesReady - ? databases.flatMap((database, databaseIndex) => { - const dbExpanded = expanded?.[connectionInfo.id] || {}; - return databaseToItems({ - connectionId: connectionInfo.id, - database, - connectionIndex, - databaseIndex, - databasesLength: databases.length, - expanded: dbExpanded, - level: 2, - colorCode, - }); - }) - : Array.from({ length: placeholdersLength }, (_, index) => ({ - key: `${connectionIndex}-${index}`, - level: 2, - type: 'placeholder' as const, - colorCode, - })) - ); -}; - -const databaseToItems = ({ - database: { - _id: id, - name, - collections, - collectionsLength, - collectionsStatus, - }, - connectionId, - connectionIndex, - databaseIndex, - databasesLength, - expanded, - level, - colorCode, -}: { - database: Database; - connectionId: string; - connectionIndex?: number; - databaseIndex: number; - databasesLength: number; - expanded?: Record; - level: number; - colorCode?: string; -}): TreeItem[] => { - const isExpanded = expanded ? expanded[id] : false; - const isInConnection = typeof connectionIndex !== undefined; - - const databaseTI: DatabaseTreeItem = { - connectionId, - key: isInConnection - ? `${connectionIndex as number}-${databaseIndex}` - : `${databaseIndex}`, - level, - id, - name, - type: 'database' as const, - isExpanded, - setSize: databasesLength, - posInSet: databaseIndex + 1, - colorCode, - }; - - if (!isExpanded) { - return [databaseTI]; - } - - const areCollectionsReady = ['ready', 'refreshing', 'error'].includes( - collectionsStatus - ); - - const placeholdersLength = Math.min( - collectionsLength, - MAX_COLLECTION_PLACEHOLDER_ITEMS - ); - - return ([databaseTI] as TreeItem[]).concat( - areCollectionsReady - ? collections.map(({ _id: id, name, type }, index) => ({ - connectionId, - key: isInConnection - ? `${connectionIndex as number}-${databaseIndex}-${index}` - : `${databaseIndex}-${index}`, - level: level + 1, - id, - name, - type: type as 'collection' | 'view' | 'timeseries', - setSize: collections.length, - posInSet: index + 1, - colorCode, - })) - : Array.from({ length: placeholdersLength }, (_, index) => ({ - key: isInConnection - ? `${connectionIndex as number}-${databaseIndex}-${index}` - : `${databaseIndex}-${index}`, - level: level + 1, - type: 'placeholder' as const, - colorCode, - })) - ); -}; - -const NavigationItem = memo<{ - index: number; - style: React.CSSProperties; - data: ListItemData; -}>(function NavigationItem({ index, style, data }) { - const { - items, - isSingleConnection, - isReadOnly, - activeWorkspace, - currentTabbable, - onConnectionExpand, - onConnectionSelect, - onDatabaseExpand, - onNamespaceAction, - } = data; - - const itemData = items[index]; - - let Item: React.ReactElement; - - if (itemData.type === 'connection') { - Item = ( - - ); - } else if (itemData.type === 'database') { - Item = ( - - ); - } else { - Item = ( -
- { - return ( - itemData.type !== 'placeholder' && ( - - ) - ); - }} - fallback={() => ( - - )} - > -
- ); - } - - return ( - - {Item} - - ); -}, areEqual); - -const navigationTree = css({ - flex: '1 0 auto', -}); +import { TopPlaceholder } from './placeholder'; +import { + collectionItemActions, + connectionItemActions, + databaseItemActions, +} from './item-actions'; -interface ConnectionsNavigationTreeProps { +export interface ConnectionsNavigationTreeProps { connections: Connection[]; expanded?: Record>; onConnectionExpand?(id: string, isExpanded: boolean): void; @@ -416,144 +46,141 @@ const ConnectionsNavigationTree: React.FunctionComponent< > = ({ connections, expanded, + isReadOnly = false, activeWorkspace, + onDatabaseExpand, + onNamespaceAction, // onConnectionExpand and onConnectionSelect only has a default to support single-connection usage // eslint-disable-next-line @typescript-eslint/no-empty-function onConnectionExpand = () => {}, // eslint-disable-next-line @typescript-eslint/no-empty-function onConnectionSelect = () => {}, - onDatabaseExpand, - onNamespaceAction, - isReadOnly = false, }) => { const isSingleConnection = !usePreference( 'enableNewMultipleConnectionSystem' ); - - const listRef = useRef(null); + const isRenameCollectionEnabled = usePreference( + 'enableRenameCollectionModal' + ); const id = useId(); - const items: TreeItem[] = useMemo(() => { - if (!isSingleConnection) { - return connections.flatMap((connection, connectionIndex) => - connectionToItems({ - connection, - connectionIndex, - connectionsLength: connections.length, - expanded, - }) - ); - } else { - const connection = connections[0]; - return connection.databases.flatMap((database, databaseIndex) => { - let isExpanded: undefined | Record = undefined; - if (expanded) { - isExpanded = expanded[connection.connectionInfo.id] || {}; - } - - return databaseToItems({ - connectionId: connection.connectionInfo.id, - database, - databaseIndex, - databasesLength: connection.databases.length || 0, - expanded: isExpanded, - level: 1, - }); - }); - } - }, [isSingleConnection, connections, expanded]); - - const onExpandedChange = useCallback( - ({ id, type, connectionId }, isExpanded: boolean) => { - if (type === 'database') onDatabaseExpand(connectionId, id, isExpanded); - if (type === 'connection') onConnectionExpand(id, isExpanded); + const treeData = useMemo(() => { + return getVirtualTreeItems(connections, isSingleConnection, expanded); + }, [connections, isSingleConnection, expanded]); + + const onDefaultAction: OnDefaultAction = useCallback( + (item, evt) => { + if (item.type === 'connection') { + onConnectionSelect(item.connectionInfo.id); + } else if (item.type === 'database') { + onNamespaceAction(item.connectionId, item.dbName, 'select-database'); + } else { + onNamespaceAction( + item.connectionId, + item.namespace, + evt.metaKey || evt.ctrlKey ? 'open-in-new-tab' : 'select-collection' + ); + } }, - [onDatabaseExpand, onConnectionExpand] + [onNamespaceAction, onConnectionSelect] ); - const onFocusMove = useCallback( - (item) => { - const idx = items.indexOf(item); - if (idx >= 0) { - // It is possible that the item we are trying to move the focus to is - // not rendered currently. Scroll it into view so that it's rendered and - // can be focused - listRef.current?.scrollToItem(idx); + const onItemExpand: OnExpandedChange = useCallback( + (item, expanded) => { + if (item.type === 'connection') { + onConnectionExpand(item.connectionInfo.id, expanded); + } else if (item.type === 'database') { + onDatabaseExpand(item.connectionId, item.dbName, expanded); } }, - [items] + [onConnectionExpand, onDatabaseExpand] ); - const [rootProps, currentTabbable] = useVirtualNavigationTree( - { - items: items as NavigationTreeData, - activeItemId: - (activeWorkspace as { namespace?: string })?.namespace || '', // TODO(COMPASS-7887) - onExpandedChange, - onFocusMove, - } + const onItemAction = useCallback( + (item: SidebarActionableItem, action: Actions) => { + const args = + item.type === 'connection' + ? ([item.connectionInfo.id, item.connectionInfo.id, action] as const) + : item.type === 'database' + ? ([item.connectionId, item.dbName, action] as const) + : ([item.connectionId, item.namespace, action] as const); + onNamespaceAction(...args); + }, + [onNamespaceAction] ); - const itemData: ListItemData = useMemo(() => { - return { - items, - isReadOnly, - isSingleConnection, - activeWorkspace, - currentTabbable, - onNamespaceAction, - onConnectionExpand, - onConnectionSelect, - onDatabaseExpand, - }; - }, [ - items, - isReadOnly, - isSingleConnection, - activeWorkspace, - currentTabbable, - onNamespaceAction, - onConnectionExpand, - onConnectionSelect, - onDatabaseExpand, - ]); - - const getItemKey = useCallback((index: number, data: typeof itemData) => { - return data.items[index].key; - }, []); + const activeItemId = useMemo(() => { + if (activeWorkspace) { + // Collection or Collections List (of a database) + if ( + activeWorkspace.type === 'Collection' || + activeWorkspace.type === 'Collections' + ) { + return `${activeWorkspace.connectionId}.${activeWorkspace.namespace}`; + } + // Database List (of a connection) + if (activeWorkspace.type === 'Databases') { + return activeWorkspace.connectionId; + } + } + }, [activeWorkspace]); + + const getItemActions = useCallback( + (item: SidebarTreeItem) => { + switch (item.type) { + case 'placeholder': + return []; + case 'connection': { + const isFavorite = + item.connectionInfo?.savedConnectionType === 'favorite'; + return connectionItemActions({ + isReadOnly, + isFavorite, + isPerformanceTabSupported: item.isPerformanceTabSupported, + }); + } + case 'database': + return databaseItemActions({ isReadOnly }); + default: + return collectionItemActions({ + isReadOnly, + type: item.type, + isRenameCollectionEnabled, + }); + } + }, + [isReadOnly, isRenameCollectionEnabled] + ); const isTestEnv = process.env.NODE_ENV === 'test'; return ( <> Databases and Collections -
- - {({ - width = isTestEnv ? 1024 : '', - height = isTestEnv ? 768 : '', - }) => ( - - {NavigationItem} - - )} - -
+ + {({ width = isTestEnv ? 1024 : '', height = isTestEnv ? 768 : '' }) => ( + + dataTestId="sidebar-navigation-tree" + activeItemId={activeItemId} + items={treeData} + width={width} + height={height} + itemHeight={ROW_HEIGHT} + onDefaultAction={onDefaultAction} + onExpandedChange={onItemExpand} + getItemKey={(item) => item.id} + renderItem={({ item }) => ( + + )} + /> + )} + ); }; @@ -561,7 +188,7 @@ const ConnectionsNavigationTree: React.FunctionComponent< const MCContainer = css({ display: 'flex', flex: '1 0 auto', - height: `calc(100% - ${spacing[3]}px)`, + height: `calc(100% - ${spacing[1600]}px - ${spacing[200]}px)`, }); const SCContainer = css({ @@ -593,11 +220,7 @@ const NavigationWithPlaceholder: React.FunctionComponent< ); }} fallback={() => { - return ( - - ); + return ; }} > ); diff --git a/packages/compass-connections-navigation/src/constants.tsx b/packages/compass-connections-navigation/src/constants.tsx index 94b90b55dbe..9bed400dd61 100644 --- a/packages/compass-connections-navigation/src/constants.tsx +++ b/packages/compass-connections-navigation/src/constants.tsx @@ -9,6 +9,7 @@ export const ROW_HEIGHT = spacing[5]; // export const COLLETIONS_MARGIN_BOTTOM = spacing[1]; export type Actions = + | 'open-shell' | 'connection-performance-metrics' | 'open-connection-info' | 'copy-connection-string' diff --git a/packages/compass-connections-navigation/src/database-item.tsx b/packages/compass-connections-navigation/src/database-item.tsx deleted file mode 100644 index 176e096df20..00000000000 --- a/packages/compass-connections-navigation/src/database-item.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { - useHoverState, - spacing, - css, - ItemActionControls, - Icon, -} from '@mongodb-js/compass-components'; -import type { ItemAction } from '@mongodb-js/compass-components'; -import { ROW_HEIGHT } from './constants'; -import { - ItemContainer, - ItemLabel, - ItemWrapper, - ItemButtonWrapper, - ExpandButton, -} from './tree-item'; -import type { - VirtualListItemProps, - TreeItemProps, - NamespaceItemProps, -} from './tree-item'; -import type { Actions } from './constants'; -import { getItemPaddingStyles } from './utils'; - -const databaseItem = css({ - height: ROW_HEIGHT, -}); - -const itemButtonWrapper = css({ - height: ROW_HEIGHT, - paddingRight: spacing[1], -}); - -const databaseItemLabel = css({ - marginLeft: spacing[2], -}); - -export const DatabaseItem: React.FunctionComponent< - VirtualListItemProps & - TreeItemProps & - NamespaceItemProps & { - isExpanded: boolean; - onDatabaseExpand( - connectionId: string, - id: string, - isExpanded: boolean - ): void; - } -> = ({ - connectionId, - id, - name, - level, - posInSet, - setSize, - isExpanded, - isActive, - isReadOnly, - isSingleConnection, - isTabbable, - style, - onNamespaceAction, - onDatabaseExpand, -}) => { - const [hoverProps, isHovered] = useHoverState(); - - const itemPaddingStyles = useMemo( - () => getItemPaddingStyles({ level, isSingleConnection }), - [level, isSingleConnection] - ); - - const onExpandButtonClick = useCallback( - (evt: React.MouseEvent) => { - evt.stopPropagation(); - onDatabaseExpand(connectionId, id, !isExpanded); - }, - [onDatabaseExpand, connectionId, id, isExpanded] - ); - - const onDefaultAction = useCallback( - (evt) => { - onNamespaceAction( - connectionId, - evt.currentTarget.dataset.id as string, - 'select-database' - ); - }, - [connectionId, onNamespaceAction] - ); - - const onAction = useCallback( - (action: Actions) => { - onNamespaceAction(connectionId, id, action); - }, - [connectionId, id, onNamespaceAction] - ); - - const actions: ItemAction[] = useMemo(() => { - return [ - { - action: 'create-collection', - icon: 'Plus', - label: 'Create collection', - }, - { - action: 'drop-database', - icon: 'Trash', - label: 'Drop database', - }, - ]; - }, []); - - return ( - - - - - - - {name} - - - {!isReadOnly && ( - - onAction={onAction} - isVisible={isActive || isHovered} - data-testid="sidebar-database-item-actions" - collapseToMenuThreshold={3} - iconSize="small" - actions={actions} - > - )} - - - ); -}; diff --git a/packages/compass-connections-navigation/src/index.ts b/packages/compass-connections-navigation/src/index.ts index 99280f9da89..fe50a8f3cb6 100644 --- a/packages/compass-connections-navigation/src/index.ts +++ b/packages/compass-connections-navigation/src/index.ts @@ -1,5 +1,3 @@ export type { Actions } from './constants'; -export { - type Connection, - NavigationWithPlaceholder as ConnectionsNavigationTree, -} from './connections-navigation-tree'; +export { type Connection } from './tree-data'; +export { NavigationWithPlaceholder as ConnectionsNavigationTree } from './connections-navigation-tree'; diff --git a/packages/compass-connections-navigation/src/item-actions.ts b/packages/compass-connections-navigation/src/item-actions.ts new file mode 100644 index 00000000000..64be94152ad --- /dev/null +++ b/packages/compass-connections-navigation/src/item-actions.ts @@ -0,0 +1,141 @@ +import type { ItemAction } from '@mongodb-js/compass-components'; +import { type Actions } from './constants'; + +export type NavigationItemActions = ItemAction[]; + +export const connectionItemActions = ({ + isReadOnly, + isFavorite, + isPerformanceTabSupported, +}: { + isReadOnly: boolean; + isFavorite: boolean; + isPerformanceTabSupported: boolean; +}): NavigationItemActions => { + const actions: NavigationItemActions = []; + if (!isReadOnly) { + actions.push({ + action: 'create-database', + icon: 'Plus', + label: 'Create database', + }); + } + actions.push( + { + action: 'open-shell', + icon: 'Shell', + label: 'Open MongoDB shell', + }, + { + action: 'connection-performance-metrics', + icon: 'Gauge', + label: 'View performance metrics', + isDisabled: !isPerformanceTabSupported, + disabledDescription: 'Not supported', + }, + { + action: 'open-connection-info', + icon: 'InfoWithCircle', + label: 'Show connection info', + }, + { + action: 'copy-connection-string', + icon: 'Copy', + label: 'Copy connection string', + }, + { + action: 'connection-toggle-favorite', + icon: 'Favorite', + label: isFavorite ? 'Unfavorite' : 'Favorite', + }, + { + action: 'connection-disconnect', + icon: 'Disconnect', + label: 'Disconnect', + variant: 'destructive', + } + ); + return actions; +}; + +export const databaseItemActions = ({ + isReadOnly, +}: { + isReadOnly: boolean; +}): NavigationItemActions => { + if (isReadOnly) { + return []; + } + return [ + { + action: 'create-collection', + icon: 'Plus', + label: 'Create collection', + }, + { + action: 'drop-database', + icon: 'Trash', + label: 'Drop database', + }, + ]; +}; + +export const collectionItemActions = ({ + isReadOnly, + type, + isRenameCollectionEnabled, +}: { + isReadOnly: boolean; + type: 'collection' | 'view' | 'timeseries'; + isRenameCollectionEnabled: boolean; +}): NavigationItemActions => { + const actions: NavigationItemActions = [ + { + action: 'open-in-new-tab', + label: 'Open in new tab', + icon: 'OpenNewTab', + }, + ]; + + if (isReadOnly) { + return actions; + } + + if (type === 'view') { + actions.push( + { + action: 'drop-collection', + label: 'Drop view', + icon: 'Trash', + }, + { + action: 'duplicate-view', + label: 'Duplicate view', + icon: 'Copy', + }, + { + action: 'modify-view', + label: 'Modify view', + icon: 'Edit', + } + ); + + return actions; + } + + if (type !== 'timeseries' && isRenameCollectionEnabled) { + actions.push({ + action: 'rename-collection', + label: 'Rename collection', + icon: 'Edit', + }); + } + + actions.push({ + action: 'drop-collection', + label: 'Drop collection', + icon: 'Trash', + }); + + return actions; +}; diff --git a/packages/compass-connections-navigation/src/navigation-item.tsx b/packages/compass-connections-navigation/src/navigation-item.tsx new file mode 100644 index 00000000000..9c261fab9ca --- /dev/null +++ b/packages/compass-connections-navigation/src/navigation-item.tsx @@ -0,0 +1,129 @@ +import React, { useCallback, useMemo } from 'react'; +import { Icon, ServerIcon } from '@mongodb-js/compass-components'; +import { PlaceholderItem } from './placeholder'; +import StyledNavigationItem from './styled-navigation-item'; +import { NavigationBaseItem } from './base-navigation-item'; +import type { NavigationItemActions } from './item-actions'; +import type { OnExpandedChange } from './virtual-list/virtual-list'; +import type { SidebarTreeItem, SidebarActionableItem } from './tree-data'; +import { getTreeItemStyles } from './utils'; + +type NavigationItemProps = { + item: SidebarTreeItem; + activeItemId?: string; + getItemActions: (item: SidebarTreeItem) => NavigationItemActions; + onItemAction: ( + item: SidebarActionableItem, + action: NavigationItemActions[number]['action'] + ) => void; + onItemExpand: OnExpandedChange; +}; + +export function NavigationItem({ + item, + activeItemId, + onItemAction, + onItemExpand, + getItemActions, +}: NavigationItemProps) { + const itemIcon = useMemo(() => { + if (item.type === 'database') { + return ; + } + if (item.type === 'collection') { + return ; + } + if (item.type === 'view') { + return ; + } + if (item.type === 'timeseries') { + return ; + } + if (item.type === 'connection') { + const isLocalhost = + item.connectionInfo.connectionOptions.connectionString.startsWith( + 'mongodb://localhost' + ); // TODO(COMPASS-7832) + const isFavorite = item.connectionInfo.savedConnectionType === 'favorite'; + if (isLocalhost) { + return ; + } + if (isFavorite) { + return ; + } + return ; + } + }, [item]); + + const onAction = useCallback( + (action: NavigationItemActions[number]['action']) => { + if (item.type !== 'placeholder') { + onItemAction(item, action); + } + }, + [item, onItemAction] + ); + + const style = useMemo(() => getTreeItemStyles(item), [item]); + + const actionProps = useMemo( + () => ({ + actions: getItemActions(item), + onAction: onAction, + ...(item.type === 'connection' && { + collapseAfter: 1, + }), + ...(item.type === 'database' && { + collapseToMenuThreshold: 3, + }), + }), + [getItemActions, item, onAction] + ); + + const itemDataProps = useMemo(() => { + if (item.type === 'placeholder') { + return {}; + } + if (item.type === 'connection') { + return { + 'data-connection-id': item.connectionInfo.id, + 'data-connection-name': item.connectionInfo.favorite?.name, + }; + } + if (item.type === 'database') { + return { + 'data-connection-id': item.connectionId, + 'data-database-name': item.dbName, + }; + } + return { + 'data-connection-id': item.connectionId, + 'data-namespace': item.namespace, + }; + }, [item]); + + return ( + + {item.type === 'placeholder' ? ( + + ) : ( + { + onItemExpand(item, isExpanded); + }} + actionProps={actionProps} + > + )} + + ); +} diff --git a/packages/compass-connections-navigation/src/placeholder-item.tsx b/packages/compass-connections-navigation/src/placeholder-item.tsx deleted file mode 100644 index dc05788de1d..00000000000 --- a/packages/compass-connections-navigation/src/placeholder-item.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React, { useMemo } from 'react'; -import type { CSSProperties } from 'react'; -import { Placeholder, css, cx } from '@mongodb-js/compass-components'; -import { ROW_HEIGHT } from './constants'; -import { getItemPaddingStyles } from './utils'; - -const placeholderItem = css({ - display: 'flex', - alignItems: 'center', - height: ROW_HEIGHT, - backgroundColor: 'var(--item-bg-color)', - color: 'var(--item-color)', -}); - -const MULTIPLE_CONNECTION_PROPS = { - gradientStart: 'var(--item-bg-color-active)', - gradientEnd: 'var(--item-bg-color)', - style: { filter: 'brightness(0.98)' }, -} as const; - -export const PlaceholderItem: React.FunctionComponent<{ - level: number; - isSingleConnection?: boolean; - style?: CSSProperties; -}> = ({ level, style, isSingleConnection }) => { - const itemPaddingStyles = useMemo( - () => - getItemPaddingStyles({ level, isPlaceholder: true, isSingleConnection }), - [level, isSingleConnection] - ); - - return ( -
- -
- ); -}; diff --git a/packages/compass-connections-navigation/src/placeholder.tsx b/packages/compass-connections-navigation/src/placeholder.tsx new file mode 100644 index 00000000000..c4df32b7f1a --- /dev/null +++ b/packages/compass-connections-navigation/src/placeholder.tsx @@ -0,0 +1,60 @@ +import React, { useMemo } from 'react'; +import type { CSSProperties } from 'react'; +import { Placeholder, css } from '@mongodb-js/compass-components'; +import { ROW_HEIGHT } from './constants'; +import { getTreeItemStyles } from './utils'; +import { usePreference } from 'compass-preferences-model/provider'; +import { getMaxNestingLevel } from './tree-data'; + +const placeholderItem = css({ + display: 'flex', + alignItems: 'center', + height: ROW_HEIGHT, + backgroundColor: 'var(--item-bg-color)', + color: 'var(--item-color)', +}); + +const MULTIPLE_CONNECTION_PROPS = { + gradientStart: 'var(--item-bg-color-active)', + gradientEnd: 'var(--item-bg-color)', + style: { filter: 'brightness(0.98)' }, +} as const; + +export const PlaceholderItem: React.FunctionComponent<{ + level: number; + maxNestingLevel: number; + style?: CSSProperties; +}> = ({ level, maxNestingLevel, style }) => { + const isSingleConnection = !usePreference( + 'enableNewMultipleConnectionSystem' + ); + const itemPaddingStyles = useMemo( + () => getTreeItemStyles({ level, maxNestingLevel }), + [level, maxNestingLevel] + ); + + return ( +
+ +
+ ); +}; + +const topPlaceholderStyles = css({ + maskImage: 'linear-gradient(to bottom, black 30%, transparent 95%)', +}); +export const TopPlaceholder = () => { + const isSingleConnection = !usePreference( + 'enableNewMultipleConnectionSystem' + ); + const items = useMemo(() => { + return Array.from({ length: 10 }, (_, idx) => ( + + )); + }, [isSingleConnection]); + return
{items}
; +}; diff --git a/packages/compass-connections-navigation/src/sc-connections-navigation-tree.spec.tsx b/packages/compass-connections-navigation/src/sc-connections-navigation-tree.spec.tsx index 7b05088529c..9dd2dab75a3 100644 --- a/packages/compass-connections-navigation/src/sc-connections-navigation-tree.spec.tsx +++ b/packages/compass-connections-navigation/src/sc-connections-navigation-tree.spec.tsx @@ -10,10 +10,8 @@ import { import userEvent from '@testing-library/user-event'; import { expect } from 'chai'; import Sinon from 'sinon'; -import { - type Connection, - ConnectionsNavigationTree, -} from './connections-navigation-tree'; +import { ConnectionsNavigationTree } from './connections-navigation-tree'; +import type { Connection } from './tree-data'; import type { PreferencesAccess } from 'compass-preferences-model'; import { createSandboxFromDefaultPreferences } from 'compass-preferences-model'; import { PreferencesProvider } from 'compass-preferences-model/provider'; @@ -48,6 +46,7 @@ const connections: Connection[] = [ isReady: true, isWritable: true, name: 'test', + isPerformanceTabSupported: false, }, ]; @@ -90,7 +89,7 @@ describe('ConnectionsNavigationTree -- Single connection usage', function () { ); - const collection = screen.getByTestId('sidebar-collection-bar.meow'); + const collection = screen.getByTestId('connectionId.bar.meow'); const showActionsButton = within(collection).getByTitle('Show actions'); expect(within(collection).getByTitle('Show actions')).to.exist; @@ -115,7 +114,7 @@ describe('ConnectionsNavigationTree -- Single connection usage', function () { ); - const collection = screen.getByTestId('sidebar-collection-bar.meow'); + const collection = screen.getByTestId('connectionId.bar.meow'); userEvent.click(within(collection).getByTitle('Show actions')); userEvent.click(screen.getByText('Rename collection')); @@ -195,7 +194,7 @@ describe('ConnectionsNavigationTree -- Single connection usage', function () { // Virtual list will be the one to grab the focus first, but will // immediately forward it to the element and mocking raf here breaks // virtual list implementatin, waitFor is to accomodate for that - expect(document.querySelector('[data-id="bar"]')).to.eq( + expect(document.querySelector('[data-id="connectionId.bar"]')).to.eq( document.activeElement ); return true; @@ -215,7 +214,7 @@ describe('ConnectionsNavigationTree -- Single connection usage', function () { userEvent.hover(screen.getByText('foo')); - const database = screen.getByTestId('sidebar-database-foo'); + const database = screen.getByTestId('connectionId.foo'); expect(within(database).getByTitle('Create collection')).to.exist; expect(within(database).getByTitle('Drop database')).to.exist; @@ -238,7 +237,7 @@ describe('ConnectionsNavigationTree -- Single connection usage', function () { > ); - const database = screen.getByTestId('sidebar-database-bar'); + const database = screen.getByTestId('connectionId.bar'); expect(within(database).getByTitle('Create collection')).to.exist; expect(within(database).getByTitle('Drop database')).to.exist; @@ -256,7 +255,7 @@ describe('ConnectionsNavigationTree -- Single connection usage', function () { > ); - const collection = screen.getByTestId('sidebar-collection-bar.meow'); + const collection = screen.getByTestId('connectionId.bar.meow'); const showActionsButton = within(collection).getByTitle('Show actions'); expect(within(collection).getByTitle('Show actions')).to.exist; @@ -285,7 +284,7 @@ describe('ConnectionsNavigationTree -- Single connection usage', function () { > ); - const collection = screen.getByTestId('sidebar-collection-bar.bwok'); + const collection = screen.getByTestId('connectionId.bar.bwok'); const showActionsButton = within(collection).getByTitle('Show actions'); expect(within(collection).getByTitle('Show actions')).to.exist; @@ -321,7 +320,7 @@ describe('ConnectionsNavigationTree -- Single connection usage', function () { > ); - const database = screen.getByTestId('sidebar-database-bar'); + const database = screen.getByTestId('connectionId.bar'); expect(() => within(database).getByTitle('Create collection')).to.throw; expect(() => within(database).getByTitle('Drop database')).to.throw; @@ -345,7 +344,7 @@ describe('ConnectionsNavigationTree -- Single connection usage', function () { > ); - const collection = screen.getByTestId('sidebar-collection-bar.bwok'); + const collection = screen.getByTestId('connectionId.bar.bwok'); expect(within(collection).getByTitle('Open in new tab')).to.exist; }); @@ -463,7 +462,7 @@ describe('ConnectionsNavigationTree -- Single connection usage', function () { > ); - const collection = screen.getByTestId('sidebar-collection-bar.meow'); + const collection = screen.getByTestId('connectionId.bar.meow'); userEvent.click(within(collection).getByTitle('Show actions')); userEvent.click(screen.getByText('Open in new tab')); @@ -488,7 +487,7 @@ describe('ConnectionsNavigationTree -- Single connection usage', function () { > ); - const collection = screen.getByTestId('sidebar-collection-bar.meow'); + const collection = screen.getByTestId('connectionId.bar.meow'); userEvent.click(within(collection).getByTitle('Show actions')); userEvent.click(screen.getByText('Drop collection')); @@ -521,7 +520,7 @@ describe('ConnectionsNavigationTree -- Single connection usage', function () { > ); - const view = screen.getByTestId('sidebar-collection-bar.bwok'); + const view = screen.getByTestId('connectionId.bar.bwok'); userEvent.click(within(view).getByTitle('Show actions')); userEvent.click(screen.getByText('Duplicate view')); @@ -552,7 +551,7 @@ describe('ConnectionsNavigationTree -- Single connection usage', function () { > ); - const view = screen.getByTestId('sidebar-collection-bar.bwok'); + const view = screen.getByTestId('connectionId.bar.bwok'); userEvent.click(within(view).getByTitle('Show actions')); userEvent.click(screen.getByText('Modify view')); diff --git a/packages/compass-connections-navigation/src/styled-navigation-item.tsx b/packages/compass-connections-navigation/src/styled-navigation-item.tsx index a599dd25106..80d75f717c8 100644 --- a/packages/compass-connections-navigation/src/styled-navigation-item.tsx +++ b/packages/compass-connections-navigation/src/styled-navigation-item.tsx @@ -3,18 +3,20 @@ import { useConnectionColor, DefaultColorCode, } from '@mongodb-js/connection-form'; +import { usePreference } from 'compass-preferences-model/provider'; export default function StyledNavigationItem({ colorCode, - isSingleConnection, children, }: { colorCode?: string; - isSingleConnection: boolean; children: React.ReactChild; }): React.ReactElement { const { connectionColorToHex, connectionColorToHexActive } = useConnectionColor(); + const isSingleConnection = !usePreference( + 'enableNewMultipleConnectionSystem' + ); const style: React.CSSProperties & { '--item-bg-color'?: string; diff --git a/packages/compass-connections-navigation/src/top-placeholder.tsx b/packages/compass-connections-navigation/src/top-placeholder.tsx deleted file mode 100644 index cb81dd49855..00000000000 --- a/packages/compass-connections-navigation/src/top-placeholder.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { css } from '@mongodb-js/compass-components'; -import React, { useMemo } from 'react'; -import { PlaceholderItem } from './placeholder-item'; - -const placeholderList = css({ - maskImage: 'linear-gradient(to bottom, black 30%, transparent 95%)', -}); - -export const TopPlaceholder: React.FunctionComponent<{ - isSingleConnection?: boolean; -}> = ({ isSingleConnection }) => { - const items = useMemo(() => { - return Array.from({ length: 10 }, (_, idx) => ( - - )); - }, [isSingleConnection]); - return
{items}
; -}; diff --git a/packages/compass-connections-navigation/src/tree-data.ts b/packages/compass-connections-navigation/src/tree-data.ts new file mode 100644 index 00000000000..d84bec4a842 --- /dev/null +++ b/packages/compass-connections-navigation/src/tree-data.ts @@ -0,0 +1,291 @@ +import type { ConnectionInfo } from '@mongodb-js/connection-info'; +import { + MAX_COLLECTION_PLACEHOLDER_ITEMS, + MAX_DATABASE_PLACEHOLDER_ITEMS, + MIN_DATABASE_PLACEHOLDER_ITEMS, +} from './constants'; +import type { + VirtualPlaceholderItem, + VirtualTreeItem, +} from './virtual-list/use-virtual-navigation-tree'; + +type Collection = { + _id: string; + name: string; + type: 'view' | 'collection' | 'timeseries'; + sourceName: string | null; + pipeline: unknown[]; +}; + +type Status = 'initial' | 'fetching' | 'refreshing' | 'ready' | 'error'; + +type Database = { + _id: string; + name: string; + collectionsStatus: Status; + collectionsLength: number; + collections: Collection[]; +}; + +export type Connection = { + connectionInfo: ConnectionInfo; + name: string; + databasesStatus: Status; + databasesLength: number; + databases: Database[]; + isReady: boolean; + isDataLake: boolean; + isWritable: boolean; + isPerformanceTabSupported: boolean; +}; + +type PlaceholderTreeItem = VirtualPlaceholderItem & { + colorCode?: string; + id: string; + maxNestingLevel: number; +}; + +export type ConnectionTreeItem = VirtualTreeItem & { + name: string; + type: 'connection'; + colorCode?: string; + isExpanded: boolean; + connectionInfo: ConnectionInfo; + isPerformanceTabSupported: boolean; + maxNestingLevel: number; +}; + +export type DatabaseTreeItem = VirtualTreeItem & { + name: string; + type: 'database'; + colorCode?: string; + isExpanded: boolean; + connectionId: string; + dbName: string; + maxNestingLevel: number; +}; + +export type CollectionTreeItem = VirtualTreeItem & { + id: string; + name: string; + type: 'collection' | 'view' | 'timeseries'; + colorCode?: string; + connectionId: string; + namespace: string; + maxNestingLevel: number; +}; + +export type SidebarActionableItem = + | ConnectionTreeItem + | DatabaseTreeItem + | CollectionTreeItem; + +export type SidebarTreeItem = PlaceholderTreeItem | SidebarActionableItem; + +const connectionToItems = ({ + connection: { + connectionInfo, + name, + databases, + databasesStatus, + databasesLength, + isPerformanceTabSupported, + }, + maxNestingLevel, + connectionIndex, + connectionsLength, + expandedItems = {}, +}: { + connection: Connection; + maxNestingLevel: number; + connectionIndex: number; + connectionsLength: number; + expandedItems: Record>; +}): SidebarTreeItem[] => { + const isExpanded = !!expandedItems[connectionInfo.id]; + const colorCode = connectionInfo.favorite?.color; + const connectionTI: ConnectionTreeItem = { + id: connectionInfo.id, + level: 1, + name, + type: 'connection' as const, + setSize: connectionsLength, + posInSet: connectionIndex + 1, + isExpanded, + colorCode, + connectionInfo, + isPerformanceTabSupported, + maxNestingLevel, + }; + + const sidebarData: SidebarTreeItem[] = [connectionTI]; + if (!isExpanded) { + return sidebarData; + } + + const areDatabasesReady = ['ready', 'refreshing', 'error'].includes( + databasesStatus + ); + const placeholdersLength = Math.max( + Math.min(databasesLength, MAX_DATABASE_PLACEHOLDER_ITEMS), + // we are connecting and we don't have metadata on how many databases are in this cluster + MIN_DATABASE_PLACEHOLDER_ITEMS + ); + + if (!areDatabasesReady) { + return sidebarData.concat( + Array.from({ length: placeholdersLength }, (_, index) => ({ + level: 2, + type: 'placeholder' as const, + colorCode, + maxNestingLevel, + id: `${connectionInfo.id}.placeholder.${index}`, + })) + ); + } + + return sidebarData.concat( + databases.flatMap((database, databaseIndex) => { + return databaseToItems({ + connectionId: connectionInfo.id, + database, + expandedItems: expandedItems[connectionInfo.id] || {}, + level: 2, + colorCode, + maxNestingLevel, + databasesLength, + databaseIndex, + }); + }) + ); +}; + +const databaseToItems = ({ + database: { + _id: id, + name, + collections, + collectionsLength, + collectionsStatus, + }, + connectionId, + expandedItems = {}, + level, + colorCode, + maxNestingLevel, + databaseIndex, + databasesLength, +}: { + database: Database; + connectionId: string; + expandedItems?: Record; + level: number; + colorCode?: string; + maxNestingLevel: number; + databaseIndex: number; + databasesLength: number; +}): SidebarTreeItem[] => { + const isExpanded = !!expandedItems[id]; + const databaseTI: DatabaseTreeItem = { + id: `${connectionId}.${id}`, + level, + name, + type: 'database' as const, + setSize: databasesLength, + posInSet: databaseIndex + 1, + isExpanded, + colorCode, + connectionId, + dbName: id, + maxNestingLevel, + }; + + const sidebarData: SidebarTreeItem[] = [databaseTI]; + if (!isExpanded) { + return sidebarData; + } + + const areCollectionsReady = ['ready', 'refreshing', 'error'].includes( + collectionsStatus + ); + const placeholdersLength = Math.min( + collectionsLength, + MAX_COLLECTION_PLACEHOLDER_ITEMS + ); + + if (!areCollectionsReady) { + return sidebarData.concat( + Array.from({ length: placeholdersLength }, (_, index) => ({ + level: level + 1, + type: 'placeholder' as const, + colorCode, + maxNestingLevel, + id: `${connectionId}.${id}.placeholder.${index}`, + })) + ); + } + + return sidebarData.concat( + collections.map(({ _id: id, name, type }, collectionIndex) => ({ + id: `${connectionId}.${id}`, // id is the namespace of the collection, so includes db as well + level: level + 1, + name, + type, + setSize: collectionsLength, + posInSet: collectionIndex + 1, + colorCode, + connectionId, + namespace: id, + maxNestingLevel, + })) + ); +}; + +export function getMaxNestingLevel(isSingleConnection: boolean): number { + return isSingleConnection ? 2 : 3; +} + +/** + * Converts a list connections to virtual tree items. + * + * When isSingleConnection is true, the connections are treated as a single connection mode + * and only two levels of items are shown: databases and collections. + * + * The IDs of the items are just to be used by the tree to correctly identify the items and + * do not represent the actual IDs of the items. + * + * @param connections - The connections. + * @param isSingleConnection - Whether the connections are a single connection. + * @param expandedItems - The expanded items. + */ +export function getVirtualTreeItems( + connections: Connection[], + isSingleConnection: boolean, + expandedItems: Record> = {} +): SidebarTreeItem[] { + if (!isSingleConnection) { + return connections.flatMap((connection, connectionIndex) => + connectionToItems({ + connection, + expandedItems, + maxNestingLevel: getMaxNestingLevel(isSingleConnection), + connectionIndex, + connectionsLength: connections.length, + }) + ); + } + + const connection = connections[0]; + const dbExpandedItems = expandedItems[connection.connectionInfo.id] || {}; + return connection.databases.flatMap((database, databaseIndex) => { + return databaseToItems({ + connectionId: connection.connectionInfo.id, + database, + expandedItems: dbExpandedItems, + level: 1, + maxNestingLevel: getMaxNestingLevel(isSingleConnection), + databasesLength: connection.databasesLength, + databaseIndex, + }); + }); +} diff --git a/packages/compass-connections-navigation/src/tree-item.tsx b/packages/compass-connections-navigation/src/tree-item.tsx index f9727151f3d..9792daaaa08 100644 --- a/packages/compass-connections-navigation/src/tree-item.tsx +++ b/packages/compass-connections-navigation/src/tree-item.tsx @@ -1,14 +1,12 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import type { CSSProperties } from 'react'; import { - useFocusRing, css, cx, mergeProps, spacing, Icon, } from '@mongodb-js/compass-components'; -import type { Actions } from './constants'; import { usePreference } from 'compass-preferences-model/provider'; const buttonReset = css({ @@ -23,7 +21,7 @@ const expandButton = css({ // Not using leafygreen spacing here because none of them allow to align the // button with the search bar content. This probably can go away when we are // rebuilding the search also - padding: 7, + padding: 6, transition: 'transform .16s linear', transform: 'rotate(0deg)', '&:hover': { @@ -39,28 +37,6 @@ export type VirtualListItemProps = { style?: CSSProperties; }; -export type TreeItemProps = { - id: string; - level: number; - posInSet: number; - setSize: number; - isTabbable: boolean; -}; - -export type NamespaceItemProps = { - connectionId: string; - name: string; - type: string; - isActive: boolean; - isReadOnly: boolean; - isSingleConnection?: boolean; - onNamespaceAction( - connectionId: string, - namespace: string, - action: Actions - ): void; -}; - export const ExpandButton: React.FunctionComponent<{ onClick: React.MouseEventHandler; isExpanded: boolean; @@ -82,35 +58,6 @@ export const ExpandButton: React.FunctionComponent<{ ); }; -export function useDefaultAction( - onDefaultAction: (evt: React.KeyboardEvent | React.MouseEvent) => void -): React.HTMLAttributes { - const onClick = useCallback( - (evt: React.MouseEvent) => { - evt.stopPropagation(); - onDefaultAction(evt); - }, - [onDefaultAction] - ); - - const onKeyDown = useCallback( - (evt: React.KeyboardEvent) => { - if ( - // Only handle keyboard events if they originated on the element - evt.target === evt.currentTarget && - [' ', 'Enter'].includes(evt.key) - ) { - evt.preventDefault(); - evt.stopPropagation(); - onDefaultAction(evt); - } - }, - [onDefaultAction] - ); - - return { onClick, onKeyDown }; -} - const itemContainer = css({ cursor: 'pointer', color: 'var(--item-color)', @@ -205,38 +152,12 @@ const itemLabel = css({ export const ItemContainer: React.FunctionComponent< { - id: string; - level: number; - setSize: number; - posInSet: number; - isExpanded?: boolean; isActive?: boolean; - isTabbable?: boolean; - onDefaultAction( - evt: - | React.KeyboardEvent - | React.MouseEvent - ): void; } & React.HTMLProps -> = ({ - id, - level, - setSize, - posInSet, - isExpanded, - isActive, - isTabbable, - onDefaultAction, - children, - className, - ...props -}) => { +> = ({ isActive, children, className, ...props }) => { const isMultipleConnection = usePreference( 'enableNewMultipleConnectionSystem' ); - const focusRingProps = useFocusRing(); - const defaultActionProps = useDefaultAction(onDefaultAction); - const extraCSS = []; if (isActive) { if (isMultipleConnection) { @@ -246,26 +167,11 @@ export const ItemContainer: React.FunctionComponent< } } - const treeItemProps = mergeProps( - { - role: 'treeitem', - 'aria-level': level, - 'aria-setsize': setSize, - 'aria-posinset': posInSet, - 'aria-expanded': isExpanded, - tabIndex: isTabbable ? 0 : -1, - className: cx(itemContainer, ...extraCSS, className), - }, - props, - defaultActionProps, - focusRingProps - ); + const allProps = mergeProps(props, { + className: cx(itemContainer, ...extraCSS, className), + }); - return ( -
- {children} -
- ); + return
{children}
; }; export const ItemWrapper: React.FunctionComponent< diff --git a/packages/compass-connections-navigation/src/utils.ts b/packages/compass-connections-navigation/src/utils.ts index 00f9cca863a..5b8e2a1f5ce 100644 --- a/packages/compass-connections-navigation/src/utils.ts +++ b/packages/compass-connections-navigation/src/utils.ts @@ -1,44 +1,12 @@ -import { spacing } from '@mongodb-js/compass-components'; +import type { SidebarTreeItem } from './tree-data'; -export const getItemPaddingStyles = ({ +export const getTreeItemStyles = ({ level, - isPlaceholder, - isSingleConnection, -}: { - level: number; - isPlaceholder?: boolean; - isSingleConnection?: boolean; -}) => { - let paddingLeft = 0; - if (isSingleConnection) { - /** SC version */ - switch (level) { - case 1: - paddingLeft = spacing[2]; - break; - case 2: - paddingLeft = spacing[6]; - break; - } - - if (isPlaceholder && level === 1) { - paddingLeft += spacing[2]; - } - } else { - /** MC version */ - switch (level) { - case 2: - paddingLeft = spacing[3] + spacing[1] + spacing[50]; - break; - case 3: - paddingLeft = spacing[6] + spacing[2]; - break; - } - - if (isPlaceholder && (level === 1 || level === 2)) { - paddingLeft += spacing[2]; - } - } - - return { paddingLeft }; + maxNestingLevel, +}: Pick): React.CSSProperties => { + const isExpandable = level < maxNestingLevel; + const defaultPadding = 20; + return { + paddingLeft: (level - 1) * defaultPadding + (!isExpandable ? 30 : 0), + }; }; diff --git a/packages/compass-connections-navigation/src/use-virtual-navigation-tree.tsx b/packages/compass-connections-navigation/src/virtual-list/use-virtual-navigation-tree.tsx similarity index 83% rename from packages/compass-connections-navigation/src/use-virtual-navigation-tree.tsx rename to packages/compass-connections-navigation/src/virtual-list/use-virtual-navigation-tree.tsx index 885266d16fc..38b7b54d19e 100644 --- a/packages/compass-connections-navigation/src/use-virtual-navigation-tree.tsx +++ b/packages/compass-connections-navigation/src/virtual-list/use-virtual-navigation-tree.tsx @@ -6,42 +6,46 @@ import { useFocusState, } from '@mongodb-js/compass-components'; -type TreeItem = { +export type VirtualTreeItem = { id: string; name: string; level: number; setSize: number; posInSet: number; isExpanded?: boolean; - type?: never; }; -type Placeholder = { type: 'placeholder' }; +export type VirtualPlaceholderItem = { + type: 'placeholder'; + level: number; +}; -type Item = TreeItem | Placeholder; +export type VirtualItem = VirtualTreeItem | VirtualPlaceholderItem; -export type NavigationTreeData = Item[]; +export type VirtualTreeData = VirtualItem[]; -function isTreeItem(item: Item): item is TreeItem { - return item.type !== 'placeholder'; +export function isPlaceholderItem( + item: VirtualItem +): item is VirtualPlaceholderItem { + return 'type' in item && item.type === 'placeholder'; } function isExpandable( - item: TreeItem -): item is TreeItem & { isExpanded: boolean } { + item: VirtualTreeItem +): item is VirtualTreeItem & { isExpanded: boolean } { return typeof item.isExpanded !== 'undefined'; } function findNext( itemIndex: number, - items: NavigationTreeData, - fn?: (item: TreeItem) => boolean, + items: VirtualTreeData, + fn?: (item: VirtualTreeItem) => boolean, shift = 0 -): TreeItem | null { +): VirtualTreeItem | null { for (let i = itemIndex + 1, len = items.length; i < len; i++) { const idx = (i + shift) % len; const item = items[idx]; - if (isTreeItem(item) && (!fn || fn(item))) { + if (!isPlaceholderItem(item) && (!fn || fn(item))) { return item; } } @@ -50,34 +54,34 @@ function findNext( function findPrev( itemIndex: number, - items: NavigationTreeData, - fn?: (item: TreeItem) => boolean, + items: VirtualTreeData, + fn?: (item: VirtualTreeItem) => boolean, shift = 0 -): TreeItem | null { +): VirtualTreeItem | null { const len = items.length; for (let i = itemIndex - 1; i >= 0; i--) { const idx = (i + shift) % len; const item = items[idx]; - if (isTreeItem(item) && (!fn || fn(item))) { + if (!isPlaceholderItem(item) && (!fn || fn(item))) { return item; } } return null; } -function findFirstItem(items: NavigationTreeData): TreeItem | null { +function findFirstItem(items: VirtualTreeData): VirtualTreeItem | null { return findNext(-1, items); } -function findLastItem(items: NavigationTreeData): TreeItem | null { +function findLastItem(items: VirtualTreeData): VirtualTreeItem | null { return findPrev(items.length, items); } function findParentItem( - currentItem: TreeItem, + currentItem: VirtualTreeItem, currentItemIndex: number, - items: NavigationTreeData -): TreeItem | null { + items: VirtualTreeData +): VirtualTreeItem | null { return findPrev( currentItemIndex, items, @@ -86,14 +90,14 @@ function findParentItem( } function findSiblings( - currentItem: TreeItem, + currentItem: VirtualTreeItem, currentItemIndex: number, - items: NavigationTreeData -): TreeItem[] { + items: VirtualTreeData +): VirtualTreeItem[] { const result = [currentItem]; for (let i = currentItemIndex - 1; i >= 0; i--) { const item = items[i]; - if (isTreeItem(item)) { + if (!isPlaceholderItem(item)) { if (item.level === currentItem.level) { result.push(item); } @@ -105,7 +109,7 @@ function findSiblings( } for (let i = currentItemIndex + 1, len = items.length; i < len; i++) { const item = items[i]; - if (isTreeItem(item)) { + if (!isPlaceholderItem(item)) { if (item.level === currentItem.level) { result.push(item); } @@ -126,10 +130,10 @@ export function useVirtualNavigationTree({ /* noop */ }, }: { - items: NavigationTreeData; - activeItemId: string; - onExpandedChange(item: TreeItem, isExpanded: boolean): void; - onFocusMove?: (item: TreeItem) => void; + items: VirtualTreeData; + activeItemId?: string; + onExpandedChange(item: VirtualTreeItem, isExpanded: boolean): void; + onFocusMove?: (item: VirtualTreeItem) => void; }): [React.HTMLProps, string | undefined] { const rootRef = useRef(null); const activeId = activeItemId || findFirstItem(items)?.id; @@ -211,7 +215,7 @@ export function useVirtualNavigationTree({ const currentItemIndex = items.indexOf(currentItem); - let nextItem: TreeItem | null = null; + let nextItem: VirtualTreeItem | null = null; if (evt.key === 'Home') { evt.stopPropagation(); diff --git a/packages/compass-connections-navigation/src/use-virtual-navigation-tree.spec.tsx b/packages/compass-connections-navigation/src/virtual-list/virtual-list.spec.tsx similarity index 63% rename from packages/compass-connections-navigation/src/use-virtual-navigation-tree.spec.tsx rename to packages/compass-connections-navigation/src/virtual-list/virtual-list.spec.tsx index b8776d903cf..9a819a4fcbc 100644 --- a/packages/compass-connections-navigation/src/use-virtual-navigation-tree.spec.tsx +++ b/packages/compass-connections-navigation/src/virtual-list/virtual-list.spec.tsx @@ -1,48 +1,14 @@ -/* eslint-disable @typescript-eslint/ban-types */ -/* eslint-disable @typescript-eslint/no-empty-function */ import React, { useCallback, useMemo, useState } from 'react'; -import { render, screen, cleanup } from '@testing-library/react'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { expect } from 'chai'; -import { useVirtualNavigationTree } from './use-virtual-navigation-tree'; - -import type { NavigationTreeData } from './use-virtual-navigation-tree'; - -function NavigationTreeItem({ - id, - name, - level, - setSize, - posInSet, - isExpanded, - isTabbable, -}: { - id: string; - name: string; - level: number; - setSize: number; - posInSet: number; - isExpanded?: boolean; - isTabbable?: boolean; -}) { - return ( -
  • - {name} -
  • - ); -} +import type { VirtualItem } from './use-virtual-navigation-tree'; +import { VirtualTree } from './virtual-list'; type MockItem = { id: string; items?: MockItem[] }; +type MockTreeItem = VirtualItem & { name: string }; + const items = [ { id: 'Fruits', @@ -68,9 +34,9 @@ const items = [ function normalizeItems( items: MockItem[], level = 1, - expanded = [] -): NavigationTreeData { - return items + expanded: string[] = [] +): MockTreeItem[] { + const data = items .map((item, index) => [ { @@ -90,16 +56,20 @@ function normalizeItems( ) ) .flat(); + + return data.map((item, index) => ({ + ...item, + posInSet: index + 1, + setSize: data.length, + })); } function NavigationTree({ activeItemId, defaultExpanded = [], - onFocusMove = () => {}, }: { activeItemId?: string; defaultExpanded?: string[]; - onFocusMove?: (item: NavigationTreeData[number]) => void; }) { const [expanded, setExpanded] = useState(defaultExpanded); @@ -113,48 +83,22 @@ function NavigationTree({ return normalizeItems(items, 1, expanded); }, [expanded]); - const [rootProps, currentTabbable] = - useVirtualNavigationTree({ - items: listItems, - activeItemId, - onExpandedChange, - onFocusMove, - }); - return ( -
      - {(listItems as any[]).map( - ({ id, name, level, setSize, posInSet, isExpanded }) => { - return ( - - ); - } - )} -
    + + activeItemId={activeItemId} + items={listItems} + height={400} + itemHeight={30} + onDefaultAction={() => {}} + onExpandedChange={onExpandedChange} + width={100} + renderItem={({ item }) => item.name} + __TEST_OVER_SCAN_COUNT={Infinity} + /> ); } -describe('useRovingTabIndex', function () { - let originalRequestAnimationFrame; - - before(function () { - originalRequestAnimationFrame = globalThis.requestAnimationFrame; - (globalThis as any).requestAnimationFrame = (fn) => fn(); - }); - - after(function () { - (globalThis as any).requestAnimationFrame = originalRequestAnimationFrame; - }); - +describe('virtual-list', function () { afterEach(cleanup); it('should make first element tabbable when no tabbableSelector provided', function () { @@ -171,42 +115,67 @@ describe('useRovingTabIndex', function () { }); describe('keyboard list navigation', function () { - it('should move focus to the next element on ArrowDown', function () { + it('should move focus to the next element on ArrowDown', async function () { render(); userEvent.tab(); + await waitFor(() => { + expect(screen.getByText('Fruits')).to.eq(document.activeElement); + }); userEvent.keyboard('{arrowdown}'); - expect(screen.getByText('Vegetables')).to.eq(document.activeElement); + await waitFor(() => { + expect(screen.getByText('Vegetables')).to.eq(document.activeElement); + }); }); - it('should move focus to the previous element on ArrowUp', function () { + it('should move focus to the previous element on ArrowUp', async function () { render(); userEvent.tab(); + await waitFor(() => { + expect(screen.getByText('Vegetables')).to.eq(document.activeElement); + }); userEvent.keyboard('{arrowup}'); - expect(screen.getByText('Fruits')).to.eq(document.activeElement); + await waitFor(() => { + expect(screen.getByText('Fruits')).to.eq(document.activeElement); + }); }); - it('should move focus to the last element on End', function () { + it('should move focus to the last element on End', async function () { render(); userEvent.tab(); + await waitFor(() => { + expect(screen.getByText('Fruits')).to.eq(document.activeElement); + }); userEvent.keyboard('{end}'); - expect(screen.getByText('Vegetables')).to.eq(document.activeElement); + await waitFor(() => { + expect(screen.getByText('Vegetables')).to.eq(document.activeElement); + }); }); - it('should move focus to the first element on Home', function () { + it('should move focus to the first element on Home', async function () { render(); userEvent.tab(); + await waitFor(() => { + expect(screen.getByText('Vegetables')).to.eq(document.activeElement); + }); userEvent.keyboard('{home}'); - expect(screen.getByText('Fruits')).to.eq(document.activeElement); + await waitFor(() => { + expect(screen.getByText('Fruits')).to.eq(document.activeElement); + }); }); - it('should move focus to the child treeitem on ArrowRight when expandable treeitem is expanded', function () { + it('should move focus to the child treeitem on ArrowRight when expandable treeitem is expanded', async function () { render(); userEvent.tab(); + await waitFor(() => { + expect(screen.getByText('Fruits')).to.eq(document.activeElement); + }); userEvent.keyboard('{arrowright}'); - expect(screen.getByText('Oranges')).to.eq(document.activeElement); + await waitFor(() => { + expect(screen.getByText('Oranges')).to.eq(document.activeElement); + }); }); - it('should move focus to the parent treeitem on ArrowLeft when expandable treeitem is collapsed', function () { + it('should move focus to the parent treeitem on ArrowLeft when expandable treeitem is collapsed', async function () { render( ); userEvent.tab(); + await waitFor(() => { + expect(screen.getByText('Bananas')).to.eq(document.activeElement); + }); userEvent.keyboard('{arrowleft}'); - expect(screen.getByText('Fruits')).to.eq(document.activeElement); + await waitFor(() => { + expect(screen.getByText('Fruits')).to.eq(document.activeElement); + }); }); - it('should move focus to the next item that starts with the pressed letter', function () { + it('should move focus to the next item that starts with the pressed letter', async function () { render( ); userEvent.tab(); + await waitFor(() => { + expect(screen.getByText('Fruits')).to.eq(document.activeElement); + }); userEvent.keyboard('{p}'); - expect(screen.getByText('Podded Vegetables')).to.eq( - document.activeElement - ); + await waitFor(() => { + expect(screen.getByText('Podded Vegetables')).to.eq( + document.activeElement + ); + }); userEvent.keyboard('{p}'); - expect(screen.getByText('Pea')).to.eq(document.activeElement); + await waitFor(() => { + expect(screen.getByText('Pea')).to.eq(document.activeElement); + }); }); }); describe('keyboard expand / collapse', function () { - it('should expand collapsed treeitem on ArrowRight and keep the focus on the item', function () { + it('should expand collapsed treeitem on ArrowRight and keep the focus on the item', async function () { render(); userEvent.tab(); + await waitFor(() => { + expect(screen.getByText('Fruits')).to.eq(document.activeElement); + }); expect(screen.getByText('Fruits')).to.have.attr('aria-expanded', 'false'); userEvent.keyboard('{arrowright}'); expect(screen.getByText('Fruits')).to.eq(document.activeElement); expect(screen.getByText('Fruits')).to.have.attr('aria-expanded', 'true'); }); - it('should collapse expanded treeitem on ArrowLeft and keep the focus on the item', function () { + it('should collapse expanded treeitem on ArrowLeft and keep the focus on the item', async function () { render(); userEvent.tab(); + await waitFor(() => { + expect(screen.getByText('Fruits')).to.eq(document.activeElement); + }); expect(screen.getByText('Fruits')).to.have.attr('aria-expanded', 'true'); userEvent.keyboard('{arrowleft}'); expect(screen.getByText('Fruits')).to.eq(document.activeElement); expect(screen.getByText('Fruits')).to.have.attr('aria-expanded', 'false'); }); - it('should expand all set siblings of a focused element on * press', function () { + it('should expand all set siblings of a focused element on * press', async function () { render( ); userEvent.tab(); + await waitFor(() => { + expect(screen.getByText('Podded Vegetables')).to.eq( + document.activeElement + ); + }); expect(screen.getByText('Podded Vegetables')).to.have.attr( 'aria-expanded', 'false' diff --git a/packages/compass-connections-navigation/src/virtual-list/virtual-list.tsx b/packages/compass-connections-navigation/src/virtual-list/virtual-list.tsx new file mode 100644 index 00000000000..d84af975fee --- /dev/null +++ b/packages/compass-connections-navigation/src/virtual-list/virtual-list.tsx @@ -0,0 +1,217 @@ +import React, { useCallback, useMemo, useRef } from 'react'; +import { + isPlaceholderItem, + useVirtualNavigationTree, + type VirtualTreeItem, + type VirtualItem, +} from './use-virtual-navigation-tree'; +import { + FixedSizeList as List, + type ListChildComponentProps, +} from 'react-window'; +import { + css, + mergeProps, + useFocusRing, + useId, +} from '@mongodb-js/compass-components'; + +function useDefaultAction( + item: T, + onDefaultAction: ( + item: T, + evt: React.MouseEvent | React.KeyboardEvent + ) => void +): React.HTMLAttributes { + const onClick = useCallback( + (evt: React.MouseEvent) => { + evt.stopPropagation(); + onDefaultAction(item, evt); + }, + [onDefaultAction, item] + ); + + const onKeyDown = useCallback( + (evt: React.KeyboardEvent) => { + if ( + // Only handle keyboard events if they originated on the element + evt.target === evt.currentTarget && + [' ', 'Enter'].includes(evt.key) + ) { + evt.preventDefault(); + evt.stopPropagation(); + onDefaultAction(item, evt); + } + }, + [onDefaultAction, item] + ); + + return { onClick, onKeyDown }; +} + +type NotPlaceholderTreeItem = T extends { type: 'placeholder' } ? never : T; +type RenderItem = (props: { index: number; item: T }) => React.ReactNode; +export type OnDefaultAction = ( + item: T, + evt: React.MouseEvent | React.KeyboardEvent +) => void; +export type OnExpandedChange = (item: T, expanded: boolean) => void; + +type VirtualTreeProps = { + dataTestId?: string; + activeItemId?: string; + items: T[]; + width: number | string; + height: number | string; + itemHeight: number; + renderItem: RenderItem; + getItemKey?: (item: T) => string; + onDefaultAction: OnDefaultAction>; + onExpandedChange: OnExpandedChange>; + + __TEST_OVER_SCAN_COUNT?: number; +}; + +const navigationTree = css({ + flex: '1 0 auto', +}); + +function useAction(fn: RenderItem): RenderItem { + const ref = useRef(fn); + ref.current = fn; + return useMemo(() => { + return (props) => ref.current(props); + }, []); +} + +export function VirtualTree({ + dataTestId, + activeItemId, + items, + width, + height, + itemHeight, + getItemKey: _getItemKey, + renderItem: _renderItem, + onDefaultAction, + onExpandedChange, + __TEST_OVER_SCAN_COUNT, +}: VirtualTreeProps) { + const listRef = useRef(null); + const renderItem = useAction(_renderItem); + const onFocusMove = useCallback( + (item: VirtualTreeItem) => { + const idx = items.findIndex( + (i) => !isPlaceholderItem(i) && i.id === item.id + ); + if (idx >= 0) { + // It is possible that the item we are trying to move the focus to is + // not rendered currently. Scroll it into view so that it's rendered and + // can be focused + listRef.current?.scrollToItem(idx); + } + }, + [items] + ); + const [rootProps, currentTabbable] = useVirtualNavigationTree( + { + items, + activeItemId, + onExpandedChange, + onFocusMove, + } + ); + + const id = useId(); + + const itemData = useMemo(() => { + return { + items, + currentTabbable, + renderItem, + onDefaultAction, + }; + }, [items, renderItem, currentTabbable, onDefaultAction]); + + const getItemKey = useCallback( + (index: number, data: VirtualItemData) => { + if (!_getItemKey) { + return index; + } + return _getItemKey(data.items[index]); + }, + [_getItemKey] + ); + + return ( +
    + > + ref={listRef} + width={width} + height={height} + itemData={itemData} + itemCount={items.length} + itemSize={itemHeight} + itemKey={getItemKey} + overscanCount={__TEST_OVER_SCAN_COUNT ?? 5} + > + {TreeItem} + +
    + ); +} + +type VirtualItemData = { + items: T[]; + currentTabbable?: string; + renderItem: RenderItem; + onDefaultAction: OnDefaultAction>; +}; +function TreeItem({ + index, + data, + style, +}: ListChildComponentProps>) { + const { renderItem, items } = data; + const item = useMemo(() => items[index], [items, index]); + const focusRingProps = useFocusRing(); + + const Component = useMemo(() => { + return renderItem({ index, item }); + }, [renderItem, index, item]); + + const actionProps = useDefaultAction( + item as NotPlaceholderTreeItem, + data.onDefaultAction + ); + + // Placeholder check + if (isPlaceholderItem(item)) { + return
    {Component}
    ; + } + + const treeItemProps = mergeProps( + { + role: 'treeitem', + 'aria-level': item.level, + 'aria-setsize': item.setSize, + 'aria-posinset': item.posInSet, + 'aria-expanded': !!item.isExpanded, + tabIndex: data.currentTabbable === item.id ? 0 : -1, + }, + actionProps, + focusRingProps, + { style } + ); + return ( +
    + {Component} +
    + ); +} diff --git a/packages/compass-connections/src/hooks/use-connection-repository.ts b/packages/compass-connections/src/hooks/use-connection-repository.ts index 030e7323848..c940e5fc6f8 100644 --- a/packages/compass-connections/src/hooks/use-connection-repository.ts +++ b/packages/compass-connections/src/hooks/use-connection-repository.ts @@ -1,5 +1,8 @@ import { usePreference } from 'compass-preferences-model/provider'; -import type { ConnectionInfo } from '@mongodb-js/connection-info'; +import { + getConnectionTitle, + type ConnectionInfo, +} from '@mongodb-js/connection-info'; import ConnectionString from 'mongodb-connection-string-url'; import { merge } from 'lodash'; import isEqual from 'lodash/isEqual'; @@ -48,6 +51,7 @@ export type ConnectionRepository = { getConnectionInfoById: ( id: ConnectionInfo['id'] ) => ConnectionInfo | undefined; + getConnectionTitleById: (id: ConnectionInfo['id']) => string | undefined; }; export function useConnectionRepository(): ConnectionRepository { @@ -173,8 +177,19 @@ export function useConnectionRepository(): ConnectionRepository { }, [favoriteConnections, nonFavoriteConnections, autoConnectInfo] ); + + const getConnectionTitleById = useCallback( + (connectionId: ConnectionInfo['id']) => { + const connectionInfo = getConnectionInfoById(connectionId); + if (connectionInfo) { + return getConnectionTitle(connectionInfo); + } + }, + [getConnectionInfoById] + ); return { getConnectionInfoById, + getConnectionTitleById, favoriteConnections, nonFavoriteConnections, saveConnection, diff --git a/packages/compass-crud/package.json b/packages/compass-crud/package.json index 5f2d167abc4..144ae662d3b 100644 --- a/packages/compass-crud/package.json +++ b/packages/compass-crud/package.json @@ -98,7 +98,7 @@ "prop-types": "^15.7.2", "react": "^17.0.2", "reflux": "^0.4.1", - "reflux-state-mixin": "github:mongodb-js/reflux-state-mixin", + "@mongodb-js/reflux-state-mixin": "^1.0.0", "semver": "^7.6.2" }, "is_compass_plugin": true diff --git a/packages/compass-crud/src/stores/crud-store.ts b/packages/compass-crud/src/stores/crud-store.ts index 0cf42823c37..c5d84b96a9d 100644 --- a/packages/compass-crud/src/stores/crud-store.ts +++ b/packages/compass-crud/src/stores/crud-store.ts @@ -3,8 +3,7 @@ import Reflux from 'reflux'; import toNS from 'mongodb-ns'; import { findIndex, isEmpty, isEqual } from 'lodash'; import semver from 'semver'; -// @ts-expect-error no types available -import StateMixin from 'reflux-state-mixin'; +import StateMixin from '@mongodb-js/reflux-state-mixin'; import type { Element } from 'hadron-document'; import { Document } from 'hadron-document'; import HadronDocument from 'hadron-document'; @@ -320,7 +319,7 @@ class CrudStoreImpl extends BaseRefluxStore implements CrudActions { - mixins = [StateMixin.store]; + mixins = [StateMixin.store()]; listenables: unknown[]; // Should this be readonly? The existence of setState would imply that... diff --git a/packages/compass-e2e-tests/helpers/commands/drop-collection-from-sidebar.ts b/packages/compass-e2e-tests/helpers/commands/drop-collection-from-sidebar.ts index fa1dcddb7d5..e60ee3f3e9e 100644 --- a/packages/compass-e2e-tests/helpers/commands/drop-collection-from-sidebar.ts +++ b/packages/compass-e2e-tests/helpers/commands/drop-collection-from-sidebar.ts @@ -18,7 +18,7 @@ export async function dropCollectionFromSidebar( collectionName ); await browser.scrollToVirtualItem( - Selectors.SidebarDatabaseAndCollectionList, + Selectors.SidebarNavigationTree, collectionSelector, 'tree' ); @@ -32,7 +32,7 @@ export async function dropCollectionFromSidebar( // confusing. Also this selector is just for the actions button and it is // assumed that at this point it is the only one. But the drop confirmation // usually catches that. - await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible(Selectors.SidebarNavigationItemShowActionsButton); await browser.clickVisible(Selectors.DropCollectionButton); await browser.dropNamespace(collectionName); diff --git a/packages/compass-e2e-tests/helpers/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index cd2e48bf5a8..46c88357262 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -236,9 +236,8 @@ export const RenameCollectionModalCloseButton = `${RenameCollectionModal} [aria- // Database-Collection Sidebar export const Sidebar = '[data-testid="navigation-sidebar"]'; -export const SidebarDatabaseAndCollectionList = - '[data-testid="databases-and-collections"]'; -export const SidebarTreeItems = `${SidebarDatabaseAndCollectionList} [role="treeitem"]`; +export const SidebarNavigationTree = '[data-testid="sidebar-navigation-tree"]'; +export const SidebarTreeItems = `${SidebarNavigationTree} [role="treeitem"]`; export const SidebarFilterInput = '[data-testid="sidebar-filter-input"]'; export const SidebarTitle = '[data-testid="sidebar-title"]'; export const SidebarShowActions = @@ -249,12 +248,12 @@ export const SidebarCreateDatabaseButton = '[data-testid="sidebar-navigation-item-actions-open-create-database-action"]'; export const SidebarRefreshDatabasesButton = '[data-testid="sidebar-navigation-item-actions-refresh-databases-action"]'; -export const CollectionShowActionsButton = - '[data-testid="sidebar-collection-item-actions-show-actions"]'; +export const SidebarNavigationItemShowActionsButton = + '[data-testid="sidebar-navigation-item-actions-show-actions"]'; export const DropDatabaseButton = '[data-action="drop-database"]'; export const CreateCollectionButton = '[data-action="create-collection"]'; export const RenameCollectionButton = - '[data-testid="sidebar-collection-item-actions-rename-collection-action"]'; + '[data-testid="sidebar-navigation-item-actions-rename-collection-action"]'; export const DropCollectionButton = '[data-action="drop-collection"]'; export const FleConnectionConfigurationBanner = '[data-testid="fle-connection-configuration"]'; @@ -265,18 +264,18 @@ export const ConnectionInfoModal = '[data-testid="connection-info-modal"]'; export const ConnectionInfoModalCloseButton = `${ConnectionInfoModal} [aria-label*="Close"]`; export const sidebarDatabase = (dbName: string): string => { - return `[data-testid="sidebar-database-${dbName}"]`; + return `${Sidebar} [data-database-name="${dbName}"]`; }; export const sidebarDatabaseToggle = (dbName: string): string => { - return `[data-testid="sidebar-database-${dbName}"] button[type=button]`; + return `${sidebarDatabase(dbName)} button[type=button]`; }; export const sidebarCollection = ( dbName: string, collectionName: string ): string => { - return `[data-testid="sidebar-collection-${dbName}.${collectionName}"]`; + return `${Sidebar} [data-namespace="${dbName}.${collectionName}"]`; }; export const sidebarFavorite = (favoriteName: string): string => { @@ -1103,7 +1102,7 @@ export const CloseWorkspaceTab = '[data-testid="close-workspace-tab"]'; export const sidebarInstanceNavigationItem = ( tabName: 'My Queries' | 'Performance' | 'Databases' = 'My Queries' ) => { - return `[data-testid="navigation-sidebar"] [aria-label="${tabName}"]`; + return `${Sidebar} [aria-label="${tabName}"]`; }; export const workspaceTab = ( title: string | null, diff --git a/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts b/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts index 099e33066cb..13a6b3cdc4f 100644 --- a/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts +++ b/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts @@ -86,7 +86,7 @@ async function chooseCollectionAction( // scroll to the collection if necessary await browser.scrollToVirtualItem( - Selectors.SidebarDatabaseAndCollectionList, + Selectors.SidebarNavigationTree, collectionSelector, 'tree' ); @@ -99,7 +99,7 @@ async function chooseCollectionAction( // click the show collections button // NOTE: This assumes it is currently closed - await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible(Selectors.SidebarNavigationItemShowActionsButton); const actionSelector = `[role="menuitem"][data-action="${actionName}"]`; diff --git a/packages/compass-e2e-tests/tests/collection-rename.test.ts b/packages/compass-e2e-tests/tests/collection-rename.test.ts index eb7ec5bdc38..54f22433bcb 100644 --- a/packages/compass-e2e-tests/tests/collection-rename.test.ts +++ b/packages/compass-e2e-tests/tests/collection-rename.test.ts @@ -58,7 +58,7 @@ class RenameCollectionModal { } async function navigateToCollectionInSidebar(browser: CompassBrowser) { - const sidebar = await browser.$(Selectors.SidebarDatabaseAndCollectionList); + const sidebar = await browser.$(Selectors.SidebarNavigationTree); await sidebar.waitForDisplayed(); // open the database in the sidebar @@ -75,7 +75,7 @@ async function navigateToCollectionInSidebar(browser: CompassBrowser) { initialName ); await browser.scrollToVirtualItem( - Selectors.SidebarDatabaseAndCollectionList, + Selectors.SidebarNavigationTree, collectionSelector, 'tree' ); @@ -147,7 +147,9 @@ describe('Collection Rename Modal', () => { await browser.hover( Selectors.sidebarCollection(databaseName, initialName) ); - await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible( + Selectors.SidebarNavigationItemShowActionsButton + ); await browser.clickVisible(Selectors.RenameCollectionButton); // go through @@ -176,7 +178,9 @@ describe('Collection Rename Modal', () => { // open the rename collection flow from the sidebar await browser.hover(collectionSelector); - await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible( + Selectors.SidebarNavigationItemShowActionsButton + ); await browser.clickVisible(Selectors.RenameCollectionButton); await renameCollectionSuccessFlow(browser, newName); @@ -199,7 +203,9 @@ describe('Collection Rename Modal', () => { await browser.hover( Selectors.sidebarCollection(databaseName, initialName) ); - await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible( + Selectors.SidebarNavigationItemShowActionsButton + ); await browser.clickVisible(Selectors.RenameCollectionButton); // wait for the collection modal to appear @@ -235,7 +241,9 @@ describe('Collection Rename Modal', () => { await browser.hover( Selectors.sidebarCollection(databaseName, initialName) ); - await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible( + Selectors.SidebarNavigationItemShowActionsButton + ); await browser.clickVisible(Selectors.RenameCollectionButton); // wait for the collection modal to appear const modal = new RenameCollectionModal(browser); @@ -251,7 +259,9 @@ describe('Collection Rename Modal', () => { await browser.hover( Selectors.sidebarCollection(databaseName, initialName) ); - await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible( + Selectors.SidebarNavigationItemShowActionsButton + ); await browser.clickVisible(Selectors.RenameCollectionButton); // wait for the collection modal to appear @@ -268,7 +278,9 @@ describe('Collection Rename Modal', () => { await browser.hover( Selectors.sidebarCollection(databaseName, initialName) ); - await browser.clickVisible(Selectors.CollectionShowActionsButton); + await browser.clickVisible( + Selectors.SidebarNavigationItemShowActionsButton + ); await browser.clickVisible(Selectors.RenameCollectionButton); // assert that the form state has reset diff --git a/packages/compass-e2e-tests/tests/instance-sidebar.test.ts b/packages/compass-e2e-tests/tests/instance-sidebar.test.ts index 6bf6da3e79a..9e466699d60 100644 --- a/packages/compass-e2e-tests/tests/instance-sidebar.test.ts +++ b/packages/compass-e2e-tests/tests/instance-sidebar.test.ts @@ -64,7 +64,7 @@ describe('Instance sidebar', function () { collectionName ); await browser.scrollToVirtualItem( - Selectors.SidebarDatabaseAndCollectionList, + Selectors.SidebarNavigationTree, collectionSelector, 'tree' ); @@ -106,7 +106,7 @@ describe('Instance sidebar', function () { const collectionSelector = Selectors.sidebarCollection('test', 'numbers'); await browser.scrollToVirtualItem( - Selectors.SidebarDatabaseAndCollectionList, + Selectors.SidebarNavigationTree, collectionSelector, 'tree' ); diff --git a/packages/compass-explain-plan/src/components/explain-tree/explain-tree-stage.tsx b/packages/compass-explain-plan/src/components/explain-tree/explain-tree-stage.tsx index fc8c655413b..75e4cb7e3e1 100644 --- a/packages/compass-explain-plan/src/components/explain-tree/explain-tree-stage.tsx +++ b/packages/compass-explain-plan/src/components/explain-tree/explain-tree-stage.tsx @@ -211,6 +211,12 @@ const shardViewTextStyles = css({ whiteSpace: 'nowrap', }); +const overflowTextStyles = css({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + const StatsBadge: React.FunctionComponent<{ stats: number | string; }> = ({ stats }) => { @@ -237,18 +243,29 @@ const ShardView: React.FunctionComponent = (props) => { ); }; +const Highlight: React.FunctionComponent<{ + value: string; + field: string; +}> = ({ field, value }) => { + return ( +
  • + {field}: + {value} +
  • + ); +}; + const Highlights: React.FunctionComponent<{ highlights: Record; }> = ({ highlights }) => { return (
      {Object.entries(highlights).map(([key, value], index) => ( -
    • - {key}: - - {typeof value === 'boolean' ? (value ? 'yes' : 'no') : value} - -
    • + ))}
    ); diff --git a/packages/compass-generative-ai/src/components/generative-ai-input.tsx b/packages/compass-generative-ai/src/components/generative-ai-input.tsx index 3de75686335..6595cd84f37 100644 --- a/packages/compass-generative-ai/src/components/generative-ai-input.tsx +++ b/packages/compass-generative-ai/src/components/generative-ai-input.tsx @@ -1,4 +1,11 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { + forwardRef, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { Banner, BannerVariant, @@ -6,7 +13,7 @@ import { Icon, IconButton, SpinLoader, - TextInput, + TextArea, css, cx, focusRing, @@ -28,6 +35,7 @@ const containerStyles = css({ width: '100%', flexDirection: 'column', gap: spacing[25], + padding: `0px ${spacing[100]}px`, marginBottom: spacing[300], }); @@ -123,11 +131,17 @@ const inputContainerStyles = css({ position: 'relative', }); +const defaultTextAreaSize = spacing[400] + spacing[300]; const textInputStyles = css({ flexGrow: 1, - input: { + textarea: { + margin: 0, + padding: spacing[50] + spacing[25], paddingLeft: spacing[800], paddingRight: spacing[1600] * 2 + spacing[200], + height: defaultTextAreaSize, // Default height, overridden runtime. + minHeight: `${defaultTextAreaSize}px`, + maxHeight: spacing[1600] * 2, }, }); @@ -216,6 +230,48 @@ const aiEntryContainerStyles = css({ display: 'flex', }); +function adjustHeight(elementRef: React.RefObject) { + if (elementRef.current) { + // Dynamically set the text area height based on the scroll height. + // We first set it to 0 so the correct scroll height is used. + elementRef.current.style.height = '0px'; + let adjustedHeight = elementRef.current.scrollHeight; + if (adjustedHeight > defaultTextAreaSize) { + // When it's greater than one line, we add pixels so that + // we don't show a scrollbar when it's still under the maxHeight. + adjustedHeight += spacing[50]; + } + elementRef.current.style.height = `${adjustedHeight}px`; + } +} + +const VerticallyResizingTextArea = forwardRef( + function VerticallyResizingTextArea( + { value, ...props }: React.ComponentProps, + forwardedRef: React.ForwardedRef + ) { + const ref = useRef(null); + + useLayoutEffect(() => adjustHeight(ref), []); + useEffect(() => adjustHeight(ref), [value]); + + return ( +