Compare commits

...

50 Commits

Author SHA1 Message Date
Sindhu Balasubramanian
e904e305e7 Add sharedThroughput db tests 2025-12-21 21:55:19 -08:00
Sindhu Balasubramanian
96822af37f Merge branch 'users/aisayas/playwright' of https://github.com/Azure/cosmos-explorer into users/sindhuba/playwright-tests 2025-12-09 09:52:37 -08:00
BChoudhury-ms
a714ef02c0 Added comprehensive unit test coverage for Container Copy jobs (#2275)
* copy job uts

* unit test coverage

* lint fix

* normalize account dropdown id
2025-12-09 09:35:58 +05:30
Asier Isayas
4801aae754 changed throughput above limit 2025-12-08 11:21:20 -08:00
Asier Isayas
2a02112d87 fix autoscale selector 2025-12-08 09:55:21 -08:00
Asier Isayas
bbfff77495 Merge branch 'master' of https://github.com/Azure/cosmos-explorer into users/aisayas/playwright 2025-12-08 07:39:33 -08:00
Laurent Nguyen
ca858c08fb Enhance accessibility by including description in aria-label for button component in Fabric Home (#2272)
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2025-12-05 17:39:18 +01:00
BChoudhury-ms
fa18b85364 copy job process performance enhancement (#2273) 2025-12-05 11:49:25 +05:30
Asier Isayas
f695b42071 fix tests 2025-12-04 12:24:47 -08:00
Asier Isayas
a8a96e22b4 fix unit tests 2025-12-04 12:05:47 -08:00
Asier Isayas
a1b026544d fix unit tests and lint 2025-12-04 11:52:31 -08:00
Asier Isayas
a912233b33 Merge branch 'master' of https://github.com/Azure/cosmos-explorer into users/aisayas/playwright 2025-12-04 11:43:17 -08:00
Asier Isayas
487130f6e3 Add playwright tests for Autoscale/Manual Throughpout and TTL 2025-12-04 11:40:48 -08:00
sunghyunkang1111
d060f22357 Added minimum RU when creating container (#2268)
* Added minimum RU when creating container

* fix data test id

* Update test snap
2025-12-04 10:31:19 -08:00
BChoudhury-ms
9a6f090374 Refactor Container Copy Permissions Screen: Group-based Validation and Improved Loading UX (#2269)
* grouped permissions and added styles

* Adding loading overlay for the permission sections
2025-12-03 07:43:13 +05:30
BChoudhury-ms
63cddeb4b8 Integrate container creation screen to copy job flow (#2265) 2025-11-27 13:19:50 +05:30
BChoudhury-ms
bb0bbd8a6e show default copy job name (#2266) 2025-11-27 10:34:08 +05:30
asier-isayas
a33429fd85 Add Session Id (#2263)
* adding sessionId to UserContext

* add session id

* add session id to settings pane and fix npm run compile

* Add conditional for Portal

* set default session id on userContext init

* fix tests

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-11-26 10:07:18 -08:00
BChoudhury-ms
784dadce30 set intra-account copy as the default one (#2267) 2025-11-26 10:06:45 -08:00
vchske
490309b403 Fixes an issue where tab titles were not truncating when characters used 4 bytes for encoding (#2254)
* Fixes an issue where tab titles were not truncating when characters used 4 bytes for encoding.

* Changed substringUtf method to be more accurate and added comments
2025-11-24 12:36:03 -08:00
sunghyunkang1111
0fac59967a Fix mongo database name handling (#2262) 2025-11-20 10:23:24 -08:00
Laurent Nguyen
c72d921866 fix: for fabric, don't display Querying offer for collection. (#2259)
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2025-11-20 15:02:32 +01:00
BChoudhury-ms
125b1c86b7 Refactor Container Copy Jobs for Intra-account copy and Online operations (#2258)
* fix: for intra-account copy, validation screen should not visible

* fix: handle online operations using a button instead manual CLI commands

* reset validation cache on leaving of permission screen

* update same account logic

* fix: update job action menu list and permission screen messages

* uplift error handling to context level

* use of logError instead of console.error
2025-11-19 22:41:13 +05:30
Laurent Nguyen
beccab02e7 fix: error handling: better handle error.message undefined or '' case. (#2253)
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2025-11-12 09:06:12 +01:00
sakshigupta12feb
a2e90b3a38 Removed unused old code from DE (#2251)
Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
2025-11-11 20:08:45 +05:30
vchske
33a7412cf3 Removed broken feature to display PR url in DE console (#2249) 2025-11-07 12:36:03 -08:00
Nishtha Ahuja
6b150dbfa0 Revert "Index Advisor Tab on Execute Query (#2177)" (#2244)
This reverts commit abf4b3bd0f.

Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-11-06 20:17:17 +05:30
vchske
bbdf0ce57e Updating Cosmos DB JS SDK to 4.7 (#2243) 2025-11-05 11:18:03 -08:00
BChoudhury-ms
2417da152d Container Copy Job implementation for SQL accounts (#2241)
* Initial dev for container copy

* remove padding from label

* Added Copy Job prerequisites screen

* Added hooks to evaluate reader role access

* added copyjob pre-requsite screen along with it's validations

* Added monitor copy job list screen

* added copy job list refresh and reset functionality

* remove arm token dependency

* fetch account details from account id instead of context

* Fix lint & typescript checks

* show copyjob screen from portal navigation

* adding copy job details screen

* remove duplicate code & show sql accounts only

* ui fixes for list job page

* pending icon

* copy job details screen ui

* reset .vscode/settings.json

* Fixed existing UTs

* disabling action buttons until it's in progress

* fixed formatting

* Adding loader on submit button and show job creation errors in the panel itself

* updating disabling action menu item logic

* added custom pager

* fix lint and ts errors

* updating file names and removing comments

* remove comments

* modularize the arom common code

* Adding content and removing tooltip

* updating job details screen

* updating online copy enabled screen

* Adding below changes
- Don't show permission screen for same account in offline mode
- Don't show identity permissions for same account in online mode
- Show error message if selected containers are identical
- Update abort signal messages

* added feedback code from explorer

* Add tooltips and long polling
- Added tooltips to permission sections
- Implemented long polling for PITR and online copy enabled sections
- Long polling automatically stops after 15 minutes
- After polling ends, a refresh button will be displayed

---------

Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-11-05 22:54:00 +05:30
sakshigupta12feb
3718f5a16a Updated document dB text changes (#2223)
* updated documentdb text changes

* updated documentdb text changes

* updated shall value

---------

Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
2025-11-05 11:49:17 +05:30
Mark Brown
08f55ded3d Fabric datasets update (#2242)
* update fabric datasets.

* fix non unicode characters in datasets

* Fix merge issue.

---------

Co-authored-by: Jade Welton <jawelton@microsoft.com>
2025-11-04 06:29:32 -08:00
sakshigupta12feb
74cd4b2ff4 fixed the issue of ddm (#2239)
* fixed the issue of ddm

* fixed the ddm issue

* updated test and ploicyvaluebtdefault as true

* fixed test

---------

Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
2025-11-03 23:01:31 +05:30
Mark Brown
27e07bcd01 fix: Update fabric datasets. (#2238) 2025-10-31 15:28:19 +01:00
sakshigupta12feb
18ecaaba78 DDM Updated the validation logic as per BE values (policyFormatVersion field is removed from BE) (#2237)
* updated the validation logic as per BE values(policyFormatVersion will be removed from BE)

* removed field

* updated all test

---------

Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
2025-10-28 19:11:12 +05:30
sakshigupta12feb
0578910b9e DDM in DE for NOSQL (#2224)
* ddm for DE for noSQL

* ddm for DE for noSQL

* ddm for DE for noSQL

* ddm fix for the default case and test fix

* formatting issue

* updated the text change

* added validation errors

---------

Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
2025-10-27 19:37:40 +05:30
asier-isayas
ff1eb6a78e Add French, German, and Spanish to Full Text Search (Update container only) (#2228)
* Add multiple languages for Full Text Search Policy

* fix tests

* show multiple languages for multi language support enabled accounts

* addressed comments

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-10-24 12:15:12 -07:00
Laurent Nguyen
31ec3c08bc fix: Add new sample data, update sample container create (#2230)
* add new sample data, update sample container create

* updated data sets

* another fix

* Refactor sample file-specific settings

---------

Co-authored-by: Mark Brown <mjbrown@microsoft.com>
2025-10-24 14:20:24 +02:00
archie-agarwal
abf4b3bd0f Index Advisor Tab on Execute Query (#2177)
Index Advisor
2025-10-24 17:11:59 +05:30
sunghyunkang1111
d0d615a85a Added infobox to advanced settings (#2231)
* Added infobox to advanced settings

* Update tooltip message and test snap
2025-10-21 13:49:52 -05:00
Dmitry Shilov
2996120235 fix: Clean file input after uploading files (#2189)
* fix: Clean file input after uploading files

- Enhance file upload component to trigger re-renders on state changes

* fix: Clean file input after uploading files

- Enhance file upload component to trigger re-renders on state changes
2025-10-20 21:49:41 +02:00
sunghyunkang1111
3cd6d5a65d Added ignore partition key option (#2227)
* Added ignore partition key option

* fix unit test

* fix unit test

* Fix Unit Test
2025-10-16 16:45:50 -05:00
bogercraig
d924824536 Updating allowed endpoint list first from server for running in non-prod environments. (#2222) 2025-10-06 09:58:45 -07:00
Laurent Nguyen
cd27814fad feat: New Fabric sample datasets (#2219)
* add two new fabric sample datasets.

* Update Fabric Home with two sample datasets. One regular and one for vector search.

* Update specs for sample data container

* Add telemetry instead of console log

* Add sampleDataFile to telemetry when importing sample data

---------

Co-authored-by: Mark Brown <mjbrown@microsoft.com>
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2025-10-03 17:31:05 +02:00
Laurent Nguyen
909957a9a1 feat: Send message to Fabric when container is updated (via settings, created or deleted) (#2221)
* Send message to Fabric when container is updated (via settings, created or deleted).

* Fix format

---------

Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2025-10-02 18:02:32 +02:00
jawelton74
569e5ed1fc Support RBAC in E2E tests for Mongo & Cassandra (#2220)
* Add E2E test changes to support RBAC for Mongo and Cassandra.

* Uncomment Mongo changes.

* Be more selective with which tokens are passed to DE for each test.
2025-10-01 08:54:43 -07:00
jawelton74
a5c3e6bea0 Preview site - update Node and dependencies. (#2218)
* Update to Node 20.

* Update vulnerable dependencies.
2025-09-29 06:17:29 -07:00
BChoudhury-ms
76e63818d3 Enable RBAC support for MongoDB and Cassandra APIs (#2198)
* enable RBAC support for Mongo & Cassandra API

* fix formatting issue

* Handling AAD integration for Mongo Shell

* remove empty aadToken error

* fix formatting issue

* added environment specific scope endpoints
2025-09-19 01:25:35 +05:30
Nishtha Ahuja
cfb5db4df6 Removed screenshot for mongo cloudshell (#2211)
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-09-16 12:16:19 +05:30
Dmitry Shilov
922ca5c523 chore: Update help link in FabricHome component to point to the new documentation (#2206) 2025-09-04 11:58:07 +02:00
Dmitry Shilov
bafe002fa3 chore: Enhance accessibility (#2208)
- Add tabIndex to button
- Add aria attributes to icons and headings
2025-09-04 11:39:11 +02:00
241 changed files with 331239 additions and 36394 deletions

View File

@@ -32,6 +32,12 @@ module.exports = {
extends: ["plugin:jest/recommended"],
plugins: ["jest"],
},
{
files: ["src/Explorer/ContainerCopy/**/*.{test,spec}.{ts,tsx}"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
],
rules: {
"no-console": ["error", { allow: ["error", "warn", "dir"] }],

View File

@@ -198,6 +198,18 @@ jobs:
GREMLIN_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-gremlin.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$GREMLIN_TESTACCOUNT_TOKEN"
echo GREMLIN_TESTACCOUNT_TOKEN=$GREMLIN_TESTACCOUNT_TOKEN >> $GITHUB_ENV
CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3
- name: Upload blob report to GitHub Actions Artifacts

1
images/AzureOpenAi.svg Normal file
View File

@@ -0,0 +1 @@
<svg id="uuid-adbdae8e-5a41-46d1-8c18-aa73cdbfee32" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><path d="m0,2.7v12.6c0,1.491,1.209,2.7,2.7,2.7h12.6c1.491,0,2.7-1.209,2.7-2.7V2.7c0-1.491-1.209-2.7-2.7-2.7H2.7C1.209,0,0,1.209,0,2.7ZM10.8,0v3.6c0,3.976,3.224,7.2,7.2,7.2h-3.6c-3.976,0-7.199,3.222-7.2,7.198v-3.598c0-3.976-3.224-7.2-7.2-7.2h3.6c3.976,0,7.2-3.224,7.2-7.2Z" fill="#000000" stroke-width="0" /></svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@@ -0,0 +1,17 @@
<svg width="96" height="104" viewBox="0 0 96 104" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.2" d="M80.5008 81.2203L41.2637 58.2012L35.7705 61.9941L74.6152 84.6208L80.5008 81.2203Z" fill="#AAAAAA"/>
<path opacity="0.2" d="M60.2283 92.5992L20.9912 69.5801L15.498 73.373L54.3428 95.9997L60.2283 92.5992Z" fill="#AAAAAA"/>
<path d="M63.7596 30.9969L74.8768 37.4057L74.746 82.1359L35.7705 59.7708L35.9013 3.00781L63.7596 19.095V30.9969Z" fill="#C9C9C9"/>
<path d="M35.9014 3.00818L41.0022 0L68.8605 16.0872L63.7597 19.0954L35.9014 3.00818Z" fill="#AAAAAA"/>
<path d="M74.8769 37.4067L79.9777 34.5293L79.8469 79.2596L74.7461 82.2677L74.8769 37.4067Z" fill="#AAAAAA"/>
<path d="M43.4872 42.245L54.6043 48.6537L54.4735 93.384L15.498 71.0188L15.6288 14.2559L43.4872 30.3431V42.245Z" fill="#F4F4F4"/>
<path d="M15.6289 14.2562L20.7297 11.248L48.5881 27.3352L43.4872 30.3434L15.6289 14.2562Z" fill="#DCDCDC"/>
<path d="M54.6044 48.6547L59.7052 45.7773L59.5745 90.5076L54.4736 93.5158L54.6044 48.6547Z" fill="#DCDCDC"/>
<path d="M63.7598 19.0961L68.8606 16.0879L79.9778 34.5293L74.8769 37.4067L63.7598 19.0961Z" fill="#C9C9C9"/>
<path d="M63.7598 19.0957L74.8769 37.4063L63.7598 30.9976V19.0957Z" fill="#DCDCDC"/>
<path d="M43.4873 30.3441L48.5881 27.3359L59.7053 45.7774L54.6045 48.6548L43.4873 30.3441Z" fill="#F4F4F4"/>
<path d="M43.4873 30.3438L54.6045 48.6544L43.4873 42.2457V30.3438Z" fill="#C9C9C9"/>
<path d="M46.8751 52.4595V55.9693L23.2275 42.1367V38.627L46.8751 52.4595Z" fill="#C9C9C9"/>
<path d="M46.8751 59.0658V62.5756L23.2275 48.6914V45.1816L46.8751 59.0658Z" fill="#C9C9C9"/>
<path d="M46.8751 65.3621V68.8719L23.2275 54.9877V51.6328L46.8751 65.3621Z" fill="#C9C9C9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 0C8.73438 0 9.44271 0.0960961 10.125 0.288288C10.8073 0.48048 11.4427 0.758091 12.0312 1.12112C12.6198 1.48415 13.1589 1.91124 13.6484 2.4024C14.138 2.89356 14.5573 3.44611 14.9062 4.06006C15.2552 4.67401 15.5234 5.32799 15.7109 6.02202C15.8984 6.71605 15.9948 7.44211 16 8.2002C16 9.08108 15.8698 9.92993 15.6094 10.7467C15.349 11.5636 14.9766 12.3136 14.4922 12.997C14.0078 13.6803 13.4323 14.2783 12.7656 14.7908C12.099 15.3033 11.3542 15.701 10.5312 15.984C10.5156 15.9893 10.4922 15.992 10.4609 15.992C10.4297 15.992 10.4062 15.9947 10.3906 16C10.2656 16 10.1667 15.9626 10.0938 15.8879C10.0208 15.8131 9.98438 15.7144 9.98438 15.5916V14.4705C9.98438 14.1021 9.98698 13.7257 9.99219 13.3413C9.99219 13.0691 9.95312 12.7941 9.875 12.5165C9.79688 12.2389 9.65625 12.0067 9.45312 11.8198C10.0573 11.7504 10.5859 11.625 11.0391 11.4434C11.4922 11.2619 11.8724 11.0057 12.1797 10.6747C12.487 10.3437 12.7161 9.94328 12.8672 9.47347C13.0182 9.00367 13.0964 8.43777 13.1016 7.77578C13.1016 7.35936 13.0339 6.96697 12.8984 6.5986C12.763 6.23023 12.5573 5.88856 12.2812 5.57357C12.3385 5.42409 12.3802 5.26927 12.4062 5.10911C12.4323 4.94895 12.4453 4.78879 12.4453 4.62863C12.4453 4.42042 12.4245 4.21488 12.3828 4.01201C12.3411 3.80914 12.2812 3.60627 12.2031 3.4034C12.1771 3.39273 12.1484 3.38739 12.1172 3.38739C12.0859 3.38739 12.0573 3.38739 12.0312 3.38739C11.8646 3.38739 11.6901 3.41408 11.5078 3.46747C11.3255 3.52085 11.1458 3.59026 10.9688 3.67568C10.7917 3.76109 10.6172 3.85452 10.4453 3.95596C10.2734 4.05739 10.125 4.15349 10 4.24424C9.34896 4.05739 8.68229 3.96396 8 3.96396C7.31771 3.96396 6.65104 4.05739 6 4.24424C5.86979 4.15349 5.72135 4.05739 5.55469 3.95596C5.38802 3.85452 5.21615 3.76376 5.03906 3.68368C4.86198 3.6036 4.67969 3.5342 4.49219 3.47548C4.30469 3.41675 4.13021 3.38739 3.96875 3.38739H3.88281C3.85156 3.38739 3.82292 3.39273 3.79688 3.4034C3.72396 3.60093 3.66667 3.80113 3.625 4.004C3.58333 4.20687 3.5599 4.41508 3.55469 4.62863C3.55469 4.78879 3.56771 4.94895 3.59375 5.10911C3.61979 5.26927 3.66146 5.42409 3.71875 5.57357C3.44271 5.88322 3.23698 6.22222 3.10156 6.59059C2.96615 6.95896 2.89844 7.35402 2.89844 7.77578C2.89844 8.42709 2.97396 8.99032 3.125 9.46547C3.27604 9.94061 3.50521 10.341 3.8125 10.6667C4.11979 10.9923 4.5 11.2513 4.95312 11.4434C5.40625 11.6356 5.9349 11.7638 6.53906 11.8278C6.38802 11.9666 6.27344 12.1321 6.19531 12.3243C6.11719 12.5165 6.0625 12.7167 6.03125 12.9249C5.89062 12.9943 5.74219 13.0477 5.58594 13.0851C5.42969 13.1225 5.27344 13.1411 5.11719 13.1411C4.78385 13.1411 4.50781 13.0611 4.28906 12.9009C4.07031 12.7407 3.875 12.5219 3.70312 12.2442C3.64062 12.1428 3.5651 12.0414 3.47656 11.9399C3.38802 11.8385 3.29167 11.7477 3.1875 11.6677C3.08333 11.5876 2.97135 11.5235 2.85156 11.4755C2.73177 11.4274 2.60677 11.4007 2.47656 11.3954H2.38281C2.34115 11.3954 2.30208 11.4034 2.26562 11.4194C2.22917 11.4354 2.19271 11.4515 2.15625 11.4675C2.11979 11.4835 2.10417 11.5102 2.10938 11.5475C2.10938 11.6116 2.14583 11.673 2.21875 11.7317C2.29167 11.7905 2.35156 11.8385 2.39844 11.8759L2.42188 11.8919C2.53646 11.9826 2.63542 12.0681 2.71875 12.1481C2.80208 12.2282 2.88021 12.3163 2.95312 12.4124C3.02604 12.5085 3.08594 12.6099 3.13281 12.7167C3.17969 12.8235 3.23958 12.9489 3.3125 13.0931C3.48958 13.5095 3.73698 13.8111 4.05469 13.998C4.3724 14.1849 4.75521 14.2809 5.20312 14.2863C5.33854 14.2863 5.47396 14.2783 5.60938 14.2623C5.74479 14.2462 5.88021 14.2222 6.01562 14.1902V15.5836C6.01562 15.7117 5.97917 15.8131 5.90625 15.8879C5.83333 15.9626 5.73177 16 5.60156 16H5.53906C5.51302 16 5.48958 15.9947 5.46875 15.984C4.65104 15.7117 3.90625 15.3193 3.23438 14.8068C2.5625 14.2943 1.98698 13.6937 1.50781 13.005C1.02865 12.3163 0.658854 11.5636 0.398438 10.7467C0.138021 9.92993 0.00520833 9.08108 0 8.2002C0 7.44745 0.09375 6.72139 0.28125 6.02202C0.46875 5.32266 0.739583 4.67134 1.09375 4.06807C1.44792 3.4648 1.86458 2.91225 2.34375 2.41041C2.82292 1.90858 3.36198 1.47881 3.96094 1.12112C4.5599 0.76343 5.19792 0.488488 5.875 0.296296C6.55208 0.104104 7.26042 0.00533867 8 0Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

96
package-lock.json generated
View File

@@ -10,7 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.5.0",
"@azure/cosmos": "4.7.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
"@azure/msal-browser": "2.14.2",
@@ -116,6 +116,7 @@
"tinykeys": "2.1.0",
"underscore": "1.12.1",
"utility-types": "3.10.0",
"uuid": "9.0.0",
"zustand": "3.5.0"
},
"devDependencies": {
@@ -391,9 +392,9 @@
"license": "0BSD"
},
"node_modules/@azure/cosmos": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.5.0.tgz",
"integrity": "sha512-JsTh4twb6FcwP7rJwxQiNZQ/LGtuF6gmciaxY9Rnp6/A325Lhsw/SH4R2ArpT0yCvozbZpweIwdPfUkXVBtp5w==",
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.7.0.tgz",
"integrity": "sha512-a8OV7E41u/ZDaaaDAFdqTTiJ7c82jZc/+ot3XzNCIIilR25NBB+1ixzWQOAgP8SHRUIKfaUl6wAPdTuiG9I66A==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
@@ -626,6 +627,14 @@
}
}
},
"node_modules/@azure/ms-rest-js/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@azure/ms-rest-js/node_modules/xml2js": {
"version": "0.5.0",
"license": "MIT",
@@ -685,6 +694,14 @@
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-node/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@babel/code-frame": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
@@ -7595,6 +7612,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/commutable/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/connected-components": {
"version": "6.8.2",
"license": "BSD-3-Clause",
@@ -9125,6 +9150,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/fixtures/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/iron-icons": {
"version": "1.0.0",
"license": "BSD-3-Clause",
@@ -9282,6 +9315,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/messaging/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/monaco-editor": {
"version": "3.2.2",
"license": "BSD-3-Clause",
@@ -9397,6 +9438,14 @@
"version": "0.18.1",
"license": "MIT"
},
"node_modules/@nteract/monaco-editor/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/mythic-configuration": {
"version": "1.0.12",
"license": "BSD-3-Clause",
@@ -9665,6 +9714,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/reducers/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nteract/selectors": {
"version": "3.2.0",
"license": "BSD-3-Clause",
@@ -9888,6 +9945,14 @@
"uuid": "^8.0.0"
}
},
"node_modules/@nteract/types/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@octokit/auth-token": {
"version": "4.0.0",
"license": "MIT",
@@ -26419,6 +26484,15 @@
"xmlbuilder": "^15.1.0"
}
},
"node_modules/jest-trx-results-processor/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/jest-util": {
"version": "24.9.0",
"license": "MIT",
@@ -33753,6 +33827,15 @@
"websocket-driver": "^0.7.4"
}
},
"node_modules/sockjs/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"license": "BSD-3-Clause",
@@ -35619,8 +35702,9 @@
}
},
"node_modules/uuid": {
"version": "8.3.2",
"license": "MIT",
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"bin": {
"uuid": "dist/bin/uuid"
}

View File

@@ -5,7 +5,7 @@
"main": "index.js",
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.5.0",
"@azure/cosmos": "4.7.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
"@azure/msal-browser": "2.14.2",
@@ -46,8 +46,8 @@
"@types/mkdirp": "1.0.1",
"@types/node-fetch": "2.5.7",
"@xmldom/xmldom": "0.7.13",
"@xterm/xterm": "5.5.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"allotment": "1.20.2",
"applicationinsights": "1.8.0",
"bootstrap": "3.4.1",
@@ -111,6 +111,7 @@
"tinykeys": "2.1.0",
"underscore": "1.12.1",
"utility-types": "3.10.0",
"uuid": "9.0.0",
"zustand": "3.5.0"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
[defaults]
group = dataexplorer-preview
sku = P1V2
sku = P1v2
appserviceplan = dataexplorer-preview
location = westus2
web = dataexplorer-preview

View File

@@ -7,7 +7,6 @@ const backendEndpoint = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
const previewSiteEndpoint = "https://dataexplorer-preview.azurewebsites.net";
const previewStorageWebsiteEndpoint = "https://dataexplorerpreview.z5.web.core.windows.net/";
const githubApiUrl = "https://api.github.com/repos/Azure/cosmos-explorer";
const githubPullRequestUrl = "https://github.com/Azure/cosmos-explorer/pull";
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
const api = createProxyMiddleware({
@@ -57,11 +56,7 @@ app.get("/pull/:pr(\\d+)", (req, res) => {
fetch(`${githubApiUrl}/pulls/${pr}`)
.then((response) => response.json())
.then(({ head: { ref, sha } }) => {
const prUrl = new URL(`${githubPullRequestUrl}/${pr}`);
prUrl.hash = ref;
search.set("feature.pr", prUrl.href);
.then(({ head: { sha } }) => {
const explorer = new URL(`${previewSiteEndpoint}/commit/${sha}/explorer.html`);
explorer.search = search.toString();

36205
preview/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,16 +4,18 @@
"description": "",
"main": "index.js",
"scripts": {
"deploy": "az webapp up --name \"dataexplorer-preview\" --subscription \"cosmosdb-portalteam-runners\" --resource-group \"dataexplorer-preview\" --runtime \"NODE:18-lts\" --sku P1V2",
"deploy": "az webapp up --name \"dataexplorer-preview\" --subscription \"cosmosdb-portalteam-runners\" --resource-group \"dataexplorer-preview\" --runtime \"NODE:20-lts\" --sku P1V2",
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "Microsoft Corporation",
"dependencies": {
"express": "^4.17.1",
"body-parser": "^1.20.3",
"express": "^4.21.2",
"http-proxy-middleware": "^3.0.3",
"node": "^18.20.6",
"node-fetch": "^2.6.1"
"node": "^20.19.5",
"node-fetch": "^2.6.1",
"path-to-regexp": "^0.1.12"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -90,6 +90,10 @@ export class CapabilityNames {
public static readonly EnableServerless: string = "EnableServerless";
public static readonly EnableNoSQLVectorSearch: string = "EnableNoSQLVectorSearch";
public static readonly EnableNoSQLFullTextSearch: string = "EnableNoSQLFullTextSearch";
public static readonly EnableDataMasking: string = "EnableDataMasking";
public static readonly EnableDynamicDataMasking: string = "EnableDynamicDataMasking";
public static readonly EnableNoSQLFullTextSearchPreviewFeatures: string = "EnableNoSQLFullTextSearchPreviewFeatures";
public static readonly EnableOnlineCopyFeature: string = "EnableOnlineContainerCopy";
}
export enum CapacityMode {
@@ -138,6 +142,14 @@ export enum MongoBackendEndpointType {
remote,
}
export class AadScopeEndpoints {
public static readonly Development: string = "https://cosmos.azure.com";
public static readonly MPAC: string = "https://cosmos.azure.com";
public static readonly Prod: string = "https://cosmos.azure.com";
public static readonly Fairfax: string = "https://cosmos.azure.us";
public static readonly Mooncake: string = "https://cosmos.azure.cn";
}
export class PortalBackendEndpoints {
public static readonly Development: string = "https://localhost:7235";
public static readonly Mpac: string = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
@@ -255,6 +267,7 @@ export class HttpHeaders {
public static activityId: string = "x-ms-activity-id";
public static apiType: string = "x-ms-cosmos-apitype";
public static authorization: string = "authorization";
public static entraIdToken: string = "x-ms-entraid-token";
public static collectionIndexTransformationProgress: string =
"x-ms-documentdb-collection-index-transformation-progress";
public static continuation: string = "x-ms-continuation";
@@ -284,6 +297,7 @@ export class HttpHeaders {
public static migrateOfferToManualThroughput: string = "x-ms-cosmos-migrate-offer-to-manual-throughput";
public static migrateOfferToAutopilot: string = "x-ms-cosmos-migrate-offer-to-autopilot";
public static xAPIKey: string = "X-API-Key";
public static sessionId: string = "x-ms-client-session-id";
}
export class ContentType {

View File

@@ -1,7 +1,7 @@
import { TagNames, WorkloadType } from "Common/Constants";
import { Tags } from "Contracts/DataModels";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { userContext } from "../UserContext";
import { ApiType, userContext } from "../UserContext";
function isVirtualNetworkFilterEnabled() {
return userContext.databaseAccount?.properties?.isVirtualNetworkFilterEnabled;
@@ -33,3 +33,33 @@ export function isGlobalSecondaryIndexEnabled(): boolean {
!isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews
);
}
export const getDatabaseEndpoint = (apiType: ApiType): string => {
switch (apiType) {
case "Mongo":
return "mongodbDatabases";
case "Cassandra":
return "cassandraKeyspaces";
case "Gremlin":
return "gremlinDatabases";
case "Tables":
return "tables";
case "SQL":
default:
return "sqlDatabases";
}
};
export const getCollectionEndpoint = (apiType: ApiType): string => {
switch (apiType) {
case "Mongo":
return "collections";
case "Cassandra":
return "tables";
case "Gremlin":
return "graphs";
case "SQL":
default:
return "containers";
}
};

View File

@@ -28,3 +28,39 @@ describe("Environment Utility Test", () => {
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Development);
});
});
describe("normalizeArmEndpoint", () => {
it("should append '/' if not present", () => {
expect(EnvironmentUtility.normalizeArmEndpoint("https://example.com")).toBe("https://example.com/");
});
it("should return the same uri if '/' is present at the end", () => {
expect(EnvironmentUtility.normalizeArmEndpoint("https://example.com/")).toBe("https://example.com/");
});
it("should handle empty string", () => {
expect(EnvironmentUtility.normalizeArmEndpoint("")).toBe("");
});
});
describe("getEnvironment", () => {
it("should return Prod environment", () => {
updateConfigContext({
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
});
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Prod);
});
it("should return Fairfax environment", () => {
updateConfigContext({
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Fairfax,
});
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Fairfax);
});
it("should return Mooncake environment", () => {
updateConfigContext({
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mooncake,
});
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Mooncake);
});
});

View File

@@ -1,4 +1,5 @@
import { PortalBackendEndpoints } from "Common/Constants";
import { AadScopeEndpoints, PortalBackendEndpoints } from "Common/Constants";
import * as Logger from "Common/Logger";
import { configContext } from "ConfigContext";
export function normalizeArmEndpoint(uri: string): string {
@@ -27,3 +28,17 @@ export const getEnvironment = (): Environment => {
return environmentMap[configContext.PORTAL_BACKEND_ENDPOINT];
};
export const getEnvironmentScopeEndpoint = (): string => {
const environment = getEnvironment();
const endpoint = AadScopeEndpoints[environment];
if (!endpoint) {
throw new Error("Cannot determine AAD scope endpoint");
}
const hrefEndpoint = new URL(endpoint).href.replace(/\/+$/, "/.default");
Logger.logInfo(
`Using AAD scope endpoint: ${hrefEndpoint}, Environment: ${environment}`,
"EnvironmentUtility/getEnvironmentScopeEndpoint",
);
return hrefEndpoint;
};

View File

@@ -23,7 +23,10 @@ export const handleError = (error: string | ARMError | Error, area: string, cons
};
export const getErrorMessage = (error: string | Error = ""): string => {
const errorMessage = typeof error === "string" ? error : error.message;
let errorMessage = typeof error === "string" ? error : error.message;
if (!errorMessage) {
errorMessage = JSON.stringify(error);
}
return replaceKnownError(errorMessage);
};

View File

@@ -0,0 +1,52 @@
import { render } from "@testing-library/react";
import React from "react";
import LoadingOverlay from "./LoadingOverlay";
describe("LoadingOverlay", () => {
const defaultProps = {
isLoading: true,
label: "Loading...",
};
it("should render loading overlay when isLoading is true", () => {
const { container } = render(<LoadingOverlay {...defaultProps} />);
expect(container.firstChild).toMatchSnapshot();
});
it("should render loading overlay with custom label", () => {
const customProps = {
isLoading: true,
label: "Processing your request...",
};
const { container } = render(<LoadingOverlay {...customProps} />);
expect(container.firstChild).toMatchSnapshot();
});
it("should render loading overlay with empty label", () => {
const emptyLabelProps = {
isLoading: true,
label: "",
};
const { container } = render(<LoadingOverlay {...emptyLabelProps} />);
expect(container.firstChild).toMatchSnapshot();
});
it("should return null when isLoading is false", () => {
const notLoadingProps = {
isLoading: false,
label: "Loading...",
};
const { container } = render(<LoadingOverlay {...notLoadingProps} />);
expect(container.firstChild).toBeNull();
});
it("should handle long labels properly", () => {
const longLabelProps = {
isLoading: true,
label:
"This is a very long loading message that might span multiple lines and should still render correctly in the loading overlay component",
};
const { container } = render(<LoadingOverlay {...longLabelProps} />);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,31 @@
import { Overlay, Spinner, SpinnerSize } from "@fluentui/react";
import React from "react";
interface LoadingOverlayProps {
isLoading: boolean;
label: string;
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) => {
if (!isLoading) {
return null;
}
return (
<Overlay
styles={{
root: {
backgroundColor: "rgba(255,255,255,0.9)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
}}
>
<Spinner size={SpinnerSize.large} label={label} styles={{ label: { fontWeight: 600 } }} />
</Overlay>
);
};
export default LoadingOverlay;

View File

@@ -7,6 +7,7 @@ import { MessageTypes } from "../Contracts/ExplorerContracts";
import { Collection } from "../Contracts/ViewModels";
import DocumentId from "../Explorer/Tree/DocumentId";
import { userContext } from "../UserContext";
import { isDataplaneRbacEnabledForProxyApi } from "../Utils/AuthorizationUtils";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import { ApiType, ContentType, HttpHeaders, HttpStatusCodes } from "./Constants";
import { MinimalQueryIterator } from "./IteratorUtilities";
@@ -16,13 +17,20 @@ const defaultHeaders = {
[HttpHeaders.apiType]: ApiType.MongoDB.toString(),
[CosmosSDKConstants.HttpHeaders.MaxEntityCount]: "100",
[CosmosSDKConstants.HttpHeaders.Version]: "2017-11-15",
[HttpHeaders.sessionId]: userContext.sessionId,
};
function authHeaders() {
if (userContext.authType === AuthType.EncryptedToken) {
return { [HttpHeaders.guestAccessToken]: userContext.accessToken };
} else {
return { [HttpHeaders.authorization]: userContext.authorizationToken };
const headers: { [key: string]: string } = {
[HttpHeaders.authorization]: userContext.authorizationToken,
};
if (isDataplaneRbacEnabledForProxyApi(userContext)) {
headers[HttpHeaders.entraIdToken] = userContext.aadToken;
}
return headers;
}
}

View File

@@ -0,0 +1,13 @@
.pager-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
gap: 16px;
}
.pager-container > div {
display: flex;
gap: 8px;
align-items: center;
}

111
src/Common/Pager/index.tsx Normal file
View File

@@ -0,0 +1,111 @@
import { IconButton, Text } from "@fluentui/react";
import * as React from "react";
import "./Pager.css";
export interface PagerProps {
startIndex: number;
totalCount: number;
pageSize: number;
onLoadPage: (startIndex: number, pageSize: number) => void;
disabled?: boolean;
showFirstLast?: boolean;
showItemCount?: boolean;
className?: string;
}
const iconButtonStyles = {
root: {
backgroundColor: "transparent",
},
rootHovered: {
backgroundColor: "transparent",
},
rootPressed: {
backgroundColor: "transparent",
},
rootDisabled: {
backgroundColor: "transparent",
},
rootFocused: {
backgroundColor: "transparent",
outline: "none",
},
};
const Pager: React.FC<PagerProps> = ({
startIndex,
totalCount,
pageSize,
onLoadPage,
disabled = false,
showFirstLast = true,
showItemCount = true,
className,
}) => {
// Calculate current page and total pages from startIndex
const currentPage = Math.floor(startIndex / pageSize) + 1;
const totalPages = Math.ceil(totalCount / pageSize);
const endIndex = Math.min(startIndex + pageSize, totalCount);
const handleFirstPage = () => onLoadPage(0, pageSize);
const handlePreviousPage = () => onLoadPage(startIndex - pageSize, pageSize);
const handleNextPage = () => onLoadPage(startIndex + pageSize, pageSize);
const handleLastPage = () => onLoadPage((totalPages - 1) * pageSize, pageSize);
if (totalCount === 0) {
return null;
}
return (
<div className={className || "pager-container"}>
{showItemCount && (
<Text>
Showing {startIndex + 1} - {endIndex} of {totalCount} items
</Text>
)}
<div>
{showFirstLast && (
<IconButton
iconProps={{ iconName: "DoubleChevronLeft" }}
title="First page"
ariaLabel="Go to first page"
onClick={handleFirstPage}
disabled={disabled || currentPage === 1}
styles={iconButtonStyles}
/>
)}
<IconButton
iconProps={{ iconName: "ChevronLeft" }}
title="Previous page"
ariaLabel="Go to previous page"
onClick={handlePreviousPage}
disabled={disabled || currentPage === 1}
styles={iconButtonStyles}
/>
<Text>
Page {currentPage} of {totalPages}
</Text>
<IconButton
iconProps={{ iconName: "ChevronRight" }}
title="Next page"
ariaLabel="Go to next page"
onClick={handleNextPage}
disabled={disabled || currentPage === totalPages}
styles={iconButtonStyles}
/>
{showFirstLast && (
<IconButton
iconProps={{ iconName: "DoubleChevronRight" }}
title="Last page"
ariaLabel="Go to last page"
onClick={handleLastPage}
disabled={disabled || currentPage === totalPages}
styles={iconButtonStyles}
/>
)}
</div>
</div>
);
};
export default Pager;

View File

@@ -0,0 +1,32 @@
import { Shimmer, ShimmerElementType, Stack } from "@fluentui/react";
import * as React from "react";
export interface IndentLevel {
level: number;
width?: string;
}
interface ShimmerTreeProps {
indentLevels: IndentLevel[];
style?: React.CSSProperties;
}
const ShimmerTree = ({ indentLevels, style = {} }: ShimmerTreeProps) => {
const renderShimmers = (indent: IndentLevel) => (
<Shimmer
key={Math.random()}
shimmerElements={[
{ type: ShimmerElementType.gap, width: `${indent.level * 20}px` },
{ type: ShimmerElementType.line, height: 16, width: indent.width || "100%" },
]}
style={{ marginBottom: 8 }}
/>
);
return (
<Stack tokens={{ childrenGap: 8 }} style={{ width: "50%", ...style }} data-testid="shimmer-stack">
{indentLevels.map((indentLevel: IndentLevel) => renderShimmers(indentLevel))}
</Stack>
);
};
export default ShimmerTree;

View File

@@ -0,0 +1,73 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LoadingOverlay should handle long labels properly 1`] = `
<div
class="ms-Overlay root-109"
>
<div
class="ms-Spinner root-111"
>
<div
class="ms-Spinner-circle ms-Spinner--large circle-112"
/>
<div
class="ms-Spinner-label label-113"
>
This is a very long loading message that might span multiple lines and should still render correctly in the loading overlay component
</div>
</div>
</div>
`;
exports[`LoadingOverlay should render loading overlay when isLoading is true 1`] = `
<div
class="ms-Overlay root-109"
>
<div
class="ms-Spinner root-111"
>
<div
class="ms-Spinner-circle ms-Spinner--large circle-112"
/>
<div
class="ms-Spinner-label label-113"
>
Loading...
</div>
</div>
</div>
`;
exports[`LoadingOverlay should render loading overlay with custom label 1`] = `
<div
class="ms-Overlay root-109"
>
<div
class="ms-Spinner root-111"
>
<div
class="ms-Spinner-circle ms-Spinner--large circle-112"
/>
<div
class="ms-Spinner-label label-113"
>
Processing your request...
</div>
</div>
</div>
`;
exports[`LoadingOverlay should render loading overlay with empty label 1`] = `
<div
class="ms-Overlay root-109"
>
<div
class="ms-Spinner root-111"
>
<div
class="ms-Spinner-circle ms-Spinner--large circle-112"
/>
</div>
</div>
`;

View File

@@ -1,4 +1,6 @@
import { ContainerRequest, ContainerResponse, DatabaseRequest, DatabaseResponse, RequestOptions } from "@azure/cosmos";
import { sendMessage } from "Common/MessageHandler";
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import * as DataModels from "../../Contracts/DataModels";
@@ -43,6 +45,14 @@ export const createCollection = async (params: DataModels.CreateCollectionParams
}
logConsoleInfo(`Successfully created container ${params.collectionId}`);
if (isFabricNative()) {
sendMessage({
type: FabricMessageTypes.ContainerUpdated,
params: { updateType: "created" },
});
}
return collection;
} catch (error) {
handleError(error, "CreateCollection", `Error while creating container ${params.collectionId}`);

View File

@@ -1,3 +1,5 @@
import { sendMessage } from "Common/MessageHandler";
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { userContext } from "../../UserContext";
@@ -19,6 +21,11 @@ export async function deleteCollection(databaseId: string, collectionId: string)
await client().database(databaseId).container(collectionId).delete();
}
logConsoleInfo(`Successfully deleted container ${collectionId}`);
sendMessage({
type: FabricMessageTypes.ContainerUpdated,
params: { updateType: "deleted" },
});
} catch (error) {
handleError(error, "DeleteCollection", `Error while deleting container ${collectionId}`);
throw error;

View File

@@ -12,13 +12,13 @@ import { handleError } from "../ErrorHandlingUtils";
import { readOfferWithSDK } from "./readOfferWithSDK";
export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise<Offer> => {
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
if (isFabric()) {
// Not exposing offers in Fabric
return undefined;
}
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
try {
if (
userContext.authType === AuthType.AAD &&

View File

@@ -1,5 +1,6 @@
import { Item, RequestOptions } from "@azure/cosmos";
import { HttpHeaders } from "Common/Constants";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { CollectionBase } from "../../Contracts/ViewModels";
import DocumentId from "../../Explorer/Tree/DocumentId";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
@@ -23,10 +24,17 @@ export const updateDocument = async (
[HttpHeaders.partitionKey]: documentId.partitionKeyValue,
}
: {};
// If user has chosen to ignore partition key on update, pass null instead of actual partition key value
const ignorePartitionKeyOnDocumentUpdateFlag = LocalStorageUtility.getEntryBoolean(
StorageKey.IgnorePartitionKeyOnDocumentUpdate,
);
const partitionKey = ignorePartitionKeyOnDocumentUpdateFlag ? undefined : getPartitionKeyValue(documentId);
const response = await client()
.database(collection.databaseId)
.container(collection.id())
.item(documentId.id(), getPartitionKeyValue(documentId))
.item(documentId.id(), partitionKey)
.replace(newDocument, options);
logConsoleInfo(`Successfully updated ${entityName} ${documentId.id()}`);

View File

@@ -110,11 +110,30 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
return;
}
if (!validateEndpoint(newContext.AAD_ENDPOINT, configContext.allowedAadEndpoints || defaultAllowedAadEndpoints)) {
if (newContext.allowedAadEndpoints) {
Object.assign(configContext, { allowedAadEndpoints: newContext.allowedAadEndpoints });
}
if (newContext.allowedArmEndpoints) {
Object.assign(configContext, { allowedArmEndpoints: newContext.allowedArmEndpoints });
}
if (newContext.allowedGraphEndpoints) {
Object.assign(configContext, { allowedGraphEndpoints: newContext.allowedGraphEndpoints });
}
if (newContext.allowedBackendEndpoints) {
Object.assign(configContext, { allowedBackendEndpoints: newContext.allowedBackendEndpoints });
}
if (newContext.allowedMongoProxyEndpoints) {
Object.assign(configContext, { allowedMongoProxyEndpoints: newContext.allowedMongoProxyEndpoints });
}
if (newContext.allowedCassandraProxyEndpoints) {
Object.assign(configContext, { allowedCassandraProxyEndpoints: newContext.allowedCassandraProxyEndpoints });
}
if (!validateEndpoint(newContext.AAD_ENDPOINT, configContext.allowedAadEndpoints)) {
delete newContext.AAD_ENDPOINT;
}
if (!validateEndpoint(newContext.ARM_ENDPOINT, configContext.allowedArmEndpoints || defaultAllowedArmEndpoints)) {
if (!validateEndpoint(newContext.ARM_ENDPOINT, configContext.allowedArmEndpoints)) {
delete newContext.ARM_ENDPOINT;
}
@@ -122,9 +141,7 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
delete newContext.EMULATOR_ENDPOINT;
}
if (
!validateEndpoint(newContext.GRAPH_ENDPOINT, configContext.allowedGraphEndpoints || defaultAllowedGraphEndpoints)
) {
if (!validateEndpoint(newContext.GRAPH_ENDPOINT, configContext.allowedGraphEndpoints)) {
delete newContext.GRAPH_ENDPOINT;
}
@@ -132,30 +149,15 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
delete newContext.ARCADIA_ENDPOINT;
}
if (
!validateEndpoint(
newContext.PORTAL_BACKEND_ENDPOINT,
configContext.allowedBackendEndpoints || defaultAllowedBackendEndpoints,
)
) {
if (!validateEndpoint(newContext.PORTAL_BACKEND_ENDPOINT, configContext.allowedBackendEndpoints)) {
delete newContext.PORTAL_BACKEND_ENDPOINT;
}
if (
!validateEndpoint(
newContext.MONGO_PROXY_ENDPOINT,
configContext.allowedMongoProxyEndpoints || defaultAllowedMongoProxyEndpoints,
)
) {
if (!validateEndpoint(newContext.MONGO_PROXY_ENDPOINT, configContext.allowedMongoProxyEndpoints)) {
delete newContext.MONGO_PROXY_ENDPOINT;
}
if (
!validateEndpoint(
newContext.CASSANDRA_PROXY_ENDPOINT,
configContext.allowedCassandraProxyEndpoints || defaultAllowedCassandraProxyEndpoints,
)
) {
if (!validateEndpoint(newContext.CASSANDRA_PROXY_ENDPOINT, configContext.allowedCassandraProxyEndpoints)) {
delete newContext.CASSANDRA_PROXY_ENDPOINT;
}

View File

@@ -1,4 +1,5 @@
import { FabricMessageTypes } from "./FabricMessageTypes";
import { MessageTypes } from "./MessageTypes";
// This is the current version of these messages
export const DATA_EXPLORER_RPC_VERSION = "3";
@@ -19,9 +20,32 @@ export type DataExploreMessageV3 =
type: FabricMessageTypes.GetAllResourceTokens;
id: string;
}
| {
type: FabricMessageTypes.GetAccessToken;
id: string;
}
| {
type: MessageTypes.TelemetryInfo;
data: {
action: string;
actionModifier: string;
data: unknown;
timestamp: number;
};
}
| {
type: FabricMessageTypes.OpenSettings;
settingsId: string;
params: [{ settingsId?: "About" | "Connection" }];
}
| {
type: FabricMessageTypes.RestoreContainer;
params: [];
}
| {
type: FabricMessageTypes.ContainerUpdated;
params: {
updateType: "created" | "deleted" | "settings";
};
};
export interface GetCosmosTokenMessageOptions {
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";

View File

@@ -10,15 +10,34 @@ export interface ArmEntity {
resourceGroup?: string;
}
export interface DatabaseAccountUserAssignedIdentity {
[key: string]: {
principalId: string;
clientId: string;
};
}
export interface DatabaseAccountIdentity {
type: string;
principalId?: string;
tenantId?: string;
userAssignedIdentities?: DatabaseAccountUserAssignedIdentity;
}
export interface DatabaseAccount extends ArmEntity {
properties: DatabaseAccountExtendedProperties;
systemData?: DatabaseAccountSystemData;
identity?: DatabaseAccountIdentity | null;
}
export interface DatabaseAccountSystemData {
createdAt: string;
}
export interface DatabaseAccountBackupPolicy {
type: string;
}
export interface DatabaseAccountExtendedProperties {
documentEndpoint?: string;
disableLocalAuth?: boolean;
@@ -29,6 +48,8 @@ export interface DatabaseAccountExtendedProperties {
capabilities?: Capability[];
enableMultipleWriteLocations?: boolean;
mongoEndpoint?: string;
backupPolicy?: DatabaseAccountBackupPolicy;
defaultIdentity?: string;
readLocations?: DatabaseAccountResponseLocation[];
writeLocations?: DatabaseAccountResponseLocation[];
enableFreeTier?: boolean;
@@ -44,6 +65,7 @@ export interface DatabaseAccountExtendedProperties {
publicNetworkAccess?: string;
enablePriorityBasedExecution?: boolean;
vcoreMongoEndpoint?: string;
enableAllVersionsAndDeletesChangeFeed?: boolean;
}
export interface DatabaseAccountResponseLocation {
@@ -101,6 +123,24 @@ export interface Subscription {
authorizationSource?: string;
}
export interface DatabaseModel extends ArmEntity {
properties: DatabaseGetProperties;
}
export interface DatabaseGetProperties {
resource: DatabaseResource & ExtendedResourceProperties;
}
export interface DatabaseResource {
id: string;
}
export interface ExtendedResourceProperties {
readonly _rid?: string;
readonly _self?: string;
readonly _ts?: number;
readonly _etag?: string;
}
export interface SubscriptionPolicies {
locationPlacementId: string;
quotaId: string;
@@ -163,6 +203,7 @@ export interface Collection extends Resource {
geospatialConfig?: GeospatialConfig;
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
fullTextPolicy?: FullTextPolicy;
dataMaskingPolicy?: DataMaskingPolicy;
schema?: ISchema;
requestSchema?: () => void;
computedProperties?: ComputedProperties;
@@ -227,6 +268,17 @@ export interface ComputedProperty {
export type ComputedProperties = ComputedProperty[];
export interface DataMaskingPolicy {
includedPaths: Array<{
path: string;
strategy: string;
startPosition: number;
length: number;
}>;
excludedPaths: string[];
isPolicyEnabled: boolean;
}
export interface MaterializedView {
id: string;
_rid: string;

View File

@@ -7,6 +7,8 @@ export enum FabricMessageTypes {
GetAccessToken = "GetAccessToken",
Ready = "Ready",
OpenSettings = "OpenSettings",
RestoreContainer = "RestoreContainer",
ContainerUpdated = "ContainerUpdated",
}
export interface AuthorizationToken {

View File

@@ -49,4 +49,5 @@ export enum MessageTypes {
Ready, // unused. Can be removed if the portal uses the same list of enums.
OpenCESCVAFeedbackBlade,
ActivateTab,
OpenContainerCopyFeedbackBlade,
}

View File

@@ -140,6 +140,7 @@ export interface Collection extends CollectionBase {
requestSchema?: () => void;
vectorEmbeddingPolicy: ko.Observable<DataModels.VectorEmbeddingPolicy>;
fullTextPolicy: ko.Observable<DataModels.FullTextPolicy>;
dataMaskingPolicy: ko.Observable<DataModels.DataMaskingPolicy>;
indexingPolicy: ko.Observable<DataModels.IndexingPolicy>;
uniqueKeyPolicy: DataModels.UniqueKeyPolicy;
usageSizeInKB: ko.Observable<number>;
@@ -444,6 +445,8 @@ export interface DataExplorerInputsFrame {
};
feedbackPolicies?: any;
aadToken?: string;
containerCopyEnabled?: boolean;
sessionId?: string;
}
export interface SelfServeFrameInputs {

View File

@@ -0,0 +1,729 @@
import "@testing-library/jest-dom";
import Explorer from "Explorer/Explorer";
import * as Logger from "../../../Common/Logger";
import { useSidePanel } from "../../../hooks/useSidePanel";
import * as dataTransferService from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
import * as CopyJobUtils from "../CopyJobUtils";
import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider";
import { CopyJobActions, CopyJobStatusType } from "../Enums/CopyJobEnums";
import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobContextState, CopyJobType } from "../Types/CopyJobTypes";
import {
getCopyJobs,
openCopyJobDetailsPanel,
openCreateCopyJobPanel,
submitCreateCopyJob,
updateCopyJobStatus,
} from "./CopyJobActions";
jest.mock("UserContext", () => ({
userContext: {
databaseAccount: {
id: "/subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
},
},
}));
jest.mock("../../../hooks/useSidePanel");
jest.mock("../../../Common/Logger");
jest.mock("../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs");
jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState");
jest.mock("../CopyJobUtils");
describe("CopyJobActions", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("openCreateCopyJobPanel", () => {
it("should open side panel with correct parameters", () => {
const mockExplorer = {} as Explorer;
const mockSetPanelHasConsole = jest.fn();
const mockOpenSidePanel = jest.fn();
(useSidePanel.getState as jest.Mock).mockReturnValue({
setPanelHasConsole: mockSetPanelHasConsole,
openSidePanel: mockOpenSidePanel,
});
openCreateCopyJobPanel(mockExplorer);
expect(mockSetPanelHasConsole).toHaveBeenCalledWith(false);
expect(mockOpenSidePanel).toHaveBeenCalledWith(expect.any(String), expect.any(Object), "650px");
});
it("should render CreateCopyJobScreensProvider in side panel", () => {
const mockExplorer = {} as Explorer;
const mockOpenSidePanel = jest.fn();
(useSidePanel.getState as jest.Mock).mockReturnValue({
setPanelHasConsole: jest.fn(),
openSidePanel: mockOpenSidePanel,
});
openCreateCopyJobPanel(mockExplorer);
const sidePanelContent = mockOpenSidePanel.mock.calls[0][1];
expect(sidePanelContent.type).toBe(CreateCopyJobScreensProvider);
expect(sidePanelContent.props.explorer).toBe(mockExplorer);
});
});
describe("openCopyJobDetailsPanel", () => {
it("should open side panel with job details", () => {
const mockJob: CopyJobType = {
ID: "1",
Mode: "online",
Name: "test-job",
Status: CopyJobStatusType.InProgress,
CompletionPercentage: 50,
Duration: "01 hours, 30 minutes, 45 seconds",
LastUpdatedTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
Source: {
component: "CosmosDBSql",
databaseName: "source-db",
containerName: "source-container",
},
Destination: {
component: "CosmosDBSql",
databaseName: "target-db",
containerName: "target-container",
},
};
const mockSetPanelHasConsole = jest.fn();
const mockOpenSidePanel = jest.fn();
(useSidePanel.getState as jest.Mock).mockReturnValue({
setPanelHasConsole: mockSetPanelHasConsole,
openSidePanel: mockOpenSidePanel,
});
openCopyJobDetailsPanel(mockJob);
expect(mockSetPanelHasConsole).toHaveBeenCalledWith(false);
expect(mockOpenSidePanel).toHaveBeenCalledWith(expect.stringContaining("test-job"), expect.any(Object), "650px");
});
it("should render CopyJobDetails component with correct job", () => {
const mockJob: CopyJobType = {
ID: "1",
Mode: "offline",
Name: "test-job-2",
Status: CopyJobStatusType.Completed,
CompletionPercentage: 100,
Duration: "02 hours, 15 minutes, 30 seconds",
LastUpdatedTime: "1/2/2025, 11:00:00 AM",
timestamp: 1704193200000,
Source: {
component: "CosmosDBSql",
databaseName: "source-db",
containerName: "source-container",
},
Destination: {
component: "CosmosDBSql",
databaseName: "target-db",
containerName: "target-container",
},
};
const mockOpenSidePanel = jest.fn();
(useSidePanel.getState as jest.Mock).mockReturnValue({
setPanelHasConsole: jest.fn(),
openSidePanel: mockOpenSidePanel,
});
openCopyJobDetailsPanel(mockJob);
const sidePanelContent = mockOpenSidePanel.mock.calls[0][1];
expect(sidePanelContent.type).toBe(CopyJobDetails);
expect(sidePanelContent.props.job).toBe(mockJob);
});
});
describe("getCopyJobs", () => {
beforeEach(() => {
(CopyJobUtils.getAccountDetailsFromResourceId as jest.Mock).mockReturnValue({
subscriptionId: "sub-123",
resourceGroup: "rg-test",
accountName: "test-account",
});
});
it("should fetch and format copy jobs successfully", async () => {
const mockResponse = {
value: [
{
properties: {
jobName: "job-1",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "online",
duration: "01:30:45",
source: {
component: "CosmosDBSql",
databaseName: "source-db",
containerName: "source-container",
},
destination: {
component: "CosmosDBSql",
databaseName: "target-db",
containerName: "target-container",
},
},
},
],
};
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
});
(CopyJobUtils.convertTime as jest.Mock).mockReturnValue("01 hours, 30 minutes, 45 seconds");
(CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("InProgress");
const result = await getCopyJobs();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
ID: "1",
Name: "job-1",
Status: "InProgress",
CompletionPercentage: 50,
Mode: "online",
});
});
it("should filter jobs by CosmosDBSql component", async () => {
const mockResponse = {
value: [
{
properties: {
jobName: "sql-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "02:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
},
{
properties: {
jobName: "other-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T11:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "01:00:00",
source: { component: "OtherComponent", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
},
],
};
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
});
(CopyJobUtils.convertTime as jest.Mock).mockReturnValue("02 hours");
(CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("Completed");
const result = await getCopyJobs();
expect(result).toHaveLength(1);
expect(result[0].Name).toBe("sql-job");
});
it("should sort jobs by last updated time (newest first)", async () => {
const mockResponse = {
value: [
{
properties: {
jobName: "older-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "01:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
},
{
properties: {
jobName: "newer-job",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-02T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "online",
duration: "00:30:00",
source: { component: "CosmosDBSql", databaseName: "db3", containerName: "c3" },
destination: { component: "CosmosDBSql", databaseName: "db4", containerName: "c4" },
},
},
],
};
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
});
(CopyJobUtils.convertTime as jest.Mock).mockReturnValue("01 hours");
(CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("Completed");
const result = await getCopyJobs();
expect(result[0].Name).toBe("newer-job");
expect(result[1].Name).toBe("older-job");
});
it("should calculate completion percentage correctly", async () => {
const mockResponse = {
value: [
{
properties: {
jobName: "job-1",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 75,
totalCount: 100,
mode: "online",
duration: "01:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
},
],
};
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
});
(CopyJobUtils.convertTime as jest.Mock).mockReturnValue("01 hours");
(CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("InProgress");
const result = await getCopyJobs();
expect(result[0].CompletionPercentage).toBe(75);
});
it("should handle zero total count gracefully", async () => {
const mockResponse = {
value: [
{
properties: {
jobName: "job-1",
status: "Pending",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 0,
totalCount: 0,
mode: "online",
duration: "00:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
},
],
};
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
});
(CopyJobUtils.convertTime as jest.Mock).mockReturnValue("0 seconds");
(CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("Pending");
const result = await getCopyJobs();
expect(result[0].CompletionPercentage).toBe(0);
});
it("should extract error messages if present", async () => {
const mockError = {
message: "Error message line 1\r\n\r\nError message line 2",
code: "ErrorCode123",
};
const mockResponse = {
value: [
{
properties: {
jobName: "failed-job",
status: "Failed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "offline",
duration: "00:30:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
error: mockError,
},
},
],
};
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
});
(CopyJobUtils.convertTime as jest.Mock).mockReturnValue("30 minutes");
(CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("Failed");
(CopyJobUtils.extractErrorMessage as jest.Mock).mockReturnValue({
message: "Error message line 1",
code: "ErrorCode123",
});
const result = await getCopyJobs();
expect(result[0].Error).toEqual({
message: "Error message line 1",
code: "ErrorCode123",
});
expect(CopyJobUtils.extractErrorMessage).toHaveBeenCalledWith(mockError);
});
it("should abort previous request when new request is made", async () => {
const mockAbortController = {
abort: jest.fn(),
signal: {} as AbortSignal,
};
(global as any).AbortController = jest.fn(() => mockAbortController);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({ value: [] });
getCopyJobs();
expect(mockAbortController.abort).not.toHaveBeenCalled();
getCopyJobs();
expect(mockAbortController.abort).toHaveBeenCalledTimes(1);
});
it("should throw error for invalid response format", async () => {
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({
value: "not-an-array",
});
await expect(getCopyJobs()).rejects.toThrow("Invalid migration job status response: Expected an array of jobs.");
});
it("should handle abort signal error", async () => {
const abortError = {
message: "Aborted",
content: JSON.stringify({ message: "signal is aborted without reason" }),
};
(dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(abortError);
await expect(getCopyJobs()).rejects.toMatchObject({
message: expect.stringContaining("Please wait for the current fetch request to complete"),
});
});
it("should handle generic errors", async () => {
const genericError = new Error("Network error");
(dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(genericError);
await expect(getCopyJobs()).rejects.toThrow("Network error");
});
});
describe("submitCreateCopyJob", () => {
let mockRefreshJobList: jest.Mock;
let mockOnSuccess: jest.Mock;
beforeEach(() => {
mockRefreshJobList = jest.fn();
mockOnSuccess = jest.fn();
(CopyJobUtils.getAccountDetailsFromResourceId as jest.Mock).mockReturnValue({
subscriptionId: "sub-123",
resourceGroup: "rg-test",
accountName: "test-account",
});
(MonitorCopyJobsRefState.getState as jest.Mock).mockReturnValue({
ref: { refreshJobList: mockRefreshJobList },
});
});
it("should create intra-account copy job successfully", async () => {
const mockState: CopyJobContextState = {
jobName: "test-job",
migrationType: "online" as any,
source: {
subscription: {} as any,
account: { id: "account-1", name: "source-account" } as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "sub-123",
account: { id: "account-1", name: "target-account" } as any,
databaseId: "target-db",
containerId: "target-container",
},
};
(CopyJobUtils.isIntraAccountCopy as jest.Mock).mockReturnValue(true);
(dataTransferService.create as jest.Mock).mockResolvedValue({ id: "job-id" });
await submitCreateCopyJob(mockState, mockOnSuccess);
expect(dataTransferService.create).toHaveBeenCalledWith(
"sub-123",
"rg-test",
"test-account",
"test-job",
expect.objectContaining({
properties: expect.objectContaining({
source: expect.objectContaining({
component: "CosmosDBSql",
databaseName: "source-db",
containerName: "source-container",
}),
destination: expect.objectContaining({
component: "CosmosDBSql",
databaseName: "target-db",
containerName: "target-container",
}),
mode: "online",
}),
}),
);
const callArgs = (dataTransferService.create as jest.Mock).mock.calls[0][4];
expect(callArgs.properties.source.remoteAccountName).toBeUndefined();
expect(mockRefreshJobList).toHaveBeenCalled();
expect(mockOnSuccess).toHaveBeenCalled();
});
it("should create inter-account copy job with source account name", async () => {
const mockState: CopyJobContextState = {
jobName: "cross-account-job",
migrationType: "offline" as any,
source: {
subscription: {} as any,
account: { id: "account-1", name: "source-account" } as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "sub-456",
account: { id: "account-2", name: "target-account" } as any,
databaseId: "target-db",
containerId: "target-container",
},
};
(CopyJobUtils.isIntraAccountCopy as jest.Mock).mockReturnValue(false);
(dataTransferService.create as jest.Mock).mockResolvedValue({ id: "job-id" });
await submitCreateCopyJob(mockState, mockOnSuccess);
const callArgs = (dataTransferService.create as jest.Mock).mock.calls[0][4];
expect(callArgs.properties.source.remoteAccountName).toBe("source-account");
expect(mockOnSuccess).toHaveBeenCalled();
});
it("should handle errors and log them", async () => {
const mockState: CopyJobContextState = {
jobName: "failing-job",
migrationType: "online" as any,
source: {
subscription: {} as any,
account: { id: "account-1", name: "source-account" } as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "sub-123",
account: { id: "account-1", name: "target-account" } as any,
databaseId: "target-db",
containerId: "target-container",
},
};
const mockError = new Error("API Error");
(CopyJobUtils.isIntraAccountCopy as jest.Mock).mockReturnValue(true);
(dataTransferService.create as jest.Mock).mockRejectedValue(mockError);
await expect(submitCreateCopyJob(mockState, mockOnSuccess)).rejects.toThrow("API Error");
expect(Logger.logError).toHaveBeenCalledWith("API Error", "CopyJob/CopyJobActions.submitCreateCopyJob");
expect(mockOnSuccess).not.toHaveBeenCalled();
expect(mockRefreshJobList).not.toHaveBeenCalled();
});
it("should handle errors without message", async () => {
const mockState: CopyJobContextState = {
jobName: "test-job",
migrationType: "online" as any,
source: {
subscription: {} as any,
account: { id: "account-1", name: "source-account" } as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "sub-123",
account: { id: "account-1", name: "target-account" } as any,
databaseId: "target-db",
containerId: "target-container",
},
};
(CopyJobUtils.isIntraAccountCopy as jest.Mock).mockReturnValue(true);
(dataTransferService.create as jest.Mock).mockRejectedValue({});
await expect(submitCreateCopyJob(mockState, mockOnSuccess)).rejects.toEqual({});
expect(Logger.logError).toHaveBeenCalledWith(
"Error submitting create copy job. Please try again later.",
"CopyJob/CopyJobActions.submitCreateCopyJob",
);
});
});
describe("updateCopyJobStatus", () => {
const mockJob: CopyJobType = {
ID: "1",
Mode: "online",
Name: "test-job",
Status: CopyJobStatusType.InProgress,
CompletionPercentage: 50,
Duration: "01 hours, 30 minutes",
LastUpdatedTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
Source: {
component: "CosmosDBSql",
databaseName: "source-db",
containerName: "source-container",
},
Destination: {
component: "CosmosDBSql",
databaseName: "target-db",
containerName: "target-container",
},
};
beforeEach(() => {
(CopyJobUtils.getAccountDetailsFromResourceId as jest.Mock).mockReturnValue({
subscriptionId: "sub-123",
resourceGroup: "rg-test",
accountName: "test-account",
});
});
it("should pause a job successfully", async () => {
const mockResponse = { id: "job-id", properties: { status: "Paused" } };
(dataTransferService.pause as jest.Mock).mockResolvedValue(mockResponse);
const result = await updateCopyJobStatus(mockJob, CopyJobActions.pause);
expect(dataTransferService.pause).toHaveBeenCalledWith("sub-123", "rg-test", "test-account", "test-job");
expect(result).toEqual(mockResponse);
});
it("should resume a job successfully", async () => {
const mockResponse = { id: "job-id", properties: { status: "InProgress" } };
(dataTransferService.resume as jest.Mock).mockResolvedValue(mockResponse);
const result = await updateCopyJobStatus(mockJob, CopyJobActions.resume);
expect(dataTransferService.resume).toHaveBeenCalledWith("sub-123", "rg-test", "test-account", "test-job");
expect(result).toEqual(mockResponse);
});
it("should cancel a job successfully", async () => {
const mockResponse = { id: "job-id", properties: { status: "Cancelled" } };
(dataTransferService.cancel as jest.Mock).mockResolvedValue(mockResponse);
const result = await updateCopyJobStatus(mockJob, CopyJobActions.cancel);
expect(dataTransferService.cancel).toHaveBeenCalledWith("sub-123", "rg-test", "test-account", "test-job");
expect(result).toEqual(mockResponse);
});
it("should complete a job successfully", async () => {
const mockResponse = { id: "job-id", properties: { status: "Completed" } };
(dataTransferService.complete as jest.Mock).mockResolvedValue(mockResponse);
const result = await updateCopyJobStatus(mockJob, CopyJobActions.complete);
expect(dataTransferService.complete).toHaveBeenCalledWith("sub-123", "rg-test", "test-account", "test-job");
expect(result).toEqual(mockResponse);
});
it("should handle case-insensitive action names", async () => {
const mockResponse = { id: "job-id", properties: { status: "Paused" } };
(dataTransferService.pause as jest.Mock).mockResolvedValue(mockResponse);
await updateCopyJobStatus(mockJob, "PAUSE");
expect(dataTransferService.pause).toHaveBeenCalled();
});
it("should throw error for unsupported action", async () => {
await expect(updateCopyJobStatus(mockJob, "invalid-action")).rejects.toThrow(
"Unsupported action: invalid-action",
);
expect(Logger.logError).toHaveBeenCalled();
});
it("should normalize error messages with status types", async () => {
const mockError = {
message: "Job must be in 'Running' or 'InProgress' state",
content: { error: "State error" },
};
(dataTransferService.pause as jest.Mock).mockRejectedValue(mockError);
await expect(updateCopyJobStatus(mockJob, CopyJobActions.pause)).rejects.toEqual(mockError);
const loggedMessage = (Logger.logError as jest.Mock).mock.calls[0][0];
expect(loggedMessage).toContain("Error updating copy job status");
});
it("should log error with correct context", async () => {
const mockError = new Error("Network failure");
(dataTransferService.resume as jest.Mock).mockRejectedValue(mockError);
await expect(updateCopyJobStatus(mockJob, CopyJobActions.resume)).rejects.toThrow("Network failure");
expect(Logger.logError).toHaveBeenCalledWith(
expect.stringContaining("Error updating copy job status"),
"CopyJob/CopyJobActions.updateCopyJobStatus",
);
});
it("should handle errors with content property", async () => {
const mockError = {
content: { message: "Content error message" },
};
(dataTransferService.cancel as jest.Mock).mockRejectedValue(mockError);
await expect(updateCopyJobStatus(mockJob, CopyJobActions.cancel)).rejects.toEqual(mockError);
expect(Logger.logError).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,208 @@
import Explorer from "Explorer/Explorer";
import React from "react";
import { userContext } from "UserContext";
import { logError } from "../../../Common/Logger";
import { useSidePanel } from "../../../hooks/useSidePanel";
import {
cancel,
complete,
create,
listByDatabaseAccount,
pause,
resume,
} from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
import {
CreateJobRequest,
DataTransferJobGetResults,
} from "../../../Utils/arm/generatedClients/dataTransferService/types";
import ContainerCopyMessages from "../ContainerCopyMessages";
import {
convertTime,
convertToCamelCase,
COSMOS_SQL_COMPONENT,
extractErrorMessage,
formatUTCDateTime,
getAccountDetailsFromResourceId,
isIntraAccountCopy,
} from "../CopyJobUtils";
import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider";
import { CopyJobActions, CopyJobStatusType } from "../Enums/CopyJobEnums";
import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types/CopyJobTypes";
export const openCreateCopyJobPanel = (explorer: Explorer) => {
const sidePanelState = useSidePanel.getState();
sidePanelState.setPanelHasConsole(false);
sidePanelState.openSidePanel(
ContainerCopyMessages.createCopyJobPanelTitle,
<CreateCopyJobScreensProvider explorer={explorer} />,
"650px",
);
};
export const openCopyJobDetailsPanel = (job: CopyJobType) => {
const sidePanelState = useSidePanel.getState();
sidePanelState.setPanelHasConsole(false);
sidePanelState.openSidePanel(
ContainerCopyMessages.copyJobDetailsPanelTitle(job.Name),
<CopyJobDetails job={job} />,
"650px",
);
};
let copyJobsAbortController: AbortController | null = null;
export const getCopyJobs = async (): Promise<CopyJobType[]> => {
try {
if (copyJobsAbortController) {
copyJobsAbortController.abort();
}
copyJobsAbortController = new AbortController();
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
userContext.databaseAccount?.id || "",
);
const response = await listByDatabaseAccount(
subscriptionId,
resourceGroup,
accountName,
copyJobsAbortController.signal,
);
const jobs = response.value || [];
if (!Array.isArray(jobs)) {
throw new Error("Invalid migration job status response: Expected an array of jobs.");
}
copyJobsAbortController = null;
const calculateCompletionPercentage = (processed: number, total: number): number => {
if (
typeof processed !== "number" ||
typeof total !== "number" ||
!isFinite(processed) ||
!isFinite(total) ||
total <= 0
) {
return 0;
}
const percentage = Math.round((processed / total) * 100);
return Math.max(0, Math.min(100, percentage));
};
const formattedJobs: CopyJobType[] = jobs
.filter(
(job: DataTransferJobGetResults) =>
job.properties?.source?.component === COSMOS_SQL_COMPONENT &&
job.properties?.destination?.component === COSMOS_SQL_COMPONENT,
)
.sort(
(current: DataTransferJobGetResults, next: DataTransferJobGetResults) =>
new Date(next.properties.lastUpdatedUtcTime).getTime() -
new Date(current.properties.lastUpdatedUtcTime).getTime(),
)
.map((job: DataTransferJobGetResults, index: number) => {
const dateTimeObj = formatUTCDateTime(job.properties.lastUpdatedUtcTime);
return {
ID: (index + 1).toString(),
Mode: job.properties.mode,
Name: job.properties.jobName,
Source: job.properties.source,
Destination: job.properties.destination,
Status: convertToCamelCase(job.properties.status) as CopyJobType["Status"],
CompletionPercentage: calculateCompletionPercentage(job.properties.processedCount, job.properties.totalCount),
Duration: convertTime(job.properties.duration),
LastUpdatedTime: dateTimeObj.formattedDateTime,
timestamp: dateTimeObj.timestamp,
Error: job.properties.error ? extractErrorMessage(job.properties.error as unknown as CopyJobErrorType) : null,
} as CopyJobType;
});
return formattedJobs;
} catch (error) {
const errorContent = JSON.stringify(error.content || error.message || error);
if (errorContent.includes("signal is aborted without reason")) {
throw {
message:
"Please wait for the current fetch request to complete. The previous copy job fetch request was aborted.",
};
} else {
throw error;
}
}
};
export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess: () => void) => {
try {
const { source, target, migrationType, jobName } = state;
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
userContext.databaseAccount?.id || "",
);
const isSameAccount = isIntraAccountCopy(source?.account?.id, target?.account?.id);
const body = {
properties: {
source: {
component: "CosmosDBSql",
...(isSameAccount ? {} : { remoteAccountName: source?.account?.name }),
databaseName: source?.databaseId,
containerName: source?.containerId,
},
destination: {
component: "CosmosDBSql",
databaseName: target?.databaseId,
containerName: target?.containerId,
},
mode: migrationType,
},
} as unknown as CreateJobRequest;
const response = await create(subscriptionId, resourceGroup, accountName, jobName, body);
MonitorCopyJobsRefState.getState().ref?.refreshJobList();
onSuccess();
return response;
} catch (error) {
const errorMessage = error.message || "Error submitting create copy job. Please try again later.";
logError(errorMessage, "CopyJob/CopyJobActions.submitCreateCopyJob");
throw error;
}
};
export const updateCopyJobStatus = async (job: CopyJobType, action: string): Promise<DataTransferJobGetResults> => {
try {
let updateFn = null;
switch (action.toLowerCase()) {
case CopyJobActions.pause:
updateFn = pause;
break;
case CopyJobActions.resume:
updateFn = resume;
break;
case CopyJobActions.cancel:
updateFn = cancel;
break;
case CopyJobActions.complete:
updateFn = complete;
break;
default:
throw new Error(`Unsupported action: ${action}`);
}
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
userContext.databaseAccount?.id || "",
);
const response = await updateFn?.(subscriptionId, resourceGroup, accountName, job.Name);
return response;
} catch (error) {
const errorMessage = JSON.stringify((error as CopyJobError).message || error.content || error);
const statusList = [CopyJobStatusType.Running, CopyJobStatusType.InProgress, CopyJobStatusType.Partitioning];
const pattern = new RegExp(`'(${statusList.join("|")})'`, "g");
const normalizedErrorMessage = errorMessage.replace(
pattern,
`'${ContainerCopyMessages.MonitorJobs.Status.InProgress}'`,
);
logError(`Error updating copy job status: ${normalizedErrorMessage}`, "CopyJob/CopyJobActions.updateCopyJobStatus");
throw error;
}
};

View File

@@ -0,0 +1,185 @@
import "@testing-library/jest-dom";
import { render } from "@testing-library/react";
import React from "react";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil";
import CopyJobCommandBar from "./CopyJobCommandBar";
import * as Utils from "./Utils";
jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState");
jest.mock("../../Menus/CommandBar/CommandBarUtil");
jest.mock("./Utils");
describe("CopyJobCommandBar", () => {
let mockExplorer: Explorer;
let mockConvertButton: jest.MockedFunction<typeof CommandBarUtil.convertButton>;
let mockGetCommandBarButtons: jest.MockedFunction<typeof Utils.getCommandBarButtons>;
beforeEach(() => {
mockExplorer = {} as Explorer;
mockConvertButton = CommandBarUtil.convertButton as jest.MockedFunction<typeof CommandBarUtil.convertButton>;
mockGetCommandBarButtons = Utils.getCommandBarButtons as jest.MockedFunction<typeof Utils.getCommandBarButtons>;
jest.clearAllMocks();
});
it("should render without crashing", () => {
mockGetCommandBarButtons.mockReturnValue([]);
mockConvertButton.mockReturnValue([]);
const { container } = render(<CopyJobCommandBar explorer={mockExplorer} />);
expect(container.querySelector(".commandBarContainer")).toBeInTheDocument();
});
it("should call getCommandBarButtons with explorer", () => {
mockGetCommandBarButtons.mockReturnValue([]);
mockConvertButton.mockReturnValue([]);
render(<CopyJobCommandBar explorer={mockExplorer} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer);
expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(1);
});
it("should call convertButton with command bar items and background color", () => {
const mockCommandButtonProps: CommandButtonComponentProps[] = [
{
iconSrc: "icon.svg",
iconAlt: "Test Icon",
onCommandClick: jest.fn(),
commandButtonLabel: "Test Button",
ariaLabel: "Test Button Aria Label",
tooltipText: "Test Tooltip",
hasPopup: false,
disabled: false,
},
];
mockGetCommandBarButtons.mockReturnValue(mockCommandButtonProps);
mockConvertButton.mockReturnValue([]);
render(<CopyJobCommandBar explorer={mockExplorer} />);
expect(mockConvertButton).toHaveBeenCalledTimes(1);
});
it("should render FluentCommandBar with correct aria label", () => {
mockGetCommandBarButtons.mockReturnValue([]);
mockConvertButton.mockReturnValue([]);
const { getByRole } = render(<CopyJobCommandBar explorer={mockExplorer} />);
const commandBar = getByRole("menubar", { hidden: true });
expect(commandBar).toHaveAttribute("aria-label", "Use left and right arrow keys to navigate between commands");
});
it("should render FluentCommandBar with converted items", () => {
const mockCommandButtonProps: CommandButtonComponentProps[] = [
{
iconSrc: "icon1.svg",
iconAlt: "Test Icon 1",
onCommandClick: jest.fn(),
commandButtonLabel: "Test Button 1",
ariaLabel: "Test Button 1 Aria Label",
tooltipText: "Test Tooltip 1",
hasPopup: false,
disabled: false,
},
{
iconSrc: "icon2.svg",
iconAlt: "Test Icon 2",
onCommandClick: jest.fn(),
commandButtonLabel: "Test Button 2",
ariaLabel: "Test Button 2 Aria Label",
tooltipText: "Test Tooltip 2",
hasPopup: false,
disabled: false,
},
];
const mockFluentItems = [
{
key: "button1",
text: "Test Button 1",
iconProps: { iconName: "Add" },
},
{
key: "button2",
text: "Test Button 2",
iconProps: { iconName: "Feedback" },
},
];
mockGetCommandBarButtons.mockReturnValue(mockCommandButtonProps);
mockConvertButton.mockReturnValue(mockFluentItems);
const { container } = render(<CopyJobCommandBar explorer={mockExplorer} />);
expect(mockConvertButton).toHaveBeenCalledTimes(1);
expect(container.querySelector(".commandBarContainer")).toBeInTheDocument();
});
it("should handle multiple command bar buttons", () => {
const mockCommandButtonProps: CommandButtonComponentProps[] = [
{
iconSrc: "create.svg",
iconAlt: "Create",
onCommandClick: jest.fn(),
commandButtonLabel: "Create Copy Job",
ariaLabel: "Create Copy Job",
tooltipText: "Create Copy Job",
hasPopup: false,
disabled: false,
},
{
iconSrc: "refresh.svg",
iconAlt: "Refresh",
onCommandClick: jest.fn(),
commandButtonLabel: "Refresh",
ariaLabel: "Refresh",
tooltipText: "Refresh",
hasPopup: false,
disabled: false,
},
{
iconSrc: "feedback.svg",
iconAlt: "Feedback",
onCommandClick: jest.fn(),
commandButtonLabel: "Feedback",
ariaLabel: "Feedback",
tooltipText: "Feedback",
hasPopup: false,
disabled: false,
},
];
mockGetCommandBarButtons.mockReturnValue(mockCommandButtonProps);
mockConvertButton.mockReturnValue([
{ key: "create", text: "Create Copy Job" },
{ key: "refresh", text: "Refresh" },
{ key: "feedback", text: "Feedback" },
]);
render(<CopyJobCommandBar explorer={mockExplorer} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer);
expect(mockConvertButton.mock.calls[0][0]).toEqual(mockCommandButtonProps);
});
it("should re-render when explorer prop changes", () => {
const mockExplorer1 = { id: "explorer1" } as unknown as Explorer;
const mockExplorer2 = { id: "explorer2" } as unknown as Explorer;
mockGetCommandBarButtons.mockReturnValue([]);
mockConvertButton.mockReturnValue([]);
const { rerender } = render(<CopyJobCommandBar explorer={mockExplorer1} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1);
rerender(<CopyJobCommandBar explorer={mockExplorer2} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2);
expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,33 @@
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
import React from "react";
import { StyleConstants } from "../../../Common/StyleConstants";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil";
import { ContainerCopyProps } from "../Types/CopyJobTypes";
import { getCommandBarButtons } from "./Utils";
const backgroundColor = StyleConstants.BaseLight;
const rootStyle = {
root: {
backgroundColor: backgroundColor,
},
};
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ explorer }) => {
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer);
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
return (
<div className="commandBarContainer">
<FluentCommandBar
ariaLabel="Use left and right arrow keys to navigate between commands"
styles={rootStyle}
items={controlButtons}
/>
</div>
);
};
CopyJobCommandBar.displayName = "CopyJobCommandBar";
export default CopyJobCommandBar;

View File

@@ -0,0 +1,268 @@
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import * as Actions from "../Actions/CopyJobActions";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { getCommandBarButtons } from "./Utils";
jest.mock("../../../ConfigContext", () => ({
configContext: {
platform: "Portal",
},
Platform: {
Portal: "Portal",
Emulator: "Emulator",
Hosted: "Hosted",
},
}));
jest.mock("../Actions/CopyJobActions", () => ({
openCreateCopyJobPanel: jest.fn(),
}));
jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState", () => ({
MonitorCopyJobsRefState: jest.fn(),
}));
describe("CommandBar Utils", () => {
let mockExplorer: Explorer;
let mockOpenContainerCopyFeedbackBlade: jest.Mock;
let mockRefreshJobList: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockOpenContainerCopyFeedbackBlade = jest.fn();
mockRefreshJobList = jest.fn();
mockExplorer = {
openContainerCopyFeedbackBlade: mockOpenContainerCopyFeedbackBlade,
} as unknown as Explorer;
(MonitorCopyJobsRefState as unknown as jest.Mock).mockImplementation((selector) => {
const state = {
ref: {
refreshJobList: mockRefreshJobList,
},
};
return selector(state);
});
});
describe("getCommandBarButtons", () => {
it("should return an array of command button props", () => {
const buttons = getCommandBarButtons(mockExplorer);
expect(buttons).toBeDefined();
expect(Array.isArray(buttons)).toBe(true);
expect(buttons.length).toBeGreaterThan(0);
});
it("should include create copy job button", () => {
const buttons = getCommandBarButtons(mockExplorer);
const createButton = buttons[0];
expect(createButton).toBeDefined();
expect(createButton.commandButtonLabel).toBeUndefined();
expect(createButton.ariaLabel).toBe("Create a new container copy job");
expect(createButton.tooltipText).toBe("Create Copy Job");
expect(createButton.hasPopup).toBe(false);
expect(createButton.disabled).toBe(false);
});
it("should include refresh button", () => {
const buttons = getCommandBarButtons(mockExplorer);
const refreshButton = buttons[1];
expect(refreshButton).toBeDefined();
expect(refreshButton.ariaLabel).toBe("Refresh copy jobs");
expect(refreshButton.tooltipText).toBe("Refresh");
expect(refreshButton.disabled).toBe(false);
});
it("should include feedback button when platform is Portal", () => {
const buttons = getCommandBarButtons(mockExplorer);
expect(buttons.length).toBe(3);
const feedbackButton = buttons[2];
expect(feedbackButton).toBeDefined();
expect(feedbackButton.ariaLabel).toBe("Provide feedback on copy jobs");
expect(feedbackButton.tooltipText).toBe("Feedback");
expect(feedbackButton.disabled).toBe(false);
});
it("should not include feedback button when platform is not Portal", async () => {
jest.resetModules();
jest.doMock("../../../ConfigContext", () => ({
configContext: {
platform: "Emulator",
},
Platform: {
Portal: "Portal",
Emulator: "Emulator",
Hosted: "Hosted",
},
}));
const { getCommandBarButtons: getCommandBarButtonsEmulator } = await import("./Utils");
const buttons = getCommandBarButtonsEmulator(mockExplorer);
expect(buttons.length).toBe(2);
});
it("should call openCreateCopyJobPanel when create button is clicked", () => {
const buttons = getCommandBarButtons(mockExplorer);
const createButton = buttons[0];
createButton.onCommandClick({} as React.SyntheticEvent);
expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledWith(mockExplorer);
expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledTimes(1);
});
it("should call refreshJobList when refresh button is clicked", () => {
const buttons = getCommandBarButtons(mockExplorer);
const refreshButton = buttons[1];
refreshButton.onCommandClick({} as React.SyntheticEvent);
expect(mockRefreshJobList).toHaveBeenCalledTimes(1);
});
it("should call openContainerCopyFeedbackBlade when feedback button is clicked", () => {
const buttons = getCommandBarButtons(mockExplorer);
const feedbackButton = buttons[2];
feedbackButton.onCommandClick({} as React.SyntheticEvent);
expect(mockOpenContainerCopyFeedbackBlade).toHaveBeenCalledTimes(1);
});
it("should return buttons with correct icon sources", () => {
const buttons = getCommandBarButtons(mockExplorer);
expect(buttons[0].iconSrc).toBeDefined();
expect(buttons[0].iconAlt).toBe("Create Copy Job");
expect(buttons[1].iconSrc).toBeDefined();
expect(buttons[1].iconAlt).toBe("Refresh");
expect(buttons[2].iconSrc).toBeDefined();
expect(buttons[2].iconAlt).toBe("Feedback");
});
it("should handle null MonitorCopyJobsRefState ref gracefully", () => {
(MonitorCopyJobsRefState as unknown as jest.Mock).mockImplementationOnce((selector) => {
const state: { ref: null } = { ref: null };
return selector(state);
});
const buttons = getCommandBarButtons(mockExplorer);
const refreshButton = buttons[1];
expect(() => refreshButton.onCommandClick({} as React.SyntheticEvent)).not.toThrow();
});
it("should set hasPopup to false for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.hasPopup).toBe(false);
});
});
it("should set commandButtonLabel to undefined for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.commandButtonLabel).toBeUndefined();
});
});
it("should respect disabled state when provided", () => {
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.disabled).toBe(false);
});
});
it("should return CommandButtonComponentProps with all required properties", () => {
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button: CommandButtonComponentProps) => {
expect(button).toHaveProperty("iconSrc");
expect(button).toHaveProperty("iconAlt");
expect(button).toHaveProperty("onCommandClick");
expect(button).toHaveProperty("commandButtonLabel");
expect(button).toHaveProperty("ariaLabel");
expect(button).toHaveProperty("tooltipText");
expect(button).toHaveProperty("hasPopup");
expect(button).toHaveProperty("disabled");
});
});
it("should maintain button order: create, refresh, feedback", () => {
const buttons = getCommandBarButtons(mockExplorer);
expect(buttons[0].tooltipText).toBe("Create Copy Job");
expect(buttons[1].tooltipText).toBe("Refresh");
expect(buttons[2].tooltipText).toBe("Feedback");
});
});
describe("Button click handlers", () => {
it("should execute click handlers without errors", () => {
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(() => button.onCommandClick({} as React.SyntheticEvent)).not.toThrow();
});
});
it("should call correct action for each button", () => {
const buttons = getCommandBarButtons(mockExplorer);
buttons[0].onCommandClick({} as React.SyntheticEvent);
expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledWith(mockExplorer);
buttons[1].onCommandClick({} as React.SyntheticEvent);
expect(mockRefreshJobList).toHaveBeenCalled();
buttons[2].onCommandClick({} as React.SyntheticEvent);
expect(mockOpenContainerCopyFeedbackBlade).toHaveBeenCalled();
});
});
describe("Accessibility", () => {
it("should have aria labels for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.ariaLabel).toBeDefined();
expect(typeof button.ariaLabel).toBe("string");
expect(button.ariaLabel.length).toBeGreaterThan(0);
});
});
it("should have tooltip text for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.tooltipText).toBeDefined();
expect(typeof button.tooltipText).toBe("string");
expect(button.tooltipText.length).toBeGreaterThan(0);
});
});
it("should have icon alt text for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.iconAlt).toBeDefined();
expect(typeof button.iconAlt).toBe("string");
expect(button.iconAlt.length).toBeGreaterThan(0);
});
});
});
});

View File

@@ -0,0 +1,59 @@
import AddIcon from "../../../../images/Add.svg";
import FeedbackIcon from "../../../../images/Feedback-Command.svg";
import RefreshIcon from "../../../../images/refresh-cosmos.svg";
import { configContext, Platform } from "../../../ConfigContext";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import * as Actions from "../Actions/CopyJobActions";
import ContainerCopyMessages from "../ContainerCopyMessages";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
const buttons: CopyJobCommandBarBtnType[] = [
{
key: "createCopyJob",
iconSrc: AddIcon,
label: ContainerCopyMessages.createCopyJobButtonLabel,
ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel,
onClick: () => Actions.openCreateCopyJobPanel(explorer),
},
{
key: "refresh",
iconSrc: RefreshIcon,
label: ContainerCopyMessages.refreshButtonLabel,
ariaLabel: ContainerCopyMessages.refreshButtonAriaLabel,
onClick: () => monitorCopyJobsRef?.refreshJobList(),
},
];
if (configContext.platform === Platform.Portal) {
buttons.push({
key: "feedback",
iconSrc: FeedbackIcon,
label: ContainerCopyMessages.feedbackButtonLabel,
ariaLabel: ContainerCopyMessages.feedbackButtonAriaLabel,
onClick: () => {
explorer.openContainerCopyFeedbackBlade();
},
});
}
return buttons;
}
function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProps {
return {
iconSrc: config.iconSrc,
iconAlt: config.label,
onCommandClick: config.onClick,
commandButtonLabel: undefined as string | undefined,
ariaLabel: config.ariaLabel,
tooltipText: config.label,
hasPopup: false,
disabled: config.disabled ?? false,
};
}
export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] {
return getCopyJobBtns(explorer).map(btnMapper);
}

View File

@@ -0,0 +1,177 @@
export default {
// Copy Job Command Bar
feedbackButtonLabel: "Feedback",
feedbackButtonAriaLabel: "Provide feedback on copy jobs",
refreshButtonLabel: "Refresh",
refreshButtonAriaLabel: "Refresh copy jobs",
createCopyJobButtonLabel: "Create Copy Job",
createCopyJobButtonAriaLabel: "Create a new container copy job",
// No Copy Jobs Found
noCopyJobsTitle: "No copy jobs to show",
createCopyJobButtonText: "Create a container copy job",
// Copy Job Details
copyJobDetailsPanelTitle: (jobName: string) => jobName || "Job Details",
errorTitle: "Error Details",
selectedContainers: "Selected Containers",
// Create Copy Job Panel
createCopyJobPanelTitle: "Create copy job",
// Select Account Screen
selectAccountDescription: "Please select a source account from which to copy.",
subscriptionDropdownLabel: "Subscription",
subscriptionDropdownPlaceholder: "Select a subscription",
sourceAccountDropdownLabel: "Account",
sourceAccountDropdownPlaceholder: "Select an account",
migrationTypeCheckboxLabel: "Copy container in offline mode",
// Select Source and Target Containers Screen
selectSourceAndTargetContainersDescription:
"Please select a source container and a destination container to copy to.",
sourceContainerSubHeading: "Source container",
targetContainerSubHeading: "Destination container",
databaseDropdownLabel: "Database",
databaseDropdownPlaceholder: "Select a database",
containerDropdownLabel: "Container",
containerDropdownPlaceholder: "Select a container",
createNewContainerSubHeading: "Select the properties for your container.",
createContainerButtonLabel: "Create a new container",
createContainerHeading: "Create new container",
// Preview and Create Screen
jobNameLabel: "Job name",
sourceSubscriptionLabel: "Source subscription",
sourceAccountLabel: "Source account",
sourceDatabaseLabel: "Source database",
sourceContainerLabel: "Source container",
targetDatabaseLabel: "Destination database",
targetContainerLabel: "Destination container",
// Assign Permissions Screen
assignPermissions: {
crossAccountDescription:
"To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.",
intraAccountOnlineDescription: (accountName: string) =>
`Follow the steps below to enable online copy on your "${accountName}" account.`,
crossAccountConfiguration: {
title: "Cross-account container copy",
description: (sourceAccount: string, destinationAccount: string) =>
`Please follow the instruction below to grant requisite permissions to copy data from "${sourceAccount}" to "${destinationAccount}".`,
},
onlineConfiguration: {
title: "Online container copy",
description: (accountName: string) =>
`Please follow the instructions below to enable online copy on your "${accountName}" account.`,
},
},
toggleBtn: {
onText: "On",
offText: "Off",
},
popoverOverlaySpinnerLabel: "Please wait while we process your request...",
addManagedIdentity: {
title: "System-assigned managed identity enabled.",
description:
"A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you dont have to store any credentials in code.",
descriptionHrefText: "Learn more about Managed identities.",
descriptionHref: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
toggleLabel: "System assigned managed identity",
tooltip: {
content: "Learn more about",
hrefText: "Managed Identities.",
href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
},
userAssignedIdentityTooltip: "You can select an existing user assigned identity or create a new one.",
userAssignedIdentityLabel: "You may also select a user assigned managed identity.",
createUserAssignedIdentityLink: "Create User Assigned Managed Identity",
enablementTitle: "Enable system assigned managed identity",
enablementDescription: (accountName: string) =>
accountName
? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button.`
: "",
},
defaultManagedIdentity: {
title: "System-assigned managed identity set as default.",
description: (accountName: string) =>
`Set the system-assigned managed identity as default for "${accountName}" by switching it on.`,
tooltip: {
content: "Learn more about",
hrefText: "Default Managed Identities.",
href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
},
popoverTitle: "System assigned managed identity set as default",
popoverDescription: (accountName: string) =>
`Assign the system-assigned managed identity as the default for "${accountName}". To confirm, click the "Yes" button. `,
},
readPermissionAssigned: {
title: "Read permissions assigned to the default identity.",
description:
"To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.",
tooltip: {
content: "Learn more about",
hrefText: "Read permissions.",
href: "https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control",
},
popoverTitle: "Read permissions assigned to default identity.",
popoverDescription:
"Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button.",
},
pointInTimeRestore: {
title: "Point In Time Restore enabled",
description: (accessName: string) =>
`To facilitate online container copy jobs, please update your "${accessName}" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.`,
tooltip: {
content: "Learn more about",
hrefText: "Continuous Backup",
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction",
},
buttonText: "Enable Point In Time Restore",
},
onlineCopyEnabled: {
title: "Online copy enabled",
description: (accountName: string) =>
`Enable online container copy by clicking the button below on your "${accountName}" account.`,
hrefText: "Learn more about online copy jobs",
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy",
buttonText: "Enable Online Copy",
validateAllVersionsAndDeletesChangeFeedSpinnerLabel:
"Validating All versions and deletes change feed mode (preview)...",
enablingAllVersionsAndDeletesChangeFeedSpinnerLabel:
"Enabling All versions and deletes change feed mode (preview)...",
enablingOnlineCopySpinnerLabel: (accountName: string) =>
`Enabling online copy on your "${accountName}" account ...`,
},
MonitorJobs: {
Columns: {
lastUpdatedTime: "Date & time",
name: "Job name",
status: "Status",
completionPercentage: "Completion %",
duration: "Duration",
error: "Error message",
mode: "Mode",
actions: "Actions",
},
Actions: {
pause: "Pause",
resume: "Resume",
cancel: "Cancel",
complete: "Complete",
viewDetails: "View Details",
},
Status: {
Pending: "Pending",
InProgress: "In Progress",
Running: "In Progress",
Partitioning: "In Progress",
Paused: "Paused",
Completed: "Completed",
Failed: "Failed",
Faulted: "Failed",
Skipped: "Cancelled",
Cancelled: "Cancelled",
},
},
};

View File

@@ -0,0 +1,131 @@
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import React from "react";
import Explorer from "../Explorer";
import ContainerCopyPanel from "./ContainerCopyPanel";
import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefState";
jest.mock("./CommandBar/CopyJobCommandBar", () => {
const MockCopyJobCommandBar = () => {
return <div data-testid="copy-job-command-bar">CopyJobCommandBar</div>;
};
MockCopyJobCommandBar.displayName = "CopyJobCommandBar";
return MockCopyJobCommandBar;
});
jest.mock("./MonitorCopyJobs/MonitorCopyJobs", () => {
const React = jest.requireActual("react");
const MockMonitorCopyJobs = React.forwardRef((_props: any, ref: any) => {
React.useImperativeHandle(ref, () => ({
refreshJobList: jest.fn(),
}));
return <div data-testid="monitor-copy-jobs">MonitorCopyJobs</div>;
});
MockMonitorCopyJobs.displayName = "MonitorCopyJobs";
return MockMonitorCopyJobs;
});
jest.mock("./MonitorCopyJobs/MonitorCopyJobRefState", () => ({
MonitorCopyJobsRefState: {
getState: jest.fn(() => ({
setRef: jest.fn(),
})),
},
}));
describe("ContainerCopyPanel", () => {
let mockExplorer: Explorer;
let mockSetRef: jest.Mock;
beforeEach(() => {
mockExplorer = {} as Explorer;
mockSetRef = jest.fn();
(MonitorCopyJobsRefState.getState as jest.Mock).mockReturnValue({
setRef: mockSetRef,
});
});
afterEach(() => {
jest.clearAllMocks();
});
it("renders the component with correct structure", () => {
render(<ContainerCopyPanel explorer={mockExplorer} />);
const wrapper = document.querySelector("#containerCopyWrapper");
expect(wrapper).toBeInTheDocument();
expect(wrapper).toHaveClass("flexContainer", "hideOverflows");
});
it("renders CopyJobCommandBar component", () => {
render(<ContainerCopyPanel explorer={mockExplorer} />);
const commandBar = screen.getByTestId("copy-job-command-bar");
expect(commandBar).toBeInTheDocument();
expect(commandBar).toHaveTextContent("CopyJobCommandBar");
});
it("renders MonitorCopyJobs component", () => {
render(<ContainerCopyPanel explorer={mockExplorer} />);
const monitorCopyJobs = screen.getByTestId("monitor-copy-jobs");
expect(monitorCopyJobs).toBeInTheDocument();
expect(monitorCopyJobs).toHaveTextContent("MonitorCopyJobs");
});
it("passes explorer prop to child components", () => {
render(<ContainerCopyPanel explorer={mockExplorer} />);
expect(screen.getByTestId("copy-job-command-bar")).toBeInTheDocument();
expect(screen.getByTestId("monitor-copy-jobs")).toBeInTheDocument();
});
it("sets the MonitorCopyJobs ref in the state on mount", async () => {
render(<ContainerCopyPanel explorer={mockExplorer} />);
await waitFor(() => {
expect(mockSetRef).toHaveBeenCalledTimes(1);
});
const refArgument = mockSetRef.mock.calls[0][0];
expect(refArgument).toBeDefined();
expect(refArgument).toHaveProperty("refreshJobList");
expect(typeof refArgument.refreshJobList).toBe("function");
});
it("updates the ref state when monitorCopyJobsRef changes", async () => {
const { rerender } = render(<ContainerCopyPanel explorer={mockExplorer} />);
await waitFor(() => {
expect(mockSetRef).toHaveBeenCalledTimes(1);
});
mockSetRef.mockClear();
rerender(<ContainerCopyPanel explorer={mockExplorer} />);
});
it("handles missing explorer prop gracefully", () => {
const { container } = render(<ContainerCopyPanel explorer={undefined as any} />);
expect(container.querySelector("#containerCopyWrapper")).toBeInTheDocument();
});
it("applies correct CSS classes to wrapper", () => {
render(<ContainerCopyPanel explorer={mockExplorer} />);
const wrapper = document.querySelector("#containerCopyWrapper");
expect(wrapper).toHaveClass("flexContainer");
expect(wrapper).toHaveClass("hideOverflows");
});
it("maintains ref across re-renders", async () => {
const { rerender } = render(<ContainerCopyPanel explorer={mockExplorer} />);
await waitFor(() => {
expect(mockSetRef).toHaveBeenCalled();
});
const firstCallRef = mockSetRef.mock.calls[0][0];
const newExplorer = {} as Explorer;
rerender(<ContainerCopyPanel explorer={newExplorer} />);
expect(mockSetRef.mock.calls[0][0]).toBe(firstCallRef);
});
});

View File

@@ -0,0 +1,25 @@
import React, { useEffect } from "react";
import CopyJobCommandBar from "./CommandBar/CopyJobCommandBar";
import "./containerCopyStyles.less";
import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefState";
import MonitorCopyJobs, { MonitorCopyJobsRef } from "./MonitorCopyJobs/MonitorCopyJobs";
import { ContainerCopyProps } from "./Types/CopyJobTypes";
const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ explorer }) => {
const monitorCopyJobsRef = React.useRef<MonitorCopyJobsRef>();
useEffect(() => {
if (monitorCopyJobsRef.current) {
MonitorCopyJobsRefState.getState().setRef(monitorCopyJobsRef.current);
}
}, [monitorCopyJobsRef.current]);
return (
<div id="containerCopyWrapper" className="flexContainer hideOverflows">
<CopyJobCommandBar explorer={explorer} />
<MonitorCopyJobs ref={monitorCopyJobsRef} explorer={explorer} />
</div>
);
};
ContainerCopyPanel.displayName = "ContainerCopyPanel";
export default ContainerCopyPanel;

View File

@@ -0,0 +1,667 @@
import "@testing-library/jest-dom";
import { act, render, screen } from "@testing-library/react";
import React from "react";
import Explorer from "../../Explorer";
import { CopyJobMigrationType } from "../Enums/CopyJobEnums";
import CopyJobContextProvider, { CopyJobContext, useCopyJobContext } from "./CopyJobContext";
jest.mock("UserContext", () => ({
userContext: {
subscriptionId: "test-subscription-id",
databaseAccount: {
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
name: "test-account",
location: "East US",
kind: "GlobalDocumentDB",
},
},
}));
describe("CopyJobContext", () => {
let mockExplorer: Explorer;
beforeEach(() => {
mockExplorer = {} as Explorer;
});
afterEach(() => {
jest.clearAllMocks();
});
describe("CopyJobContextProvider", () => {
it("should render children correctly", () => {
render(
<CopyJobContextProvider explorer={mockExplorer}>
<div data-testid="test-child">Test Child</div>
</CopyJobContextProvider>,
);
expect(screen.getByTestId("test-child")).toBeInTheDocument();
expect(screen.getByTestId("test-child")).toHaveTextContent("Test Child");
});
it("should initialize with default state", () => {
let contextValue: any;
render(
<CopyJobContextProvider explorer={mockExplorer}>
<CopyJobContext.Consumer>
{(value) => {
contextValue = value;
return null;
}}
</CopyJobContext.Consumer>
</CopyJobContextProvider>,
);
expect(contextValue).toBeDefined();
expect(contextValue.copyJobState).toEqual({
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: {
subscriptionId: "test-subscription-id",
},
account: {
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
name: "test-account",
location: "East US",
kind: "GlobalDocumentDB",
},
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "test-subscription-id",
account: {
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
name: "test-account",
location: "East US",
kind: "GlobalDocumentDB",
},
databaseId: "",
containerId: "",
},
sourceReadAccessFromTarget: false,
});
expect(contextValue.flow).toBeNull();
expect(contextValue.contextError).toBeNull();
expect(contextValue.explorer).toBe(mockExplorer);
});
it("should provide setCopyJobState function", () => {
let contextValue: any;
render(
<CopyJobContextProvider explorer={mockExplorer}>
<CopyJobContext.Consumer>
{(value) => {
contextValue = value;
return null;
}}
</CopyJobContext.Consumer>
</CopyJobContextProvider>,
);
expect(contextValue.setCopyJobState).toBeDefined();
expect(typeof contextValue.setCopyJobState).toBe("function");
});
it("should provide setFlow function", () => {
let contextValue: any;
render(
<CopyJobContextProvider explorer={mockExplorer}>
<CopyJobContext.Consumer>
{(value) => {
contextValue = value;
return null;
}}
</CopyJobContext.Consumer>
</CopyJobContextProvider>,
);
expect(contextValue.setFlow).toBeDefined();
expect(typeof contextValue.setFlow).toBe("function");
});
it("should provide setContextError function", () => {
let contextValue: any;
render(
<CopyJobContextProvider explorer={mockExplorer}>
<CopyJobContext.Consumer>
{(value) => {
contextValue = value;
return null;
}}
</CopyJobContext.Consumer>
</CopyJobContextProvider>,
);
expect(contextValue.setContextError).toBeDefined();
expect(typeof contextValue.setContextError).toBe("function");
});
it("should provide resetCopyJobState function", () => {
let contextValue: any;
render(
<CopyJobContextProvider explorer={mockExplorer}>
<CopyJobContext.Consumer>
{(value) => {
contextValue = value;
return null;
}}
</CopyJobContext.Consumer>
</CopyJobContextProvider>,
);
expect(contextValue.resetCopyJobState).toBeDefined();
expect(typeof contextValue.resetCopyJobState).toBe("function");
});
it("should update copyJobState when setCopyJobState is called", () => {
let contextValue: any;
const TestComponent = (): JSX.Element => {
const context = useCopyJobContext();
contextValue = context;
return (
<button
onClick={() =>
context.setCopyJobState({
...context.copyJobState,
jobName: "test-job",
migrationType: CopyJobMigrationType.Online,
})
}
>
Update Job
</button>
);
};
render(
<CopyJobContextProvider explorer={mockExplorer}>
<TestComponent />
</CopyJobContextProvider>,
);
const button = screen.getByText("Update Job");
act(() => {
button.click();
});
expect(contextValue.copyJobState.jobName).toBe("test-job");
expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Online);
});
it("should update flow when setFlow is called", () => {
let contextValue: any;
const TestComponent = (): JSX.Element => {
const context = useCopyJobContext();
contextValue = context;
const handleSetFlow = (): void => {
context.setFlow({ currentScreen: "source-selection" });
};
return <button onClick={handleSetFlow}>Set Flow</button>;
};
render(
<CopyJobContextProvider explorer={mockExplorer}>
<TestComponent />
</CopyJobContextProvider>,
);
expect(contextValue.flow).toBeNull();
const button = screen.getByText("Set Flow");
act(() => {
button.click();
});
expect(contextValue.flow).toEqual({ currentScreen: "source-selection" });
});
it("should update contextError when setContextError is called", () => {
let contextValue: any;
const TestComponent = (): JSX.Element => {
const context = useCopyJobContext();
contextValue = context;
return <button onClick={() => context.setContextError("Test error message")}>Set Error</button>;
};
render(
<CopyJobContextProvider explorer={mockExplorer}>
<TestComponent />
</CopyJobContextProvider>,
);
expect(contextValue.contextError).toBeNull();
const button = screen.getByText("Set Error");
act(() => {
button.click();
});
expect(contextValue.contextError).toBe("Test error message");
});
it("should reset copyJobState when resetCopyJobState is called", () => {
let contextValue: any;
const TestComponent = (): JSX.Element => {
const context = useCopyJobContext();
contextValue = context;
const handleUpdate = (): void => {
context.setCopyJobState({
...context.copyJobState,
jobName: "modified-job",
migrationType: CopyJobMigrationType.Online,
source: {
...context.copyJobState.source,
databaseId: "test-db",
containerId: "test-container",
},
});
};
return (
<>
<button onClick={handleUpdate}>Update</button>
<button onClick={context.resetCopyJobState}>Reset</button>
</>
);
};
render(
<CopyJobContextProvider explorer={mockExplorer}>
<TestComponent />
</CopyJobContextProvider>,
);
const updateButton = screen.getByText("Update");
act(() => {
updateButton.click();
});
expect(contextValue.copyJobState.jobName).toBe("modified-job");
expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Online);
expect(contextValue.copyJobState.source.databaseId).toBe("test-db");
const resetButton = screen.getByText("Reset");
act(() => {
resetButton.click();
});
expect(contextValue.copyJobState.jobName).toBe("");
expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Offline);
expect(contextValue.copyJobState.source.databaseId).toBe("");
expect(contextValue.copyJobState.source.containerId).toBe("");
});
it("should maintain explorer reference", () => {
let contextValue: any;
render(
<CopyJobContextProvider explorer={mockExplorer}>
<CopyJobContext.Consumer>
{(value) => {
contextValue = value;
return null;
}}
</CopyJobContext.Consumer>
</CopyJobContextProvider>,
);
expect(contextValue.explorer).toBe(mockExplorer);
});
it("should handle multiple state updates correctly", () => {
let contextValue: any;
const TestComponent = (): JSX.Element => {
const context = useCopyJobContext();
contextValue = context;
return (
<>
<button onClick={() => context.setCopyJobState({ ...context.copyJobState, jobName: "job-1" })}>
Update 1
</button>
<button onClick={() => context.setFlow({ currentScreen: "screen-1" })}>Flow 1</button>
<button onClick={() => context.setContextError("error-1")}>Error 1</button>
</>
);
};
render(
<CopyJobContextProvider explorer={mockExplorer}>
<TestComponent />
</CopyJobContextProvider>,
);
act(() => {
screen.getByText("Update 1").click();
});
expect(contextValue.copyJobState.jobName).toBe("job-1");
act(() => {
screen.getByText("Flow 1").click();
});
expect(contextValue.flow).toEqual({ currentScreen: "screen-1" });
act(() => {
screen.getByText("Error 1").click();
});
expect(contextValue.contextError).toBe("error-1");
});
it("should handle partial state updates", () => {
let contextValue: any;
const TestComponent = (): JSX.Element => {
const context = useCopyJobContext();
contextValue = context;
const handlePartialUpdate = (): void => {
context.setCopyJobState((prev) => ({
...prev,
jobName: "partial-update",
}));
};
return <button onClick={handlePartialUpdate}>Partial Update</button>;
};
render(
<CopyJobContextProvider explorer={mockExplorer}>
<TestComponent />
</CopyJobContextProvider>,
);
const initialState = { ...contextValue.copyJobState };
act(() => {
screen.getByText("Partial Update").click();
});
expect(contextValue.copyJobState.jobName).toBe("partial-update");
expect(contextValue.copyJobState.migrationType).toBe(initialState.migrationType);
expect(contextValue.copyJobState.source).toEqual(initialState.source);
expect(contextValue.copyJobState.target).toEqual(initialState.target);
});
});
describe("useCopyJobContext", () => {
it("should return context value when used within provider", () => {
let contextValue: any;
const TestComponent = (): null => {
const context = useCopyJobContext();
contextValue = context;
return null;
};
render(
<CopyJobContextProvider explorer={mockExplorer}>
<TestComponent />
</CopyJobContextProvider>,
);
expect(contextValue).toBeDefined();
expect(contextValue.copyJobState).toBeDefined();
expect(contextValue.setCopyJobState).toBeDefined();
expect(contextValue.flow).toBeNull();
expect(contextValue.setFlow).toBeDefined();
expect(contextValue.contextError).toBeNull();
expect(contextValue.setContextError).toBeDefined();
expect(contextValue.resetCopyJobState).toBeDefined();
expect(contextValue.explorer).toBe(mockExplorer);
});
it("should throw error when used outside provider", () => {
const originalError = console.error;
console.error = jest.fn();
const TestComponent = (): null => {
useCopyJobContext();
return null;
};
expect(() => {
render(<TestComponent />);
}).toThrow("useCopyJobContext must be used within a CopyJobContextProvider");
console.error = originalError;
});
it("should allow updating state through hook", () => {
let contextValue: any;
const TestComponent = (): JSX.Element => {
const context = useCopyJobContext();
contextValue = context;
return (
<button
onClick={() =>
context.setCopyJobState({
...context.copyJobState,
jobName: "hook-test-job",
})
}
>
Update
</button>
);
};
render(
<CopyJobContextProvider explorer={mockExplorer}>
<TestComponent />
</CopyJobContextProvider>,
);
act(() => {
screen.getByText("Update").click();
});
expect(contextValue.copyJobState.jobName).toBe("hook-test-job");
});
it("should allow resetting state through hook", () => {
let contextValue: any;
const TestComponent = (): JSX.Element => {
const context = useCopyJobContext();
contextValue = context;
return (
<>
<button
onClick={() =>
context.setCopyJobState({
...context.copyJobState,
jobName: "modified",
source: {
...context.copyJobState.source,
databaseId: "modified-db",
},
})
}
>
Modify
</button>
<button onClick={() => context.resetCopyJobState()}>Reset</button>
</>
);
};
render(
<CopyJobContextProvider explorer={mockExplorer}>
<TestComponent />
</CopyJobContextProvider>,
);
act(() => {
screen.getByText("Modify").click();
});
expect(contextValue.copyJobState.jobName).toBe("modified");
expect(contextValue.copyJobState.source.databaseId).toBe("modified-db");
act(() => {
screen.getByText("Reset").click();
});
expect(contextValue.copyJobState.jobName).toBe("");
expect(contextValue.copyJobState.source.databaseId).toBe("");
});
it("should maintain state consistency across multiple components", () => {
let contextValue1: any;
let contextValue2: any;
const TestComponent1 = (): JSX.Element => {
const context = useCopyJobContext();
contextValue1 = context;
return (
<button
onClick={() =>
context.setCopyJobState({
...context.copyJobState,
jobName: "shared-job",
})
}
>
Update From Component 1
</button>
);
};
const TestComponent2 = (): JSX.Element => {
const context = useCopyJobContext();
contextValue2 = context;
return <div data-testid="component-2">Component 2</div>;
};
render(
<CopyJobContextProvider explorer={mockExplorer}>
<TestComponent1 />
<TestComponent2 />
</CopyJobContextProvider>,
);
expect(contextValue1.copyJobState).toEqual(contextValue2.copyJobState);
act(() => {
screen.getByText("Update From Component 1").click();
});
expect(contextValue1.copyJobState.jobName).toBe("shared-job");
expect(contextValue2.copyJobState.jobName).toBe("shared-job");
});
});
describe("Initial State", () => {
it("should initialize with offline migration type", () => {
let contextValue: any;
render(
<CopyJobContextProvider explorer={mockExplorer}>
<CopyJobContext.Consumer>
{(value) => {
contextValue = value;
return null;
}}
</CopyJobContext.Consumer>
</CopyJobContextProvider>,
);
expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Offline);
});
it("should initialize source with userContext values", () => {
let contextValue: any;
render(
<CopyJobContextProvider explorer={mockExplorer}>
<CopyJobContext.Consumer>
{(value) => {
contextValue = value;
return null;
}}
</CopyJobContext.Consumer>
</CopyJobContextProvider>,
);
expect(contextValue.copyJobState.source.subscription.subscriptionId).toBe("test-subscription-id");
expect(contextValue.copyJobState.source.account.name).toBe("test-account");
});
it("should initialize target with userContext values", () => {
let contextValue: any;
render(
<CopyJobContextProvider explorer={mockExplorer}>
<CopyJobContext.Consumer>
{(value) => {
contextValue = value;
return null;
}}
</CopyJobContext.Consumer>
</CopyJobContextProvider>,
);
expect(contextValue.copyJobState.target.subscriptionId).toBe("test-subscription-id");
expect(contextValue.copyJobState.target.account.name).toBe("test-account");
});
it("should initialize sourceReadAccessFromTarget as false", () => {
let contextValue: any;
render(
<CopyJobContextProvider explorer={mockExplorer}>
<CopyJobContext.Consumer>
{(value) => {
contextValue = value;
return null;
}}
</CopyJobContext.Consumer>
</CopyJobContextProvider>,
);
expect(contextValue.copyJobState.sourceReadAccessFromTarget).toBe(false);
});
it("should initialize with empty database and container ids", () => {
let contextValue: any;
render(
<CopyJobContextProvider explorer={mockExplorer}>
<CopyJobContext.Consumer>
{(value) => {
contextValue = value;
return null;
}}
</CopyJobContext.Consumer>
</CopyJobContextProvider>,
);
expect(contextValue.copyJobState.source.databaseId).toBe("");
expect(contextValue.copyJobState.source.containerId).toBe("");
expect(contextValue.copyJobState.target.databaseId).toBe("");
expect(contextValue.copyJobState.target.containerId).toBe("");
});
});
});

View File

@@ -0,0 +1,67 @@
import Explorer from "Explorer/Explorer";
import { Subscription } from "Contracts/DataModels";
import React from "react";
import { userContext } from "UserContext";
import { CopyJobMigrationType } from "../Enums/CopyJobEnums";
import { CopyJobContextProviderType, CopyJobContextState, CopyJobFlowType } from "../Types/CopyJobTypes";
export const CopyJobContext = React.createContext<CopyJobContextProviderType>(null);
export const useCopyJobContext = (): CopyJobContextProviderType => {
const context = React.useContext(CopyJobContext);
if (!context) {
throw new Error("useCopyJobContext must be used within a CopyJobContextProvider");
}
return context;
};
interface CopyJobContextProviderProps {
children: React.ReactNode;
explorer: Explorer;
}
const getInitialCopyJobState = (): CopyJobContextState => {
return {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: {
subscriptionId: userContext.subscriptionId || "",
} as Subscription,
account: userContext.databaseAccount || null,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: userContext.subscriptionId || "",
account: userContext.databaseAccount || null,
databaseId: "",
containerId: "",
},
sourceReadAccessFromTarget: false,
};
};
const CopyJobContextProvider: React.FC<CopyJobContextProviderProps> = (props) => {
const [copyJobState, setCopyJobState] = React.useState<CopyJobContextState>(getInitialCopyJobState());
const [flow, setFlow] = React.useState<CopyJobFlowType | null>(null);
const [contextError, setContextError] = React.useState<string | null>(null);
const resetCopyJobState = () => {
setCopyJobState(getInitialCopyJobState());
};
const contextValue: CopyJobContextProviderType = {
contextError,
setContextError,
copyJobState,
setCopyJobState,
flow,
setFlow,
resetCopyJobState,
explorer: props.explorer,
};
return <CopyJobContext.Provider value={contextValue}>{props.children}</CopyJobContext.Provider>;
};
export default CopyJobContextProvider;

View File

@@ -0,0 +1,490 @@
import { DatabaseAccount } from "Contracts/DataModels";
import * as CopyJobUtils from "./CopyJobUtils";
import { CopyJobContextState, CopyJobErrorType, CopyJobType } from "./Types/CopyJobTypes";
describe("CopyJobUtils", () => {
describe("buildResourceLink", () => {
const mockResource: DatabaseAccount = {
id: "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1",
name: "account1",
location: "eastus",
type: "Microsoft.DocumentDB/databaseAccounts",
kind: "GlobalDocumentDB",
properties: {},
};
let originalLocation: Location;
beforeEach(() => {
originalLocation = window.location;
});
afterEach(() => {
(window as any).location = originalLocation;
});
it("should build resource link with Azure portal endpoint", () => {
delete (window as any).location;
(window as any).location = {
...originalLocation,
origin: "https://portal.azure.com",
ancestorOrigins: ["https://portal.azure.com"] as any,
} as Location;
const link = CopyJobUtils.buildResourceLink(mockResource);
expect(link).toBe(
"https://portal.azure.com/#resource/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1",
);
});
it("should replace cosmos.azure with portal.azure", () => {
delete (window as any).location;
(window as any).location = {
...originalLocation,
origin: "https://cosmos.azure.com",
ancestorOrigins: ["https://cosmos.azure.com"] as any,
} as Location;
const link = CopyJobUtils.buildResourceLink(mockResource);
expect(link).toContain("https://portal.azure.com");
});
it("should use Azure portal endpoint for localhost", () => {
delete (window as any).location;
(window as any).location = {
...originalLocation,
origin: "http://localhost:1234",
ancestorOrigins: ["http://localhost:1234"] as any,
} as Location;
const link = CopyJobUtils.buildResourceLink(mockResource);
expect(link).toContain("https://ms.portal.azure.com");
});
it("should remove trailing slash from origin", () => {
delete (window as any).location;
(window as any).location = {
...originalLocation,
origin: "https://portal.azure.com/",
ancestorOrigins: ["https://portal.azure.com/"] as any,
} as Location;
const link = CopyJobUtils.buildResourceLink(mockResource);
expect(link).toBe(
"https://portal.azure.com/#resource/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1",
);
});
});
describe("buildDataTransferJobPath", () => {
it("should build basic path without jobName or action", () => {
const path = CopyJobUtils.buildDataTransferJobPath({
subscriptionId: "sub123",
resourceGroup: "rg1",
accountName: "account1",
});
expect(path).toBe(
"/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1/dataTransferJobs",
);
});
it("should build path with jobName", () => {
const path = CopyJobUtils.buildDataTransferJobPath({
subscriptionId: "sub123",
resourceGroup: "rg1",
accountName: "account1",
jobName: "job1",
});
expect(path).toBe(
"/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1/dataTransferJobs/job1",
);
});
it("should build path with jobName and action", () => {
const path = CopyJobUtils.buildDataTransferJobPath({
subscriptionId: "sub123",
resourceGroup: "rg1",
accountName: "account1",
jobName: "job1",
action: "cancel",
});
expect(path).toBe(
"/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1/dataTransferJobs/job1/cancel",
);
});
});
describe("convertTime", () => {
it("should convert time string with hours, minutes, and seconds", () => {
const result = CopyJobUtils.convertTime("02:30:45");
expect(result).toBe("02 hours, 30 minutes, 45 seconds");
});
it("should convert time string with only seconds", () => {
const result = CopyJobUtils.convertTime("00:00:30");
expect(result).toBe("30 seconds");
});
it("should convert time string with only minutes and seconds", () => {
const result = CopyJobUtils.convertTime("00:05:15");
expect(result).toBe("05 minutes, 15 seconds");
});
it("should round seconds", () => {
const result = CopyJobUtils.convertTime("00:00:45.678");
expect(result).toBe("46 seconds");
});
it("should return '0 seconds' for zero time", () => {
const result = CopyJobUtils.convertTime("00:00:00");
expect(result).toBe("0 seconds");
});
it("should return null for invalid time format", () => {
const result = CopyJobUtils.convertTime("invalid");
expect(result).toBeNull();
});
it("should return null for incomplete time string", () => {
const result = CopyJobUtils.convertTime("10:30");
expect(result).toBeNull();
});
it("should pad single digit values", () => {
const result = CopyJobUtils.convertTime("1:5:9");
expect(result).toBe("01 hours, 05 minutes, 09 seconds");
});
});
describe("formatUTCDateTime", () => {
it("should format valid UTC date string", () => {
const result = CopyJobUtils.formatUTCDateTime("2025-11-26T10:30:00Z");
expect(result).not.toBeNull();
expect(result?.formattedDateTime).toContain("11/26/25, 10:30:00 AM");
expect(result?.timestamp).toBeGreaterThan(0);
});
it("should return null for invalid date string", () => {
const result = CopyJobUtils.formatUTCDateTime("invalid-date");
expect(result).toBeNull();
});
it("should return timestamp for valid date", () => {
const result = CopyJobUtils.formatUTCDateTime("2025-01-01T00:00:00Z");
expect(result).not.toBeNull();
expect(typeof result?.timestamp).toBe("number");
expect(result?.timestamp).toBe(new Date("2025-01-01T00:00:00Z").getTime());
});
});
describe("convertToCamelCase", () => {
it("should convert string to camel case", () => {
const result = CopyJobUtils.convertToCamelCase("hello world");
expect(result).toBe("HelloWorld");
});
it("should handle single word", () => {
const result = CopyJobUtils.convertToCamelCase("hello");
expect(result).toBe("Hello");
});
it("should handle multiple spaces", () => {
const result = CopyJobUtils.convertToCamelCase("hello world test");
expect(result).toBe("HelloWorldTest");
});
it("should handle mixed case input", () => {
const result = CopyJobUtils.convertToCamelCase("HELLO WORLD");
expect(result).toBe("HelloWorld");
});
it("should handle empty string", () => {
const result = CopyJobUtils.convertToCamelCase("");
expect(result).toBe("");
});
});
describe("extractErrorMessage", () => {
it("should extract first part of error message before line breaks", () => {
const error: CopyJobErrorType = {
message: "Error occurred\r\n\r\nAdditional details\r\n\r\nMore info",
code: "500",
};
const result = CopyJobUtils.extractErrorMessage(error);
expect(result.message).toBe("Error occurred");
expect(result.code).toBe("500");
});
it("should return same message if no line breaks", () => {
const error: CopyJobErrorType = {
message: "Simple error message",
code: "404",
};
const result = CopyJobUtils.extractErrorMessage(error);
expect(result.message).toBe("Simple error message");
expect(result.code).toBe("404");
});
});
describe("getAccountDetailsFromResourceId", () => {
it("should extract account details from valid resource ID", () => {
const resourceId =
"/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1";
const details = CopyJobUtils.getAccountDetailsFromResourceId(resourceId);
expect(details).toEqual({
subscriptionId: "sub123",
resourceGroup: "rg1",
accountName: "account1",
});
});
it("should be case insensitive", () => {
const resourceId =
"/subscriptions/sub123/resourceGroups/rg1/providers/microsoft.documentdb/databaseAccounts/account1";
const details = CopyJobUtils.getAccountDetailsFromResourceId(resourceId);
expect(details).toEqual({
subscriptionId: "sub123",
resourceGroup: "rg1",
accountName: "account1",
});
});
it("should return null for undefined resource ID", () => {
const details = CopyJobUtils.getAccountDetailsFromResourceId(undefined);
expect(details).toBeNull();
});
it("should return null for invalid resource ID", () => {
const details = CopyJobUtils.getAccountDetailsFromResourceId("invalid-resource-id");
expect(details).toEqual({ accountName: undefined, resourceGroup: undefined, subscriptionId: undefined });
});
});
describe("getContainerIdentifiers", () => {
it("should extract container identifiers", () => {
const container = {
account: {
id: "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1",
name: "account1",
location: "eastus",
type: "Microsoft.DocumentDB/databaseAccounts",
kind: "GlobalDocumentDB",
properties: {},
},
databaseId: "db1",
containerId: "container1",
} as CopyJobContextState["source"];
const identifiers = CopyJobUtils.getContainerIdentifiers(container);
expect(identifiers).toEqual({
accountId: container.account.id,
databaseId: "db1",
containerId: "container1",
});
});
it("should return empty strings for undefined values", () => {
const container = {
account: undefined,
databaseId: undefined,
containerId: undefined,
} as CopyJobContextState["source"];
const identifiers = CopyJobUtils.getContainerIdentifiers(container);
expect(identifiers).toEqual({
accountId: "",
databaseId: "",
containerId: "",
});
});
});
describe("isIntraAccountCopy", () => {
const sourceAccountId =
"/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1";
const targetAccountId =
"/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1";
const differentAccountId =
"/subscriptions/sub456/resourceGroups/rg2/providers/Microsoft.DocumentDB/databaseAccounts/account2";
it("should return true for same account", () => {
const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, targetAccountId);
expect(result).toBe(true);
});
it("should return false for different accounts", () => {
const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, differentAccountId);
expect(result).toBe(false);
});
it("should return false for different subscriptions", () => {
const differentSubId =
"/subscriptions/sub999/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1";
const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, differentSubId);
expect(result).toBe(false);
});
it("should return false for different resource groups", () => {
const differentRgId =
"/subscriptions/sub123/resourceGroups/rg999/providers/Microsoft.DocumentDB/databaseAccounts/account1";
const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, differentRgId);
expect(result).toBe(false);
});
it("should return false for undefined source", () => {
const result = CopyJobUtils.isIntraAccountCopy(undefined, targetAccountId);
expect(result).toBe(false);
});
it("should return false for undefined target", () => {
const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, undefined);
expect(result).toBe(false);
});
});
describe("isEqual", () => {
const createMockJob = (name: string, status: string): CopyJobType => ({
ID: name,
Mode: "Online",
Name: name,
Status: status as any,
CompletionPercentage: 50,
Duration: "00:05:00",
LastUpdatedTime: "2025-11-26T10:00:00Z",
timestamp: Date.now(),
Source: {} as any,
Destination: {} as any,
});
it("should return true for equal job arrays", () => {
const jobs1 = [createMockJob("job1", "Running"), createMockJob("job2", "Completed")];
const jobs2 = [createMockJob("job1", "Running"), createMockJob("job2", "Completed")];
const result = CopyJobUtils.isEqual(jobs1, jobs2);
expect(result).toBe(true);
});
it("should return false for different lengths", () => {
const jobs1 = [createMockJob("job1", "Running")];
const jobs2 = [createMockJob("job1", "Running"), createMockJob("job2", "Completed")];
const result = CopyJobUtils.isEqual(jobs1, jobs2);
expect(result).toBe(false);
});
it("should return false for different status", () => {
const jobs1 = [createMockJob("job1", "Running")];
const jobs2 = [createMockJob("job1", "Completed")];
const result = CopyJobUtils.isEqual(jobs1, jobs2);
expect(result).toBe(false);
});
it("should return false for missing job in second array", () => {
const jobs1 = [createMockJob("job1", "Running")];
const jobs2 = [createMockJob("job2", "Running")];
const result = CopyJobUtils.isEqual(jobs1, jobs2);
expect(result).toBe(false);
});
it("should return true for empty arrays", () => {
const result = CopyJobUtils.isEqual([], []);
expect(result).toBe(true);
});
});
describe("getDefaultJobName", () => {
beforeEach(() => {
jest.spyOn(Date.prototype, "getTime").mockReturnValue(1234567890);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should generate default job name for single container", () => {
const containers = [
{
sourceDatabaseName: "sourceDb",
sourceContainerName: "sourceCont",
targetDatabaseName: "targetDb",
targetContainerName: "targetCont",
},
];
const jobName = CopyJobUtils.getDefaultJobName(containers);
expect(jobName).toBe("sourc.sourc_targe.targe_1234567890");
});
it("should truncate long names", () => {
const containers = [
{
sourceDatabaseName: "veryLongSourceDatabaseName",
sourceContainerName: "veryLongSourceContainerName",
targetDatabaseName: "veryLongTargetDatabaseName",
targetContainerName: "veryLongTargetContainerName",
},
];
const jobName = CopyJobUtils.getDefaultJobName(containers);
expect(jobName).toBe("veryL.veryL_veryL.veryL_1234567890");
});
it("should return empty string for multiple containers", () => {
const containers = [
{
sourceDatabaseName: "db1",
sourceContainerName: "cont1",
targetDatabaseName: "db2",
targetContainerName: "cont2",
},
{
sourceDatabaseName: "db3",
sourceContainerName: "cont3",
targetDatabaseName: "db4",
targetContainerName: "cont4",
},
];
const jobName = CopyJobUtils.getDefaultJobName(containers);
expect(jobName).toBe("");
});
it("should return empty string for empty array", () => {
const jobName = CopyJobUtils.getDefaultJobName([]);
expect(jobName).toBe("");
});
it("should handle short names without truncation", () => {
const containers = [
{
sourceDatabaseName: "src",
sourceContainerName: "cont",
targetDatabaseName: "tgt",
targetContainerName: "dest",
},
];
const jobName = CopyJobUtils.getDefaultJobName(containers);
expect(jobName).toBe("src.cont_tgt.dest_1234567890");
});
});
describe("constants", () => {
it("should have correct COSMOS_SQL_COMPONENT value", () => {
expect(CopyJobUtils.COSMOS_SQL_COMPONENT).toBe("CosmosDBSql");
});
it("should have correct COPY_JOB_API_VERSION value", () => {
expect(CopyJobUtils.COPY_JOB_API_VERSION).toBe("2025-05-01-preview");
});
});
});

View File

@@ -0,0 +1,171 @@
import { DatabaseAccount } from "Contracts/DataModels";
import { CopyJobContextState, CopyJobErrorType, CopyJobType } from "./Types/CopyJobTypes";
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
export const buildResourceLink = (resource: DatabaseAccount): string => {
const resourceId = resource.id;
let parentOrigin = window.location.ancestorOrigins?.[0] ?? window.location.origin;
if (/\/\/localhost:/.test(parentOrigin)) {
parentOrigin = azurePortalMpacEndpoint;
} else if (/\/\/cosmos\.azure/.test(parentOrigin)) {
parentOrigin = parentOrigin.replace("cosmos.azure", "portal.azure");
}
parentOrigin = parentOrigin.replace(/\/$/, "");
return `${parentOrigin}/#resource${resourceId}`;
};
export const COSMOS_SQL_COMPONENT = "CosmosDBSql";
export const COPY_JOB_API_VERSION = "2025-05-01-preview";
export function buildDataTransferJobPath({
subscriptionId,
resourceGroup,
accountName,
jobName,
action,
}: {
subscriptionId: string;
resourceGroup: string;
accountName: string;
jobName?: string;
action?: string;
}) {
let path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/dataTransferJobs`;
if (jobName) {
path += `/${jobName}`;
}
if (action) {
path += `/${action}`;
}
return path;
}
export function convertTime(timeStr: string): string | null {
const timeParts = timeStr.split(":").map(Number);
if (timeParts.length !== 3 || timeParts.some(isNaN)) {
return null;
}
const formatPart = (value: number, unit: string) => {
if (unit === "seconds") {
value = Math.round(value);
}
return value > 0 ? `${value.toString().padStart(2, "0")} ${unit}` : "";
};
const [hours, minutes, seconds] = timeParts;
const formattedTimeParts = [
formatPart(hours, "hours"),
formatPart(minutes, "minutes"),
formatPart(seconds, "seconds"),
]
.filter(Boolean)
.join(", ");
return formattedTimeParts || "0 seconds";
}
export function formatUTCDateTime(utcStr: string): { formattedDateTime: string; timestamp: number } | null {
const date = new Date(utcStr);
if (isNaN(date.getTime())) {
return null;
}
return {
formattedDateTime: new Intl.DateTimeFormat("en-US", {
dateStyle: "short",
timeStyle: "medium",
timeZone: "UTC",
}).format(date),
timestamp: date.getTime(),
};
}
export function convertToCamelCase(str: string): string {
const formattedStr = str
.split(/\s+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("");
return formattedStr;
}
export function extractErrorMessage(error: CopyJobErrorType): CopyJobErrorType {
return {
...error,
message: error.message.split("\r\n\r\n")[0],
};
}
export function getAccountDetailsFromResourceId(accountId: string | undefined) {
if (!accountId) {
return null;
}
const pattern = new RegExp(
"/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\\.DocumentDB?/databaseAccounts/([^/]+)",
"i",
);
const matches = accountId.match(pattern);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, subscriptionId, resourceGroup, accountName] = matches || [];
return { subscriptionId, resourceGroup, accountName };
}
export function getContainerIdentifiers(container: CopyJobContextState["source"] | CopyJobContextState["target"]) {
return {
accountId: container?.account?.id || "",
databaseId: container?.databaseId || "",
containerId: container?.containerId || "",
};
}
export function isIntraAccountCopy(sourceAccountId: string | undefined, targetAccountId: string | undefined): boolean {
const sourceAccountDetails = getAccountDetailsFromResourceId(sourceAccountId);
const targetAccountDetails = getAccountDetailsFromResourceId(targetAccountId);
return (
sourceAccountDetails?.subscriptionId === targetAccountDetails?.subscriptionId &&
sourceAccountDetails?.resourceGroup === targetAccountDetails?.resourceGroup &&
sourceAccountDetails?.accountName === targetAccountDetails?.accountName
);
}
export function isEqual(prevJobs: CopyJobType[], newJobs: CopyJobType[]): boolean {
if (prevJobs.length !== newJobs.length) {
return false;
}
return prevJobs.every((prevJob: CopyJobType) => {
const newJob = newJobs.find((job) => job.Name === prevJob.Name);
if (!newJob) {
return false;
}
return prevJob.Status === newJob.Status;
});
}
const truncateLength = 5;
const truncateName = (name: string, length: number = truncateLength): string => {
return name.length <= length ? name : name.slice(0, length);
};
export function getDefaultJobName(
selectedDatabaseAndContainers: {
sourceDatabaseName?: string;
sourceContainerName?: string;
targetDatabaseName?: string;
targetContainerName?: string;
}[],
): string {
if (selectedDatabaseAndContainers.length === 1) {
const { sourceDatabaseName, sourceContainerName, targetDatabaseName, targetContainerName } =
selectedDatabaseAndContainers[0];
const timestamp = new Date().getTime().toString();
const sourcePart = `${truncateName(sourceDatabaseName)}.${truncateName(sourceContainerName)}`;
const targetPart = `${truncateName(targetDatabaseName)}.${truncateName(targetContainerName)}`;
return `${sourcePart}_${targetPart}_${timestamp}`;
}
return "";
}

View File

@@ -0,0 +1,295 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { DatabaseAccount } from "Contracts/DataModels";
import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes";
import React from "react";
import { updateSystemIdentity } from "../../../../../Utils/arm/identityUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { CopyJobContext } from "../../../Context/CopyJobContext";
import AddManagedIdentity from "./AddManagedIdentity";
jest.mock("../../../../../Utils/arm/identityUtils", () => ({
updateSystemIdentity: jest.fn(),
}));
jest.mock("@fluentui/react", () => ({
...jest.requireActual("@fluentui/react"),
getTheme: () => ({
semanticColors: {
bodySubtext: "#666666",
errorIcon: "#d13438",
successIcon: "#107c10",
},
palette: {
themePrimary: "#0078d4",
},
}),
mergeStyles: () => "mocked-styles",
mergeStyleSets: (styleSet: any) => {
const result: any = {};
Object.keys(styleSet).forEach((key) => {
result[key] = "mocked-style-" + key;
});
return result;
},
}));
jest.mock("../../../CopyJobUtils", () => ({
getAccountDetailsFromResourceId: jest.fn(() => ({
subscriptionId: "test-subscription-id",
resourceGroup: "test-resource-group",
accountName: "test-account-name",
})),
}));
jest.mock("../../../../../Common/Logger", () => ({
logError: jest.fn(),
}));
const mockUpdateSystemIdentity = updateSystemIdentity as jest.MockedFunction<typeof updateSystemIdentity>;
describe("AddManagedIdentity", () => {
const mockCopyJobState = {
jobName: "test-job",
migrationType: "Offline" as any,
source: {
subscription: { subscriptionId: "source-sub-id" },
account: { id: "source-account-id", name: "source-account-name" },
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "target-sub-id",
account: {
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
name: "test-target-account",
},
databaseId: "target-db",
containerId: "target-container",
},
sourceReadAccessFromTarget: false,
};
const mockContextValue = {
copyJobState: mockCopyJobState,
setCopyJobState: jest.fn(),
flow: { currentScreen: "AssignPermissions" },
setFlow: jest.fn(),
resetCopyJobState: jest.fn(),
explorer: {} as any,
contextError: "",
setContextError: jest.fn(),
} as unknown as CopyJobContextProviderType;
const renderWithContext = (contextValue = mockContextValue) => {
return render(
<CopyJobContext.Provider value={contextValue}>
<AddManagedIdentity />
</CopyJobContext.Provider>,
);
};
beforeEach(() => {
jest.clearAllMocks();
mockUpdateSystemIdentity.mockResolvedValue({
id: "updated-account-id",
name: "updated-account-name",
} as any);
});
describe("Snapshot Tests", () => {
it("renders initial state correctly", () => {
const { container } = renderWithContext();
expect(container.firstChild).toMatchSnapshot();
});
it("renders with toggle on and popover visible", () => {
const { container } = renderWithContext();
const toggle = screen.getByRole("switch");
fireEvent.click(toggle);
expect(container.firstChild).toMatchSnapshot();
});
it("renders loading state", async () => {
mockUpdateSystemIdentity.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({} as any), 100)),
);
const { container } = renderWithContext();
const toggle = screen.getByRole("switch");
fireEvent.click(toggle);
const primaryButton = screen.getByText("Yes");
fireEvent.click(primaryButton);
expect(container.firstChild).toMatchSnapshot();
});
});
describe("Component Rendering", () => {
it("renders all required elements", () => {
renderWithContext();
expect(screen.getByText(ContainerCopyMessages.addManagedIdentity.description)).toBeInTheDocument();
expect(screen.getByText(ContainerCopyMessages.addManagedIdentity.descriptionHrefText)).toBeInTheDocument();
expect(screen.getByRole("switch")).toBeInTheDocument();
});
it("renders description link with correct href", () => {
renderWithContext();
const link = screen.getByText(ContainerCopyMessages.addManagedIdentity.descriptionHrefText);
expect(link.closest("a")).toHaveAttribute("href", ContainerCopyMessages.addManagedIdentity.descriptionHref);
expect(link.closest("a")).toHaveAttribute("target", "_blank");
expect(link.closest("a")).toHaveAttribute("rel", "noopener noreferrer");
});
it("toggle shows correct initial state", () => {
renderWithContext();
const toggle = screen.getByRole("switch");
expect(toggle).not.toBeChecked();
});
});
describe("Toggle Functionality", () => {
it("toggles state when clicked", () => {
renderWithContext();
const toggle = screen.getByRole("switch");
expect(toggle).not.toBeChecked();
fireEvent.click(toggle);
expect(toggle).toBeChecked();
fireEvent.click(toggle);
expect(toggle).not.toBeChecked();
});
it("shows popover when toggle is on", () => {
renderWithContext();
const toggle = screen.getByRole("switch");
fireEvent.click(toggle);
expect(screen.getByText(ContainerCopyMessages.addManagedIdentity.enablementTitle)).toBeInTheDocument();
});
it("hides popover when toggle is off", () => {
renderWithContext();
const toggle = screen.getByRole("switch");
fireEvent.click(toggle);
fireEvent.click(toggle);
expect(screen.queryByText(ContainerCopyMessages.addManagedIdentity.enablementTitle)).not.toBeInTheDocument();
});
});
describe("Popover Functionality", () => {
beforeEach(() => {
renderWithContext();
const toggle = screen.getByRole("switch");
fireEvent.click(toggle);
});
it("displays correct enablement description with account name", () => {
const expectedDescription = ContainerCopyMessages.addManagedIdentity.enablementDescription(
mockCopyJobState.target.account.name,
);
expect(screen.getByText(expectedDescription)).toBeInTheDocument();
});
it("calls handleAddSystemIdentity when primary button clicked", async () => {
const primaryButton = screen.getByText("Yes");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(mockUpdateSystemIdentity).toHaveBeenCalledWith(
"test-subscription-id",
"test-resource-group",
"test-account-name",
);
});
});
it.skip("closes popover when cancel button clicked", () => {
const cancelButton = screen.getByText("Cancel");
fireEvent.click(cancelButton);
expect(screen.queryByText(ContainerCopyMessages.addManagedIdentity.enablementTitle)).not.toBeInTheDocument();
const toggle = screen.getByRole("switch");
expect(toggle).not.toBeChecked();
});
});
describe("Managed Identity Operations", () => {
it("successfully updates system identity", async () => {
const setCopyJobState = jest.fn();
const contextWithMockSetter = {
...mockContextValue,
setCopyJobState,
};
renderWithContext(contextWithMockSetter);
const toggle = screen.getByRole("switch");
fireEvent.click(toggle);
const primaryButton = screen.getByText("Yes");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(mockUpdateSystemIdentity).toHaveBeenCalled();
});
await waitFor(() => {
expect(setCopyJobState).toHaveBeenCalledWith(expect.any(Function));
});
});
it("handles error during identity update", async () => {
const setContextError = jest.fn();
const contextWithErrorHandler = {
...mockContextValue,
setContextError,
};
const errorMessage = "Failed to update identity";
mockUpdateSystemIdentity.mockRejectedValue(new Error(errorMessage));
renderWithContext(contextWithErrorHandler);
const toggle = screen.getByRole("switch");
fireEvent.click(toggle);
const primaryButton = screen.getByText("Yes");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(setContextError).toHaveBeenCalledWith(errorMessage);
});
});
});
describe("Edge Cases", () => {
it("handles missing target account gracefully", () => {
const contextWithoutTargetAccount = {
...mockContextValue,
copyJobState: {
...mockCopyJobState,
target: {
...mockCopyJobState.target,
account: null as DatabaseAccount | null,
},
},
} as unknown as CopyJobContextProviderType;
expect(() => renderWithContext(contextWithoutTargetAccount)).not.toThrow();
});
});
});

View File

@@ -0,0 +1,56 @@
import { Link, Stack, Text, Toggle } from "@fluentui/react";
import React from "react";
import { updateSystemIdentity } from "../../../../../Utils/arm/identityUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import InfoTooltip from "../Components/InfoTooltip";
import PopoverMessage from "../Components/PopoverContainer";
import useManagedIdentity from "./hooks/useManagedIdentity";
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
import useToggle from "./hooks/useToggle";
const managedIdentityTooltip = (
<Text>
{ContainerCopyMessages.addManagedIdentity.tooltip.content} &nbsp;
<Link href={ContainerCopyMessages.addManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.addManagedIdentity.tooltip.hrefText}
</Link>
</Text>
);
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
const { copyJobState } = useCopyJobContext();
const [systemAssigned, onToggle] = useToggle(false);
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateSystemIdentity);
return (
<Stack className="addManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<Text>
{ContainerCopyMessages.addManagedIdentity.description}&ensp;
<Link href={ContainerCopyMessages.addManagedIdentity.descriptionHref} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.addManagedIdentity.descriptionHrefText}
</Link>{" "}
&nbsp;
<InfoTooltip content={managedIdentityTooltip} />
</Text>
<Toggle
checked={systemAssigned}
onText={ContainerCopyMessages.toggleBtn.onText}
offText={ContainerCopyMessages.toggleBtn.offText}
onChange={onToggle}
/>
<PopoverMessage
isLoading={loading}
visible={systemAssigned}
title={ContainerCopyMessages.addManagedIdentity.enablementTitle}
onCancel={() => onToggle(null, false)}
onPrimary={handleAddSystemIdentity}
>
{ContainerCopyMessages.addManagedIdentity.enablementDescription(copyJobState.target?.account?.name)}
</PopoverMessage>
</Stack>
);
};
export default AddManagedIdentity;

View File

@@ -0,0 +1,503 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { CopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobContextProviderType } from "../../../Types/CopyJobTypes";
import AddReadPermissionToDefaultIdentity from "./AddReadPermissionToDefaultIdentity";
jest.mock("../../../../../Common/Logger", () => ({
logError: jest.fn(),
}));
jest.mock("../../../../../Utils/arm/RbacUtils", () => ({
assignRole: jest.fn(),
}));
jest.mock("../../../CopyJobUtils", () => ({
getAccountDetailsFromResourceId: jest.fn(),
}));
jest.mock("../Components/InfoTooltip", () => {
const MockInfoTooltip = ({ content }: { content: React.ReactNode }) => {
return <div data-testid="info-tooltip">{content}</div>;
};
MockInfoTooltip.displayName = "MockInfoTooltip";
return MockInfoTooltip;
});
jest.mock("../Components/PopoverContainer", () => {
const MockPopoverContainer = ({
isLoading,
visible,
title,
onCancel,
onPrimary,
children,
}: {
isLoading?: boolean;
visible: boolean;
title: string;
onCancel: () => void;
onPrimary: () => void;
children: React.ReactNode;
}) => {
if (!visible) {
return null;
}
return (
<div data-testid="popover-message" data-loading={isLoading}>
<div data-testid="popover-title">{title}</div>
<div data-testid="popover-content">{children}</div>
<button onClick={onCancel} data-testid="popover-cancel">
Cancel
</button>
<button onClick={onPrimary} data-testid="popover-primary">
Primary
</button>
</div>
);
};
MockPopoverContainer.displayName = "MockPopoverContainer";
return MockPopoverContainer;
});
jest.mock("./hooks/useToggle", () => {
return jest.fn();
});
import { Subscription } from "Contracts/DataModels";
import { CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums";
import { logError } from "../../../../../Common/Logger";
import { assignRole, RoleAssignmentType } from "../../../../../Utils/arm/RbacUtils";
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import useToggle from "./hooks/useToggle";
describe("AddReadPermissionToDefaultIdentity Component", () => {
const mockUseToggle = useToggle as jest.MockedFunction<typeof useToggle>;
const mockAssignRole = assignRole as jest.MockedFunction<typeof assignRole>;
const mockGetAccountDetailsFromResourceId = getAccountDetailsFromResourceId as jest.MockedFunction<
typeof getAccountDetailsFromResourceId
>;
const mockLogError = logError as jest.MockedFunction<typeof logError>;
const mockContextValue: CopyJobContextProviderType = {
copyJobState: {
jobName: "test-job",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: { subscriptionId: "source-sub-id" } as Subscription,
account: {
id: "/subscriptions/source-sub-id/resourceGroups/source-rg/providers/Microsoft.DocumentDB/databaseAccounts/source-account",
name: "source-account",
location: "East US",
kind: "GlobalDocumentDB",
type: "Microsoft.DocumentDB/databaseAccounts",
properties: {
documentEndpoint: "https://source-account.documents.azure.com:443/",
},
},
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "target-sub-id",
account: {
id: "/subscriptions/target-sub-id/resourceGroups/target-rg/providers/Microsoft.DocumentDB/databaseAccounts/target-account",
name: "target-account",
location: "West US",
kind: "GlobalDocumentDB",
type: "Microsoft.DocumentDB/databaseAccounts",
properties: {
documentEndpoint: "https://target-account.documents.azure.com:443/",
},
identity: {
principalId: "target-principal-id",
type: "SystemAssigned",
},
},
databaseId: "target-db",
containerId: "target-container",
},
sourceReadAccessFromTarget: false,
},
setCopyJobState: jest.fn(),
setContextError: jest.fn(),
contextError: null,
flow: null,
setFlow: jest.fn(),
resetCopyJobState: jest.fn(),
explorer: {} as any,
};
const renderComponent = (contextValue = mockContextValue) => {
return render(
<CopyJobContext.Provider value={contextValue}>
<AddReadPermissionToDefaultIdentity />
</CopyJobContext.Provider>,
);
};
beforeEach(() => {
jest.clearAllMocks();
mockUseToggle.mockReturnValue([false, jest.fn()]);
});
describe("Rendering", () => {
it("should render correctly with default state", () => {
const { container } = renderComponent();
expect(container).toMatchSnapshot();
});
it("should render correctly when toggle is on", () => {
mockUseToggle.mockReturnValue([true, jest.fn()]);
const { container } = renderComponent();
expect(container).toMatchSnapshot();
});
it("should render correctly with different context states", () => {
const contextWithError = {
...mockContextValue,
contextError: "Test error message",
};
const { container } = renderComponent(contextWithError);
expect(container).toMatchSnapshot();
});
it("should render correctly when sourceReadAccessFromTarget is true", () => {
const contextWithAccess = {
...mockContextValue,
copyJobState: {
...mockContextValue.copyJobState,
sourceReadAccessFromTarget: true,
},
};
const { container } = renderComponent(contextWithAccess);
expect(container).toMatchSnapshot();
});
});
describe("Component Structure", () => {
it("should display the description text", () => {
renderComponent();
expect(screen.getByText(ContainerCopyMessages.readPermissionAssigned.description)).toBeInTheDocument();
});
it("should display the info tooltip", () => {
renderComponent();
expect(screen.getByTestId("info-tooltip")).toBeInTheDocument();
});
it("should display the toggle component", () => {
renderComponent();
expect(screen.getByRole("switch")).toBeInTheDocument();
});
});
describe("Toggle Interaction", () => {
it("should call onToggle when toggle is clicked", () => {
const mockOnToggle = jest.fn();
mockUseToggle.mockReturnValue([false, mockOnToggle]);
renderComponent();
const toggle = screen.getByRole("switch");
fireEvent.click(toggle);
expect(mockOnToggle).toHaveBeenCalledTimes(1);
});
it("should show popover when toggle is turned on", () => {
mockUseToggle.mockReturnValue([true, jest.fn()]);
renderComponent();
expect(screen.getByTestId("popover-message")).toBeInTheDocument();
expect(screen.getByTestId("popover-title")).toHaveTextContent(
ContainerCopyMessages.readPermissionAssigned.popoverTitle,
);
expect(screen.getByTestId("popover-content")).toHaveTextContent(
ContainerCopyMessages.readPermissionAssigned.popoverDescription,
);
});
it("should not show popover when toggle is turned off", () => {
mockUseToggle.mockReturnValue([false, jest.fn()]);
renderComponent();
expect(screen.queryByTestId("popover-message")).not.toBeInTheDocument();
});
});
describe("Popover Interactions", () => {
beforeEach(() => {
mockUseToggle.mockReturnValue([true, jest.fn()]);
});
it("should call onToggle with false when cancel button is clicked", () => {
const mockOnToggle = jest.fn();
mockUseToggle.mockReturnValue([true, mockOnToggle]);
renderComponent();
const cancelButton = screen.getByTestId("popover-cancel");
fireEvent.click(cancelButton);
expect(mockOnToggle).toHaveBeenCalledWith(null, false);
});
it("should call handleAddReadPermission when primary button is clicked", async () => {
mockGetAccountDetailsFromResourceId.mockReturnValue({
subscriptionId: "source-sub-id",
resourceGroup: "source-rg",
accountName: "source-account",
});
mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType);
renderComponent();
const primaryButton = screen.getByTestId("popover-primary");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledWith(
"/subscriptions/source-sub-id/resourceGroups/source-rg/providers/Microsoft.DocumentDB/databaseAccounts/source-account",
);
});
});
});
describe("handleAddReadPermission Function", () => {
beforeEach(() => {
mockUseToggle.mockReturnValue([true, jest.fn()]);
});
it("should successfully assign role and update context", async () => {
mockGetAccountDetailsFromResourceId.mockReturnValue({
subscriptionId: "source-sub-id",
resourceGroup: "source-rg",
accountName: "source-account",
});
mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType);
renderComponent();
const primaryButton = screen.getByTestId("popover-primary");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(mockAssignRole).toHaveBeenCalledWith(
"source-sub-id",
"source-rg",
"source-account",
"target-principal-id",
);
});
await waitFor(() => {
expect(mockContextValue.setCopyJobState).toHaveBeenCalledWith(expect.any(Function));
});
});
it("should handle error when assignRole fails", async () => {
mockGetAccountDetailsFromResourceId.mockReturnValue({
subscriptionId: "source-sub-id",
resourceGroup: "source-rg",
accountName: "source-account",
});
mockAssignRole.mockRejectedValue(new Error("Permission denied"));
renderComponent();
const primaryButton = screen.getByTestId("popover-primary");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(mockLogError).toHaveBeenCalledWith(
"Permission denied",
"CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission",
);
});
await waitFor(() => {
expect(mockContextValue.setContextError).toHaveBeenCalledWith("Permission denied");
});
});
it("should handle error without message", async () => {
mockGetAccountDetailsFromResourceId.mockReturnValue({
subscriptionId: "source-sub-id",
resourceGroup: "source-rg",
accountName: "source-account",
});
mockAssignRole.mockRejectedValue({});
renderComponent();
const primaryButton = screen.getByTestId("popover-primary");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(mockLogError).toHaveBeenCalledWith(
"Error assigning read permission to default identity. Please try again later.",
"CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission",
);
});
await waitFor(() => {
expect(mockContextValue.setContextError).toHaveBeenCalledWith(
"Error assigning read permission to default identity. Please try again later.",
);
});
});
it("should show loading state during role assignment", async () => {
mockGetAccountDetailsFromResourceId.mockReturnValue({
subscriptionId: "source-sub-id",
resourceGroup: "source-rg",
accountName: "source-account",
});
mockAssignRole.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ id: "role-id" } as RoleAssignmentType), 100)),
);
renderComponent();
const primaryButton = screen.getByTestId("popover-primary");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(screen.getByTestId("popover-message")).toHaveAttribute("data-loading", "true");
});
});
it.skip("should not assign role when assignRole returns falsy", async () => {
mockGetAccountDetailsFromResourceId.mockReturnValue({
subscriptionId: "source-sub-id",
resourceGroup: "source-rg",
accountName: "source-account",
});
mockAssignRole.mockResolvedValue(null);
renderComponent();
const primaryButton = screen.getByTestId("popover-primary");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(mockAssignRole).toHaveBeenCalled();
});
expect(mockContextValue.setCopyJobState).not.toHaveBeenCalled();
});
});
describe("Edge Cases", () => {
it("should handle missing target account identity", () => {
const contextWithoutIdentity = {
...mockContextValue,
copyJobState: {
...mockContextValue.copyJobState,
target: {
...mockContextValue.copyJobState.target,
account: {
...mockContextValue.copyJobState.target.account!,
identity: undefined as any,
},
},
},
};
const { container } = renderComponent(contextWithoutIdentity);
expect(container).toMatchSnapshot();
});
it("should handle missing source account", () => {
const contextWithoutSource = {
...mockContextValue,
copyJobState: {
...mockContextValue.copyJobState,
source: {
...mockContextValue.copyJobState.source,
account: null as any,
},
},
};
const { container } = renderComponent(contextWithoutSource);
expect(container).toMatchSnapshot();
});
it("should handle empty string principal ID", async () => {
const contextWithEmptyPrincipal = {
...mockContextValue,
copyJobState: {
...mockContextValue.copyJobState,
target: {
...mockContextValue.copyJobState.target,
account: {
...mockContextValue.copyJobState.target.account!,
identity: {
principalId: "",
type: "SystemAssigned",
},
},
},
},
};
mockUseToggle.mockReturnValue([true, jest.fn()]);
mockGetAccountDetailsFromResourceId.mockReturnValue({
subscriptionId: "source-sub-id",
resourceGroup: "source-rg",
accountName: "source-account",
});
mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType);
renderComponent(contextWithEmptyPrincipal);
const primaryButton = screen.getByTestId("popover-primary");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(mockAssignRole).toHaveBeenCalledWith("source-sub-id", "source-rg", "source-account", "");
});
});
});
describe("Component Integration", () => {
it("should work with all context updates", async () => {
const setCopyJobStateMock = jest.fn();
const setContextErrorMock = jest.fn();
const fullContextValue = {
...mockContextValue,
setCopyJobState: setCopyJobStateMock,
setContextError: setContextErrorMock,
};
mockUseToggle.mockReturnValue([true, jest.fn()]);
mockGetAccountDetailsFromResourceId.mockReturnValue({
subscriptionId: "source-sub-id",
resourceGroup: "source-rg",
accountName: "source-account",
});
mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType);
renderComponent(fullContextValue);
const primaryButton = screen.getByTestId("popover-primary");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(setCopyJobStateMock).toHaveBeenCalledWith(expect.any(Function));
});
const setCopyJobStateCall = setCopyJobStateMock.mock.calls[0][0];
const updatedState = setCopyJobStateCall(mockContextValue.copyJobState);
expect(updatedState).toEqual({
...mockContextValue.copyJobState,
sourceReadAccessFromTarget: true,
});
});
});
});

View File

@@ -0,0 +1,91 @@
import { Link, Stack, Text, Toggle } from "@fluentui/react";
import React from "react";
import { logError } from "../../../../../Common/Logger";
import { assignRole } from "../../../../../Utils/arm/RbacUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import InfoTooltip from "../Components/InfoTooltip";
import PopoverMessage from "../Components/PopoverContainer";
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
import useToggle from "./hooks/useToggle";
const TooltipContent = (
<Text>
{ContainerCopyMessages.readPermissionAssigned.tooltip.content} &nbsp;
<Link href={ContainerCopyMessages.readPermissionAssigned.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText}
</Link>
</Text>
);
type AddReadPermissionToDefaultIdentityProps = Partial<PermissionSectionConfig>;
const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIdentityProps> = () => {
const [loading, setLoading] = React.useState(false);
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
const [readPermissionAssigned, onToggle] = useToggle(false);
const handleAddReadPermission = async () => {
const { source, target } = copyJobState;
const selectedSourceAccount = source?.account;
try {
const {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
setLoading(true);
const assignedRole = await assignRole(
sourceSubscriptionId,
sourceResourceGroup,
sourceAccountName,
target?.account?.identity?.principalId ?? "",
);
if (assignedRole) {
setCopyJobState((prevState) => ({
...prevState,
sourceReadAccessFromTarget: true,
}));
}
} catch (error) {
const errorMessage =
error.message || "Error assigning read permission to default identity. Please try again later.";
logError(errorMessage, "CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission");
setContextError(errorMessage);
} finally {
setLoading(false);
}
};
return (
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<Text className="toggle-label">
{ContainerCopyMessages.readPermissionAssigned.description}&ensp;
<InfoTooltip content={TooltipContent} />
</Text>
<Toggle
checked={readPermissionAssigned}
onText={ContainerCopyMessages.toggleBtn.onText}
offText={ContainerCopyMessages.toggleBtn.offText}
onChange={onToggle}
inlineLabel
styles={{
root: { marginTop: 8, marginBottom: 12 },
label: { display: "none" },
}}
/>
<PopoverMessage
isLoading={loading}
visible={readPermissionAssigned}
title={ContainerCopyMessages.readPermissionAssigned.popoverTitle}
onCancel={() => onToggle(null, false)}
onPrimary={handleAddReadPermission}
>
{ContainerCopyMessages.readPermissionAssigned.popoverDescription}
</PopoverMessage>
</Stack>
);
};
export default AddReadPermissionToDefaultIdentity;

View File

@@ -0,0 +1,379 @@
import "@testing-library/jest-dom";
import { render, RenderResult } from "@testing-library/react";
import React from "react";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { CopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes";
import AssignPermissions from "./AssignPermissions";
jest.mock("../../Utils/useCopyJobPrerequisitesCache", () => ({
useCopyJobPrerequisitesCache: () => ({
validationCache: new Map<string, boolean>(),
setValidationCache: jest.fn(),
}),
}));
jest.mock("../../../CopyJobUtils", () => ({
isIntraAccountCopy: jest.fn((sourceId: string, targetId: string) => sourceId === targetId),
}));
jest.mock("./hooks/usePermissionsSection", () => ({
__esModule: true,
default: jest.fn((): any[] => []),
}));
jest.mock("../../../../../Common/ShimmerTree/ShimmerTree", () => {
const MockShimmerTree = (props: any) => {
return (
<div data-testid="shimmer-tree" {...props}>
Loading...
</div>
);
};
MockShimmerTree.displayName = "MockShimmerTree";
return MockShimmerTree;
});
jest.mock("./AddManagedIdentity", () => {
const MockAddManagedIdentity = () => {
return <div data-testid="add-managed-identity">Add Managed Identity Component</div>;
};
MockAddManagedIdentity.displayName = "MockAddManagedIdentity";
return MockAddManagedIdentity;
});
jest.mock("./AddReadPermissionToDefaultIdentity", () => {
const MockAddReadPermissionToDefaultIdentity = () => {
return <div data-testid="add-read-permission">Add Read Permission Component</div>;
};
MockAddReadPermissionToDefaultIdentity.displayName = "MockAddReadPermissionToDefaultIdentity";
return MockAddReadPermissionToDefaultIdentity;
});
jest.mock("./DefaultManagedIdentity", () => {
const MockDefaultManagedIdentity = () => {
return <div data-testid="default-managed-identity">Default Managed Identity Component</div>;
};
MockDefaultManagedIdentity.displayName = "MockDefaultManagedIdentity";
return MockDefaultManagedIdentity;
});
jest.mock("./OnlineCopyEnabled", () => {
const MockOnlineCopyEnabled = () => {
return <div data-testid="online-copy-enabled">Online Copy Enabled Component</div>;
};
MockOnlineCopyEnabled.displayName = "MockOnlineCopyEnabled";
return MockOnlineCopyEnabled;
});
jest.mock("./PointInTimeRestore", () => {
const MockPointInTimeRestore = () => {
return <div data-testid="point-in-time-restore">Point In Time Restore Component</div>;
};
MockPointInTimeRestore.displayName = "MockPointInTimeRestore";
return MockPointInTimeRestore;
});
jest.mock("../../../../../../images/successfulPopup.svg", () => "checkmark-icon");
jest.mock("../../../../../../images/warning.svg", () => "warning-icon");
describe("AssignPermissions Component", () => {
const mockExplorer = {} as any;
const createMockCopyJobState = (overrides: Partial<CopyJobContextState> = {}): CopyJobContextState => ({
jobName: "test-job",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: { subscriptionId: "source-sub" } as any,
account: { id: "source-account", name: "Source Account" } as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "target-sub",
account: { id: "target-account", name: "Target Account" } as any,
databaseId: "target-db",
containerId: "target-container",
},
sourceReadAccessFromTarget: false,
...overrides,
});
const createMockContextValue = (copyJobState: CopyJobContextState): CopyJobContextProviderType => ({
contextError: null,
setContextError: jest.fn(),
copyJobState,
setCopyJobState: jest.fn(),
flow: null,
setFlow: jest.fn(),
resetCopyJobState: jest.fn(),
explorer: mockExplorer,
});
const renderWithContext = (copyJobState: CopyJobContextState): RenderResult => {
const contextValue = createMockContextValue(copyJobState);
return render(
<CopyJobContext.Provider value={contextValue}>
<AssignPermissions />
</CopyJobContext.Provider>,
);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe("Rendering", () => {
it("should render without crashing with offline migration", () => {
const copyJobState = createMockCopyJobState();
const { container } = renderWithContext(copyJobState);
expect(container.firstChild).toBeTruthy();
expect(container).toMatchSnapshot();
});
it("should render without crashing with online migration", () => {
const copyJobState = createMockCopyJobState({
migrationType: CopyJobMigrationType.Online,
});
const { container } = renderWithContext(copyJobState);
expect(container.firstChild).toBeTruthy();
expect(container).toMatchSnapshot();
});
it("should display shimmer tree when no permission groups are available", () => {
const copyJobState = createMockCopyJobState();
const { getByTestId } = renderWithContext(copyJobState);
expect(getByTestId("shimmer-tree")).toBeInTheDocument();
});
it("should display cross account description for different accounts", () => {
const copyJobState = createMockCopyJobState();
const { getByText } = renderWithContext(copyJobState);
expect(getByText(ContainerCopyMessages.assignPermissions.crossAccountDescription)).toBeInTheDocument();
});
it("should display intra account description for same accounts with online migration", async () => {
const { isIntraAccountCopy } = await import("../../../CopyJobUtils");
(isIntraAccountCopy as jest.Mock).mockReturnValue(true);
const copyJobState = createMockCopyJobState({
migrationType: CopyJobMigrationType.Online,
source: {
subscription: { subscriptionId: "same-sub" } as any,
account: { id: "same-account", name: "Same Account" } as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "same-sub",
account: { id: "same-account", name: "Same Account" } as any,
databaseId: "target-db",
containerId: "target-container",
},
});
const { getByText } = renderWithContext(copyJobState);
expect(
getByText(ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription("Same Account")),
).toBeInTheDocument();
});
});
describe("Permission Groups", () => {
it("should render permission groups when available", async () => {
const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock;
mockUsePermissionSections.mockReturnValue([
{
id: "crossAccountConfigs",
title: "Cross Account Configuration",
description: "Configure permissions for cross-account copy",
sections: [
{
id: "addManagedIdentity",
title: "Add Managed Identity",
Component: () => <div data-testid="add-managed-identity">Add Managed Identity Component</div>,
disabled: false,
completed: true,
},
{
id: "readPermissionAssigned",
title: "Read Permission Assigned",
Component: () => <div data-testid="add-read-permission">Add Read Permission Component</div>,
disabled: false,
completed: false,
},
],
},
]);
const copyJobState = createMockCopyJobState();
const { container } = renderWithContext(copyJobState);
expect(container).toMatchSnapshot();
});
it("should render online migration specific groups", async () => {
const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock;
mockUsePermissionSections.mockReturnValue([
{
id: "onlineConfigs",
title: "Online Configuration",
description: "Configure settings for online migration",
sections: [
{
id: "pointInTimeRestore",
title: "Point In Time Restore",
Component: () => <div data-testid="point-in-time-restore">Point In Time Restore Component</div>,
disabled: false,
completed: true,
},
{
id: "onlineCopyEnabled",
title: "Online Copy Enabled",
Component: () => <div data-testid="online-copy-enabled">Online Copy Enabled Component</div>,
disabled: false,
completed: false,
},
],
},
]);
const copyJobState = createMockCopyJobState({
migrationType: CopyJobMigrationType.Online,
});
const { container } = renderWithContext(copyJobState);
expect(container).toMatchSnapshot();
});
it("should render multiple permission groups", async () => {
const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock;
mockUsePermissionSections.mockReturnValue([
{
id: "crossAccountConfigs",
title: "Cross Account Configuration",
description: "Configure permissions for cross-account copy",
sections: [
{
id: "addManagedIdentity",
title: "Add Managed Identity",
Component: () => <div data-testid="add-managed-identity">Add Managed Identity Component</div>,
disabled: false,
completed: true,
},
],
},
{
id: "onlineConfigs",
title: "Online Configuration",
description: "Configure settings for online migration",
sections: [
{
id: "onlineCopyEnabled",
title: "Online Copy Enabled",
Component: () => <div data-testid="online-copy-enabled">Online Copy Enabled Component</div>,
disabled: false,
completed: false,
},
],
},
]);
const copyJobState = createMockCopyJobState({
migrationType: CopyJobMigrationType.Online,
});
const { container, getByText } = renderWithContext(copyJobState);
expect(getByText("Cross Account Configuration")).toBeInTheDocument();
expect(getByText("Online Configuration")).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
});
describe("Accordion Behavior", () => {
it("should render accordion sections with proper status icons", async () => {
const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock;
mockUsePermissionSections.mockReturnValue([
{
id: "testGroup",
title: "Test Group",
description: "Test Description",
sections: [
{
id: "completedSection",
title: "Completed Section",
Component: () => <div>Completed Component</div>,
disabled: false,
completed: true,
},
{
id: "incompleteSection",
title: "Incomplete Section",
Component: () => <div>Incomplete Component</div>,
disabled: false,
completed: false,
},
{
id: "disabledSection",
title: "Disabled Section",
Component: () => <div>Disabled Component</div>,
disabled: true,
completed: false,
},
],
},
]);
const copyJobState = createMockCopyJobState();
const { container, getByText, getAllByRole } = renderWithContext(copyJobState);
expect(getByText("Completed Section")).toBeInTheDocument();
expect(getByText("Incomplete Section")).toBeInTheDocument();
expect(getByText("Disabled Section")).toBeInTheDocument();
const images = getAllByRole("img");
expect(images.length).toBeGreaterThan(0);
expect(container).toMatchSnapshot();
});
});
describe("Edge Cases", () => {
it("should handle missing account names", () => {
const copyJobState = createMockCopyJobState({
source: {
subscription: { subscriptionId: "source-sub" } as any,
account: { id: "source-account" } as any,
databaseId: "source-db",
containerId: "source-container",
},
});
const { container } = renderWithContext(copyJobState);
expect(container).toMatchSnapshot();
});
it("should calculate correct indent levels for offline migration", () => {
const copyJobState = createMockCopyJobState({
migrationType: CopyJobMigrationType.Offline,
});
const { container } = renderWithContext(copyJobState);
expect(container).toMatchSnapshot();
});
it("should calculate correct indent levels for online migration", () => {
const copyJobState = createMockCopyJobState({
migrationType: CopyJobMigrationType.Online,
});
const { container } = renderWithContext(copyJobState);
expect(container).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,124 @@
import { Image, Stack, Text } from "@fluentui/react";
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel } from "@fluentui/react-components";
import React, { useEffect } from "react";
import CheckmarkIcon from "../../../../../../images/successfulPopup.svg";
import WarningIcon from "../../../../../../images/warning.svg";
import ShimmerTree, { IndentLevel } from "../../../../../Common/ShimmerTree/ShimmerTree";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { isIntraAccountCopy } from "../../../CopyJobUtils";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
import { useCopyJobPrerequisitesCache } from "../../Utils/useCopyJobPrerequisitesCache";
import usePermissionSections, { PermissionGroupConfig, PermissionSectionConfig } from "./hooks/usePermissionsSection";
const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Component, completed, disabled }) => (
<AccordionItem key={id} value={id} disabled={disabled}>
<AccordionHeader className="accordionHeader">
<Text className="accordionHeaderText" variant="medium">
{title}
</Text>
<Image
className="statusIcon"
src={completed ? CheckmarkIcon : WarningIcon}
alt={completed ? "Checkmark icon" : "Warning icon"}
width={completed ? 20 : 24}
height={completed ? 20 : 24}
/>
</AccordionHeader>
<AccordionPanel aria-disabled={disabled} className="accordionPanel">
<Component />
</AccordionPanel>
</AccordionItem>
);
const PermissionGroup: React.FC<PermissionGroupConfig> = ({ title, description, sections }) => {
const [openItems, setOpenItems] = React.useState<string[]>([]);
useEffect(() => {
const firstIncompleteSection = sections.find((section) => !section.completed);
const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : [];
if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) {
setOpenItems(nextOpenItems);
}
}, [sections]);
return (
<Stack
tokens={{ childrenGap: 15 }}
styles={{
root: {
background: "#fafafa",
border: "1px solid #e1e1e1",
borderRadius: 8,
padding: 16,
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
},
}}
>
<Stack tokens={{ childrenGap: 5 }}>
<Text variant="medium" style={{ fontWeight: 600 }}>
{title}
</Text>
{description && (
<Text variant="small" styles={{ root: { color: "#605E5C" } }}>
{description}
</Text>
)}
</Stack>
<Accordion className="permissionsAccordion" collapsible openItems={openItems}>
{sections.map((section) => (
<PermissionSection key={section.id} {...section} />
))}
</Accordion>
</Stack>
);
};
const AssignPermissions = () => {
const { setValidationCache } = useCopyJobPrerequisitesCache();
const { copyJobState } = useCopyJobContext();
const permissionGroups = usePermissionSections(copyJobState);
const totalSectionsCount = React.useMemo(
() => permissionGroups.reduce((total, group) => total + group.sections.length, 0),
[permissionGroups],
);
const indentLevels = React.useMemo<IndentLevel[]>(
() => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }),
[copyJobState.migrationType],
);
const isSameAccount = isIntraAccountCopy(copyJobState?.source?.account?.id, copyJobState?.target?.account?.id);
useEffect(() => {
return () => {
setValidationCache(new Map<string, boolean>());
};
}, []);
return (
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 20 }}>
<Text variant="medium">
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(
copyJobState?.source?.account?.name || "",
)
: ContainerCopyMessages.assignPermissions.crossAccountDescription}
</Text>
{totalSectionsCount === 0 ? (
<ShimmerTree indentLevels={indentLevels} style={{ width: "100%" }} />
) : (
<Stack tokens={{ childrenGap: 25 }}>
{permissionGroups.map((group) => (
<PermissionGroup key={group.id} {...group} />
))}
</Stack>
)}
</Stack>
);
};
export default AssignPermissions;

View File

@@ -0,0 +1,355 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { updateDefaultIdentity } from "../../../../../Utils/arm/identityUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { CopyJobContext } from "../../../Context/CopyJobContext";
import DefaultManagedIdentity from "./DefaultManagedIdentity";
jest.mock("./hooks/useManagedIdentity");
jest.mock("./hooks/useToggle");
jest.mock("../../../../../Utils/arm/identityUtils", () => ({
updateDefaultIdentity: jest.fn(),
}));
jest.mock("../Components/InfoTooltip", () => {
const MockInfoTooltip = ({ content }: { content: React.ReactNode }) => {
return <div data-testid="info-tooltip">{content}</div>;
};
MockInfoTooltip.displayName = "MockInfoTooltip";
return MockInfoTooltip;
});
jest.mock("../Components/PopoverContainer", () => {
const MockPopoverContainer = ({
children,
isLoading,
visible,
title,
onCancel,
onPrimary,
}: {
children: React.ReactNode;
isLoading: boolean;
visible: boolean;
title: string;
onCancel: () => void;
onPrimary: () => void;
}) => {
if (!visible) {
return null;
}
return (
<div data-testid="popover-message">
<div data-testid="popover-title">{title}</div>
<div data-testid="popover-content">{children}</div>
<div data-testid="popover-loading">{isLoading ? "Loading" : "Not Loading"}</div>
<button data-testid="popover-cancel" onClick={onCancel}>
Cancel
</button>
<button data-testid="popover-primary" onClick={onPrimary}>
Primary
</button>
</div>
);
};
MockPopoverContainer.displayName = "MockPopoverContainer";
return MockPopoverContainer;
});
import { DatabaseAccount } from "Contracts/DataModels";
import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes";
import useManagedIdentity from "./hooks/useManagedIdentity";
import useToggle from "./hooks/useToggle";
const mockUseManagedIdentity = useManagedIdentity as jest.MockedFunction<typeof useManagedIdentity>;
const mockUseToggle = useToggle as jest.MockedFunction<typeof useToggle>;
describe("DefaultManagedIdentity", () => {
const mockCopyJobContextValue = {
copyJobState: {
target: {
account: {
name: "test-cosmos-account",
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-cosmos-account",
},
},
},
setCopyJobState: jest.fn(),
setContextError: jest.fn(),
contextError: "",
flow: {},
setFlow: jest.fn(),
resetCopyJobState: jest.fn(),
explorer: {} as any,
};
const mockHandleAddSystemIdentity = jest.fn();
const mockOnToggle = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockUseManagedIdentity.mockReturnValue({
loading: false,
handleAddSystemIdentity: mockHandleAddSystemIdentity,
});
mockUseToggle.mockReturnValue([false, mockOnToggle]);
});
const renderComponent = (contextValue = mockCopyJobContextValue) => {
return render(
<CopyJobContext.Provider value={contextValue as unknown as CopyJobContextProviderType}>
<DefaultManagedIdentity />
</CopyJobContext.Provider>,
);
};
describe("Rendering", () => {
it("should render correctly with default state", () => {
const { container } = renderComponent();
expect(container).toMatchSnapshot();
});
it("should render the description with account name", () => {
renderComponent();
const description = screen.getByText(
/Set the system-assigned managed identity as default for "test-cosmos-account"/,
);
expect(description).toBeInTheDocument();
});
it("should render the info tooltip", () => {
renderComponent();
const tooltip = screen.getByTestId("info-tooltip");
expect(tooltip).toBeInTheDocument();
expect(tooltip).toHaveTextContent("Learn more about");
expect(tooltip).toHaveTextContent("Default Managed Identities.");
});
it("should render the toggle button with correct initial state", () => {
renderComponent();
const toggle = screen.getByRole("switch");
expect(toggle).toBeInTheDocument();
expect(toggle).not.toBeChecked();
});
it("should not show popover when toggle is false", () => {
renderComponent();
const popover = screen.queryByTestId("popover-message");
expect(popover).not.toBeInTheDocument();
});
});
describe("Toggle Interactions", () => {
it("should call onToggle when toggle is clicked", () => {
renderComponent();
const toggle = screen.getByRole("switch");
fireEvent.click(toggle);
expect(mockOnToggle).toHaveBeenCalledTimes(1);
});
it("should show popover when toggle is true", () => {
mockUseToggle.mockReturnValue([true, mockOnToggle]);
renderComponent();
const popover = screen.getByTestId("popover-message");
expect(popover).toBeInTheDocument();
const title = screen.getByTestId("popover-title");
expect(title).toHaveTextContent(ContainerCopyMessages.defaultManagedIdentity.popoverTitle);
const content = screen.getByTestId("popover-content");
expect(content).toHaveTextContent(
/Assign the system-assigned managed identity as the default for "test-cosmos-account"/,
);
});
it("should render toggle with checked state when toggle is true", () => {
mockUseToggle.mockReturnValue([true, mockOnToggle]);
const { container } = renderComponent();
expect(container).toMatchSnapshot();
});
});
describe("Loading States", () => {
it("should show loading state in popover when loading is true", () => {
mockUseToggle.mockReturnValue([true, mockOnToggle]);
mockUseManagedIdentity.mockReturnValue({
loading: true,
handleAddSystemIdentity: mockHandleAddSystemIdentity,
});
renderComponent();
const loadingIndicator = screen.getByTestId("popover-loading");
expect(loadingIndicator).toHaveTextContent("Loading");
});
it("should not show loading state when loading is false", () => {
mockUseToggle.mockReturnValue([true, mockOnToggle]);
renderComponent();
const loadingIndicator = screen.getByTestId("popover-loading");
expect(loadingIndicator).toHaveTextContent("Not Loading");
});
it("should render loading state snapshot", () => {
mockUseToggle.mockReturnValue([true, mockOnToggle]);
mockUseManagedIdentity.mockReturnValue({
loading: true,
handleAddSystemIdentity: mockHandleAddSystemIdentity,
});
const { container } = renderComponent();
expect(container).toMatchSnapshot();
});
});
describe("Popover Interactions", () => {
beforeEach(() => {
mockUseToggle.mockReturnValue([true, mockOnToggle]);
});
it("should call onToggle with false when cancel button is clicked", () => {
renderComponent();
const cancelButton = screen.getByTestId("popover-cancel");
fireEvent.click(cancelButton);
expect(mockOnToggle).toHaveBeenCalledWith(null, false);
});
it("should call handleAddSystemIdentity when primary button is clicked", () => {
renderComponent();
const primaryButton = screen.getByTestId("popover-primary");
fireEvent.click(primaryButton);
expect(mockHandleAddSystemIdentity).toHaveBeenCalledTimes(1);
});
it("should handle primary button click correctly when loading", async () => {
mockUseManagedIdentity.mockReturnValue({
loading: true,
handleAddSystemIdentity: mockHandleAddSystemIdentity,
});
renderComponent();
const primaryButton = screen.getByTestId("popover-primary");
fireEvent.click(primaryButton);
expect(mockHandleAddSystemIdentity).toHaveBeenCalledTimes(1);
});
});
describe("Edge Cases", () => {
it("should handle missing account name gracefully", () => {
const contextValueWithoutAccount = {
...mockCopyJobContextValue,
copyJobState: {
target: {
account: {
name: "",
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/",
},
},
},
};
const { container } = renderComponent(contextValueWithoutAccount);
expect(container).toMatchSnapshot();
});
it("should handle null account", () => {
const contextValueWithNullAccount = {
...mockCopyJobContextValue,
copyJobState: {
target: {
account: null as DatabaseAccount | null,
},
},
};
const { container } = renderComponent(contextValueWithNullAccount);
expect(container).toMatchSnapshot();
});
});
describe("Hook Integration", () => {
it("should pass updateDefaultIdentity to useManagedIdentity hook", () => {
renderComponent();
expect(mockUseManagedIdentity).toHaveBeenCalledWith(updateDefaultIdentity);
});
it("should initialize useToggle with false", () => {
renderComponent();
expect(mockUseToggle).toHaveBeenCalledWith(false);
});
});
describe("Accessibility", () => {
it("should have proper ARIA attributes", () => {
renderComponent();
const toggle = screen.getByRole("switch");
expect(toggle).toBeInTheDocument();
});
it("should have proper link accessibility", () => {
renderComponent();
const link = screen.getByRole("link");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});
describe("Component Structure", () => {
it("should have correct CSS class", () => {
const { container } = renderComponent();
const componentContainer = container.querySelector(".defaultManagedIdentityContainer");
expect(componentContainer).toBeInTheDocument();
});
it("should render all required FluentUI components", () => {
renderComponent();
expect(screen.getByRole("switch")).toBeInTheDocument();
expect(screen.getByRole("link")).toBeInTheDocument();
});
});
describe("Messages and Text Content", () => {
it("should display correct toggle button text", () => {
renderComponent();
const onText = screen.queryByText(ContainerCopyMessages.toggleBtn.onText);
const offText = screen.queryByText(ContainerCopyMessages.toggleBtn.offText);
expect(onText || offText).toBeTruthy();
});
it("should display correct link text in tooltip", () => {
renderComponent();
const linkText = screen.getByText(ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText);
expect(linkText).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,57 @@
import { Link, Stack, Text, Toggle } from "@fluentui/react";
import React from "react";
import { updateDefaultIdentity } from "../../../../../Utils/arm/identityUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import InfoTooltip from "../Components/InfoTooltip";
import PopoverMessage from "../Components/PopoverContainer";
import useManagedIdentity from "./hooks/useManagedIdentity";
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
import useToggle from "./hooks/useToggle";
const managedIdentityTooltip = (
<Text>
{ContainerCopyMessages.defaultManagedIdentity.tooltip.content} &nbsp;
<Link href={ContainerCopyMessages.defaultManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText}
</Link>
</Text>
);
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
const DefaultManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
const { copyJobState } = useCopyJobContext();
const [defaultSystemAssigned, onToggle] = useToggle(false);
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateDefaultIdentity);
return (
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<div className="toggle-label">
{ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account?.name)} &nbsp;
<InfoTooltip content={managedIdentityTooltip} />
</div>
<Toggle
checked={defaultSystemAssigned}
onText={ContainerCopyMessages.toggleBtn.onText}
offText={ContainerCopyMessages.toggleBtn.offText}
onChange={onToggle}
inlineLabel
styles={{
root: { marginTop: 8, marginBottom: 12 },
label: { display: "none" },
}}
/>
<PopoverMessage
isLoading={loading}
visible={defaultSystemAssigned}
title={ContainerCopyMessages.defaultManagedIdentity.popoverTitle}
onCancel={() => onToggle(null, false)}
onPrimary={handleAddSystemIdentity}
>
{ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account?.name)}
</PopoverMessage>
</Stack>
);
};
export default DefaultManagedIdentity;

View File

@@ -0,0 +1,579 @@
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { DatabaseAccount } from "Contracts/DataModels";
import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes";
import React from "react";
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
import { CapabilityNames } from "../../../../../Common/Constants";
import { logError } from "../../../../../Common/Logger";
import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { CopyJobContext } from "../../../Context/CopyJobContext";
import OnlineCopyEnabled from "./OnlineCopyEnabled";
jest.mock("Utils/arm/databaseAccountUtils", () => ({
fetchDatabaseAccount: jest.fn(),
}));
jest.mock("../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts", () => ({
update: jest.fn(),
}));
jest.mock("../../../../../Common/Logger", () => ({
logError: jest.fn(),
}));
jest.mock("../../../../../Common/LoadingOverlay", () => {
const MockLoadingOverlay = ({ isLoading, label }: { isLoading: boolean; label: string }) => {
return isLoading ? <div data-testid="loading-overlay">{label}</div> : null;
};
MockLoadingOverlay.displayName = "MockLoadingOverlay";
return MockLoadingOverlay;
});
const mockFetchDatabaseAccount = fetchDatabaseAccount as jest.MockedFunction<typeof fetchDatabaseAccount>;
const mockUpdateDatabaseAccount = updateDatabaseAccount as jest.MockedFunction<typeof updateDatabaseAccount>;
const mockLogError = logError as jest.MockedFunction<typeof logError>;
describe("OnlineCopyEnabled", () => {
const mockSetContextError = jest.fn();
const mockSetCopyJobState = jest.fn();
const mockSourceAccount: DatabaseAccount = {
id: "/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
name: "test-account",
location: "East US",
type: "Microsoft.DocumentDB/databaseAccounts",
kind: "GlobalDocumentDB",
properties: {
capabilities: [],
enableAllVersionsAndDeletesChangeFeed: false,
locations: [],
writeLocations: [],
readLocations: [],
},
};
const mockCopyJobContextValue = {
copyJobState: {
source: {
account: mockSourceAccount,
},
},
setCopyJobState: mockSetCopyJobState,
setContextError: mockSetContextError,
contextError: "",
flow: { currentScreen: "" },
setFlow: jest.fn(),
resetCopyJobState: jest.fn(),
explorer: {} as any,
} as unknown as CopyJobContextProviderType;
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
const renderComponent = (contextValue = mockCopyJobContextValue) => {
return render(
<CopyJobContext.Provider value={contextValue}>
<OnlineCopyEnabled />
</CopyJobContext.Provider>,
);
};
describe("Rendering", () => {
it("should render correctly with initial state", () => {
const { container } = renderComponent();
expect(container).toMatchSnapshot();
});
it("should render the description with account name", () => {
renderComponent();
const description = screen.getByText(ContainerCopyMessages.onlineCopyEnabled.description("test-account"));
expect(description).toBeInTheDocument();
});
it("should render the learn more link", () => {
renderComponent();
const link = screen.getByRole("link", {
name: ContainerCopyMessages.onlineCopyEnabled.hrefText,
});
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", ContainerCopyMessages.onlineCopyEnabled.href);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("should render the enable button with correct text when not loading", () => {
renderComponent();
const button = screen.getByRole("button", {
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
});
expect(button).toBeInTheDocument();
expect(button).not.toBeDisabled();
});
it("should not show loading overlay initially", () => {
renderComponent();
const loadingOverlay = screen.queryByTestId("loading-overlay");
expect(loadingOverlay).not.toBeInTheDocument();
});
it("should not show refresh button initially", () => {
renderComponent();
const refreshButton = screen.queryByRole("button", {
name: ContainerCopyMessages.refreshButtonLabel,
});
expect(refreshButton).not.toBeInTheDocument();
});
});
describe("Enable Online Copy Flow", () => {
it("should handle complete enable online copy flow successfully", async () => {
const accountAfterChangeFeedUpdate = {
...mockSourceAccount,
properties: {
...mockSourceAccount.properties,
enableAllVersionsAndDeletesChangeFeed: true,
},
};
const accountWithOnlineCopyEnabled: DatabaseAccount = {
...accountAfterChangeFeedUpdate,
properties: {
...accountAfterChangeFeedUpdate.properties,
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }],
},
};
mockFetchDatabaseAccount
.mockResolvedValueOnce(mockSourceAccount)
.mockResolvedValueOnce(accountWithOnlineCopyEnabled);
mockUpdateDatabaseAccount.mockResolvedValue({} as any);
renderComponent();
const enableButton = screen.getByRole("button", {
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
});
await act(async () => {
fireEvent.click(enableButton);
});
expect(screen.getByTestId("loading-overlay")).toBeInTheDocument();
await waitFor(() => {
expect(mockFetchDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account");
});
await waitFor(() => {
expect(mockUpdateDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account", {
properties: {
enableAllVersionsAndDeletesChangeFeed: true,
},
});
});
await waitFor(() => {
expect(mockUpdateDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account", {
properties: {
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature }],
},
});
});
});
it("should skip change feed enablement if already enabled", async () => {
const accountWithChangeFeedEnabled = {
...mockSourceAccount,
properties: {
...mockSourceAccount.properties,
enableAllVersionsAndDeletesChangeFeed: true,
},
};
const accountWithOnlineCopyEnabled: DatabaseAccount = {
...accountWithChangeFeedEnabled,
properties: {
...accountWithChangeFeedEnabled.properties,
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }],
},
};
mockFetchDatabaseAccount
.mockResolvedValueOnce(accountWithChangeFeedEnabled)
.mockResolvedValueOnce(accountWithOnlineCopyEnabled);
mockUpdateDatabaseAccount.mockResolvedValue({} as any);
renderComponent();
const enableButton = screen.getByRole("button", {
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
});
await act(async () => {
fireEvent.click(enableButton);
});
await waitFor(() => {
expect(mockUpdateDatabaseAccount).toHaveBeenCalledTimes(1);
expect(mockUpdateDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account", {
properties: {
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature }],
},
});
});
});
it("should show correct loading messages during the process", async () => {
mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
mockUpdateDatabaseAccount.mockImplementation(() => new Promise(() => {}));
renderComponent();
const enableButton = screen.getByRole("button", {
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
});
await act(async () => {
fireEvent.click(enableButton);
});
await waitFor(() => {
expect(mockFetchDatabaseAccount).toHaveBeenCalled();
});
await waitFor(() => {
expect(
screen.getByText(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel("test-account")),
).toBeInTheDocument();
});
});
it("should handle error during update operations", async () => {
const errorMessage = "Failed to update account";
mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
mockUpdateDatabaseAccount.mockRejectedValue(new Error(errorMessage));
renderComponent();
const enableButton = screen.getByRole("button", {
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
});
await act(async () => {
fireEvent.click(enableButton);
});
await waitFor(() => {
expect(mockLogError).toHaveBeenCalledWith(errorMessage, "CopyJob/OnlineCopyEnabled.handleOnlineCopyEnable");
expect(mockSetContextError).toHaveBeenCalledWith(errorMessage);
});
expect(screen.queryByTestId("loading-overlay")).not.toBeInTheDocument();
});
it("should handle refresh button click", async () => {
const accountWithOnlineCopyEnabled: DatabaseAccount = {
...mockSourceAccount,
properties: {
...mockSourceAccount.properties,
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }],
},
};
mockFetchDatabaseAccount
.mockResolvedValueOnce(mockSourceAccount)
.mockResolvedValueOnce(mockSourceAccount)
.mockResolvedValueOnce(accountWithOnlineCopyEnabled);
mockUpdateDatabaseAccount.mockResolvedValue({} as any);
renderComponent();
const enableButton = screen.getByRole("button", {
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
});
await act(async () => {
fireEvent.click(enableButton);
});
await act(async () => {
jest.advanceTimersByTime(10 * 60 * 1000);
});
const refreshButton = screen.getByRole("button", {
name: ContainerCopyMessages.refreshButtonLabel,
});
await act(async () => {
fireEvent.click(refreshButton);
});
expect(screen.getByTestId("loading-overlay")).toBeInTheDocument();
await waitFor(() => {
expect(mockSetCopyJobState).toHaveBeenCalled();
});
});
});
describe("Account Validation and State Updates", () => {
it("should update state when account capabilities change", async () => {
const accountWithOnlineCopyEnabled: DatabaseAccount = {
...mockSourceAccount,
properties: {
...mockSourceAccount.properties,
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }],
},
};
mockFetchDatabaseAccount.mockResolvedValue(accountWithOnlineCopyEnabled);
mockUpdateDatabaseAccount.mockResolvedValue({} as any);
renderComponent();
const enableButton = screen.getByRole("button", {
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
});
await act(async () => {
fireEvent.click(enableButton);
});
await act(async () => {
jest.advanceTimersByTime(30000);
});
await waitFor(() => {
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
});
const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
const newState = stateUpdateFunction({
source: { account: mockSourceAccount },
});
expect(newState.source.account).toEqual(accountWithOnlineCopyEnabled);
});
it("should not update state when account capabilities remain unchanged", async () => {
mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
mockUpdateDatabaseAccount.mockResolvedValue({} as any);
renderComponent();
const enableButton = screen.getByRole("button", {
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
});
await act(async () => {
fireEvent.click(enableButton);
});
await act(async () => {
jest.advanceTimersByTime(30000);
});
expect(mockSetCopyJobState).not.toHaveBeenCalled();
});
});
describe("Button States and Interactions", () => {
it("should disable button during loading", async () => {
mockFetchDatabaseAccount.mockImplementation(() => new Promise(() => {}));
renderComponent();
const enableButton = screen.getByRole("button", {
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
});
await act(async () => {
fireEvent.click(enableButton);
});
const loadingButton = screen.getByRole("button");
expect(loadingButton).toBeDisabled();
});
it("should show sync icon during loading", async () => {
mockFetchDatabaseAccount.mockImplementation(() => new Promise(() => {}));
renderComponent();
const enableButton = screen.getByRole("button", {
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
});
await act(async () => {
fireEvent.click(enableButton);
});
const loadingButton = screen.getByRole("button");
expect(loadingButton.querySelector("[data-icon-name='SyncStatusSolid']")).toBeInTheDocument();
});
it("should disable refresh button during loading", async () => {
mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
mockUpdateDatabaseAccount.mockResolvedValue({} as any);
renderComponent();
const enableButton = screen.getByRole("button", {
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
});
await act(async () => {
fireEvent.click(enableButton);
});
await act(async () => {
jest.advanceTimersByTime(10 * 60 * 1000);
});
mockFetchDatabaseAccount.mockImplementation(() => new Promise(() => {}));
const refreshButton = screen.getByRole("button", {
name: ContainerCopyMessages.refreshButtonLabel,
});
await act(async () => {
fireEvent.click(refreshButton);
});
expect(refreshButton).toBeDisabled();
});
});
describe("Edge Cases", () => {
it("should handle missing account name gracefully", () => {
const contextWithoutAccountName = {
...mockCopyJobContextValue,
copyJobState: {
source: {
account: {
...mockSourceAccount,
name: "",
},
},
},
} as CopyJobContextProviderType;
const { container } = renderComponent(contextWithoutAccountName);
expect(container).toMatchSnapshot();
});
it("should handle null account", () => {
const contextWithNullAccount = {
...mockCopyJobContextValue,
copyJobState: {
source: {
account: null as DatabaseAccount | null,
},
},
} as CopyJobContextProviderType;
const { container } = renderComponent(contextWithNullAccount);
expect(container).toMatchSnapshot();
});
it("should handle account with existing online copy capability", () => {
const accountWithExistingCapability = {
...mockSourceAccount,
properties: {
...mockSourceAccount.properties,
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature }, { name: "SomeOtherCapability" }],
},
};
const contextWithExistingCapability = {
...mockCopyJobContextValue,
copyJobState: {
source: {
account: accountWithExistingCapability,
},
},
} as CopyJobContextProviderType;
const { container } = renderComponent(contextWithExistingCapability);
expect(container).toMatchSnapshot();
});
it("should handle account with no capabilities array", () => {
const accountWithNoCapabilities = {
...mockSourceAccount,
properties: {
...mockSourceAccount.properties,
capabilities: undefined,
},
} as DatabaseAccount;
const contextWithNoCapabilities = {
...mockCopyJobContextValue,
copyJobState: {
source: {
account: accountWithNoCapabilities,
},
},
} as CopyJobContextProviderType;
renderComponent(contextWithNoCapabilities);
const enableButton = screen.getByRole("button", {
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
});
expect(enableButton).toBeInTheDocument();
});
});
describe("Accessibility", () => {
it("should have proper button role and accessibility attributes", () => {
renderComponent();
const button = screen.getByRole("button", {
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
});
expect(button).toBeInTheDocument();
});
it("should have proper link accessibility", () => {
renderComponent();
const link = screen.getByRole("link");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});
describe("CSS Classes and Styling", () => {
it("should apply correct CSS class to container", () => {
const { container } = renderComponent();
const onlineCopyContainer = container.querySelector(".onlineCopyContainer");
expect(onlineCopyContainer).toBeInTheDocument();
});
it("should apply fullWidth class to buttons", () => {
renderComponent();
const button = screen.getByRole("button");
expect(button).toHaveClass("fullWidth");
});
});
});

View File

@@ -0,0 +1,163 @@
import { Link, PrimaryButton, Stack } from "@fluentui/react";
import { DatabaseAccount } from "Contracts/DataModels";
import React from "react";
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
import { CapabilityNames } from "../../../../../Common/Constants";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import { logError } from "../../../../../Common/Logger";
import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => {
const prevCapabilities = prev?.properties?.capabilities ?? [];
const nextCapabilities = next?.properties?.capabilities ?? [];
return JSON.stringify(prevCapabilities) !== JSON.stringify(nextCapabilities);
};
const OnlineCopyEnabled: React.FC = () => {
const [loading, setLoading] = React.useState(false);
const [loaderMessage, setLoaderMessage] = React.useState("");
const [showRefreshButton, setShowRefreshButton] = React.useState(false);
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const { setContextError, copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
const selectedSourceAccount = source?.account;
const sourceAccountCapabilities = selectedSourceAccount?.properties?.capabilities ?? [];
const {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id) || {};
const handleFetchAccount = async () => {
try {
const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName);
if (account && validatorFn(selectedSourceAccount, account)) {
setCopyJobState((prevState) => ({
...prevState,
source: { ...prevState.source, account: account },
}));
setLoading(false);
}
} catch (error) {
const errorMessage =
error.message || "Error fetching source account after enabling online copy. Please try again later.";
logError(errorMessage, "CopyJob/OnlineCopyEnabled.handleFetchAccount");
setContextError(errorMessage);
clearAccountFetchInterval();
}
};
const clearAccountFetchInterval = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setLoading(false);
};
const clearIntervalAndShowRefresh = () => {
clearAccountFetchInterval();
setShowRefreshButton(true);
};
const handleRefresh = () => {
setLoading(true);
handleFetchAccount();
};
const handleOnlineCopyEnable = async () => {
setLoading(true);
setShowRefreshButton(false);
try {
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.validateAllVersionsAndDeletesChangeFeedSpinnerLabel);
const sourAccountBeforeUpdate = await fetchDatabaseAccount(
sourceSubscriptionId,
sourceResourceGroup,
sourceAccountName,
);
if (!sourAccountBeforeUpdate?.properties.enableAllVersionsAndDeletesChangeFeed) {
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingAllVersionsAndDeletesChangeFeedSpinnerLabel);
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
enableAllVersionsAndDeletesChangeFeed: true,
},
});
}
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel(sourceAccountName));
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
capabilities: [...sourceAccountCapabilities, { name: CapabilityNames.EnableOnlineCopyFeature }],
},
});
intervalRef.current = setInterval(() => {
handleFetchAccount();
}, 30 * 1000);
timeoutRef.current = setTimeout(
() => {
clearIntervalAndShowRefresh();
},
10 * 60 * 1000,
);
} catch (error) {
const errorMessage = error.message || "Failed to enable online copy feature. Please try again later.";
logError(errorMessage, "CopyJob/OnlineCopyEnabled.handleOnlineCopyEnable");
setContextError(errorMessage);
setLoading(false);
}
};
React.useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
return (
<Stack className="onlineCopyContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<LoadingOverlay isLoading={loading} label={loaderMessage} />
<Stack.Item className="info-message">
{ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")}&ensp;
<Link href={ContainerCopyMessages.onlineCopyEnabled.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.onlineCopyEnabled.hrefText}
</Link>
</Stack.Item>
<Stack.Item>
{showRefreshButton ? (
<PrimaryButton
className="fullWidth"
text={ContainerCopyMessages.refreshButtonLabel}
iconProps={{ iconName: "Refresh" }}
onClick={handleRefresh}
disabled={loading}
/>
) : (
<PrimaryButton
className="fullWidth"
text={loading ? "" : ContainerCopyMessages.onlineCopyEnabled.buttonText}
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
disabled={loading}
onClick={handleOnlineCopyEnable}
/>
)}
</Stack.Item>
</Stack>
);
};
export default OnlineCopyEnabled;

View File

@@ -0,0 +1,341 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { logError } from "Common/Logger";
import { DatabaseAccount } from "Contracts/DataModels";
import React from "react";
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
import { CopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes";
import PointInTimeRestore from "./PointInTimeRestore";
jest.mock("Utils/arm/databaseAccountUtils");
jest.mock("Common/Logger");
const mockFetchDatabaseAccount = fetchDatabaseAccount as jest.MockedFunction<typeof fetchDatabaseAccount>;
const mockLogError = logError as jest.MockedFunction<typeof logError>;
const mockWindowOpen = jest.fn();
Object.defineProperty(window, "open", {
value: mockWindowOpen,
writable: true,
});
global.clearInterval = jest.fn();
global.clearTimeout = jest.fn();
describe("PointInTimeRestore", () => {
const mockSourceAccount: DatabaseAccount = {
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
name: "test-account",
type: "Microsoft.DocumentDB/databaseAccounts",
location: "East US",
properties: {
backupPolicy: {
type: "Continuous",
},
},
} as DatabaseAccount;
const mockUpdatedAccount: DatabaseAccount = {
...mockSourceAccount,
properties: {
backupPolicy: {
type: "Periodic",
},
},
} as DatabaseAccount;
const defaultCopyJobState = {
jobName: "test-job",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: { subscriptionId: "test-sub", displayName: "Test Subscription" },
account: mockSourceAccount,
databaseId: "test-db",
containerId: "test-container",
},
target: {
subscriptionId: "test-sub",
account: mockSourceAccount,
databaseId: "target-db",
containerId: "target-container",
},
sourceReadAccessFromTarget: false,
} as CopyJobContextState;
const mockSetCopyJobState = jest.fn();
const createMockContext = (overrides?: Partial<CopyJobContextProviderType>): CopyJobContextProviderType => ({
copyJobState: defaultCopyJobState,
setCopyJobState: mockSetCopyJobState,
flow: null,
setFlow: jest.fn(),
contextError: null,
setContextError: jest.fn(),
resetCopyJobState: jest.fn(),
...overrides,
});
const renderWithContext = (contextValue: CopyJobContextProviderType) => {
return render(
<CopyJobContext.Provider value={contextValue}>
<PointInTimeRestore />
</CopyJobContext.Provider>,
);
};
beforeEach(() => {
jest.clearAllMocks();
mockFetchDatabaseAccount.mockClear();
mockLogError.mockClear();
mockWindowOpen.mockClear();
mockSetCopyJobState.mockClear();
});
afterEach(() => {
jest.clearAllTimers();
});
describe("Initial Render", () => {
it("should render correctly with default props", () => {
const mockContext = createMockContext();
const { container } = renderWithContext(mockContext);
expect(container).toMatchSnapshot();
});
it("should display the correct description with account name", () => {
const mockContext = createMockContext();
renderWithContext(mockContext);
expect(screen.getByText(/test-account/)).toBeInTheDocument();
});
it("should show the primary action button with correct text", () => {
const mockContext = createMockContext();
renderWithContext(mockContext);
const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
expect(button).not.toBeDisabled();
});
it("should render with empty account name gracefully", () => {
const contextWithoutAccount = createMockContext({
copyJobState: {
...defaultCopyJobState,
source: {
...defaultCopyJobState.source,
account: { ...mockSourceAccount, name: "" },
},
},
});
const { container } = renderWithContext(contextWithoutAccount);
expect(container).toMatchSnapshot();
});
});
describe("Button Interactions", () => {
it("should open window and start monitoring when button is clicked", () => {
const mockContext = createMockContext();
renderWithContext(mockContext);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringMatching(
/#resource\/subscriptions\/test-sub\/resourceGroups\/test-rg\/providers\/Microsoft.DocumentDB\/databaseAccounts\/test-account\/backupRestore$/,
),
"_blank",
);
});
it("should disable button and show loading state after click", () => {
const mockContext = createMockContext();
renderWithContext(mockContext);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(button).toBeDisabled();
expect(screen.getByText(/Please wait while we process your request/)).toBeInTheDocument();
});
it("should show refresh button when timeout occurs", async () => {
jest.useFakeTimers();
const mockContext = createMockContext();
renderWithContext(mockContext);
const button = screen.getByRole("button");
fireEvent.click(button);
jest.advanceTimersByTime(10 * 60 * 1000 + 1000);
await waitFor(() => {
expect(screen.getByText(/Refresh/)).toBeInTheDocument();
});
jest.useRealTimers();
});
it("should fetch account periodically after button click", async () => {
jest.useFakeTimers();
mockFetchDatabaseAccount.mockResolvedValue(mockUpdatedAccount);
const mockContext = createMockContext();
renderWithContext(mockContext);
const button = screen.getByRole("button");
fireEvent.click(button);
jest.advanceTimersByTime(30 * 1000);
await waitFor(() => {
expect(mockFetchDatabaseAccount).toHaveBeenCalledWith("test-sub", "test-rg", "test-account");
});
jest.useRealTimers();
});
it("should not update context when account validation fails", async () => {
jest.useFakeTimers();
mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
const mockContext = createMockContext();
renderWithContext(mockContext);
const button = screen.getByRole("button");
fireEvent.click(button);
jest.advanceTimersByTime(30 * 1000);
await waitFor(() => {
expect(mockFetchDatabaseAccount).toHaveBeenCalled();
});
expect(mockSetCopyJobState).not.toHaveBeenCalled();
jest.useRealTimers();
});
});
describe("Refresh Button Functionality", () => {
it("should handle refresh button click", async () => {
jest.useFakeTimers();
mockFetchDatabaseAccount.mockResolvedValue(mockUpdatedAccount);
const mockContext = createMockContext();
renderWithContext(mockContext);
const button = screen.getByRole("button");
fireEvent.click(button);
jest.advanceTimersByTime(10 * 60 * 1000 + 1000);
await waitFor(() => {
const refreshButton = screen.getByText(/Refresh/);
expect(refreshButton).toBeInTheDocument();
});
const refreshButton = screen.getByText(/Refresh/);
fireEvent.click(refreshButton);
await waitFor(() => {
expect(mockFetchDatabaseAccount).toHaveBeenCalledWith("test-sub", "test-rg", "test-account");
});
jest.useRealTimers();
});
it("should show loading state during refresh", async () => {
jest.useFakeTimers();
mockFetchDatabaseAccount.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(mockUpdatedAccount), 1000)),
);
const mockContext = createMockContext();
renderWithContext(mockContext);
const button = screen.getByRole("button");
fireEvent.click(button);
jest.advanceTimersByTime(10 * 60 * 1000 + 1000);
await waitFor(() => {
expect(screen.getByText(/Refresh/)).toBeInTheDocument();
});
const refreshButton = screen.getByText(/Refresh/);
fireEvent.click(refreshButton);
expect(screen.getByText(/Please wait while we process your request/)).toBeInTheDocument();
jest.useRealTimers();
});
});
describe("Edge Cases", () => {
it("should handle missing source account gracefully", () => {
const contextWithoutSourceAccount = createMockContext({
copyJobState: {
...defaultCopyJobState,
source: {
...defaultCopyJobState.source,
account: null as any,
},
},
});
const { container } = renderWithContext(contextWithoutSourceAccount);
expect(container).toMatchSnapshot();
});
it("should handle missing account ID gracefully", () => {
const contextWithoutAccountId = createMockContext({
copyJobState: {
...defaultCopyJobState,
source: {
...defaultCopyJobState.source,
account: { ...mockSourceAccount, id: undefined as any },
},
},
});
const { container } = renderWithContext(contextWithoutAccountId);
expect(container).toMatchSnapshot();
});
});
describe("Snapshots", () => {
it("should match snapshot in loading state", () => {
const mockContext = createMockContext();
const { container } = renderWithContext(mockContext);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(container).toMatchSnapshot();
});
it("should match snapshot with refresh button", async () => {
jest.useFakeTimers();
const mockContext = createMockContext();
const { container } = renderWithContext(mockContext);
const button = screen.getByRole("button");
fireEvent.click(button);
jest.advanceTimersByTime(10 * 60 * 1000 + 1000);
await waitFor(() => {
expect(screen.getByText(/Refresh/)).toBeInTheDocument();
});
expect(container).toMatchSnapshot();
jest.useRealTimers();
});
});
});

View File

@@ -0,0 +1,149 @@
import { Link, PrimaryButton, Stack, Text } from "@fluentui/react";
import { DatabaseAccount } from "Contracts/DataModels";
import React, { useEffect, useRef, useState } from "react";
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import { logError } from "../../../../../Common/Logger";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { buildResourceLink, getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
import InfoTooltip from "../Components/InfoTooltip";
const tooltipContent = (
<Text>
{ContainerCopyMessages.pointInTimeRestore.tooltip.content} &nbsp;
<Link href={ContainerCopyMessages.pointInTimeRestore.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.pointInTimeRestore.tooltip.hrefText}
</Link>
</Text>
);
const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => {
const prevBackupPolicy = prev?.properties?.backupPolicy?.type ?? "";
const nextBackupPolicy = next?.properties?.backupPolicy?.type ?? "";
return prevBackupPolicy !== nextBackupPolicy;
};
const PointInTimeRestore: React.FC = () => {
const [loading, setLoading] = useState(false);
const [showRefreshButton, setShowRefreshButton] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const { copyJobState: { source } = {}, setCopyJobState, setContextError } = useCopyJobContext();
if (!source?.account?.id) {
setContextError("Invalid source account. Please select a valid source account for Point-in-Time Restore.");
return null;
}
const sourceAccountLink = buildResourceLink(source?.account);
const featureUrl = `${sourceAccountLink}/backupRestore`;
const selectedSourceAccount = source?.account;
const {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id) || {};
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
const handleFetchAccount = async () => {
try {
const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName);
if (account && validatorFn(selectedSourceAccount, account)) {
setCopyJobState((prevState) => ({
...prevState,
source: { ...prevState.source, account: account },
}));
setLoading(false);
}
} catch (error) {
const errorMessage =
error.message || "Error fetching source account after Point-in-Time Restore. Please try again later.";
logError(errorMessage, "CopyJob/PointInTimeRestore.handleFetchAccount");
clearAccountFetchInterval();
}
};
const clearAccountFetchInterval = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setLoading(false);
};
const clearIntervalAndShowRefresh = () => {
clearAccountFetchInterval();
setShowRefreshButton(true);
};
const handleRefresh = async () => {
setLoading(true);
await handleFetchAccount();
setLoading(false);
};
const openWindowAndMonitor = () => {
setLoading(true);
setShowRefreshButton(false);
window.open(featureUrl, "_blank");
intervalRef.current = setInterval(() => {
handleFetchAccount();
}, 30 * 1000);
timeoutRef.current = setTimeout(
() => {
clearIntervalAndShowRefresh();
},
10 * 60 * 1000,
);
};
return (
<Stack className="pointInTimeRestoreContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<LoadingOverlay isLoading={loading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Stack.Item className="toggle-label">
{ContainerCopyMessages.pointInTimeRestore.description(source.account?.name ?? "")}
{tooltipContent && (
<>
{" "}
<InfoTooltip content={tooltipContent} />
</>
)}
</Stack.Item>
<Stack.Item>
{showRefreshButton ? (
<PrimaryButton
className="fullWidth"
text={ContainerCopyMessages.refreshButtonLabel}
iconProps={{ iconName: "Refresh" }}
onClick={handleRefresh}
/>
) : (
<PrimaryButton
className="fullWidth"
text={loading ? "" : ContainerCopyMessages.pointInTimeRestore.buttonText}
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
disabled={loading}
onClick={openWindowAndMonitor}
/>
)}
</Stack.Item>
</Stack>
);
};
export default PointInTimeRestore;

View File

@@ -0,0 +1,406 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddManagedIdentity Snapshot Tests renders initial state correctly 1`] = `
<div
class="ms-Stack addManagedIdentityContainer css-109"
>
<span
class="css-110"
>
A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you dont have to store any credentials in code.
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
rel="noopener noreferrer"
target="_blank"
>
Learn more about Managed identities.
</a>
 
<div
class="ms-TooltipHost root-105"
role="none"
>
<div
class="ms-Image root-112"
style="width: 14px; height: 14px;"
>
<img
alt="Information"
class="ms-Image-image ms-Image-image--portrait is-notLoaded is-fadeIn image-113"
src="[object Object]"
/>
</div>
<div
hidden=""
id="tooltip0"
style="position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0px; border: 0px; overflow: hidden; white-space: nowrap;"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
rel="noopener noreferrer"
target="_blank"
>
Managed Identities.
</a>
</span>
</div>
</div>
</span>
<div
class="ms-Toggle is-enabled root-114"
>
<div
class="ms-Toggle-innerContainer container-116"
>
<button
aria-checked="false"
aria-labelledby="Toggle1-stateText"
class="ms-Toggle-background pill-117"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle1"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-118"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-120"
for="Toggle1"
id="Toggle1-stateText"
>
Off
</label>
</div>
</div>
</div>
`;
exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = `
<div
class="ms-Stack addManagedIdentityContainer css-109"
>
<span
class="css-110"
>
A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you dont have to store any credentials in code.
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
rel="noopener noreferrer"
target="_blank"
>
Learn more about Managed identities.
</a>
 
<div
class="ms-TooltipHost root-105"
role="none"
>
<div
class="ms-Image root-112"
style="width: 14px; height: 14px;"
>
<img
alt="Information"
class="ms-Image-image ms-Image-image--portrait is-notLoaded is-fadeIn image-113"
src="[object Object]"
/>
</div>
<div
hidden=""
id="tooltip10"
style="position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0px; border: 0px; overflow: hidden; white-space: nowrap;"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
rel="noopener noreferrer"
target="_blank"
>
Managed Identities.
</a>
</span>
</div>
</div>
</span>
<div
class="ms-Toggle is-checked is-enabled root-114"
>
<div
class="ms-Toggle-innerContainer container-116"
>
<button
aria-checked="true"
aria-labelledby="Toggle11-stateText"
class="ms-Toggle-background pill-121"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle11"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-122"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-120"
for="Toggle11"
id="Toggle11-stateText"
>
On
</label>
</div>
</div>
<div
class="ms-Stack popover-container foreground loading css-123"
style="max-width: 450px;"
>
<div
class="ms-Overlay root-135"
>
<div
class="ms-Spinner root-137"
>
<div
class="ms-Spinner-circle ms-Spinner--large circle-138"
/>
<div
class="ms-Spinner-label label-139"
>
Please wait while we process your request...
</div>
</div>
</div>
<span
class="css-124"
style="font-weight: 600;"
>
Enable system assigned managed identity
</span>
<span
class="css-110"
>
Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button.
</span>
<div
class="ms-Stack css-125"
>
<button
aria-disabled="true"
class="ms-Button ms-Button--primary is-disabled root-140"
data-is-focusable="false"
disabled=""
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-127"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-128"
>
<span
class="ms-Button-label label-130"
id="id__12"
>
Yes
</span>
</span>
</span>
</button>
<button
aria-disabled="true"
class="ms-Button ms-Button--default is-disabled root-143"
data-is-focusable="false"
disabled=""
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-127"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-128"
>
<span
class="ms-Button-label label-130"
id="id__15"
>
No
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;
exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover visible 1`] = `
<div
class="ms-Stack addManagedIdentityContainer css-109"
>
<span
class="css-110"
>
A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you dont have to store any credentials in code.
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
rel="noopener noreferrer"
target="_blank"
>
Learn more about Managed identities.
</a>
 
<div
class="ms-TooltipHost root-105"
role="none"
>
<div
class="ms-Image root-112"
style="width: 14px; height: 14px;"
>
<img
alt="Information"
class="ms-Image-image ms-Image-image--portrait is-notLoaded is-fadeIn image-113"
src="[object Object]"
/>
</div>
<div
hidden=""
id="tooltip2"
style="position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0px; border: 0px; overflow: hidden; white-space: nowrap;"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
rel="noopener noreferrer"
target="_blank"
>
Managed Identities.
</a>
</span>
</div>
</div>
</span>
<div
class="ms-Toggle is-checked is-enabled root-114"
>
<div
class="ms-Toggle-innerContainer container-116"
>
<button
aria-checked="true"
aria-labelledby="Toggle3-stateText"
class="ms-Toggle-background pill-121"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle3"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-122"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-120"
for="Toggle3"
id="Toggle3-stateText"
>
On
</label>
</div>
</div>
<div
class="ms-Stack popover-container foreground css-123"
style="max-width: 450px;"
>
<span
class="css-124"
style="font-weight: 600;"
>
Enable system assigned managed identity
</span>
<span
class="css-110"
>
Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button.
</span>
<div
class="ms-Stack css-125"
>
<button
class="ms-Button ms-Button--primary root-126"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-127"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-128"
>
<span
class="ms-Button-label label-130"
id="id__4"
>
Yes
</span>
</span>
</span>
</button>
<button
class="ms-Button ms-Button--default root-134"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-127"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-128"
>
<span
class="ms-Button-label label-130"
id="id__7"
>
No
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,398 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle missing source account 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
>
<span
class="toggle-label css-110"
>
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
<div
data-testid="info-tooltip"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control"
rel="noopener noreferrer"
target="_blank"
>
Read permissions.
</a>
</span>
</div>
</span>
<div
class="ms-Toggle is-enabled root-112"
>
<div
class="ms-Toggle-innerContainer container-114"
>
<button
aria-checked="false"
aria-labelledby="Toggle17-stateText"
class="ms-Toggle-background pill-115"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle17"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-116"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-118"
for="Toggle17"
id="Toggle17-stateText"
>
Off
</label>
</div>
</div>
</div>
</div>
`;
exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle missing target account identity 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
>
<span
class="toggle-label css-110"
>
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
<div
data-testid="info-tooltip"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control"
rel="noopener noreferrer"
target="_blank"
>
Read permissions.
</a>
</span>
</div>
</span>
<div
class="ms-Toggle is-enabled root-112"
>
<div
class="ms-Toggle-innerContainer container-114"
>
<button
aria-checked="false"
aria-labelledby="Toggle16-stateText"
class="ms-Toggle-background pill-115"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle16"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-116"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-118"
for="Toggle16"
id="Toggle16-stateText"
>
Off
</label>
</div>
</div>
</div>
</div>
`;
exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly when sourceReadAccessFromTarget is true 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
>
<span
class="toggle-label css-110"
>
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
<div
data-testid="info-tooltip"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control"
rel="noopener noreferrer"
target="_blank"
>
Read permissions.
</a>
</span>
</div>
</span>
<div
class="ms-Toggle is-enabled root-112"
>
<div
class="ms-Toggle-innerContainer container-114"
>
<button
aria-checked="false"
aria-labelledby="Toggle3-stateText"
class="ms-Toggle-background pill-115"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle3"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-116"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-118"
for="Toggle3"
id="Toggle3-stateText"
>
Off
</label>
</div>
</div>
</div>
</div>
`;
exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly when toggle is on 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
>
<span
class="toggle-label css-110"
>
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
<div
data-testid="info-tooltip"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control"
rel="noopener noreferrer"
target="_blank"
>
Read permissions.
</a>
</span>
</div>
</span>
<div
class="ms-Toggle is-checked is-enabled root-112"
>
<div
class="ms-Toggle-innerContainer container-114"
>
<button
aria-checked="true"
aria-labelledby="Toggle1-stateText"
class="ms-Toggle-background pill-119"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle1"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-120"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-118"
for="Toggle1"
id="Toggle1-stateText"
>
On
</label>
</div>
</div>
<div
data-loading="false"
data-testid="popover-message"
>
<div
data-testid="popover-title"
>
Read permissions assigned to default identity.
</div>
<div
data-testid="popover-content"
>
Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button.
</div>
<button
data-testid="popover-cancel"
>
Cancel
</button>
<button
data-testid="popover-primary"
>
Primary
</button>
</div>
</div>
</div>
`;
exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly with default state 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
>
<span
class="toggle-label css-110"
>
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
<div
data-testid="info-tooltip"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control"
rel="noopener noreferrer"
target="_blank"
>
Read permissions.
</a>
</span>
</div>
</span>
<div
class="ms-Toggle is-enabled root-112"
>
<div
class="ms-Toggle-innerContainer container-114"
>
<button
aria-checked="false"
aria-labelledby="Toggle0-stateText"
class="ms-Toggle-background pill-115"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle0"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-116"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-118"
for="Toggle0"
id="Toggle0-stateText"
>
Off
</label>
</div>
</div>
</div>
</div>
`;
exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly with different context states 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
>
<span
class="toggle-label css-110"
>
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
<div
data-testid="info-tooltip"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control"
rel="noopener noreferrer"
target="_blank"
>
Read permissions.
</a>
</span>
</div>
</span>
<div
class="ms-Toggle is-enabled root-112"
>
<div
class="ms-Toggle-innerContainer container-114"
>
<button
aria-checked="false"
aria-labelledby="Toggle2-stateText"
class="ms-Toggle-background pill-115"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle2"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-116"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-118"
for="Toggle2"
id="Toggle2-stateText"
>
Off
</label>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,369 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DefaultManagedIdentity Edge Cases should handle missing account name gracefully 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
>
<div
class="toggle-label"
>
Set the system-assigned managed identity as default for "" by switching it on.
 
<div
data-testid="info-tooltip"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
rel="noopener noreferrer"
target="_blank"
>
Default Managed Identities.
</a>
</span>
</div>
</div>
<div
class="ms-Toggle is-enabled root-112"
>
<div
class="ms-Toggle-innerContainer container-114"
>
<button
aria-checked="false"
aria-labelledby="Toggle14-stateText"
class="ms-Toggle-background pill-115"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle14"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-116"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-118"
for="Toggle14"
id="Toggle14-stateText"
>
Off
</label>
</div>
</div>
</div>
</div>
`;
exports[`DefaultManagedIdentity Edge Cases should handle null account 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
>
<div
class="toggle-label"
>
Set the system-assigned managed identity as default for "undefined" by switching it on.
 
<div
data-testid="info-tooltip"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
rel="noopener noreferrer"
target="_blank"
>
Default Managed Identities.
</a>
</span>
</div>
</div>
<div
class="ms-Toggle is-enabled root-112"
>
<div
class="ms-Toggle-innerContainer container-114"
>
<button
aria-checked="false"
aria-labelledby="Toggle15-stateText"
class="ms-Toggle-background pill-115"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle15"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-116"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-118"
for="Toggle15"
id="Toggle15-stateText"
>
Off
</label>
</div>
</div>
</div>
</div>
`;
exports[`DefaultManagedIdentity Loading States should render loading state snapshot 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
>
<div
class="toggle-label"
>
Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on.
 
<div
data-testid="info-tooltip"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
rel="noopener noreferrer"
target="_blank"
>
Default Managed Identities.
</a>
</span>
</div>
</div>
<div
class="ms-Toggle is-checked is-enabled root-112"
>
<div
class="ms-Toggle-innerContainer container-114"
>
<button
aria-checked="true"
aria-labelledby="Toggle10-stateText"
class="ms-Toggle-background pill-119"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle10"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-120"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-118"
for="Toggle10"
id="Toggle10-stateText"
>
On
</label>
</div>
</div>
<div
data-testid="popover-message"
>
<div
data-testid="popover-title"
>
System assigned managed identity set as default
</div>
<div
data-testid="popover-content"
>
Assign the system-assigned managed identity as the default for "test-cosmos-account". To confirm, click the "Yes" button.
</div>
<div
data-testid="popover-loading"
>
Loading
</div>
<button
data-testid="popover-cancel"
>
Cancel
</button>
<button
data-testid="popover-primary"
>
Primary
</button>
</div>
</div>
</div>
`;
exports[`DefaultManagedIdentity Rendering should render correctly with default state 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
>
<div
class="toggle-label"
>
Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on.
 
<div
data-testid="info-tooltip"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
rel="noopener noreferrer"
target="_blank"
>
Default Managed Identities.
</a>
</span>
</div>
</div>
<div
class="ms-Toggle is-enabled root-112"
>
<div
class="ms-Toggle-innerContainer container-114"
>
<button
aria-checked="false"
aria-labelledby="Toggle0-stateText"
class="ms-Toggle-background pill-115"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle0"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-116"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-118"
for="Toggle0"
id="Toggle0-stateText"
>
Off
</label>
</div>
</div>
</div>
</div>
`;
exports[`DefaultManagedIdentity Toggle Interactions should render toggle with checked state when toggle is true 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
>
<div
class="toggle-label"
>
Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on.
 
<div
data-testid="info-tooltip"
>
<span
class="css-110"
>
Learn more about
 
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
rel="noopener noreferrer"
target="_blank"
>
Default Managed Identities.
</a>
</span>
</div>
</div>
<div
class="ms-Toggle is-checked is-enabled root-112"
>
<div
class="ms-Toggle-innerContainer container-114"
>
<button
aria-checked="true"
aria-labelledby="Toggle7-stateText"
class="ms-Toggle-background pill-119"
data-is-focusable="true"
data-ktp-target="true"
id="Toggle7"
role="switch"
type="button"
>
<span
class="ms-Toggle-thumb thumb-120"
/>
</button>
<label
class="ms-Label ms-Toggle-stateText text-118"
for="Toggle7"
id="Toggle7-stateText"
>
On
</label>
</div>
</div>
<div
data-testid="popover-message"
>
<div
data-testid="popover-title"
>
System assigned managed identity set as default
</div>
<div
data-testid="popover-content"
>
Assign the system-assigned managed identity as the default for "test-cosmos-account". To confirm, click the "Yes" button.
</div>
<div
data-testid="popover-loading"
>
Not Loading
</div>
<button
data-testid="popover-cancel"
>
Cancel
</button>
<button
data-testid="popover-primary"
>
Primary
</button>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,193 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`OnlineCopyEnabled Edge Cases should handle account with existing online copy capability 1`] = `
<div>
<div
class="ms-Stack onlineCopyContainer css-109"
>
<div
class="ms-StackItem info-message css-110"
>
Enable online container copy by clicking the button below on your "test-account" account.
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy"
rel="noopener noreferrer"
target="_blank"
>
Learn more about online copy jobs
</a>
</div>
<div
class="ms-StackItem css-110"
>
<button
class="ms-Button ms-Button--primary fullWidth root-112"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-113"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-114"
>
<span
class="ms-Button-label label-116"
id="id__54"
>
Enable Online Copy
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;
exports[`OnlineCopyEnabled Edge Cases should handle missing account name gracefully 1`] = `
<div>
<div
class="ms-Stack onlineCopyContainer css-109"
>
<div
class="ms-StackItem info-message css-110"
>
Enable online container copy by clicking the button below on your "" account.
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy"
rel="noopener noreferrer"
target="_blank"
>
Learn more about online copy jobs
</a>
</div>
<div
class="ms-StackItem css-110"
>
<button
class="ms-Button ms-Button--primary fullWidth root-112"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-113"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-114"
>
<span
class="ms-Button-label label-116"
id="id__48"
>
Enable Online Copy
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;
exports[`OnlineCopyEnabled Edge Cases should handle null account 1`] = `
<div>
<div
class="ms-Stack onlineCopyContainer css-109"
>
<div
class="ms-StackItem info-message css-110"
>
Enable online container copy by clicking the button below on your "" account.
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy"
rel="noopener noreferrer"
target="_blank"
>
Learn more about online copy jobs
</a>
</div>
<div
class="ms-StackItem css-110"
>
<button
class="ms-Button ms-Button--primary fullWidth root-112"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-113"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-114"
>
<span
class="ms-Button-label label-116"
id="id__51"
>
Enable Online Copy
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;
exports[`OnlineCopyEnabled Rendering should render correctly with initial state 1`] = `
<div>
<div
class="ms-Stack onlineCopyContainer css-109"
>
<div
class="ms-StackItem info-message css-110"
>
Enable online container copy by clicking the button below on your "test-account" account.
<a
class="ms-Link root-111"
href="https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy"
rel="noopener noreferrer"
target="_blank"
>
Learn more about online copy jobs
</a>
</div>
<div
class="ms-StackItem css-110"
>
<button
class="ms-Button ms-Button--primary fullWidth root-112"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-113"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-114"
>
<span
class="ms-Button-label label-116"
id="id__0"
>
Enable Online Copy
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,333 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PointInTimeRestore Edge Cases should handle missing account ID gracefully 1`] = `<div />`;
exports[`PointInTimeRestore Edge Cases should handle missing source account gracefully 1`] = `<div />`;
exports[`PointInTimeRestore Initial Render should render correctly with default props 1`] = `
<div>
<div
class="ms-Stack pointInTimeRestoreContainer css-109"
>
<div
class="ms-StackItem toggle-label css-110"
>
To facilitate online container copy jobs, please update your "test-account" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.
<div
class="ms-TooltipHost root-105"
role="none"
>
<div
class="ms-Image root-111"
style="width: 14px; height: 14px;"
>
<img
alt="Information"
class="ms-Image-image ms-Image-image--portrait is-notLoaded is-fadeIn image-112"
src="[object Object]"
/>
</div>
<div
hidden=""
id="tooltip0"
style="position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0px; border: 0px; overflow: hidden; white-space: nowrap;"
>
<span
class="css-113"
>
Learn more about
 
<a
class="ms-Link root-114"
href="https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction"
rel="noopener noreferrer"
target="_blank"
>
Continuous Backup
</a>
</span>
</div>
</div>
</div>
<div
class="ms-StackItem css-110"
>
<button
class="ms-Button ms-Button--primary fullWidth root-115"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-116"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-117"
>
<span
class="ms-Button-label label-119"
id="id__1"
>
Enable Point In Time Restore
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;
exports[`PointInTimeRestore Initial Render should render with empty account name gracefully 1`] = `
<div>
<div
class="ms-Stack pointInTimeRestoreContainer css-109"
>
<div
class="ms-StackItem toggle-label css-110"
>
To facilitate online container copy jobs, please update your "" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.
<div
class="ms-TooltipHost root-105"
role="none"
>
<div
class="ms-Image root-111"
style="width: 14px; height: 14px;"
>
<img
alt="Information"
class="ms-Image-image ms-Image-image--portrait is-notLoaded is-fadeIn image-112"
src="[object Object]"
/>
</div>
<div
hidden=""
id="tooltip12"
style="position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0px; border: 0px; overflow: hidden; white-space: nowrap;"
>
<span
class="css-113"
>
Learn more about
 
<a
class="ms-Link root-114"
href="https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction"
rel="noopener noreferrer"
target="_blank"
>
Continuous Backup
</a>
</span>
</div>
</div>
</div>
<div
class="ms-StackItem css-110"
>
<button
class="ms-Button ms-Button--primary fullWidth root-115"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-116"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-117"
>
<span
class="ms-Button-label label-119"
id="id__13"
>
Enable Point In Time Restore
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;
exports[`PointInTimeRestore Snapshots should match snapshot in loading state 1`] = `
<div>
<div
class="ms-Stack pointInTimeRestoreContainer css-109"
>
<div
class="ms-Overlay root-123"
>
<div
class="ms-Spinner root-125"
>
<div
class="ms-Spinner-circle ms-Spinner--large circle-126"
/>
<div
class="ms-Spinner-label label-127"
>
Please wait while we process your request...
</div>
</div>
</div>
<div
class="ms-StackItem toggle-label css-110"
>
To facilitate online container copy jobs, please update your "test-account" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.
<div
class="ms-TooltipHost root-105"
role="none"
>
<div
class="ms-Image root-111"
style="width: 14px; height: 14px;"
>
<img
alt="Information"
class="ms-Image-image ms-Image-image--portrait is-notLoaded is-fadeIn image-112"
src="[object Object]"
/>
</div>
<div
hidden=""
id="tooltip44"
style="position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0px; border: 0px; overflow: hidden; white-space: nowrap;"
>
<span
class="css-113"
>
Learn more about
 
<a
class="ms-Link root-114"
href="https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction"
rel="noopener noreferrer"
target="_blank"
>
Continuous Backup
</a>
</span>
</div>
</div>
</div>
<div
class="ms-StackItem css-110"
>
<button
aria-disabled="true"
class="ms-Button ms-Button--primary is-disabled fullWidth root-128"
data-is-focusable="false"
disabled=""
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-116"
data-automationid="splitbuttonprimary"
>
<i
aria-hidden="true"
class="ms-Icon root-105 css-132 ms-Button-icon icon-129"
data-icon-name="SyncStatusSolid"
style="font-family: "FabricMDL2Icons-16";"
>
</i>
<span
class="ms-Button-label label-119"
id="id__45"
/>
</span>
</button>
</div>
</div>
</div>
`;
exports[`PointInTimeRestore Snapshots should match snapshot with refresh button 1`] = `
<div>
<div
class="ms-Stack pointInTimeRestoreContainer css-109"
>
<div
class="ms-StackItem toggle-label css-110"
>
To facilitate online container copy jobs, please update your "test-account" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.
<div
class="ms-TooltipHost root-105"
role="none"
>
<div
class="ms-Image root-111"
style="width: 14px; height: 14px;"
>
<img
alt="Information"
class="ms-Image-image ms-Image-image--portrait is-notLoaded is-fadeIn image-112"
src="[object Object]"
/>
</div>
<div
hidden=""
id="tooltip48"
style="position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0px; border: 0px; overflow: hidden; white-space: nowrap;"
>
<span
class="css-113"
>
Learn more about
 
<a
class="ms-Link root-114"
href="https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction"
rel="noopener noreferrer"
target="_blank"
>
Continuous Backup
</a>
</span>
</div>
</div>
</div>
<div
class="ms-StackItem css-110"
>
<button
class="ms-Button ms-Button--primary fullWidth root-115"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-116"
data-automationid="splitbuttonprimary"
>
<i
aria-hidden="true"
class="ms-Icon root-105 css-134 ms-Button-icon icon-118"
data-icon-name="Refresh"
style="font-family: "FabricMDL2Icons-0";"
>
</i>
<span
class="ms-Button-textContainer textContainer-117"
>
<span
class="ms-Button-label label-119"
id="id__49"
>
Refresh
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,255 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { logError } from "../../../../../../Common/Logger";
import { DatabaseAccount } from "../../../../../../Contracts/DataModels";
import Explorer from "../../../../../Explorer";
import CopyJobContextProvider, { useCopyJobContext } from "../../../../Context/CopyJobContext";
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
import useManagedIdentity from "./useManagedIdentity";
jest.mock("../../../../CopyJobUtils");
jest.mock("../../../../../../Common/Logger");
const mockGetAccountDetailsFromResourceId = getAccountDetailsFromResourceId as jest.MockedFunction<
typeof getAccountDetailsFromResourceId
>;
const mockLogError = logError as jest.MockedFunction<typeof logError>;
const mockDatabaseAccount: DatabaseAccount = {
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
name: "test-account",
location: "East US",
type: "Microsoft.DocumentDB/databaseAccounts",
kind: "GlobalDocumentDB",
properties: {
documentEndpoint: "https://test-account.documents.azure.com:443/",
},
} as DatabaseAccount;
interface TestComponentProps {
updateIdentityFn: (
subscriptionId: string,
resourceGroup?: string,
accountName?: string,
) => Promise<DatabaseAccount | undefined>;
onError?: (error: string) => void;
}
const TestComponent: React.FC<TestComponentProps> = ({ updateIdentityFn, onError }) => {
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateIdentityFn);
const { contextError } = useCopyJobContext();
React.useEffect(() => {
if (contextError && onError) {
onError(contextError);
}
}, [contextError, onError]);
const handleClick = async () => {
await handleAddSystemIdentity();
};
return (
<div>
<button onClick={handleClick} disabled={loading} data-testid="add-identity-button">
{loading ? "Loading..." : "Add System Identity"}
</button>
<div data-testid="loading-status">{loading ? "true" : "false"}</div>
{contextError && <div data-testid="error-message">{contextError}</div>}
</div>
);
};
const TestWrapper: React.FC<TestComponentProps> = (props) => {
const mockExplorer = new Explorer();
return (
<CopyJobContextProvider explorer={mockExplorer}>
<TestComponent {...props} />
</CopyJobContextProvider>
);
};
describe("useManagedIdentity", () => {
const mockUpdateIdentityFn = jest.fn();
const mockOnError = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockGetAccountDetailsFromResourceId.mockReturnValue({
subscriptionId: "test-subscription",
resourceGroup: "test-resource-group",
accountName: "test-account-name",
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should initialize with loading false", () => {
render(<TestWrapper updateIdentityFn={mockUpdateIdentityFn} onError={mockOnError} />);
expect(screen.getByTestId("loading-status")).toHaveTextContent("false");
expect(screen.getByTestId("add-identity-button")).toHaveTextContent("Add System Identity");
expect(screen.getByTestId("add-identity-button")).not.toBeDisabled();
});
it("should show loading state when handleAddSystemIdentity is called", async () => {
mockUpdateIdentityFn.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(mockDatabaseAccount), 100)),
);
render(<TestWrapper updateIdentityFn={mockUpdateIdentityFn} onError={mockOnError} />);
const button = screen.getByTestId("add-identity-button");
fireEvent.click(button);
expect(screen.getByTestId("loading-status")).toHaveTextContent("true");
expect(button).toHaveTextContent("Loading...");
expect(button).toBeDisabled();
});
it("should call updateIdentityFn with correct parameters", async () => {
mockUpdateIdentityFn.mockResolvedValue(mockDatabaseAccount);
render(<TestWrapper updateIdentityFn={mockUpdateIdentityFn} onError={mockOnError} />);
const button = screen.getByTestId("add-identity-button");
fireEvent.click(button);
await waitFor(() => {
expect(mockUpdateIdentityFn).toHaveBeenCalledWith(
"test-subscription",
"test-resource-group",
"test-account-name",
);
});
});
it("should handle successful identity update", async () => {
const updatedAccount = {
...mockDatabaseAccount,
properties: {
...mockDatabaseAccount.properties,
identity: { type: "SystemAssigned" },
},
};
mockUpdateIdentityFn.mockResolvedValue(updatedAccount);
render(<TestWrapper updateIdentityFn={mockUpdateIdentityFn} onError={mockOnError} />);
const button = screen.getByTestId("add-identity-button");
fireEvent.click(button);
await waitFor(() => {
expect(mockUpdateIdentityFn).toHaveBeenCalled();
});
expect(screen.queryByTestId("error-message")).toBeNull();
});
it("should handle error when updateIdentityFn fails", async () => {
const errorMessage = "Failed to update identity";
mockUpdateIdentityFn.mockRejectedValue(new Error(errorMessage));
render(<TestWrapper updateIdentityFn={mockUpdateIdentityFn} onError={mockOnError} />);
const button = screen.getByTestId("add-identity-button");
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByTestId("error-message")).toHaveTextContent(errorMessage);
});
expect(mockLogError).toHaveBeenCalledWith(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity");
expect(mockOnError).toHaveBeenCalledWith(errorMessage);
});
it("should handle error without message", async () => {
const errorWithoutMessage = {} as Error;
mockUpdateIdentityFn.mockRejectedValue(errorWithoutMessage);
render(<TestWrapper updateIdentityFn={mockUpdateIdentityFn} onError={mockOnError} />);
const button = screen.getByTestId("add-identity-button");
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByTestId("error-message")).toHaveTextContent(
"Error enabling system-assigned managed identity. Please try again later.",
);
});
expect(mockLogError).toHaveBeenCalledWith(
"Error enabling system-assigned managed identity. Please try again later.",
"CopyJob/useManagedIdentity.handleAddSystemIdentity",
);
});
it("should handle case when getAccountDetailsFromResourceId returns null", async () => {
mockGetAccountDetailsFromResourceId.mockReturnValue(null);
mockUpdateIdentityFn.mockResolvedValue(undefined);
render(<TestWrapper updateIdentityFn={mockUpdateIdentityFn} onError={mockOnError} />);
const button = screen.getByTestId("add-identity-button");
fireEvent.click(button);
await waitFor(() => {
expect(mockUpdateIdentityFn).toHaveBeenCalledWith(undefined, undefined, undefined);
});
});
it("should handle case when updateIdentityFn returns undefined", async () => {
mockUpdateIdentityFn.mockResolvedValue(undefined);
render(<TestWrapper updateIdentityFn={mockUpdateIdentityFn} onError={mockOnError} />);
const button = screen.getByTestId("add-identity-button");
fireEvent.click(button);
await waitFor(() => {
expect(mockUpdateIdentityFn).toHaveBeenCalled();
});
expect(screen.queryByTestId("error-message")).toBeNull();
});
it("should call getAccountDetailsFromResourceId with target account id", async () => {
mockUpdateIdentityFn.mockResolvedValue(mockDatabaseAccount);
render(<TestWrapper updateIdentityFn={mockUpdateIdentityFn} onError={mockOnError} />);
const button = screen.getByTestId("add-identity-button");
fireEvent.click(button);
await waitFor(() => {
expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalled();
});
const callArgs = mockGetAccountDetailsFromResourceId.mock.calls[0];
expect(callArgs).toBeDefined();
});
it("should reset loading state on error", async () => {
const errorMessage = "Network error";
mockUpdateIdentityFn.mockRejectedValue(new Error(errorMessage));
render(<TestWrapper updateIdentityFn={mockUpdateIdentityFn} onError={mockOnError} />);
const button = screen.getByTestId("add-identity-button");
fireEvent.click(button);
expect(screen.getByTestId("loading-status")).toHaveTextContent("true");
await waitFor(() => {
expect(screen.getByTestId("error-message")).toHaveTextContent(errorMessage);
});
expect(screen.getByTestId("loading-status")).toHaveTextContent("false");
expect(button).not.toBeDisabled();
expect(button).toHaveTextContent("Add System Identity");
});
});

View File

@@ -0,0 +1,54 @@
import { DatabaseAccount } from "Contracts/DataModels";
import { useCallback, useState } from "react";
import { logError } from "../../../../../../Common/Logger";
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
interface UseManagedIdentityUpdaterParams {
updateIdentityFn: (
subscriptionId: string,
resourceGroup?: string,
accountName?: string,
) => Promise<DatabaseAccount | undefined>;
}
interface UseManagedIdentityUpdaterReturn {
loading: boolean;
handleAddSystemIdentity: () => Promise<void>;
}
const useManagedIdentity = (
updateIdentityFn: UseManagedIdentityUpdaterParams["updateIdentityFn"],
): UseManagedIdentityUpdaterReturn => {
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
const [loading, setLoading] = useState<boolean>(false);
const handleAddSystemIdentity = useCallback(async (): Promise<void> => {
try {
setLoading(true);
const selectedTargetAccount = copyJobState?.target?.account;
const {
subscriptionId: targetSubscriptionId,
resourceGroup: targetResourceGroup,
accountName: targetAccountName,
} = getAccountDetailsFromResourceId(selectedTargetAccount?.id) || {};
const updatedAccount = await updateIdentityFn(targetSubscriptionId, targetResourceGroup, targetAccountName);
if (updatedAccount) {
setCopyJobState((prevState) => ({
...prevState,
target: { ...prevState.target, account: updatedAccount },
}));
}
} catch (error) {
const errorMessage = error.message || "Error enabling system-assigned managed identity. Please try again later.";
logError(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity");
setContextError(errorMessage);
setLoading(false);
}
}, [copyJobState?.target?.account?.id, updateIdentityFn, setCopyJobState]);
return { loading, handleAddSystemIdentity };
};
export default useManagedIdentity;

View File

@@ -0,0 +1,691 @@
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { noop } from "underscore";
import { CapabilityNames } from "../../../../../../Common/Constants";
import * as RbacUtils from "../../../../../../Utils/arm/RbacUtils";
import {
BackupPolicyType,
CopyJobMigrationType,
DefaultIdentityType,
IdentityType,
} from "../../../../Enums/CopyJobEnums";
import { CopyJobContextState } from "../../../../Types/CopyJobTypes";
import * as CopyJobPrerequisitesCacheModule from "../../../Utils/useCopyJobPrerequisitesCache";
import usePermissionSections, {
checkTargetHasReaderRoleOnSource,
PermissionGroupConfig,
SECTION_IDS,
} from "./usePermissionsSection";
jest.mock("../../../../../../Utils/arm/RbacUtils");
jest.mock("../../../Utils/useCopyJobPrerequisitesCache");
jest.mock("../../../../CopyJobUtils", () => ({
getAccountDetailsFromResourceId: jest.fn(() => ({
subscriptionId: "sub-123",
resourceGroup: "rg-test",
accountName: "account-test",
})),
getContainerIdentifiers: jest.fn((container: any) => ({
accountId: container?.account?.id || "default-account-id",
})),
isIntraAccountCopy: jest.fn((sourceId: string, targetId: string) => sourceId === targetId),
}));
jest.mock("../AddManagedIdentity", () => {
const MockAddManagedIdentity = () => {
return <div data-testid="add-managed-identity">AddManagedIdentity</div>;
};
MockAddManagedIdentity.displayName = "MockAddManagedIdentity";
return MockAddManagedIdentity;
});
jest.mock("../AddReadPermissionToDefaultIdentity", () => {
const MockAddReadPermissionToDefaultIdentity = () => {
return <div data-testid="add-read-permission">AddReadPermissionToDefaultIdentity</div>;
};
MockAddReadPermissionToDefaultIdentity.displayName = "MockAddReadPermissionToDefaultIdentity";
return MockAddReadPermissionToDefaultIdentity;
});
jest.mock("../DefaultManagedIdentity", () => {
const MockDefaultManagedIdentity = () => {
return <div data-testid="default-managed-identity">DefaultManagedIdentity</div>;
};
MockDefaultManagedIdentity.displayName = "MockDefaultManagedIdentity";
return MockDefaultManagedIdentity;
});
jest.mock("../OnlineCopyEnabled", () => {
const MockOnlineCopyEnabled = () => {
return <div data-testid="online-copy-enabled">OnlineCopyEnabled</div>;
};
MockOnlineCopyEnabled.displayName = "MockOnlineCopyEnabled";
return MockOnlineCopyEnabled;
});
jest.mock("../PointInTimeRestore", () => {
const MockPointInTimeRestore = () => {
return <div data-testid="point-in-time-restore">PointInTimeRestore</div>;
};
MockPointInTimeRestore.displayName = "MockPointInTimeRestore";
return MockPointInTimeRestore;
});
const mockedRbacUtils = RbacUtils as jest.Mocked<typeof RbacUtils>;
const mockedCopyJobPrerequisitesCache = CopyJobPrerequisitesCacheModule as jest.Mocked<
typeof CopyJobPrerequisitesCacheModule
>;
interface TestWrapperProps {
state: CopyJobContextState;
onResult?: (result: PermissionGroupConfig[]) => void;
}
const TestWrapper: React.FC<TestWrapperProps> = ({ state, onResult }) => {
const result = usePermissionSections(state);
React.useEffect(() => {
if (onResult) {
onResult(result);
}
}, [result, onResult]);
return (
<div data-testid="test-wrapper">
<div data-testid="groups-count">{result.length}</div>
{result.map((group) => (
<div key={group.id} data-testid={`group-${group.id}`}>
<h3>{group.title}</h3>
<p>{group.description}</p>
{group.sections.map((section) => (
<div key={section.id} data-testid={`section-${section.id}`}>
<span data-testid={`section-${section.id}-completed`}>
{section.completed?.toString() || "undefined"}
</span>
<span data-testid={`section-${section.id}-disabled`}>{section.disabled.toString()}</span>
</div>
))}
</div>
))}
</div>
);
};
describe("usePermissionsSection", () => {
let mockValidationCache: Map<string, boolean>;
let mockSetValidationCache: jest.Mock;
const createMockState = (overrides: Partial<CopyJobContextState> = {}): CopyJobContextState => ({
jobName: "test-job",
migrationType: CopyJobMigrationType.Offline,
source: {
account: {
id: "source-account-id",
name: "source-account",
properties: {
backupPolicy: {
type: BackupPolicyType.Periodic,
},
capabilities: [],
},
location: "",
type: "",
kind: "",
},
subscription: undefined,
databaseId: "",
containerId: "",
},
target: {
account: {
id: "target-account-id",
name: "target-account",
identity: {
type: IdentityType.None,
principalId: "principal-123",
},
properties: {
defaultIdentity: DefaultIdentityType.FirstPartyIdentity,
},
location: "",
type: "",
kind: "",
},
subscriptionId: "",
databaseId: "",
containerId: "",
},
...overrides,
});
beforeEach(() => {
mockValidationCache = new Map();
mockSetValidationCache = jest.fn();
mockedCopyJobPrerequisitesCache.useCopyJobPrerequisitesCache.mockReturnValue({
validationCache: mockValidationCache,
setValidationCache: mockSetValidationCache,
});
mockedRbacUtils.fetchRoleAssignments.mockResolvedValue([]);
mockedRbacUtils.fetchRoleDefinitions.mockResolvedValue([]);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("Cross-account copy scenarios", () => {
it("should return cross-account configuration for different accounts", async () => {
const state = createMockState();
let capturedResult: PermissionGroupConfig[] = [];
render(<TestWrapper state={state} onResult={(result) => (capturedResult = result)} />);
await waitFor(() => {
expect(screen.getByTestId("groups-count")).toHaveTextContent("1");
});
expect(capturedResult).toHaveLength(1);
expect(capturedResult[0].id).toBe("crossAccountConfigs");
expect(capturedResult[0].sections).toHaveLength(3);
expect(capturedResult[0].sections.map((s) => s.id)).toEqual([
SECTION_IDS.addManagedIdentity,
SECTION_IDS.defaultManagedIdentity,
SECTION_IDS.readPermissionAssigned,
]);
});
it("should not return cross-account configuration for same account (intra-account copy)", async () => {
const state = createMockState({
source: {
account: {
id: "same-account-id",
name: "same-account",
properties: undefined,
location: "",
type: "",
kind: "",
},
subscription: undefined,
databaseId: "",
containerId: "",
},
target: {
account: {
id: "same-account-id",
name: "same-account",
identity: { type: IdentityType.None, principalId: "principal-123" },
properties: { defaultIdentity: DefaultIdentityType.FirstPartyIdentity },
location: "",
type: "",
kind: "",
},
subscriptionId: "",
databaseId: "",
containerId: "",
},
});
let capturedResult: PermissionGroupConfig[] = [];
render(<TestWrapper state={state} onResult={(result) => (capturedResult = result)} />);
await waitFor(() => {
expect(screen.getByTestId("groups-count")).toHaveTextContent("0");
});
expect(capturedResult).toHaveLength(0);
});
});
describe("Online copy scenarios", () => {
it("should return online configuration for online migration", async () => {
const state = createMockState({
migrationType: CopyJobMigrationType.Online,
});
let capturedResult: PermissionGroupConfig[] = [];
render(<TestWrapper state={state} onResult={(result) => (capturedResult = result)} />);
await waitFor(() => {
expect(screen.getByTestId("groups-count")).toHaveTextContent("2");
});
const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs");
expect(onlineGroup).toBeDefined();
expect(onlineGroup?.sections).toHaveLength(2);
expect(onlineGroup?.sections.map((s) => s.id)).toEqual([
SECTION_IDS.pointInTimeRestore,
SECTION_IDS.onlineCopyEnabled,
]);
});
it("should not return online configuration for offline migration", async () => {
const state = createMockState({
migrationType: CopyJobMigrationType.Offline,
});
let capturedResult: PermissionGroupConfig[] = [];
render(<TestWrapper state={state} onResult={(result) => (capturedResult = result)} />);
await waitFor(() => {
expect(screen.getByTestId("groups-count")).toHaveTextContent("1");
});
const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs");
expect(onlineGroup).toBeUndefined();
});
});
describe("Section validation", () => {
it("should validate addManagedIdentity section correctly", async () => {
const stateWithSystemAssigned = createMockState({
target: {
account: {
id: "target-account-id",
name: "target-account",
identity: {
type: IdentityType.SystemAssigned,
principalId: "principal-123",
},
properties: {
defaultIdentity: DefaultIdentityType.FirstPartyIdentity,
},
location: "",
type: "",
kind: "",
},
subscriptionId: "",
databaseId: "",
containerId: "",
},
});
let capturedResult: PermissionGroupConfig[] = [];
render(<TestWrapper state={stateWithSystemAssigned} onResult={(result) => (capturedResult = result)} />);
await waitFor(() => {
expect(screen.getByTestId(`section-${SECTION_IDS.addManagedIdentity}-completed`)).toHaveTextContent("true");
});
const crossAccountGroup = capturedResult.find((g) => g.id === "crossAccountConfigs");
const addManagedIdentitySection = crossAccountGroup?.sections.find(
(s) => s.id === SECTION_IDS.addManagedIdentity,
);
expect(addManagedIdentitySection?.completed).toBe(true);
});
it("should validate defaultManagedIdentity section correctly", async () => {
const stateWithSystemAssignedIdentity = createMockState({
target: {
account: {
id: "target-account-id",
name: "target-account",
identity: {
type: IdentityType.SystemAssigned,
principalId: "principal-123",
},
properties: {
defaultIdentity: DefaultIdentityType.SystemAssignedIdentity,
},
location: "",
type: "",
kind: "",
},
subscriptionId: "",
databaseId: "",
containerId: "",
},
});
let capturedResult: PermissionGroupConfig[] = [];
render(<TestWrapper state={stateWithSystemAssignedIdentity} onResult={(result) => (capturedResult = result)} />);
await waitFor(() => {
expect(screen.getByTestId(`section-${SECTION_IDS.defaultManagedIdentity}-completed`)).toHaveTextContent("true");
});
const crossAccountGroup = capturedResult.find((g) => g.id === "crossAccountConfigs");
const defaultManagedIdentitySection = crossAccountGroup?.sections.find(
(s) => s.id === SECTION_IDS.defaultManagedIdentity,
);
expect(defaultManagedIdentitySection?.completed).toBe(true);
});
it("should validate readPermissionAssigned section with reader role", async () => {
const mockRoleDefinitions: RbacUtils.RoleDefinitionType[] = [
{
id: "role-1",
name: "Custom Role",
permissions: [
{
dataActions: [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
],
},
],
assignableScopes: [],
resourceGroup: "",
roleName: "",
type: "",
typePropertiesType: "",
},
];
mockedRbacUtils.fetchRoleAssignments.mockResolvedValue([{ roleDefinitionId: "role-def-1" }] as any);
mockedRbacUtils.fetchRoleDefinitions.mockResolvedValue(mockRoleDefinitions);
const state = createMockState({
target: {
account: {
id: "target-account-id",
name: "target-account",
identity: {
type: IdentityType.SystemAssigned,
principalId: "principal-123",
},
properties: {
defaultIdentity: DefaultIdentityType.SystemAssignedIdentity,
},
location: "",
type: "",
kind: "",
},
subscriptionId: "",
databaseId: "",
containerId: "",
},
});
render(<TestWrapper state={state} onResult={noop} />);
await waitFor(() => {
expect(screen.getByTestId(`section-${SECTION_IDS.readPermissionAssigned}-completed`)).toHaveTextContent("true");
});
expect(mockedRbacUtils.fetchRoleAssignments).toHaveBeenCalledWith(
"sub-123",
"rg-test",
"account-test",
"principal-123",
);
});
it("should validate pointInTimeRestore section for continuous backup", async () => {
const state = createMockState({
migrationType: CopyJobMigrationType.Online,
source: {
account: {
id: "source-account-id",
name: "source-account",
properties: {
backupPolicy: {
type: BackupPolicyType.Continuous,
},
capabilities: [],
},
location: "",
type: "",
kind: "",
},
subscription: undefined,
databaseId: "",
containerId: "",
},
});
let capturedResult: PermissionGroupConfig[] = [];
render(<TestWrapper state={state} onResult={(result) => (capturedResult = result)} />);
await waitFor(() => {
expect(screen.getByTestId(`section-${SECTION_IDS.pointInTimeRestore}-completed`)).toHaveTextContent("true");
});
const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs");
const pointInTimeSection = onlineGroup?.sections.find((s) => s.id === SECTION_IDS.pointInTimeRestore);
expect(pointInTimeSection?.completed).toBe(true);
});
it("should validate onlineCopyEnabled section with proper capability", async () => {
const state = createMockState({
migrationType: CopyJobMigrationType.Online,
source: {
account: {
id: "source-account-id",
name: "source-account",
properties: {
backupPolicy: {
type: BackupPolicyType.Continuous,
},
capabilities: [
{
name: CapabilityNames.EnableOnlineCopyFeature,
description: "",
},
],
},
location: "",
type: "",
kind: "",
},
subscription: undefined,
databaseId: "",
containerId: "",
},
});
let capturedResult: PermissionGroupConfig[] = [];
render(<TestWrapper state={state} onResult={(result) => (capturedResult = result)} />);
await waitFor(() => {
expect(screen.getByTestId(`section-${SECTION_IDS.onlineCopyEnabled}-completed`)).toHaveTextContent("true");
});
const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs");
const onlineCopySection = onlineGroup?.sections.find((s) => s.id === SECTION_IDS.onlineCopyEnabled);
expect(onlineCopySection?.completed).toBe(true);
});
});
describe("Validation caching", () => {
it("should use cached validation results", async () => {
mockValidationCache.set(SECTION_IDS.addManagedIdentity, true);
mockValidationCache.set(SECTION_IDS.defaultManagedIdentity, true);
const state = createMockState();
render(<TestWrapper state={state} onResult={noop} />);
await waitFor(() => {
expect(screen.getByTestId(`section-${SECTION_IDS.addManagedIdentity}-completed`)).toHaveTextContent("true");
});
expect(screen.getByTestId(`section-${SECTION_IDS.defaultManagedIdentity}-completed`)).toHaveTextContent("true");
});
it("should clear online job validation cache when migration type changes to offline", async () => {
mockValidationCache.set(SECTION_IDS.pointInTimeRestore, true);
mockValidationCache.set(SECTION_IDS.onlineCopyEnabled, true);
const state = createMockState({
migrationType: CopyJobMigrationType.Offline,
});
render(<TestWrapper state={state} />);
await waitFor(() => {
expect(screen.getByTestId("groups-count")).toHaveTextContent("1");
});
expect(mockSetValidationCache).toHaveBeenCalled();
});
});
describe("Sequential validation within groups", () => {
it("should stop validation at first failure within a group", async () => {
const state = createMockState({
target: {
account: {
id: "target-account-id",
name: "target-account",
identity: {
type: IdentityType.None,
principalId: "principal-123",
},
properties: {
defaultIdentity: DefaultIdentityType.FirstPartyIdentity,
},
location: "",
type: "",
kind: "",
},
subscriptionId: "",
databaseId: "",
containerId: "",
},
});
let capturedResult: PermissionGroupConfig[] = [];
render(<TestWrapper state={state} onResult={(result) => (capturedResult = result)} />);
await waitFor(() => {
expect(screen.getByTestId(`section-${SECTION_IDS.addManagedIdentity}-completed`)).toHaveTextContent("false");
});
const crossAccountGroup = capturedResult.find((g) => g.id === "crossAccountConfigs");
expect(crossAccountGroup?.sections[0].completed).toBe(false);
expect(crossAccountGroup?.sections[1].completed).toBe(false);
expect(crossAccountGroup?.sections[2].completed).toBe(false);
});
});
});
describe("checkTargetHasReaderRoleOnSource", () => {
it("should return true for built-in Reader role", () => {
const roleDefinitions: RbacUtils.RoleDefinitionType[] = [
{
id: "role-1",
name: "00000000-0000-0000-0000-000000000001",
permissions: [],
assignableScopes: [],
resourceGroup: "",
roleName: "",
type: "",
typePropertiesType: "",
},
];
const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
expect(result).toBe(true);
});
it("should return true for custom role with required data actions", () => {
const roleDefinitions: RbacUtils.RoleDefinitionType[] = [
{
id: "role-1",
name: "Custom Reader Role",
permissions: [
{
dataActions: [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
],
},
],
assignableScopes: [],
resourceGroup: "",
roleName: "",
type: "",
typePropertiesType: "",
},
];
const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
expect(result).toBe(true);
});
it("should return false for role without required permissions", () => {
const roleDefinitions: RbacUtils.RoleDefinitionType[] = [
{
id: "role-1",
name: "Insufficient Role",
permissions: [
{
dataActions: ["Microsoft.DocumentDB/databaseAccounts/readMetadata"],
},
],
assignableScopes: [],
resourceGroup: "",
roleName: "",
type: "",
typePropertiesType: "",
},
];
const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
expect(result).toBe(false);
});
it("should return false for empty role definitions", () => {
const result = checkTargetHasReaderRoleOnSource([]);
expect(result).toBe(false);
});
it("should return false for role definitions without permissions", () => {
const roleDefinitions: RbacUtils.RoleDefinitionType[] = [
{
id: "role-1",
name: "No Permissions Role",
permissions: [],
assignableScopes: [],
resourceGroup: "",
roleName: "",
type: "",
typePropertiesType: "",
},
];
const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
expect(result).toBe(false);
});
it("should handle multiple roles and return true if any has sufficient permissions", () => {
const roleDefinitions: RbacUtils.RoleDefinitionType[] = [
{
id: "role-1",
name: "Insufficient Role",
permissions: [
{
dataActions: ["Microsoft.DocumentDB/databaseAccounts/readMetadata"],
},
],
assignableScopes: [],
resourceGroup: "",
roleName: "",
type: "",
typePropertiesType: "",
},
{
id: "role-2",
name: "00000000-0000-0000-0000-000000000001",
permissions: [],
assignableScopes: [],
resourceGroup: "",
roleName: "",
type: "",
typePropertiesType: "",
},
];
const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
expect(result).toBe(true);
});
});

View File

@@ -0,0 +1,262 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CapabilityNames } from "../../../../../../Common/Constants";
import { fetchRoleAssignments, fetchRoleDefinitions, RoleDefinitionType } from "../../../../../../Utils/arm/RbacUtils";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { getAccountDetailsFromResourceId, getContainerIdentifiers, isIntraAccountCopy } from "../../../../CopyJobUtils";
import {
BackupPolicyType,
CopyJobMigrationType,
DefaultIdentityType,
IdentityType,
} from "../../../../Enums/CopyJobEnums";
import { CopyJobContextState } from "../../../../Types/CopyJobTypes";
import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache";
import AddManagedIdentity from "../AddManagedIdentity";
import AddReadPermissionToDefaultIdentity from "../AddReadPermissionToDefaultIdentity";
import DefaultManagedIdentity from "../DefaultManagedIdentity";
import OnlineCopyEnabled from "../OnlineCopyEnabled";
import PointInTimeRestore from "../PointInTimeRestore";
export interface PermissionSectionConfig {
id: string;
title: string;
Component: React.ComponentType;
disabled: boolean;
completed?: boolean;
validate?: (state: CopyJobContextState) => boolean | Promise<boolean>;
}
export interface PermissionGroupConfig {
id: string;
title: string;
description: string;
sections: PermissionSectionConfig[];
}
export const SECTION_IDS = {
addManagedIdentity: "addManagedIdentity",
defaultManagedIdentity: "defaultManagedIdentity",
readPermissionAssigned: "readPermissionAssigned",
pointInTimeRestore: "pointInTimeRestore",
onlineCopyEnabled: "onlineCopyEnabled",
} as const;
const PERMISSION_SECTIONS_CONFIG: PermissionSectionConfig[] = [
{
id: SECTION_IDS.addManagedIdentity,
title: ContainerCopyMessages.addManagedIdentity.title,
Component: AddManagedIdentity,
disabled: true,
validate: (state: CopyJobContextState) => {
const targetAccountIdentityType = (state?.target?.account?.identity?.type ?? "").toLowerCase();
return (
targetAccountIdentityType === IdentityType.SystemAssigned ||
targetAccountIdentityType === IdentityType.UserAssigned
);
},
},
{
id: SECTION_IDS.defaultManagedIdentity,
title: ContainerCopyMessages.defaultManagedIdentity.title,
Component: DefaultManagedIdentity,
disabled: true,
validate: (state: CopyJobContextState) => {
const targetAccountDefaultIdentity = (state?.target?.account?.properties?.defaultIdentity ?? "").toLowerCase();
return targetAccountDefaultIdentity === DefaultIdentityType.SystemAssignedIdentity;
},
},
{
id: SECTION_IDS.readPermissionAssigned,
title: ContainerCopyMessages.readPermissionAssigned.title,
Component: AddReadPermissionToDefaultIdentity,
disabled: true,
validate: async (state: CopyJobContextState) => {
const principalId = state?.target?.account?.identity?.principalId;
const selectedSourceAccount = state?.source?.account;
const {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
const rolesAssigned = await fetchRoleAssignments(
sourceSubscriptionId,
sourceResourceGroup,
sourceAccountName,
principalId,
);
const roleDefinitions = await fetchRoleDefinitions(rolesAssigned ?? []);
return checkTargetHasReaderRoleOnSource(roleDefinitions ?? []);
},
},
];
const PERMISSION_SECTIONS_FOR_ONLINE_JOBS: PermissionSectionConfig[] = [
{
id: SECTION_IDS.pointInTimeRestore,
title: ContainerCopyMessages.pointInTimeRestore.title,
Component: PointInTimeRestore,
disabled: true,
validate: (state: CopyJobContextState) => {
const sourceAccountBackupPolicy = state?.source?.account?.properties?.backupPolicy?.type ?? "";
return sourceAccountBackupPolicy === BackupPolicyType.Continuous;
},
},
{
id: SECTION_IDS.onlineCopyEnabled,
title: ContainerCopyMessages.onlineCopyEnabled.title,
Component: OnlineCopyEnabled,
disabled: true,
validate: (state: CopyJobContextState) => {
const accountCapabilities = state?.source?.account?.properties?.capabilities ?? [];
const onlineCopyCapability = accountCapabilities.find(
(capability) => capability.name === CapabilityNames.EnableOnlineCopyFeature,
);
return !!onlineCopyCapability;
},
},
];
/**
* Checks if the user has the Reader role based on role definitions.
*/
export function checkTargetHasReaderRoleOnSource(roleDefinitions: RoleDefinitionType[]): boolean {
return roleDefinitions?.some(
(role) =>
role.name === "00000000-0000-0000-0000-000000000001" ||
role.permissions.some(
(permission) =>
permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/readMetadata") &&
permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read"),
),
);
}
/**
* Validates sections within a group sequentially.
*/
const validateSectionsInGroup = async (
sections: PermissionSectionConfig[],
state: CopyJobContextState,
validationCache: Map<string, boolean>,
): Promise<PermissionSectionConfig[]> => {
const result: PermissionSectionConfig[] = [];
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
if (validationCache.has(section.id) && validationCache.get(section.id) === true) {
result.push({ ...section, completed: true });
continue;
}
if (section.validate) {
const isValid = await section.validate(state);
validationCache.set(section.id, isValid);
result.push({ ...section, completed: isValid });
if (!isValid) {
// Mark remaining sections in this group as incomplete
for (let j = i + 1; j < sections.length; j++) {
result.push({ ...sections[j], completed: false });
}
break;
}
} else {
validationCache.set(section.id, false);
result.push({ ...section, completed: false });
}
}
return result;
};
/**
* Returns the permission groups configuration for the Assign Permissions screen.
* Groups validate independently but sections within each group validate sequentially.
*/
const usePermissionSections = (state: CopyJobContextState): PermissionGroupConfig[] => {
const sourceAccount = getContainerIdentifiers(state.source);
const targetAccount = getContainerIdentifiers(state.target);
const { validationCache, setValidationCache } = useCopyJobPrerequisitesCache();
const [permissionGroups, setPermissionGroups] = useState<PermissionGroupConfig[] | null>(null);
const isValidatingRef = useRef(false);
const groupsToValidate = useMemo(() => {
const isSameAccount = isIntraAccountCopy(sourceAccount.accountId, targetAccount.accountId);
const crossAccountSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG];
const groups: PermissionGroupConfig[] = [];
const sourceAccountName = state.source?.account?.name || "";
const targetAccountName = state.target?.account?.name || "";
if (crossAccountSections.length > 0) {
groups.push({
id: "crossAccountConfigs",
title: ContainerCopyMessages.assignPermissions.crossAccountConfiguration.title,
description: ContainerCopyMessages.assignPermissions.crossAccountConfiguration.description(
sourceAccountName,
targetAccountName,
),
sections: crossAccountSections,
});
}
if (state.migrationType === CopyJobMigrationType.Online) {
groups.push({
id: "onlineConfigs",
title: ContainerCopyMessages.assignPermissions.onlineConfiguration.title,
description: ContainerCopyMessages.assignPermissions.onlineConfiguration.description(sourceAccountName),
sections: [...PERMISSION_SECTIONS_FOR_ONLINE_JOBS],
});
}
return groups;
}, [sourceAccount.accountId, targetAccount.accountId, state.migrationType]);
const memoizedValidationCache = useMemo(() => {
if (state.migrationType === CopyJobMigrationType.Offline) {
validationCache.delete(SECTION_IDS.pointInTimeRestore);
validationCache.delete(SECTION_IDS.onlineCopyEnabled);
}
return validationCache;
}, [state.migrationType]);
useEffect(() => {
const validateGroups = async () => {
if (isValidatingRef.current) {
return;
}
isValidatingRef.current = true;
const newValidationCache = new Map(memoizedValidationCache);
// Validate all groups independently (in parallel)
const validatedGroups = await Promise.all(
groupsToValidate.map(async (group) => {
const validatedSections = await validateSectionsInGroup(group.sections, state, newValidationCache);
return {
...group,
sections: validatedSections,
};
}),
);
setValidationCache(newValidationCache);
setPermissionGroups(validatedGroups);
isValidatingRef.current = false;
};
validateGroups();
return () => {
isValidatingRef.current = false;
};
}, [state, groupsToValidate]);
return permissionGroups ?? [];
};
export default usePermissionSections;

View File

@@ -0,0 +1,78 @@
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import useToggle from "./useToggle";
const TestToggleComponent: React.FC<{ initialState?: boolean }> = ({ initialState }) => {
const [state, onToggle] = useToggle(initialState);
return (
<div>
<span data-testid="toggle-state">{state ? "true" : "false"}</span>
<button data-testid="toggle-button" onClick={() => onToggle(null, !state)}>
Toggle
</button>
<button data-testid="set-true-button" onClick={() => onToggle(null, true)}>
Set True
</button>
<button data-testid="set-false-button" onClick={() => onToggle(null, false)}>
Set False
</button>
</div>
);
};
describe("useToggle hook", () => {
it("should initialize with false as default", () => {
render(<TestToggleComponent />);
const stateElement = screen.getByTestId("toggle-state");
expect(stateElement.textContent).toBe("false");
});
it("should initialize with provided initial state", () => {
render(<TestToggleComponent initialState={true} />);
const stateElement = screen.getByTestId("toggle-state");
expect(stateElement.textContent).toBe("true");
});
it("should toggle state when onToggle is called with opposite value", () => {
render(<TestToggleComponent />);
const stateElement = screen.getByTestId("toggle-state");
const toggleButton = screen.getByTestId("toggle-button");
expect(stateElement.textContent).toBe("false");
fireEvent.click(toggleButton);
expect(stateElement.textContent).toBe("true");
fireEvent.click(toggleButton);
expect(stateElement.textContent).toBe("false");
});
it("should handle undefined checked parameter gracefully", () => {
const TestUndefinedComponent: React.FC = () => {
const [state, onToggle] = useToggle(false);
return (
<div>
<span data-testid="toggle-state">{state ? "true" : "false"}</span>
<button data-testid="undefined-button" onClick={() => onToggle(null, undefined)}>
Set Undefined
</button>
</div>
);
};
render(<TestUndefinedComponent />);
const stateElement = screen.getByTestId("toggle-state");
const undefinedButton = screen.getByTestId("undefined-button");
expect(stateElement.textContent).toBe("false");
fireEvent.click(undefinedButton);
expect(stateElement.textContent).toBe("false");
});
});

View File

@@ -0,0 +1,11 @@
import { useCallback, useState } from "react";
const useToggle = (initialState = false) => {
const [state, setState] = useState<boolean>(initialState);
const onToggle = useCallback((_, checked?: boolean) => {
setState(checked);
}, []);
return [state, onToggle] as const;
};
export default useToggle;

View File

@@ -0,0 +1,251 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import React from "react";
import FieldRow from "./FieldRow";
describe("FieldRow", () => {
const mockChildContent = "Test Child Content";
const testLabel = "Test Label";
const customClassName = "custom-label-class";
describe("Component Rendering", () => {
it("renders the component with correct structure", () => {
const { container } = render(
<FieldRow label={testLabel}>
<div>{mockChildContent}</div>
</FieldRow>,
);
expect(container.firstChild).toHaveClass("flex-row");
expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument();
expect(screen.getByText(mockChildContent)).toBeInTheDocument();
});
it("renders children content correctly", () => {
render(
<FieldRow label={testLabel}>
<input type="text" data-testid="test-input" />
<button data-testid="test-button">Click me</button>
</FieldRow>,
);
expect(screen.getByTestId("test-input")).toBeInTheDocument();
expect(screen.getByTestId("test-button")).toBeInTheDocument();
});
it("renders complex children components correctly", () => {
const ComplexChild = () => (
<div>
<span>Nested content</span>
<input type="text" placeholder="Enter value" />
</div>
);
render(
<FieldRow label={testLabel}>
<ComplexChild />
</FieldRow>,
);
expect(screen.getByText("Nested content")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Enter value")).toBeInTheDocument();
});
it("does not render label when not provided", () => {
const { container } = render(
<FieldRow>
<div>{mockChildContent}</div>
</FieldRow>,
);
expect(container.querySelector("label")).not.toBeInTheDocument();
expect(screen.getByText(mockChildContent)).toBeInTheDocument();
});
it("applies custom label className when provided", () => {
render(
<FieldRow label={testLabel} labelClassName={customClassName}>
<div>{mockChildContent}</div>
</FieldRow>,
);
const label = screen.getByText(`${testLabel}:`);
expect(label).toHaveClass("field-label", customClassName);
});
});
describe("CSS Classes and Styling", () => {
it("applies default CSS classes correctly", () => {
const { container } = render(
<FieldRow label={testLabel}>
<div>{mockChildContent}</div>
</FieldRow>,
);
const mainContainer = container.firstChild as Element;
expect(mainContainer).toHaveClass("flex-row");
const labelContainer = container.querySelector(".flex-fixed-width");
expect(labelContainer).toBeInTheDocument();
const childContainer = container.querySelector(".flex-grow-col");
expect(childContainer).toBeInTheDocument();
const label = screen.getByText(`${testLabel}:`);
expect(label).toHaveClass("field-label");
});
});
describe("Layout and Structure", () => {
it("uses horizontal Stack with space-between alignment", () => {
const { container } = render(
<FieldRow label={testLabel}>
<div>{mockChildContent}</div>
</FieldRow>,
);
const mainContainer = container.firstChild as Element;
expect(mainContainer).toHaveClass("flex-row");
});
it("positions label in fixed-width container with center alignment", () => {
const { container } = render(
<FieldRow label={testLabel}>
<div>{mockChildContent}</div>
</FieldRow>,
);
const labelContainer = container.querySelector(".flex-fixed-width");
expect(labelContainer).toBeInTheDocument();
expect(labelContainer).toContainElement(screen.getByText(`${testLabel}:`));
});
it("positions children in grow container with center alignment", () => {
const { container } = render(
<FieldRow label={testLabel}>
<div data-testid="child-content">{mockChildContent}</div>
</FieldRow>,
);
const childContainer = container.querySelector(".flex-grow-col");
expect(childContainer).toBeInTheDocument();
expect(childContainer).toContainElement(screen.getByTestId("child-content"));
});
it("maintains layout when no label is provided", () => {
const { container } = render(
<FieldRow>
<div data-testid="child-content">{mockChildContent}</div>
</FieldRow>,
);
expect(container.firstChild).toHaveClass("flex-row");
expect(container.querySelector(".flex-fixed-width")).not.toBeInTheDocument();
const childContainer = container.querySelector(".flex-grow-col");
expect(childContainer).toBeInTheDocument();
expect(childContainer).toContainElement(screen.getByTestId("child-content"));
});
});
describe("Edge Cases and Error Handling", () => {
it("handles null children gracefully", () => {
render(<FieldRow label={testLabel}>{null}</FieldRow>);
expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument();
});
it("handles zero as children", () => {
render(<FieldRow label={testLabel}>{0}</FieldRow>);
expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument();
expect(screen.getByText("0")).toBeInTheDocument();
});
it("handles empty string as children", () => {
render(<FieldRow label={testLabel}>{""}</FieldRow>);
expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument();
});
it("handles array of children", () => {
render(<FieldRow label={testLabel}>{[<span key="1">First</span>, <span key="2">Second</span>]}</FieldRow>);
expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument();
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.getByText("Second")).toBeInTheDocument();
});
});
describe("Snapshot Testing", () => {
it("matches snapshot with minimal props", () => {
const { container } = render(
<FieldRow>
<input type="text" placeholder="Simple input" />
</FieldRow>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with label only", () => {
const { container } = render(
<FieldRow label="Database Name">
<input type="text" placeholder="Enter database name" />
</FieldRow>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with custom className", () => {
const { container } = render(
<FieldRow label="Container Name" labelClassName="custom-style">
<select>
<option>Option 1</option>
<option>Option 2</option>
</select>
</FieldRow>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with complex children", () => {
const { container } = render(
<FieldRow label="Advanced Settings" labelClassName="advanced-label">
<div>
<input type="checkbox" id="enable-feature" />
<label htmlFor="enable-feature">Enable advanced feature</label>
<button type="button">Configure</button>
</div>
</FieldRow>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with no label", () => {
const { container } = render(
<FieldRow>
<div>
<h4>Section Title</h4>
<p>Section description goes here</p>
</div>
</FieldRow>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with empty label", () => {
const { container } = render(
<FieldRow label="">
<button type="submit">Submit Form</button>
</FieldRow>,
);
expect(container.firstChild).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,25 @@
import { Stack } from "@fluentui/react";
import React from "react";
interface FieldRowProps {
label?: string;
children: React.ReactNode;
labelClassName?: string;
}
const FieldRow: React.FC<FieldRowProps> = ({ label = "", children, labelClassName = "" }) => {
return (
<Stack horizontal horizontalAlign="space-between" className="flex-row">
{label && (
<Stack.Item align="center" className="flex-fixed-width">
<label className={`field-label ${labelClassName}`}>{label}: </label>
</Stack.Item>
)}
<Stack.Item align="center" className="flex-grow-col">
{children}
</Stack.Item>
</Stack>
);
};
export default FieldRow;

View File

@@ -0,0 +1,39 @@
import "@testing-library/jest-dom";
import { render } from "@testing-library/react";
import React from "react";
import InfoTooltip from "./InfoTooltip";
describe("InfoTooltip", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("Component Rendering", () => {
it("should render null when no content is provided", () => {
const { container } = render(<InfoTooltip />);
expect(container.firstChild).toBeNull();
});
it("should render null when content is undefined", () => {
const { container } = render(<InfoTooltip content={undefined} />);
expect(container.firstChild).toBeNull();
});
it("should render tooltip with image when content is provided", () => {
const { container } = render(<InfoTooltip content="Test tooltip content" />);
expect(container).toMatchSnapshot();
});
it("should render with JSX element content", () => {
const jsxContent = (
<div>
<strong>Important:</strong> This is a JSX tooltip
</div>
);
const { container } = render(<InfoTooltip content={jsxContent} />);
expect(container).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,17 @@
import { Image, ITooltipHostStyles, TooltipHost } from "@fluentui/react";
import React from "react";
import InfoIcon from "../../../../../../images/Info.svg";
const InfoTooltip: React.FC<{ content?: string | JSX.Element }> = ({ content }) => {
if (!content) {
return null;
}
const hostStyles: Partial<ITooltipHostStyles> = { root: { display: "inline-block" } };
return (
<TooltipHost content={content} calloutProps={{ gapSpace: 0 }} styles={hostStyles}>
<Image src={InfoIcon} alt="Information" width={14} height={14} />
</TooltipHost>
);
};
export default React.memo(InfoTooltip);

View File

@@ -0,0 +1,112 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import NavigationControls from "./NavigationControls";
describe("NavigationControls", () => {
const defaultProps = {
primaryBtnText: "Next",
onPrimary: jest.fn(),
onPrevious: jest.fn(),
onCancel: jest.fn(),
isPrimaryDisabled: false,
isPreviousDisabled: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
it("renders all buttons with correct text", () => {
render(<NavigationControls {...defaultProps} />);
expect(screen.getByText("Next")).toBeInTheDocument();
expect(screen.getByText("Previous")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
});
it("renders primary button with custom text", () => {
const customProps = {
...defaultProps,
primaryBtnText: "Complete",
};
render(<NavigationControls {...customProps} />);
expect(screen.getByText("Complete")).toBeInTheDocument();
expect(screen.queryByText("Next")).not.toBeInTheDocument();
});
it("calls onPrimary when primary button is clicked", () => {
render(<NavigationControls {...defaultProps} />);
fireEvent.click(screen.getByText("Next"));
expect(defaultProps.onPrimary).toHaveBeenCalledTimes(1);
});
it("calls onPrevious when previous button is clicked", () => {
render(<NavigationControls {...defaultProps} />);
fireEvent.click(screen.getByText("Previous"));
expect(defaultProps.onPrevious).toHaveBeenCalledTimes(1);
});
it("calls onCancel when cancel button is clicked", () => {
render(<NavigationControls {...defaultProps} />);
fireEvent.click(screen.getByText("Cancel"));
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
});
it("disables primary button when isPrimaryDisabled is true", () => {
const disabledProps = {
...defaultProps,
isPrimaryDisabled: true,
};
render(<NavigationControls {...disabledProps} />);
const primaryButton = screen.getByText("Next").closest("button");
expect(primaryButton).toHaveAttribute("aria-disabled", "true");
expect(primaryButton).toHaveAttribute("data-is-focusable", "true");
});
it("disables previous button when isPreviousDisabled is true", () => {
const disabledProps = {
...defaultProps,
isPreviousDisabled: true,
};
render(<NavigationControls {...disabledProps} />);
const previousButton = screen.getByText("Previous").closest("button");
expect(previousButton).toHaveAttribute("aria-disabled", "true");
expect(previousButton).toHaveAttribute("data-is-focusable", "true");
});
it("does not call onPrimary when disabled primary button is clicked", () => {
const disabledProps = {
...defaultProps,
isPrimaryDisabled: true,
};
render(<NavigationControls {...disabledProps} />);
fireEvent.click(screen.getByText("Next"));
expect(defaultProps.onPrimary).not.toHaveBeenCalled();
});
it("does not call onPrevious when disabled previous button is clicked", () => {
const disabledProps = {
...defaultProps,
isPreviousDisabled: true,
};
render(<NavigationControls {...disabledProps} />);
fireEvent.click(screen.getByText("Previous"));
expect(defaultProps.onPrevious).not.toHaveBeenCalled();
});
it("enables both buttons when neither is disabled", () => {
render(<NavigationControls {...defaultProps} />);
expect(screen.getByText("Next").closest("button")).not.toHaveAttribute("aria-disabled");
expect(screen.getByText("Previous").closest("button")).not.toHaveAttribute("aria-disabled");
expect(screen.getByText("Cancel").closest("button")).not.toHaveAttribute("aria-disabled");
});
});

View File

@@ -0,0 +1,28 @@
import { DefaultButton, PrimaryButton, Stack } from "@fluentui/react";
import React from "react";
type NavigationControlsProps = {
primaryBtnText: string;
onPrimary: () => void;
onPrevious: () => void;
onCancel: () => void;
isPrimaryDisabled: boolean;
isPreviousDisabled: boolean;
};
const NavigationControls: React.FC<NavigationControlsProps> = ({
primaryBtnText,
onPrimary,
onPrevious,
onCancel,
isPrimaryDisabled,
isPreviousDisabled,
}) => (
<Stack horizontal tokens={{ childrenGap: 20 }}>
<PrimaryButton text={primaryBtnText} onClick={onPrimary} allowDisabledFocus disabled={isPrimaryDisabled} />
<DefaultButton text="Previous" onClick={onPrevious} allowDisabledFocus disabled={isPreviousDisabled} />
<DefaultButton text="Cancel" onClick={onCancel} />
</Stack>
);
export default React.memo(NavigationControls);

View File

@@ -0,0 +1,251 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import PopoverMessage from "./PopoverContainer";
jest.mock("../../../../../Common/LoadingOverlay", () => {
const MockLoadingOverlay = ({ isLoading, label }: { isLoading: boolean; label: string }) => {
return isLoading ? <div data-testid="loading-overlay" aria-label={label} /> : null;
};
MockLoadingOverlay.displayName = "MockLoadingOverlay";
return MockLoadingOverlay;
});
describe("PopoverMessage Component", () => {
const defaultProps = {
visible: true,
title: "Test Title",
onCancel: jest.fn(),
onPrimary: jest.fn(),
children: <div>Test content</div>,
};
beforeEach(() => {
jest.clearAllMocks();
});
describe("Rendering", () => {
it("should render correctly when visible", () => {
const { container } = render(<PopoverMessage {...defaultProps} />);
expect(container).toMatchSnapshot();
});
it("should render correctly when not visible", () => {
const { container } = render(<PopoverMessage {...defaultProps} visible={false} />);
expect(container).toMatchSnapshot();
});
it("should render correctly with loading state", () => {
const { container } = render(<PopoverMessage {...defaultProps} isLoading={true} />);
expect(container).toMatchSnapshot();
});
it("should render correctly with different title", () => {
const { container } = render(<PopoverMessage {...defaultProps} title="Custom Title" />);
expect(container).toMatchSnapshot();
});
it("should render correctly with different children content", () => {
const customChildren = (
<div>
<p>First paragraph</p>
<p>Second paragraph</p>
</div>
);
const { container } = render(<PopoverMessage {...defaultProps}>{customChildren}</PopoverMessage>);
expect(container).toMatchSnapshot();
});
});
describe("Visibility", () => {
it("should not render anything when visible is false", () => {
render(<PopoverMessage {...defaultProps} visible={false} />);
expect(screen.queryByText("Test Title")).not.toBeInTheDocument();
expect(screen.queryByText("Test content")).not.toBeInTheDocument();
});
it("should render content when visible is true", () => {
render(<PopoverMessage {...defaultProps} />);
expect(screen.getByText("Test Title")).toBeInTheDocument();
expect(screen.getByText("Test content")).toBeInTheDocument();
});
});
describe("Title Display", () => {
it("should display the provided title", () => {
render(<PopoverMessage {...defaultProps} title="Custom Popover Title" />);
expect(screen.getByText("Custom Popover Title")).toBeInTheDocument();
});
it("should handle empty title", () => {
render(<PopoverMessage {...defaultProps} title="" />);
expect(screen.queryByText("Test Title")).not.toBeInTheDocument();
});
});
describe("Children Content", () => {
it("should render children content", () => {
const customChildren = <span>Custom child content</span>;
render(<PopoverMessage {...defaultProps}>{customChildren}</PopoverMessage>);
expect(screen.getByText("Custom child content")).toBeInTheDocument();
});
it("should render complex children content", () => {
const complexChildren = (
<div>
<h3>Heading</h3>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
</div>
);
render(<PopoverMessage {...defaultProps}>{complexChildren}</PopoverMessage>);
expect(screen.getByText("Heading")).toBeInTheDocument();
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
});
});
describe("Button Interactions", () => {
it("should call onPrimary when Yes button is clicked", () => {
const onPrimaryMock = jest.fn();
render(<PopoverMessage {...defaultProps} onPrimary={onPrimaryMock} />);
const yesButton = screen.getByText("Yes");
fireEvent.click(yesButton);
expect(onPrimaryMock).toHaveBeenCalledTimes(1);
});
it("should call onCancel when No button is clicked", () => {
const onCancelMock = jest.fn();
render(<PopoverMessage {...defaultProps} onCancel={onCancelMock} />);
const noButton = screen.getByText("No");
fireEvent.click(noButton);
expect(onCancelMock).toHaveBeenCalledTimes(1);
});
it("should not call handlers multiple times on rapid clicks", () => {
const onPrimaryMock = jest.fn();
const onCancelMock = jest.fn();
render(<PopoverMessage {...defaultProps} onPrimary={onPrimaryMock} onCancel={onCancelMock} />);
const yesButton = screen.getByText("Yes");
const noButton = screen.getByText("No");
fireEvent.click(yesButton);
fireEvent.click(yesButton);
fireEvent.click(noButton);
fireEvent.click(noButton);
expect(onPrimaryMock).toHaveBeenCalledTimes(2);
expect(onCancelMock).toHaveBeenCalledTimes(2);
});
});
describe("Loading State", () => {
test("should show loading overlay when isLoading is true", () => {
render(<PopoverMessage {...defaultProps} isLoading={true} />);
expect(screen.getByTestId("loading-overlay")).toBeInTheDocument();
});
it("should not show loading overlay when isLoading is false", () => {
render(<PopoverMessage {...defaultProps} isLoading={false} />);
expect(screen.queryByTestId("loading-overlay")).not.toBeInTheDocument();
});
it("should disable buttons when loading", () => {
render(<PopoverMessage {...defaultProps} isLoading={true} />);
const yesButton = screen.getByText("Yes").closest("button");
const noButton = screen.getByText("No").closest("button");
expect(yesButton).toHaveAttribute("aria-disabled", "true");
expect(noButton).toHaveAttribute("aria-disabled", "true");
});
it("should enable buttons when not loading", () => {
render(<PopoverMessage {...defaultProps} isLoading={false} />);
const yesButton = screen.getByText("Yes").closest("button");
const noButton = screen.getByText("No").closest("button");
expect(yesButton).not.toHaveAttribute("aria-disabled");
expect(noButton).not.toHaveAttribute("aria-disabled");
});
it("should use correct loading overlay label", () => {
render(<PopoverMessage {...defaultProps} isLoading={true} />);
const loadingOverlay = screen.getByTestId("loading-overlay");
expect(loadingOverlay).toHaveAttribute("aria-label", ContainerCopyMessages.popoverOverlaySpinnerLabel);
});
});
describe("Default Props", () => {
it("should handle missing isLoading prop (defaults to false)", () => {
const propsWithoutLoading = { ...defaultProps };
delete (propsWithoutLoading as any).isLoading;
render(<PopoverMessage {...propsWithoutLoading} />);
expect(screen.queryByTestId("loading-overlay")).not.toBeInTheDocument();
expect(screen.getByText("Yes")).not.toBeDisabled();
expect(screen.getByText("No")).not.toBeDisabled();
});
});
describe("CSS Classes and Styling", () => {
it("should apply correct CSS classes", () => {
const { container } = render(<PopoverMessage {...defaultProps} />);
const popoverContainer = container.querySelector(".popover-container");
expect(popoverContainer).toHaveClass("foreground");
});
it("should apply loading class when isLoading is true", () => {
const { container } = render(<PopoverMessage {...defaultProps} isLoading={true} />);
const popoverContainer = container.querySelector(".popover-container");
expect(popoverContainer).toHaveClass("loading");
});
it("should not apply loading class when isLoading is false", () => {
const { container } = render(<PopoverMessage {...defaultProps} isLoading={false} />);
const popoverContainer = container.querySelector(".popover-container");
expect(popoverContainer).not.toHaveClass("loading");
});
});
describe("Edge Cases", () => {
it("should handle undefined children", () => {
const propsWithUndefinedChildren = { ...defaultProps, children: undefined as React.ReactNode };
const { container } = render(<PopoverMessage {...propsWithUndefinedChildren} />);
expect(container).toMatchSnapshot();
});
it("should handle null children", () => {
const propsWithNullChildren = { ...defaultProps, children: null as React.ReactNode };
const { container } = render(<PopoverMessage {...propsWithNullChildren} />);
expect(container).toMatchSnapshot();
});
it("should handle empty string title", () => {
const propsWithEmptyTitle = { ...defaultProps, title: "" };
const { container } = render(<PopoverMessage {...propsWithEmptyTitle} />);
expect(container).toMatchSnapshot();
});
it("should handle very long title", () => {
const longTitle =
"This is a very long title that might cause layout issues or text wrapping in the popover component";
const propsWithLongTitle = { ...defaultProps, title: longTitle };
const { container } = render(<PopoverMessage {...propsWithLongTitle} />);
expect(container).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,65 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react";
import React from "react";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
interface PopoverContainerProps {
isLoading?: boolean;
title?: string;
children?: React.ReactNode;
onPrimary: () => void;
onCancel: () => void;
}
const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
({ isLoading = false, title, children, onPrimary, onCancel }) => {
return (
<Stack
className={`popover-container foreground ${isLoading ? "loading" : ""}`}
tokens={{ childrenGap: 20 }}
style={{ maxWidth: 450 }}
>
<LoadingOverlay isLoading={isLoading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Text variant="mediumPlus" style={{ fontWeight: 600 }}>
{title}
</Text>
<Text>{children}</Text>
<Stack horizontal tokens={{ childrenGap: 20 }}>
<PrimaryButton text={"Yes"} onClick={onPrimary} disabled={isLoading} />
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />
</Stack>
</Stack>
);
},
);
interface PopoverMessageProps {
isLoading?: boolean;
visible: boolean;
title: string;
onCancel: () => void;
onPrimary: () => void;
children: React.ReactNode;
}
const PopoverMessage: React.FC<PopoverMessageProps> = ({
isLoading = false,
visible,
title,
onCancel,
onPrimary,
children,
}) => {
if (!visible) {
return null;
}
return (
<PopoverContainer title={title} onCancel={onCancel} onPrimary={onPrimary} isLoading={isLoading}>
{children}
</PopoverContainer>
);
};
export default PopoverMessage;

View File

@@ -0,0 +1,145 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FieldRow Snapshot Testing matches snapshot with complex children 1`] = `
<div
class="ms-Stack flex-row css-109"
>
<div
class="ms-StackItem flex-fixed-width css-110"
>
<label
class="field-label advanced-label"
>
Advanced Settings
:
</label>
</div>
<div
class="ms-StackItem flex-grow-col css-110"
>
<div>
<input
id="enable-feature"
type="checkbox"
/>
<label
for="enable-feature"
>
Enable advanced feature
</label>
<button
type="button"
>
Configure
</button>
</div>
</div>
</div>
`;
exports[`FieldRow Snapshot Testing matches snapshot with custom className 1`] = `
<div
class="ms-Stack flex-row css-109"
>
<div
class="ms-StackItem flex-fixed-width css-110"
>
<label
class="field-label custom-style"
>
Container Name
:
</label>
</div>
<div
class="ms-StackItem flex-grow-col css-110"
>
<select>
<option>
Option 1
</option>
<option>
Option 2
</option>
</select>
</div>
</div>
`;
exports[`FieldRow Snapshot Testing matches snapshot with empty label 1`] = `
<div
class="ms-Stack flex-row css-109"
>
<div
class="ms-StackItem flex-grow-col css-110"
>
<button
type="submit"
>
Submit Form
</button>
</div>
</div>
`;
exports[`FieldRow Snapshot Testing matches snapshot with label only 1`] = `
<div
class="ms-Stack flex-row css-109"
>
<div
class="ms-StackItem flex-fixed-width css-110"
>
<label
class="field-label "
>
Database Name
:
</label>
</div>
<div
class="ms-StackItem flex-grow-col css-110"
>
<input
placeholder="Enter database name"
type="text"
/>
</div>
</div>
`;
exports[`FieldRow Snapshot Testing matches snapshot with minimal props 1`] = `
<div
class="ms-Stack flex-row css-109"
>
<div
class="ms-StackItem flex-grow-col css-110"
>
<input
placeholder="Simple input"
type="text"
/>
</div>
</div>
`;
exports[`FieldRow Snapshot Testing matches snapshot with no label 1`] = `
<div
class="ms-Stack flex-row css-109"
>
<div
class="ms-StackItem flex-grow-col css-110"
>
<div>
<h4>
Section Title
</h4>
<p>
Section description goes here
</p>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,60 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InfoTooltip Component Rendering should render tooltip with image when content is provided 1`] = `
<div>
<div
class="ms-TooltipHost root-105"
role="none"
>
<div
class="ms-Image root-109"
style="width: 14px; height: 14px;"
>
<img
alt="Information"
class="ms-Image-image ms-Image-image--portrait is-notLoaded is-fadeIn image-110"
src="[object Object]"
/>
</div>
<div
hidden=""
id="tooltip0"
style="position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0px; border: 0px; overflow: hidden; white-space: nowrap;"
>
Test tooltip content
</div>
</div>
</div>
`;
exports[`InfoTooltip Component Rendering should render with JSX element content 1`] = `
<div>
<div
class="ms-TooltipHost root-105"
role="none"
>
<div
class="ms-Image root-109"
style="width: 14px; height: 14px;"
>
<img
alt="Information"
class="ms-Image-image ms-Image-image--portrait is-notLoaded is-fadeIn image-110"
src="[object Object]"
/>
</div>
<div
hidden=""
id="tooltip1"
style="position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0px; border: 0px; overflow: hidden; white-space: nowrap;"
>
<div>
<strong>
Important:
</strong>
This is a JSX tooltip
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,552 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PopoverMessage Component Edge Cases should handle empty string title 1`] = `
<div>
<div
class="ms-Stack popover-container foreground css-109"
style="max-width: 450px;"
>
<span
class="css-110"
style="font-weight: 600;"
/>
<span
class="css-111"
>
<div>
Test content
</div>
</span>
<div
class="ms-Stack css-112"
>
<button
class="ms-Button ms-Button--primary root-113"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__138"
>
Yes
</span>
</span>
</span>
</button>
<button
class="ms-Button ms-Button--default root-121"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__141"
>
No
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;
exports[`PopoverMessage Component Edge Cases should handle null children 1`] = `
<div>
<div
class="ms-Stack popover-container foreground css-109"
style="max-width: 450px;"
>
<span
class="css-110"
style="font-weight: 600;"
>
Test Title
</span>
<div
class="ms-Stack css-112"
>
<button
class="ms-Button ms-Button--primary root-113"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__132"
>
Yes
</span>
</span>
</span>
</button>
<button
class="ms-Button ms-Button--default root-121"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__135"
>
No
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;
exports[`PopoverMessage Component Edge Cases should handle undefined children 1`] = `
<div>
<div
class="ms-Stack popover-container foreground css-109"
style="max-width: 450px;"
>
<span
class="css-110"
style="font-weight: 600;"
>
Test Title
</span>
<div
class="ms-Stack css-112"
>
<button
class="ms-Button ms-Button--primary root-113"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__126"
>
Yes
</span>
</span>
</span>
</button>
<button
class="ms-Button ms-Button--default root-121"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__129"
>
No
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;
exports[`PopoverMessage Component Edge Cases should handle very long title 1`] = `
<div>
<div
class="ms-Stack popover-container foreground css-109"
style="max-width: 450px;"
>
<span
class="css-110"
style="font-weight: 600;"
>
This is a very long title that might cause layout issues or text wrapping in the popover component
</span>
<span
class="css-111"
>
<div>
Test content
</div>
</span>
<div
class="ms-Stack css-112"
>
<button
class="ms-Button ms-Button--primary root-113"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__144"
>
Yes
</span>
</span>
</span>
</button>
<button
class="ms-Button ms-Button--default root-121"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__147"
>
No
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;
exports[`PopoverMessage Component Rendering should render correctly when not visible 1`] = `<div />`;
exports[`PopoverMessage Component Rendering should render correctly when visible 1`] = `
<div>
<div
class="ms-Stack popover-container foreground css-109"
style="max-width: 450px;"
>
<span
class="css-110"
style="font-weight: 600;"
>
Test Title
</span>
<span
class="css-111"
>
<div>
Test content
</div>
</span>
<div
class="ms-Stack css-112"
>
<button
class="ms-Button ms-Button--primary root-113"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__0"
>
Yes
</span>
</span>
</span>
</button>
<button
class="ms-Button ms-Button--default root-121"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__3"
>
No
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;
exports[`PopoverMessage Component Rendering should render correctly with different children content 1`] = `
<div>
<div
class="ms-Stack popover-container foreground css-109"
style="max-width: 450px;"
>
<span
class="css-110"
style="font-weight: 600;"
>
Test Title
</span>
<span
class="css-111"
>
<div>
<p>
First paragraph
</p>
<p>
Second paragraph
</p>
</div>
</span>
<div
class="ms-Stack css-112"
>
<button
class="ms-Button ms-Button--primary root-113"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__18"
>
Yes
</span>
</span>
</span>
</button>
<button
class="ms-Button ms-Button--default root-121"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__21"
>
No
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;
exports[`PopoverMessage Component Rendering should render correctly with different title 1`] = `
<div>
<div
class="ms-Stack popover-container foreground css-109"
style="max-width: 450px;"
>
<span
class="css-110"
style="font-weight: 600;"
>
Custom Title
</span>
<span
class="css-111"
>
<div>
Test content
</div>
</span>
<div
class="ms-Stack css-112"
>
<button
class="ms-Button ms-Button--primary root-113"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__12"
>
Yes
</span>
</span>
</span>
</button>
<button
class="ms-Button ms-Button--default root-121"
data-is-focusable="true"
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__15"
>
No
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;
exports[`PopoverMessage Component Rendering should render correctly with loading state 1`] = `
<div>
<div
class="ms-Stack popover-container foreground loading css-109"
style="max-width: 450px;"
>
<div
aria-label="Please wait while we process your request..."
data-testid="loading-overlay"
/>
<span
class="css-110"
style="font-weight: 600;"
>
Test Title
</span>
<span
class="css-111"
>
<div>
Test content
</div>
</span>
<div
class="ms-Stack css-112"
>
<button
aria-disabled="true"
class="ms-Button ms-Button--primary is-disabled root-122"
data-is-focusable="false"
disabled=""
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__6"
>
Yes
</span>
</span>
</span>
</button>
<button
aria-disabled="true"
class="ms-Button ms-Button--default is-disabled root-125"
data-is-focusable="false"
disabled=""
type="button"
>
<span
class="ms-Button-flexContainer flexContainer-114"
data-automationid="splitbuttonprimary"
>
<span
class="ms-Button-textContainer textContainer-115"
>
<span
class="ms-Button-label label-117"
id="id__9"
>
No
</span>
</span>
</span>
</button>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,261 @@
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import { CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums";
import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes";
import Explorer from "Explorer/Explorer";
import { useSidePanel } from "hooks/useSidePanel";
import React from "react";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import AddCollectionPanelWrapper from "./AddCollectionPanelWrapper";
jest.mock("hooks/useSidePanel");
jest.mock("../../../Context/CopyJobContext");
jest.mock("../../../../Panes/AddCollectionPanel/AddCollectionPanel", () => ({
AddCollectionPanel: ({
explorer,
isCopyJobFlow,
onSubmitSuccess,
}: {
explorer?: Explorer;
isCopyJobFlow: boolean;
onSubmitSuccess: (data: { databaseId: string; collectionId: string }) => void;
}) => (
<div data-testid="add-collection-panel">
<div data-testid="explorer-prop">{explorer ? "explorer-present" : "no-explorer"}</div>
<div data-testid="copy-job-flow">{isCopyJobFlow ? "true" : "false"}</div>
<button
data-testid="submit-button"
onClick={() => onSubmitSuccess({ databaseId: "test-db", collectionId: "test-collection" })}
>
Submit
</button>
</div>
),
}));
jest.mock("immer", () => ({
produce: jest.fn((updater) => (state: any) => {
const draft = { ...state };
updater(draft);
return draft;
}),
}));
const mockUseSidePanel = useSidePanel as jest.MockedFunction<typeof useSidePanel>;
const mockUseCopyJobContext = useCopyJobContext as jest.MockedFunction<typeof useCopyJobContext>;
describe("AddCollectionPanelWrapper", () => {
const mockSetCopyJobState = jest.fn();
const mockGoBack = jest.fn();
const mockSetHeaderText = jest.fn();
const mockExplorer = {} as Explorer;
const mockSidePanelState = {
isOpen: false,
panelWidth: "440px",
hasConsole: true,
headerText: "",
setHeaderText: mockSetHeaderText,
openSidePanel: jest.fn(),
closeSidePanel: jest.fn(),
setPanelHasConsole: jest.fn(),
};
const mockCopyJobContextValue = {
contextError: null,
setContextError: jest.fn(),
copyJobState: {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: { subscriptionId: "" },
account: null,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "",
account: null,
databaseId: "",
containerId: "",
},
sourceReadAccessFromTarget: false,
},
setCopyJobState: mockSetCopyJobState,
flow: null,
setFlow: jest.fn(),
resetCopyJobState: jest.fn(),
explorer: mockExplorer,
} as unknown as CopyJobContextProviderType;
beforeEach(() => {
jest.clearAllMocks();
mockUseSidePanel.mockReturnValue(mockSidePanelState);
mockUseSidePanel.getState = jest.fn().mockReturnValue(mockSidePanelState);
mockUseCopyJobContext.mockReturnValue(mockCopyJobContextValue);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("Component Rendering", () => {
it("should render correctly with all required elements", () => {
const { container } = render(<AddCollectionPanelWrapper />);
expect(container.querySelector(".addCollectionPanelWrapper")).toBeInTheDocument();
expect(container.querySelector(".addCollectionPanelHeader")).toBeInTheDocument();
expect(container.querySelector(".addCollectionPanelBody")).toBeInTheDocument();
expect(screen.getByText(ContainerCopyMessages.createNewContainerSubHeading)).toBeInTheDocument();
expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument();
});
it("should match snapshot", () => {
const { container } = render(<AddCollectionPanelWrapper />);
expect(container).toMatchSnapshot();
});
it("should match snapshot with explorer prop", () => {
const { container } = render(<AddCollectionPanelWrapper explorer={mockExplorer} />);
expect(container).toMatchSnapshot();
});
it("should match snapshot with goBack prop", () => {
const { container } = render(<AddCollectionPanelWrapper goBack={mockGoBack} />);
expect(container).toMatchSnapshot();
});
it("should match snapshot with both props", () => {
const { container } = render(<AddCollectionPanelWrapper explorer={mockExplorer} goBack={mockGoBack} />);
expect(container).toMatchSnapshot();
});
});
describe("Side Panel Header Management", () => {
it("should set header text to create container heading on mount", () => {
render(<AddCollectionPanelWrapper />);
expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createContainerHeading);
});
it("should reset header text to create copy job panel title on unmount", () => {
const { unmount } = render(<AddCollectionPanelWrapper />);
unmount();
expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createCopyJobPanelTitle);
});
it("should not change header text if already set correctly", () => {
const modifiedSidePanelState = {
...mockSidePanelState,
headerText: ContainerCopyMessages.createContainerHeading,
};
mockUseSidePanel.getState = jest.fn().mockReturnValue(modifiedSidePanelState);
render(<AddCollectionPanelWrapper />);
expect(mockSetHeaderText).not.toHaveBeenCalled();
});
});
describe("AddCollectionPanel Integration", () => {
it("should pass explorer prop to AddCollectionPanel", () => {
render(<AddCollectionPanelWrapper explorer={mockExplorer} />);
expect(screen.getByTestId("explorer-prop")).toHaveTextContent("explorer-present");
});
it("should pass undefined explorer to AddCollectionPanel when not provided", () => {
render(<AddCollectionPanelWrapper />);
expect(screen.getByTestId("explorer-prop")).toHaveTextContent("no-explorer");
});
it("should pass isCopyJobFlow as true to AddCollectionPanel", () => {
render(<AddCollectionPanelWrapper />);
expect(screen.getByTestId("copy-job-flow")).toHaveTextContent("true");
});
});
describe("Collection Success Handler", () => {
it("should update copy job state when handleAddCollectionSuccess is called", async () => {
render(<AddCollectionPanelWrapper goBack={mockGoBack} />);
const submitButton = screen.getByTestId("submit-button");
submitButton.click();
await waitFor(() => {
expect(mockSetCopyJobState).toHaveBeenCalledTimes(1);
});
const stateUpdater = mockSetCopyJobState.mock.calls[0][0];
const mockState = {
target: { databaseId: "", containerId: "" },
};
const updatedState = stateUpdater(mockState);
expect(updatedState.target.databaseId).toBe("test-db");
expect(updatedState.target.containerId).toBe("test-collection");
});
it("should call goBack when handleAddCollectionSuccess is called and goBack is provided", async () => {
render(<AddCollectionPanelWrapper goBack={mockGoBack} />);
const submitButton = screen.getByTestId("submit-button");
submitButton.click();
await waitFor(() => {
expect(mockGoBack).toHaveBeenCalledTimes(1);
});
});
it("should not call goBack when handleAddCollectionSuccess is called and goBack is not provided", async () => {
render(<AddCollectionPanelWrapper />);
const submitButton = screen.getByTestId("submit-button");
submitButton.click();
await waitFor(() => {
expect(mockSetCopyJobState).toHaveBeenCalledTimes(1);
});
expect(mockGoBack).not.toHaveBeenCalled();
});
});
describe("Error Handling", () => {
it("should handle missing setCopyJobState gracefully", () => {
const mockCopyJobContextValueWithoutSetState = {
...mockCopyJobContextValue,
setCopyJobState: undefined as any,
};
mockUseCopyJobContext.mockReturnValue(mockCopyJobContextValueWithoutSetState);
expect(() => render(<AddCollectionPanelWrapper />)).not.toThrow();
});
});
describe("Component Lifecycle", () => {
it("should properly cleanup on unmount", () => {
const { unmount } = render(<AddCollectionPanelWrapper />);
expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createContainerHeading);
mockSetHeaderText.mockClear();
unmount();
expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createCopyJobPanelTitle);
});
it("should re-render correctly when props change", () => {
const { rerender } = render(<AddCollectionPanelWrapper />);
expect(screen.getByTestId("explorer-prop")).toHaveTextContent("no-explorer");
rerender(<AddCollectionPanelWrapper explorer={mockExplorer} />);
expect(screen.getByTestId("explorer-prop")).toHaveTextContent("explorer-present");
});
});
});

View File

@@ -0,0 +1,53 @@
import { Stack, Text } from "@fluentui/react";
import Explorer from "Explorer/Explorer";
import { useSidePanel } from "hooks/useSidePanel";
import { produce } from "immer";
import React, { useCallback, useEffect } from "react";
import { AddCollectionPanel } from "../../../../Panes/AddCollectionPanel/AddCollectionPanel";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
type AddCollectionPanelWrapperProps = {
explorer?: Explorer;
goBack?: () => void;
};
const AddCollectionPanelWrapper: React.FunctionComponent<AddCollectionPanelWrapperProps> = ({ explorer, goBack }) => {
const { setCopyJobState } = useCopyJobContext();
useEffect(() => {
const sidePanelStore = useSidePanel.getState();
if (sidePanelStore.headerText !== ContainerCopyMessages.createContainerHeading) {
sidePanelStore.setHeaderText(ContainerCopyMessages.createContainerHeading);
}
return () => {
sidePanelStore.setHeaderText(ContainerCopyMessages.createCopyJobPanelTitle);
};
}, []);
const handleAddCollectionSuccess = useCallback(
(collectionData: { databaseId: string; collectionId: string }) => {
setCopyJobState(
produce((state) => {
state.target.databaseId = collectionData.databaseId;
state.target.containerId = collectionData.collectionId;
}),
);
goBack?.();
},
[goBack],
);
return (
<Stack className="addCollectionPanelWrapper">
<Stack.Item className="addCollectionPanelHeader">
<Text>{ContainerCopyMessages.createNewContainerSubHeading}</Text>
</Stack.Item>
<Stack.Item className="addCollectionPanelBody">
<AddCollectionPanel explorer={explorer} isCopyJobFlow={true} onSubmitSuccess={handleAddCollectionSuccess} />
</Stack.Item>
</Stack>
);
};
export default AddCollectionPanelWrapper;

View File

@@ -0,0 +1,165 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddCollectionPanelWrapper Component Rendering should match snapshot 1`] = `
<div>
<div
class="ms-Stack addCollectionPanelWrapper css-109"
>
<div
class="ms-StackItem addCollectionPanelHeader css-110"
>
<span
class="css-111"
>
Select the properties for your container.
</span>
</div>
<div
class="ms-StackItem addCollectionPanelBody css-110"
>
<div
data-testid="add-collection-panel"
>
<div
data-testid="explorer-prop"
>
no-explorer
</div>
<div
data-testid="copy-job-flow"
>
true
</div>
<button
data-testid="submit-button"
>
Submit
</button>
</div>
</div>
</div>
</div>
`;
exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with both props 1`] = `
<div>
<div
class="ms-Stack addCollectionPanelWrapper css-109"
>
<div
class="ms-StackItem addCollectionPanelHeader css-110"
>
<span
class="css-111"
>
Select the properties for your container.
</span>
</div>
<div
class="ms-StackItem addCollectionPanelBody css-110"
>
<div
data-testid="add-collection-panel"
>
<div
data-testid="explorer-prop"
>
explorer-present
</div>
<div
data-testid="copy-job-flow"
>
true
</div>
<button
data-testid="submit-button"
>
Submit
</button>
</div>
</div>
</div>
</div>
`;
exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with explorer prop 1`] = `
<div>
<div
class="ms-Stack addCollectionPanelWrapper css-109"
>
<div
class="ms-StackItem addCollectionPanelHeader css-110"
>
<span
class="css-111"
>
Select the properties for your container.
</span>
</div>
<div
class="ms-StackItem addCollectionPanelBody css-110"
>
<div
data-testid="add-collection-panel"
>
<div
data-testid="explorer-prop"
>
explorer-present
</div>
<div
data-testid="copy-job-flow"
>
true
</div>
<button
data-testid="submit-button"
>
Submit
</button>
</div>
</div>
</div>
</div>
`;
exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with goBack prop 1`] = `
<div>
<div
class="ms-Stack addCollectionPanelWrapper css-109"
>
<div
class="ms-StackItem addCollectionPanelHeader css-110"
>
<span
class="css-111"
>
Select the properties for your container.
</span>
</div>
<div
class="ms-StackItem addCollectionPanelBody css-110"
>
<div
data-testid="add-collection-panel"
>
<div
data-testid="explorer-prop"
>
no-explorer
</div>
<div
data-testid="copy-job-flow"
>
true
</div>
<button
data-testid="submit-button"
>
Submit
</button>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,426 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import CreateCopyJobScreens from "./CreateCopyJobScreens";
jest.mock("../../Context/CopyJobContext", () => ({
useCopyJobContext: jest.fn(),
}));
jest.mock("../Utils/useCopyJobNavigation", () => ({
useCopyJobNavigation: jest.fn(),
}));
jest.mock("./Components/NavigationControls", () => {
const MockedNavigationControls = ({
primaryBtnText,
onPrimary,
onPrevious,
onCancel,
isPrimaryDisabled,
isPreviousDisabled,
}: {
primaryBtnText: string;
onPrimary: () => void;
onPrevious: () => void;
onCancel: () => void;
isPrimaryDisabled: boolean;
isPreviousDisabled: boolean;
}) => (
<div data-testid="navigation-controls">
<button data-testid="primary-button" onClick={onPrimary} disabled={isPrimaryDisabled}>
{primaryBtnText}
</button>
<button data-testid="previous-button" onClick={onPrevious} disabled={isPreviousDisabled}>
Previous
</button>
<button data-testid="cancel-button" onClick={onCancel}>
Cancel
</button>
</div>
);
return MockedNavigationControls;
});
import { useCopyJobContext } from "../../Context/CopyJobContext";
import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation";
const createMockNavigationHook = (overrides = {}) => ({
currentScreen: {
key: "SelectAccount",
component: <div data-testid="mock-screen">Mock Screen Component</div>,
},
isPrimaryDisabled: false,
isPreviousDisabled: true,
handlePrimary: jest.fn(),
handlePrevious: jest.fn(),
handleCancel: jest.fn(),
primaryBtnText: "Next",
showAddCollectionPanel: jest.fn(),
...overrides,
});
const createMockContext = (overrides = {}) => ({
contextError: "",
setContextError: jest.fn(),
copyJobState: {},
setCopyJobState: jest.fn(),
flow: {},
setFlow: jest.fn(),
resetCopyJobState: jest.fn(),
explorer: {},
...overrides,
});
describe("CreateCopyJobScreens", () => {
const mockNavigationHook = createMockNavigationHook();
const mockContext = createMockContext();
beforeEach(() => {
jest.clearAllMocks();
(useCopyJobNavigation as jest.Mock).mockReturnValue(mockNavigationHook);
(useCopyJobContext as jest.Mock).mockReturnValue(mockContext);
});
describe("Rendering", () => {
test("should render without error", () => {
render(<CreateCopyJobScreens />);
expect(screen.getByTestId("mock-screen")).toBeInTheDocument();
expect(screen.getByTestId("navigation-controls")).toBeInTheDocument();
});
test("should render current screen component", () => {
const customScreen = <div data-testid="custom-screen">Custom Screen Content</div>;
(useCopyJobNavigation as jest.Mock).mockReturnValue(
createMockNavigationHook({
currentScreen: { component: customScreen },
}),
);
render(<CreateCopyJobScreens />);
expect(screen.getByTestId("custom-screen")).toBeInTheDocument();
expect(screen.getByText("Custom Screen Content")).toBeInTheDocument();
});
test("should have correct CSS classes", () => {
const { container } = render(<CreateCopyJobScreens />);
const mainContainer = container.querySelector(".createCopyJobScreensContainer");
const contentContainer = container.querySelector(".createCopyJobScreensContent");
const footerContainer = container.querySelector(".createCopyJobScreensFooter");
expect(mainContainer).toBeInTheDocument();
expect(contentContainer).toBeInTheDocument();
expect(footerContainer).toBeInTheDocument();
});
});
describe("Error Message Bar", () => {
test("should not show error message bar when no error", () => {
render(<CreateCopyJobScreens />);
expect(screen.queryByRole("region")).not.toBeInTheDocument();
});
test("should show error message bar when context error exists", () => {
const errorMessage = "Something went wrong";
(useCopyJobContext as jest.Mock).mockReturnValue(
createMockContext({
contextError: errorMessage,
}),
);
render(<CreateCopyJobScreens />);
const messageBar = screen.getByRole("region");
expect(messageBar).toBeInTheDocument();
expect(messageBar).toHaveClass("createCopyJobErrorMessageBar");
});
test("should have correct error message bar properties", () => {
const errorMessage = "Test error message";
(useCopyJobContext as jest.Mock).mockReturnValue(
createMockContext({
contextError: errorMessage,
}),
);
render(<CreateCopyJobScreens />);
const messageBar = screen.getByRole("region");
expect(messageBar).toHaveClass("createCopyJobErrorMessageBar");
});
test("should call setContextError when dismiss button is clicked", () => {
const mockSetContextError = jest.fn();
(useCopyJobContext as jest.Mock).mockReturnValue(
createMockContext({
contextError: "Test error",
setContextError: mockSetContextError,
}),
);
render(<CreateCopyJobScreens />);
const dismissButton = screen.getByLabelText("Close");
fireEvent.click(dismissButton);
expect(mockSetContextError).toHaveBeenCalledWith(null);
});
test("should show overflow button with correct aria label", () => {
(useCopyJobContext as jest.Mock).mockReturnValue(
createMockContext({
contextError: "A very long error message that should trigger overflow behavior",
}),
);
render(<CreateCopyJobScreens />);
const overflowButton = screen.getByLabelText("See more");
expect(overflowButton).toBeInTheDocument();
});
});
describe("Navigation Controls Integration", () => {
test("should pass correct props to NavigationControls", () => {
const mockHook = createMockNavigationHook({
primaryBtnText: "Create",
isPrimaryDisabled: true,
isPreviousDisabled: false,
});
(useCopyJobNavigation as jest.Mock).mockReturnValue(mockHook);
render(<CreateCopyJobScreens />);
const primaryButton = screen.getByTestId("primary-button");
const previousButton = screen.getByTestId("previous-button");
expect(primaryButton).toHaveTextContent("Create");
expect(primaryButton).toBeDisabled();
expect(previousButton).not.toBeDisabled();
});
test("should call navigation handlers when buttons are clicked", () => {
const mockHandlePrimary = jest.fn();
const mockHandlePrevious = jest.fn();
const mockHandleCancel = jest.fn();
(useCopyJobNavigation as jest.Mock).mockReturnValue(
createMockNavigationHook({
handlePrimary: mockHandlePrimary,
handlePrevious: mockHandlePrevious,
handleCancel: mockHandleCancel,
isPrimaryDisabled: false,
isPreviousDisabled: false,
}),
);
render(<CreateCopyJobScreens />);
fireEvent.click(screen.getByTestId("primary-button"));
fireEvent.click(screen.getByTestId("previous-button"));
fireEvent.click(screen.getByTestId("cancel-button"));
expect(mockHandlePrimary).toHaveBeenCalledTimes(1);
expect(mockHandlePrevious).toHaveBeenCalledTimes(1);
expect(mockHandleCancel).toHaveBeenCalledTimes(1);
});
});
describe("Screen Component Props", () => {
test("should pass showAddCollectionPanel prop to screen component", () => {
const mockShowAddCollectionPanel = jest.fn();
const TestScreen = ({ showAddCollectionPanel }: { showAddCollectionPanel: () => void }) => (
<div>
<button data-testid="add-collection-btn" onClick={showAddCollectionPanel}>
Add Collection
</button>
</div>
);
(useCopyJobNavigation as jest.Mock).mockReturnValue(
createMockNavigationHook({
currentScreen: { component: <TestScreen showAddCollectionPanel={() => {}} /> },
showAddCollectionPanel: mockShowAddCollectionPanel,
}),
);
render(<CreateCopyJobScreens />);
const addButton = screen.getByTestId("add-collection-btn");
expect(addButton).toBeInTheDocument();
});
test("should handle screen component without props", () => {
const SimpleScreen = () => <div data-testid="simple-screen">Simple Screen</div>;
(useCopyJobNavigation as jest.Mock).mockReturnValue(
createMockNavigationHook({
currentScreen: { component: <SimpleScreen /> },
}),
);
expect(() => render(<CreateCopyJobScreens />)).not.toThrow();
expect(screen.getByTestId("simple-screen")).toBeInTheDocument();
});
});
describe("Layout and Structure", () => {
test("should maintain vertical layout with space-between alignment", () => {
const { container } = render(<CreateCopyJobScreens />);
const stackContainer = container.querySelector(".createCopyJobScreensContainer");
expect(stackContainer).toBeInTheDocument();
});
test("should have content area above navigation controls", () => {
const { container } = render(<CreateCopyJobScreens />);
const content = container.querySelector(".createCopyJobScreensContent");
const footer = container.querySelector(".createCopyJobScreensFooter");
expect(content).toBeInTheDocument();
expect(footer).toBeInTheDocument();
const contentIndex = Array.from(container.querySelectorAll("*")).indexOf(content!);
const footerIndex = Array.from(container.querySelectorAll("*")).indexOf(footer!);
expect(contentIndex).toBeLessThan(footerIndex);
});
});
describe("Error Scenarios", () => {
test("should handle missing current screen gracefully", () => {
(useCopyJobNavigation as jest.Mock).mockReturnValue(
createMockNavigationHook({
currentScreen: null,
}),
);
expect(() => render(<CreateCopyJobScreens />)).toThrow();
});
test("should handle missing screen component", () => {
(useCopyJobNavigation as jest.Mock).mockReturnValue(
createMockNavigationHook({
currentScreen: { key: "test", component: null },
}),
);
expect(() => render(<CreateCopyJobScreens />)).toThrow();
});
test("should render with valid screen component", () => {
(useCopyJobNavigation as jest.Mock).mockReturnValue(
createMockNavigationHook({
currentScreen: {
key: "test",
component: <div data-testid="valid-screen">Valid Screen</div>,
},
}),
);
expect(() => render(<CreateCopyJobScreens />)).not.toThrow();
expect(screen.getByTestId("valid-screen")).toBeInTheDocument();
});
test("should handle context hook throwing error", () => {
(useCopyJobContext as jest.Mock).mockImplementation(() => {
throw new Error("Context not available");
});
expect(() => render(<CreateCopyJobScreens />)).toThrow("Context not available");
});
test("should handle navigation hook throwing error", () => {
(useCopyJobNavigation as jest.Mock).mockImplementation(() => {
throw new Error("Navigation not available");
});
expect(() => render(<CreateCopyJobScreens />)).toThrow("Navigation not available");
});
});
describe("Multiple Error States", () => {
test("should handle error message changes", () => {
const mockSetContextError = jest.fn();
const { rerender } = render(<CreateCopyJobScreens />);
expect(screen.queryByRole("region")).not.toBeInTheDocument();
(useCopyJobContext as jest.Mock).mockReturnValue(
createMockContext({
contextError: "First error",
setContextError: mockSetContextError,
}),
);
rerender(<CreateCopyJobScreens />);
expect(screen.getByRole("region")).toBeInTheDocument();
(useCopyJobContext as jest.Mock).mockReturnValue(
createMockContext({
contextError: "Second error",
setContextError: mockSetContextError,
}),
);
rerender(<CreateCopyJobScreens />);
expect(screen.getByRole("region")).toBeInTheDocument();
(useCopyJobContext as jest.Mock).mockReturnValue(
createMockContext({
contextError: null,
setContextError: mockSetContextError,
}),
);
rerender(<CreateCopyJobScreens />);
expect(screen.queryByRole("region")).not.toBeInTheDocument();
});
});
describe("Accessibility", () => {
test("should have proper ARIA labels for message bar", () => {
(useCopyJobContext as jest.Mock).mockReturnValue(
createMockContext({
contextError: "Test error",
}),
);
render(<CreateCopyJobScreens />);
const dismissButton = screen.getByLabelText("Close");
const overflowButton = screen.getByLabelText("See more");
expect(dismissButton).toBeInTheDocument();
expect(overflowButton).toBeInTheDocument();
});
test("should have proper region role for message bar", () => {
(useCopyJobContext as jest.Mock).mockReturnValue(
createMockContext({
contextError: "Test error",
}),
);
render(<CreateCopyJobScreens />);
const messageRegion = screen.getByRole("region");
expect(messageRegion).toBeInTheDocument();
const alert = screen.getByRole("alert");
expect(alert).toBeInTheDocument();
});
});
describe("Component Integration", () => {
test("should integrate with both context and navigation hooks", () => {
const mockContext = createMockContext({
contextError: "Integration test error",
});
const mockNavigation = createMockNavigationHook({
primaryBtnText: "Integration Test",
isPrimaryDisabled: true,
});
(useCopyJobContext as jest.Mock).mockReturnValue(mockContext);
(useCopyJobNavigation as jest.Mock).mockReturnValue(mockNavigation);
render(<CreateCopyJobScreens />);
expect(screen.getByRole("region")).toBeInTheDocument();
expect(screen.getByText("Integration Test")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,52 @@
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
import React from "react";
import { useCopyJobContext } from "../../Context/CopyJobContext";
import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation";
import NavigationControls from "./Components/NavigationControls";
const CreateCopyJobScreens: React.FC = () => {
const {
currentScreen,
isPrimaryDisabled,
isPreviousDisabled,
handlePrimary,
handlePrevious,
handleCancel,
primaryBtnText,
showAddCollectionPanel,
} = useCopyJobNavigation();
const { contextError, setContextError } = useCopyJobContext();
return (
<Stack verticalAlign="space-between" className="createCopyJobScreensContainer">
<Stack.Item className="createCopyJobScreensContent">
{contextError && (
<MessageBar
className="createCopyJobErrorMessageBar"
messageBarType={MessageBarType.blocked}
isMultiline={false}
onDismiss={() => setContextError(null)}
dismissButtonAriaLabel="Close"
truncated={true}
overflowButtonAriaLabel="See more"
>
{contextError}
</MessageBar>
)}
{React.cloneElement(currentScreen?.component as React.ReactElement, { showAddCollectionPanel })}
</Stack.Item>
<Stack.Item className="createCopyJobScreensFooter">
<NavigationControls
primaryBtnText={primaryBtnText}
onPrimary={handlePrimary}
onPrevious={handlePrevious}
onCancel={handleCancel}
isPrimaryDisabled={isPrimaryDisabled}
isPreviousDisabled={isPreviousDisabled}
/>
</Stack.Item>
</Stack>
);
};
export default CreateCopyJobScreens;

View File

@@ -0,0 +1,95 @@
import { shallow } from "enzyme";
import Explorer from "Explorer/Explorer";
import React from "react";
import CreateCopyJobScreensProvider from "./CreateCopyJobScreensProvider";
jest.mock("../../Context/CopyJobContext", () => ({
__esModule: true,
default: ({ children, explorer }: { children: React.ReactNode; explorer: Explorer }) => (
<div data-testid="copy-job-context-provider" data-explorer={explorer ? "explorer-instance" : "null"}>
{children}
</div>
),
}));
jest.mock("./CreateCopyJobScreens", () => ({
__esModule: true,
default: () => <div data-testid="create-copy-job-screens">CreateCopyJobScreens</div>,
}));
const mockExplorer = {
databaseAccount: {
id: "test-account",
name: "test-account-name",
location: "East US",
type: "DocumentDB",
kind: "GlobalDocumentDB",
properties: {
documentEndpoint: "https://test-account.documents.azure.com:443/",
gremlinEndpoint: "https://test-account.gremlin.cosmosdb.azure.com:443/",
tableEndpoint: "https://test-account.table.cosmosdb.azure.com:443/",
cassandraEndpoint: "https://test-account.cassandra.cosmosdb.azure.com:443/",
},
},
subscriptionId: "test-subscription-id",
resourceGroup: "test-resource-group",
} as unknown as Explorer;
describe("CreateCopyJobScreensProvider", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("should render with explorer prop", () => {
const wrapper = shallow(<CreateCopyJobScreensProvider explorer={mockExplorer} />);
expect(wrapper).toMatchSnapshot();
});
it("should render with null explorer", () => {
const wrapper = shallow(<CreateCopyJobScreensProvider explorer={null as unknown as Explorer} />);
expect(wrapper).toMatchSnapshot();
});
it("should render with undefined explorer", () => {
const wrapper = shallow(<CreateCopyJobScreensProvider explorer={undefined as unknown as Explorer} />);
expect(wrapper).toMatchSnapshot();
});
it("should not crash with minimal explorer object", () => {
const minimalExplorer = {} as Explorer;
expect(() => {
const wrapper = shallow(<CreateCopyJobScreensProvider explorer={minimalExplorer} />);
expect(wrapper).toBeDefined();
}).not.toThrow();
});
it("should match snapshot for default render", () => {
const wrapper = shallow(<CreateCopyJobScreensProvider explorer={mockExplorer} />);
expect(wrapper).toMatchSnapshot("default-render");
});
it("should match snapshot for edge cases", () => {
const emptyExplorer = {} as Explorer;
const wrapperEmpty = shallow(<CreateCopyJobScreensProvider explorer={emptyExplorer} />);
expect(wrapperEmpty).toMatchSnapshot("empty-explorer");
const partialExplorer = {
databaseAccount: { id: "partial-account" },
} as unknown as Explorer;
const wrapperPartial = shallow(<CreateCopyJobScreensProvider explorer={partialExplorer} />);
expect(wrapperPartial).toMatchSnapshot("partial-explorer");
});
describe("Error Boundaries and Edge Cases", () => {
it("should handle React rendering errors gracefully", () => {
const edgeCases = [null, undefined, {}, { invalidProperty: "test" }];
edgeCases.forEach((explorerCase) => {
expect(() => {
shallow(<CreateCopyJobScreensProvider explorer={explorerCase as unknown as Explorer} />);
}).not.toThrow();
});
});
});
});

View File

@@ -0,0 +1,14 @@
import Explorer from "Explorer/Explorer";
import React from "react";
import CopyJobContextProvider from "../../Context/CopyJobContext";
import CreateCopyJobScreens from "./CreateCopyJobScreens";
const CreateCopyJobScreensProvider = ({ explorer }: { explorer: Explorer }) => {
return (
<CopyJobContextProvider explorer={explorer}>
<CreateCopyJobScreens />
</CopyJobContextProvider>
);
};
export default CreateCopyJobScreensProvider;

View File

@@ -0,0 +1,366 @@
import "@testing-library/jest-dom";
import { fireEvent, render, waitFor } from "@testing-library/react";
import { Subscription } from "Contracts/DataModels";
import React from "react";
import { CopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes";
import PreviewCopyJob from "./PreviewCopyJob";
jest.mock("./Utils/PreviewCopyJobUtils", () => ({
getPreviewCopyJobDetailsListColumns: () => [
{
key: "sourcedbname",
name: "Source Database",
fieldName: "sourceDatabaseName",
minWidth: 130,
maxWidth: 140,
},
{
key: "sourcecolname",
name: "Source Container",
fieldName: "sourceContainerName",
minWidth: 130,
maxWidth: 140,
},
{
key: "targetdbname",
name: "Destination Database",
fieldName: "targetDatabaseName",
minWidth: 130,
maxWidth: 140,
},
{
key: "targetcolname",
name: "Destination Container",
fieldName: "targetContainerName",
minWidth: 130,
maxWidth: 140,
},
],
}));
jest.mock("../../../CopyJobUtils", () => ({
getDefaultJobName: jest.fn((selectedDatabaseAndContainers) => {
if (selectedDatabaseAndContainers.length === 1) {
const { sourceDatabaseName, sourceContainerName, targetDatabaseName, targetContainerName } =
selectedDatabaseAndContainers[0];
return `${sourceDatabaseName}.${sourceContainerName}_${targetDatabaseName}.${targetContainerName}_123456789`;
}
return "";
}),
}));
describe("PreviewCopyJob", () => {
const mockSetCopyJobState = jest.fn();
const mockSetContextError = jest.fn();
const mockSetFlow = jest.fn();
const mockResetCopyJobState = jest.fn();
const mockSubscription: Subscription = {
subscriptionId: "test-subscription-id",
displayName: "Test Subscription",
state: "Enabled",
subscriptionPolicies: {
locationPlacementId: "test",
quotaId: "test",
},
authorizationSource: "test",
};
const mockDatabaseAccount = {
id: "/subscriptions/test-subscription-id/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
name: "test-account",
location: "East US",
type: "Microsoft.DocumentDB/databaseAccounts",
kind: "GlobalDocumentDB",
properties: {
documentEndpoint: "https://test-account.documents.azure.com:443/",
gremlinEndpoint: "https://test-account.gremlin.cosmosdb.azure.com:443/",
tableEndpoint: "https://test-account.table.cosmosdb.azure.com:443/",
cassandraEndpoint: "https://test-account.cassandra.cosmosdb.azure.com:443/",
},
};
const createMockContext = (overrides: Partial<CopyJobContextState> = {}): CopyJobContextProviderType => {
const defaultState: CopyJobContextState = {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: mockSubscription,
account: mockDatabaseAccount,
databaseId: "source-database",
containerId: "source-container",
},
target: {
subscriptionId: "test-subscription-id",
account: mockDatabaseAccount,
databaseId: "target-database",
containerId: "target-container",
},
sourceReadAccessFromTarget: false,
...overrides,
};
return {
contextError: null,
setContextError: mockSetContextError,
copyJobState: defaultState,
setCopyJobState: mockSetCopyJobState,
flow: null,
setFlow: mockSetFlow,
resetCopyJobState: mockResetCopyJobState,
explorer: {} as any,
};
};
beforeEach(() => {
jest.clearAllMocks();
});
it("should render with default state and empty job name", () => {
const mockContext = createMockContext();
const { container } = render(
<CopyJobContext.Provider value={mockContext}>
<PreviewCopyJob />
</CopyJobContext.Provider>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("should render with pre-filled job name", () => {
const mockContext = createMockContext({
jobName: "custom-job-name-123",
});
const { container } = render(
<CopyJobContext.Provider value={mockContext}>
<PreviewCopyJob />
</CopyJobContext.Provider>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("should render with missing source subscription information", () => {
const mockContext = createMockContext({
source: {
subscription: undefined,
account: mockDatabaseAccount,
databaseId: "source-database",
containerId: "source-container",
},
});
const { container } = render(
<CopyJobContext.Provider value={mockContext}>
<PreviewCopyJob />
</CopyJobContext.Provider>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("should render with missing source account information", () => {
const mockContext = createMockContext({
source: {
subscription: mockSubscription,
account: null,
databaseId: "source-database",
containerId: "source-container",
},
});
const { container } = render(
<CopyJobContext.Provider value={mockContext}>
<PreviewCopyJob />
</CopyJobContext.Provider>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("should render with undefined database and container names", () => {
const mockContext = createMockContext({
source: {
subscription: mockSubscription,
account: mockDatabaseAccount,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "test-subscription-id",
account: mockDatabaseAccount,
databaseId: "",
containerId: "",
},
});
const { container } = render(
<CopyJobContext.Provider value={mockContext}>
<PreviewCopyJob />
</CopyJobContext.Provider>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("should render with long subscription and account names", () => {
const longNameSubscription: Subscription = {
...mockSubscription,
displayName: "This is a very long subscription name that might cause display issues if not handled properly",
};
const longNameAccount = {
...mockDatabaseAccount,
name: "this-is-a-very-long-database-account-name-that-might-cause-display-issues",
};
const mockContext = createMockContext({
source: {
subscription: longNameSubscription,
account: longNameAccount,
databaseId: "long-database-name-for-testing-purposes",
containerId: "long-container-name-for-testing-purposes",
},
});
const { container } = render(
<CopyJobContext.Provider value={mockContext}>
<PreviewCopyJob />
</CopyJobContext.Provider>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("should render with online migration type", () => {
const mockContext = createMockContext({
migrationType: CopyJobMigrationType.Online,
jobName: "online-migration-job",
});
const { container } = render(
<CopyJobContext.Provider value={mockContext}>
<PreviewCopyJob />
</CopyJobContext.Provider>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("should handle special characters in database and container names", () => {
const mockContext = createMockContext({
source: {
subscription: mockSubscription,
account: mockDatabaseAccount,
databaseId: "test-db_with@special#chars",
containerId: "test-container_with@special#chars",
},
target: {
subscriptionId: "test-subscription-id",
account: mockDatabaseAccount,
databaseId: "target-db_with@special#chars",
containerId: "target-container_with@special#chars",
},
jobName: "job-with@special#chars_123",
});
const { container } = render(
<CopyJobContext.Provider value={mockContext}>
<PreviewCopyJob />
</CopyJobContext.Provider>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("should render component with cross-subscription setup", () => {
const targetAccount = {
...mockDatabaseAccount,
id: "/subscriptions/target-subscription-id/resourceGroups/target-rg/providers/Microsoft.DocumentDB/databaseAccounts/target-account",
name: "target-account",
};
const mockContext = createMockContext({
target: {
subscriptionId: "target-subscription-id",
account: targetAccount,
databaseId: "target-database",
containerId: "target-container",
},
sourceReadAccessFromTarget: true,
});
const { container } = render(
<CopyJobContext.Provider value={mockContext}>
<PreviewCopyJob />
</CopyJobContext.Provider>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("should call setCopyJobState with default job name on mount", async () => {
const mockContext = createMockContext();
render(
<CopyJobContext.Provider value={mockContext}>
<PreviewCopyJob />
</CopyJobContext.Provider>,
);
await waitFor(() => {
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
});
});
it("should update job name when text field is changed", async () => {
const mockContext = createMockContext({
jobName: "initial-job-name",
});
const { getByDisplayValue } = render(
<CopyJobContext.Provider value={mockContext}>
<PreviewCopyJob />
</CopyJobContext.Provider>,
);
const jobNameInput = getByDisplayValue("initial-job-name");
fireEvent.change(jobNameInput, { target: { value: "updated-job-name" } });
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
});
it("should handle empty job name input", () => {
const mockContext = createMockContext({
jobName: "existing-name",
});
const { getByDisplayValue } = render(
<CopyJobContext.Provider value={mockContext}>
<PreviewCopyJob />
</CopyJobContext.Provider>,
);
const jobNameInput = getByDisplayValue("existing-name");
fireEvent.change(jobNameInput, { target: { value: "" } });
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
});
it("should display proper field labels from ContainerCopyMessages", () => {
const mockContext = createMockContext();
const { getByText } = render(
<CopyJobContext.Provider value={mockContext}>
<PreviewCopyJob />
</CopyJobContext.Provider>,
);
expect(getByText(/Job name/i)).toBeInTheDocument();
expect(getByText(/Source subscription/i)).toBeInTheDocument();
expect(getByText(/Source account/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,58 @@
import { DetailsList, DetailsListLayoutMode, Stack, Text, TextField } from "@fluentui/react";
import React, { useEffect } from "react";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { getDefaultJobName } from "../../../CopyJobUtils";
import FieldRow from "../Components/FieldRow";
import { getPreviewCopyJobDetailsListColumns } from "./Utils/PreviewCopyJobUtils";
const PreviewCopyJob: React.FC = () => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const selectedDatabaseAndContainers = [
{
sourceDatabaseName: copyJobState.source?.databaseId,
sourceContainerName: copyJobState.source?.containerId,
targetDatabaseName: copyJobState.target?.databaseId,
targetContainerName: copyJobState.target?.containerId,
},
];
useEffect(() => {
onJobNameChange(undefined, getDefaultJobName(selectedDatabaseAndContainers));
}, []);
const jobName = copyJobState.jobName;
const onJobNameChange = (_ev?: React.FormEvent, newValue?: string) => {
setCopyJobState((prevState) => ({
...prevState,
jobName: newValue || "",
}));
};
return (
<Stack tokens={{ childrenGap: 20 }} className="previewCopyJobContainer">
<FieldRow label={ContainerCopyMessages.jobNameLabel}>
<TextField value={jobName} onChange={onJobNameChange} />
</FieldRow>
<Stack>
<Text className="bold">{ContainerCopyMessages.sourceSubscriptionLabel}</Text>
<Text>{copyJobState.source?.subscription?.displayName}</Text>
</Stack>
<Stack>
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text>{copyJobState.source?.account?.name}</Text>
</Stack>
<Stack>
<DetailsList
items={selectedDatabaseAndContainers}
layoutMode={DetailsListLayoutMode.justified}
checkboxVisibility={2}
columns={getPreviewCopyJobDetailsListColumns()}
/>
</Stack>
</Stack>
);
};
export default PreviewCopyJob;

View File

@@ -0,0 +1,8 @@
import { getPreviewCopyJobDetailsListColumns } from "./PreviewCopyJobUtils";
describe("PreviewCopyJobUtils", () => {
it("should return correctly formatted columns for preview copy job details list", () => {
const columns = getPreviewCopyJobDetailsListColumns();
expect(columns).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,43 @@
import { IColumn } from "@fluentui/react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
const commonProps = {
minWidth: 130,
maxWidth: 140,
styles: {
root: {
whiteSpace: "normal",
lineHeight: "1.2",
wordBreak: "break-word",
},
},
};
export const getPreviewCopyJobDetailsListColumns = (): IColumn[] => {
return [
{
key: "sourcedbname",
name: ContainerCopyMessages.sourceDatabaseLabel,
fieldName: "sourceDatabaseName",
...commonProps,
},
{
key: "sourcecolname",
name: ContainerCopyMessages.sourceContainerLabel,
fieldName: "sourceContainerName",
...commonProps,
},
{
key: "targetdbname",
name: ContainerCopyMessages.targetDatabaseLabel,
fieldName: "targetDatabaseName",
...commonProps,
},
{
key: "targetcolname",
name: ContainerCopyMessages.targetContainerLabel,
fieldName: "targetContainerName",
...commonProps,
},
];
};

View File

@@ -0,0 +1,62 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PreviewCopyJobUtils should return correctly formatted columns for preview copy job details list 1`] = `
[
{
"fieldName": "sourceDatabaseName",
"key": "sourcedbname",
"maxWidth": 140,
"minWidth": 130,
"name": "Source database",
"styles": {
"root": {
"lineHeight": "1.2",
"whiteSpace": "normal",
"wordBreak": "break-word",
},
},
},
{
"fieldName": "sourceContainerName",
"key": "sourcecolname",
"maxWidth": 140,
"minWidth": 130,
"name": "Source container",
"styles": {
"root": {
"lineHeight": "1.2",
"whiteSpace": "normal",
"wordBreak": "break-word",
},
},
},
{
"fieldName": "targetDatabaseName",
"key": "targetdbname",
"maxWidth": 140,
"minWidth": 130,
"name": "Destination database",
"styles": {
"root": {
"lineHeight": "1.2",
"whiteSpace": "normal",
"wordBreak": "break-word",
},
},
},
{
"fieldName": "targetContainerName",
"key": "targetcolname",
"maxWidth": 140,
"minWidth": 130,
"name": "Destination container",
"styles": {
"root": {
"lineHeight": "1.2",
"whiteSpace": "normal",
"wordBreak": "break-word",
},
},
},
]
`;

View File

@@ -0,0 +1,219 @@
import "@testing-library/jest-dom";
import { render } from "@testing-library/react";
import React from "react";
import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
import { AccountDropdown } from "./AccountDropdown";
describe("AccountDropdown", () => {
const mockOnChange = jest.fn();
const mockAccountOptions: DropdownOptionType[] = [
{
key: "account-1",
text: "Development Account",
data: {
id: "account-1",
name: "Development Account",
location: "East US",
resourceGroup: "dev-rg",
kind: "GlobalDocumentDB",
properties: {
documentEndpoint: "https://dev-account.documents.azure.com:443/",
provisioningState: "Succeeded",
consistencyPolicy: {
defaultConsistencyLevel: "Session",
},
},
},
},
{
key: "account-2",
text: "Production Account",
data: {
id: "account-2",
name: "Production Account",
location: "West US 2",
resourceGroup: "prod-rg",
kind: "GlobalDocumentDB",
properties: {
documentEndpoint: "https://prod-account.documents.azure.com:443/",
provisioningState: "Succeeded",
consistencyPolicy: {
defaultConsistencyLevel: "Strong",
},
},
},
},
{
key: "account-3",
text: "Testing Account",
data: {
id: "account-3",
name: "Testing Account",
location: "Central US",
resourceGroup: "test-rg",
kind: "GlobalDocumentDB",
properties: {
documentEndpoint: "https://test-account.documents.azure.com:443/",
provisioningState: "Succeeded",
consistencyPolicy: {
defaultConsistencyLevel: "Eventual",
},
},
},
},
];
beforeEach(() => {
jest.clearAllMocks();
});
describe("Snapshot Testing", () => {
it("matches snapshot with all account options", () => {
const { container } = render(
<AccountDropdown options={mockAccountOptions} disabled={false} onChange={mockOnChange} />,
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with selected account", () => {
const { container } = render(
<AccountDropdown
options={mockAccountOptions}
selectedKey="account-2"
disabled={false}
onChange={mockOnChange}
/>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with disabled dropdown", () => {
const { container } = render(
<AccountDropdown
options={mockAccountOptions}
selectedKey="account-1"
disabled={true}
onChange={mockOnChange}
/>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with empty options", () => {
const { container } = render(<AccountDropdown options={[]} disabled={false} onChange={mockOnChange} />);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with single option", () => {
const { container } = render(
<AccountDropdown
options={[mockAccountOptions[0]]}
selectedKey="account-1"
disabled={false}
onChange={mockOnChange}
/>,
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with special characters in options", () => {
const specialOptions = [
{
key: "special",
text: 'Account with & <special> "characters"',
data: {
id: "special",
name: 'Account with & <special> "characters"',
location: "East US",
},
},
];
const { container } = render(
<AccountDropdown options={specialOptions} disabled={false} onChange={mockOnChange} />,
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with long account name", () => {
const longNameOption = [
{
key: "long",
text: "This is an extremely long account name that tests how the component handles text overflow and layout constraints in the dropdown",
data: {
id: "long",
name: "This is an extremely long account name that tests how the component handles text overflow and layout constraints in the dropdown",
location: "North Central US",
},
},
];
const { container } = render(
<AccountDropdown options={longNameOption} selectedKey="long" disabled={false} onChange={mockOnChange} />,
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with disabled state and no selection", () => {
const { container } = render(
<AccountDropdown options={mockAccountOptions} disabled={true} onChange={mockOnChange} />,
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches snapshot with multiple account types", () => {
const mixedAccountOptions = [
{
key: "sql-account",
text: "SQL API Account",
data: {
id: "sql-account",
name: "SQL API Account",
kind: "GlobalDocumentDB",
location: "East US",
},
},
{
key: "mongo-account",
text: "MongoDB Account",
data: {
id: "mongo-account",
name: "MongoDB Account",
kind: "MongoDB",
location: "West US",
},
},
{
key: "cassandra-account",
text: "Cassandra Account",
data: {
id: "cassandra-account",
name: "Cassandra Account",
kind: "Cassandra",
location: "Central US",
},
},
];
const { container } = render(
<AccountDropdown
options={mixedAccountOptions}
selectedKey="mongo-account"
disabled={false}
onChange={mockOnChange}
/>,
);
expect(container.firstChild).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,31 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { Dropdown } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
import FieldRow from "../../Components/FieldRow";
interface AccountDropdownProps {
options: DropdownOptionType[];
selectedKey?: string;
disabled: boolean;
onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void;
}
export const AccountDropdown: React.FC<AccountDropdownProps> = React.memo(
({ options, selectedKey, disabled, onChange }) => (
<FieldRow label={ContainerCopyMessages.sourceAccountDropdownLabel}>
<Dropdown
placeholder={ContainerCopyMessages.sourceAccountDropdownPlaceholder}
ariaLabel={ContainerCopyMessages.sourceAccountDropdownLabel}
options={options}
disabled={disabled}
required
selectedKey={selectedKey}
onChange={onChange}
/>
</FieldRow>
),
(prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey,
);

View File

@@ -0,0 +1,72 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import React from "react";
import { MigrationTypeCheckbox } from "./MigrationTypeCheckbox";
describe("MigrationTypeCheckbox", () => {
const mockOnChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
describe("Component Rendering", () => {
it("should render with default props (unchecked state)", () => {
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
expect(container.firstChild).toMatchSnapshot();
});
it("should render in checked state", () => {
const { container } = render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
expect(container.firstChild).toMatchSnapshot();
});
it("should display the correct label text", () => {
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toBeInTheDocument();
const label = screen.getByText("Copy container in offline mode");
expect(label).toBeInTheDocument();
});
it("should have correct accessibility attributes when checked", () => {
render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toBeChecked();
expect(checkbox).toHaveAttribute("checked");
});
});
describe("FluentUI Integration", () => {
it("should render FluentUI Checkbox component correctly", () => {
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toBeInTheDocument();
expect(checkbox).toHaveAttribute("type", "checkbox");
});
it("should render FluentUI Stack component correctly", () => {
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
const stackContainer = document.querySelector(".migrationTypeRow");
expect(stackContainer).toBeInTheDocument();
});
it("should apply FluentUI Stack horizontal alignment correctly", () => {
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
const stackContainer = container.querySelector(".migrationTypeRow");
expect(stackContainer).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,16 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { Checkbox, Stack } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
interface MigrationTypeCheckboxProps {
checked: boolean;
onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
}
export const MigrationTypeCheckbox: React.FC<MigrationTypeCheckboxProps> = React.memo(({ checked, onChange }) => (
<Stack horizontal horizontalAlign="space-between" className="migrationTypeRow">
<Checkbox label={ContainerCopyMessages.migrationTypeCheckboxLabel} checked={checked} onChange={onChange} />
</Stack>
));

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