Compare commits

...

139 Commits

Author SHA1 Message Date
jawelton74
8c0e6da377 Exclude the obj directory when creating the Nuget packages. (#2277) 2025-12-09 10:31:59 -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
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
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
vchske
0817acf404 Commenting or deleting UI references to Query Advisor (#2209)
* Commenting or deleting UI references to Query Advisor

* Removing (commenting out) QueryTabComponent from two views

* Added new splash screen button, commented out copilot prompt bar

* Fixing unit test
2025-08-28 15:47:29 -07:00
asier-isayas
8e2c46301d Allow Mongo users to change thee Guid Representation when conducting CRUD operations for documents (#2204)
* mongo guid representation

* format

* fix return type

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-08-18 12:30:04 -07:00
BChoudhury-ms
012d043c78 Fix CloudShell terminal hanging for Mongo and Cassandra shells due to missing updateTerminalData method (#2199) 2025-08-13 13:02:27 -07:00
Mike Krüger
3afd74a957 Fix faifax default cloud shell region. (#2201) 2025-08-13 11:25:18 -07:00
jawelton74
0ef4399ba4 Support data plane RBAC for E2E tests. (#2176)
* Acquire token for NoSQL account prior to running tests.

* Change client id to user assigned managed identity.

* Change to use managed identity. Add token variables for gremlin and
tables.

* Add RBAC details to test README.

* Add token for SQL readonly database. Skip resource token tests when RBAC
enabled.

* Use hardcoded account name for sql readonly.

* Use specific tag for sql readonly.

* Remove comment.
2025-08-05 10:59:57 -07:00
BChoudhury-ms
870863a723 Disabling telemetry for mongo shell (#2195)
* Disabling telemetry for mongo shell

* fix formatting issues

* removed empty spaces from terminal and segregated disableTelemetry command
2025-07-31 10:03:09 +05:30
JustinKol
e3815734db Min/Max UX changes for Scale & Settings tab (#2192)
* master pull

* changed min max text boxes for autoscale

* test update

* Update .npmrc

* Update settings.json

* Prettier run
2025-07-28 13:24:19 -04:00
BChoudhury-ms
5ea78f9abf Add VCore MongoDB support for VS Code extension integration (#2181)
* support for vCore mongodb support for vscode extension

* added comment for future referance on the double encoded connection string
2025-07-28 22:52:23 +05:30
sindhuba
8a56214ec2 Upgrade SDK version to 4.5.0 (#2194) 2025-07-21 12:44:42 -07:00
Laurent Nguyen
e3ae006100 feat: Disable few checks if Fabric native (#2188)
* Disable few checks if Fabric native

* Fix typo in error message
2025-07-17 08:47:56 +02:00
asier-isayas
589b61afaf Upgrade Cosmos SDK to v4.4.0 (#2187)
* Upgrade Cosmos SDK to v4.4.0

* fix unit tests

* explain crypto.subtle

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-07-15 11:28:28 -07:00
Laurent Nguyen
eb3f6bc93f Fabric: set default throughput for new container to 5k (#2190) 2025-07-15 20:22:06 +02:00
bogercraig
6ec909a97b Use Config.json to Set Environment Specific AAD Auth Settings (#2184)
* Force hosted explorer to load config.json before calling useAADAuth.

* Ensure AAD endpoint from config.json loads before useAADAuth.

* Fix immediate linting errors and warnings.

* Remove separate spinner for waiting on config to load.

* Simplifying auth and reintroducing "No tokens for given scope, and no authorization code was passed to acquireToken." error.  Blocking on login if incorrect AAD_ENDPOINT provided.

* Fix linting errors.

* Add error handling to prevent unhandled errors thrown when login prompt is cancelled prematurely.
2025-07-11 15:34:10 -07:00
asier-isayas
08a51ca6b1 Remove unneeded and misleading console log (#2186)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-07-10 08:58:11 -07:00
jawelton74
30a3b5c7a4 Support data plane RBAC for Gremlin API (#2182)
* Refactor logic for determining if we should use data plane RBAC to a
common function.

* Support RBAC for gremlin API.

* Refactor to use common function.

* Fix unit tests.

* Move test function inside test scope.

* Minor clean ups.

* Reinstate utf8ToB64 function in case this breaks a corner case.
2025-07-09 16:23:09 -07:00
jawelton74
f370507a27 Refactor logic for determining if we should use data plane RBAC (#2180)
* Refactor logic for determining if we should use data plane RBAC to a
common function.

* Move test function into test scope.
2025-07-08 11:41:43 -07:00
sunghyunkang1111
e0edaf405c MSRC fixes for testExplorer and HeatMap (#2183)
* MSRC fixes for testExplorer and HeatMap

* MSRC fixes for testExplorer and HeatMap
2025-07-07 11:00:29 -05:00
Sourabh Jain
f8231600d6 DB Shell: Fix User Consent Message as per Privacy Guidlines and enable this for customers (#2173)
* Fix user Consent msg and fix issues

* fix format

* test fix

* fix snap file
2025-07-07 21:13:34 +05:30
asier-isayas
45c8d70c77 do not set disableNonStreamingOrderByQuery flag (#2179)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-06-26 07:43:55 -07:00
bogercraig
70d7ee755b Add Additional Config from Config.json and Clean Up Unused Config (#2178)
* Cleaning up unused config from portal backend migration.

* Remove config used during backend migration.

* Add backend endpoint override from config.json.

* Add AAD and ARM endpoint overrides from config.json.

* Add GRAPH_ENDPOINT override from config.json.

* Remove unused catalog api version.

* Remove isTerminalEnabled from config.  Cannot find reference in DE, DE Release, or Frontend.

* Fix mongo client unit tests.

* Removing BackendApi from constants since no longer referenced in the codebase.

* Talked with Tara and added the CATALOG_API_VERSION back to the config and substituted out the hard coded string it was intended to replace.

* Include existing portal backend endpoints in default allow list.

* Add localhost:1234 endpoint for Mongo unit tests.

* Removing old backend local test endpoint from backend endpoint list.
2025-06-24 12:50:21 -07:00
Vsevolod Kukol
0a4aed4f47 feat: Enable Container Vector Policy for Fabric Native and adjust throughput/vecotr input visibility logic (#2170) 2025-06-23 17:18:19 +02:00
Dmitry Shilov
a7d007e0dd fix: Partition Keys block for fabric (#2174)
* fix: Partition Keys block for fabric

- Show Partition Keys block for fabric
- Adjust layout and visibility of separators in AddCollectionPanel
2025-06-23 17:18:09 +02:00
sunghyunkang1111
5f4a4e5c4c Added Quickstart open action (#2175) 2025-06-17 12:08:12 -05:00
asier-isayas
1b64827c24 Upgrade ARM Client API version to 2025-05-01-preview & Fetch enableMaterializedViews account property from ARM (#2171)
* Upgrade ARM Client API version to 2025-05-01-preview

* fix npm run compile

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-06-13 06:20:08 -04:00
SATYA SB
a6ae784a45 [accessibility-3739744]:[Supporting the Platform - Azure Cosmos DB- Data Explorer - New Vertex]: When page viewport is set to 320x256px, Content under 'New Vertex' pane is not properly visible. (#2163)
Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2025-06-12 17:58:10 +05:30
SATYA SB
7458107efd [accessibility-3726486]:[Keyboard Navigation - Azure Cosmos DB - Data Explorer - New Graph]: Keyboard focus indicator is not landing on the first interactive control after activating the 'New Graph' control. (#2073)
Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2025-06-12 17:57:21 +05:30
JustinKol
64533b445f Autoscale min/max UX changes (#2162)
* master pull

* Better VSCode detection

* Prettier run

* Update .npmrc

* Update settings.json

* Fixed ESLint error

* Changed the VSCode detection to a test url that will not open if successful

* Initial UX changes to Add Collection Panel

* Removing changes from other branch

* Reverting explorer changes

* Snapshot updates and Lint fixes

* Formatting fixes

* Setting separator spacing the same

* Update test snapshot

* Reverting Manual to old UI
2025-06-04 10:40:57 -04:00
Dmitry Shilov
d7bdd0032e fix: Activate the last opened React tab after closing active tab (#2156) 2025-06-04 11:10:44 +02:00
SATYA SB
372ac6921f fix: Enhance splash screen layout with responsive design for stack elements (#2159)
Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2025-06-04 10:29:45 +05:30
SATYA SB
c6eda097fc [Supporting the Platform - Azure Cosmos DB- Data Explorer - Graphs]: When page viewport is set to 320x256px, Content under 'Notification' section is not properly visible. (#2161)
* [accessibility-3739643]: [Supporting the Platform - Azure Cosmos DB- Data Explorer - Graphs]: When page viewport is set to 320x256px, Content under 'Notification' section is not properly visible.

* fix: Adjust notification console styles for better responsiveness.

---------

Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2025-06-04 10:27:12 +05:30
Dmitry Shilov
05d02f08fa feat: Integrate Fabric Native support for throughput management (#2166) 2025-06-03 08:47:00 +02:00
jawelton74
ab4f02f74a Remove frame-src from CSP. (#2169) 2025-05-29 10:54:37 -07:00
asier-isayas
0fc6647627 Upgrade Cosmos SDK to v4.3 (#2137)
* Upgrade Cosmos SDK to v4.3

* push pkg lock

* fix tests

* fix package-lock.json

* fix package-lock

* fix package-lock.json

* fix package-lock.json

* log console warning when RU limit is reached

* fix tests

* fix format

* fix tests

* added description to RU limit message

* show warning icon on tab header

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-05-29 11:35:06 -04:00
jawelton74
c5ed537109 Fix invalid reference in frame-src and add delimiter for (#2168)
frame-ancestors.
2025-05-29 07:27:15 -07:00
jawelton74
db322ccb59 Add login.microsoftonline.com to CSP. (#2167) 2025-05-28 12:25:14 -07:00
sunghyunkang1111
2d7631c358 Added throughput buckets in the scale update (#2164) 2025-05-27 11:54:20 -05:00
JustinKol
e401c88df6 Add query results, export to json/csv button (#2143)
* master pull

* Added export to json button

* Update .npmrc

* Update settings.json

* Update .npmrc

* Update .npmrc

* revert .npmrc file

* Added export to csv

* Prettier run

* Disable react/prop-types ESLint check

* Changed to download icon

* Added titles

* Switched to download icon already present

* Fixed download title

* Added check for all unique headers and added seperator header for excel only

* Moved to inline dropdown under download button

* Capitalized CSV and JSON

* Fixed where format wasn't updating before exporting

* removed testing console log

* Removed unnecessary async

* Added csv escaping

* Removing unnecessary escape character

* Separated into different functions for better organization and readability

* Fixed any value
2025-05-21 07:54:34 -04:00
asier-isayas
f14b574527 Enable Full Text Search on all NoSQL accounts (#2157)
* Enable Full Text Search on all NoSQL accounts

* format

* fix tests

* run tests

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-05-20 10:49:09 -04:00
Dmitry Shilov
45513e5e1b fix: Update grid after uploading documents (#2131)
- Refactor UploadItemsPane to support onUpload callback
- Enhance bulk insert result type
- Insert uploaded documents to the grid
2025-05-19 11:35:04 +02:00
Dmitry Shilov
15154dfd6a fix: Implement abort functionality for bulk document deletion (#2139) 2025-05-19 11:34:44 +02:00
Nishtha Ahuja
7aeb682bea Connect with VScode on Quickstart Page (#2151)
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-05-15 17:55:51 +05:30
SATYA SB
35051bace5 [Screen reader - Azure Cosmos DB-New Container]: Screen reader does not announce the associated label information for the 'Estimated monthly cost' info icon under 'New Container' blade. (#2091)
* [accessibility-3690553]:[Screen reader - Azure Cosmos DB-New Container]: Screen reader does not announce the associated label information for the 'Estimated monthly cost' info icon under 'New Container' blade.

* snap updated.

---------

Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2025-05-14 22:45:44 +05:30
JustinKol
5fc53a7f89 [BUG] Prevent dialog from opening if VS Code is open (#2145)
* master pull

* Reverting .npmrc file

* Removed logging userContext

* Prettier run

* Added support for opening CosmosDB Account without clicking database tab

* Reverting change in settings.json

* Prettier run

* Added check if the link closed

* Added check if the link didn't closed

* Check if VS Code was opened, if not popup with download button link

* Prettier run

* Redirect to Download VS Code if not opened

* Added error message to VS Code timeout and redirect

* Fixing baseUrl from testing

* Increased timeout for when user is asked to open VS Code

* switched to iframe for redirects

* Fixed VS Code url

* Removed insider url

* Added log messages

* Added link to vCore data explorer dashboard

* Increased timeout to 2.5 secs to see if that helps with VS Code open popup

* Changed to dialog box

* Changed param name

* Increase startTime for extra popup

* Changed to dialog box only when no VS Code detected

* Fixed vscode url

* Changed title back to Open CosmosDB in VS Code

* Added text on required extensions

* Removed text on required extensions as it will prompt by default

* Fixed wording and Primary Button timeout

* Spelled out VS Code

* Removed console log of timeout

* Updated snapshots and lowered timeout

* Remove VS Code button from Gremlin

* Prettier run on CommandBarComponentButtonFactory

* Changed from referencing location to a link

* Prettier run

* Reverting back to popup for opening

* Updated unit test snapshots

* Added vscode: to Content Security Policy

* Reverting back to popup only if opening times out

* Corrected misspelled url

* Corrected url

* Added event listener to check if DE is in focus or not, to prevent showing dialog when VS Code is opened

* Prettier and url fix

* Moved closeDialog before removing event listener

* Changed handleFocus to a const rather than function

* Changed listener to document

* Decreased timeout time

* Reverting back to popup by default as too many factors are present using a timeout
2025-05-14 10:01:39 -04:00
SATYA SB
ed83bf47e4 [accessibility-3556762]:[Screen Reader- Azure Cosmos DB- Data Explorer]: Two H1 heading are defined on the page. (#2061)
Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2025-05-14 15:29:08 +05:30
SATYA SB
d657c4919e [Supporting the platform - Azure Cosmos DB - Data Explorer]: All the controls present under 'Data Explorer' page are truncated after setting the viewport to 320*256 pixel. (#2092)
* [accessibility-2278267]:[Supporting the platform - Azure Cosmos DB - Data Explorer]: All the controls present under 'Data Explorer' page are truncated after setting the viewport to 320*256 pixel.

* feat: implement zoom level hook and update components for responsive design.

* Format fixed.

* feat: add conditionalClass utility and refactor className assignments for improved readability.

---------

Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2025-05-14 10:16:19 +05:30
sunghyunkang1111
95d33356c3 Hide throughput bucket settings for nonshared, nondedicated throughput container (#2146) 2025-05-13 18:58:42 -05:00
sunghyunkang1111
1081432bbd Added AFEC check in userContext (#2140)
* Added AFEC check in userContext

* Update unit tests and fix Group to Bucket
2025-05-13 11:26:47 -05:00
Nishtha Ahuja
44d815454c for manual isAutoscale is supposed to be false (#2141)
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-05-13 20:04:12 +05:30
JustinKol
6d604490d3 URL Correction in VS Code link (#2142)
* master pull

* Reverting .npmrc file

* Removed logging userContext

* Prettier run

* Added support for opening CosmosDB Account without clicking database tab

* Reverting change in settings.json

* Prettier run

* Added check if the link closed

* Added check if the link didn't closed

* Check if VS Code was opened, if not popup with download button link

* Prettier run

* Redirect to Download VS Code if not opened

* Added error message to VS Code timeout and redirect

* Fixing baseUrl from testing

* Increased timeout for when user is asked to open VS Code

* switched to iframe for redirects

* Fixed VS Code url

* Removed insider url

* Added log messages

* Added link to vCore data explorer dashboard

* Increased timeout to 2.5 secs to see if that helps with VS Code open popup

* Changed to dialog box

* Changed param name

* Increase startTime for extra popup

* Changed to dialog box only when no VS Code detected

* Fixed vscode url

* Changed title back to Open CosmosDB in VS Code

* Added text on required extensions

* Removed text on required extensions as it will prompt by default

* Fixed wording and Primary Button timeout

* Spelled out VS Code

* Removed console log of timeout

* Updated snapshots and lowered timeout

* Remove VS Code button from Gremlin

* Prettier run on CommandBarComponentButtonFactory

* Changed from referencing location to a link

* Prettier run

* Reverting back to popup for opening

* Updated unit test snapshots

* Added vscode: to Content Security Policy

* Reverting back to popup only if opening times out

* Corrected misspelled url

* Corrected url
2025-05-13 10:26:54 -04:00
asier-isayas
34edd96c76 Default New Global Secondary Index Panel to be sharded (#2138)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-05-12 13:12:20 -04:00
JustinKol
7c0aae6ffa Open VS Code via Data Explorer button (#2116)
* master pull

* Reverting .npmrc file

* Removed logging userContext

* Prettier run

* Added support for opening CosmosDB Account without clicking database tab

* Reverting change in settings.json

* Prettier run

* Added check if the link closed

* Added check if the link didn't closed

* Check if VS Code was opened, if not popup with download button link

* Prettier run

* Redirect to Download VS Code if not opened

* Added error message to VS Code timeout and redirect

* Fixing baseUrl from testing

* Increased timeout for when user is asked to open VS Code

* switched to iframe for redirects

* Fixed VS Code url

* Removed insider url

* Added log messages

* Added link to vCore data explorer dashboard

* Increased timeout to 2.5 secs to see if that helps with VS Code open popup

* Changed to dialog box

* Changed param name

* Increase startTime for extra popup

* Changed to dialog box only when no VS Code detected

* Fixed vscode url

* Changed title back to Open CosmosDB in VS Code

* Added text on required extensions

* Removed text on required extensions as it will prompt by default

* Fixed wording and Primary Button timeout

* Spelled out VS Code

* Removed console log of timeout

* Updated snapshots and lowered timeout

* Remove VS Code button from Gremlin

* Prettier run on CommandBarComponentButtonFactory

* Changed from referencing location to a link

* Prettier run

* Reverting back to popup for opening

* Updated unit test snapshots

* Added vscode: to Content Security Policy

* Reverting back to popup only if opening times out
2025-05-12 10:55:06 -04:00
JustinKol
86e8bf3c80 Show unique keys in Settings for SQL api (#2136)
* master pull

* Added unique keys in Settings for SQL api

* Revert settings.json

* Reverting other PR changes that haven't merged

* Adding space back in

* Added unit tests
2025-05-09 12:37:56 -04:00
Dmitry Shilov
e98c9a83b8 fix: Add collapsible feature to Accordion in SettingsPane (#2125) 2025-05-09 12:13:17 +02:00
Dmitry Shilov
7d57a90d50 fix: Correct documentId method call in error logging for deletion failures (#2132) 2025-05-09 12:12:32 +02:00
Dmitry Shilov
0f896f556b feat: Enhance UploadItemsPane with error handling and status icons for file uploads (#2133) 2025-05-09 12:11:26 +02:00
sunghyunkang1111
985c744198 get the user defined system key value for updating (#2134)
* get the user defined system key value for updating

* Added the systemkey check for non-defined system key
2025-05-08 09:14:09 -05:00
Sourabh Jain
2dbec019af CloudShell: Changed User Consent Message and Add appName in commands (#2130)
* message change

* updated commands
2025-05-08 06:41:35 +05:30
Laurent Nguyen
2fa95a281e Disable "Learn more" link for now in Fabric Home (#2129) 2025-05-07 11:52:41 +02:00
Sourabh Jain
ea6f3d1579 Cloudshell: Few Enhancement (#2128)
* few enhancement

* fix time
2025-05-05 21:17:36 +05:30
Dmitry Shilov
f9b0abdd14 fix: Add overflow property and set minimum heights for flex and sidebar containers (#2124)
* fix: Add overflow property and set minimum heights for flex and sidebar containers

* fix: Update overflow and minimum height properties for tab panes and containers
2025-05-05 15:50:43 +02:00
asier-isayas
10cda21401 Add Vector Index Shard Key option on container creation (#2097)
* Add vector index shard key

* npm run format

* rename shard key to vector index shard key

* add tooltip for quantization byte size

* change text for GSI and container in VectorEmbedding Policy

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-05-02 11:05:40 -04:00
Sourabh Jain
205355bf55 Shell: Integrate Cloudshell to existing shells (#2098)
* first draft

* refactored code

* ux fix

* add custom header support and fix ui

* minor changes

* hide last command also

* remove logger

* bug fixes

* updated loick file

* fix tests

* moved files

* update readme

* documentation update

* fix compilationerror

* undefined check handle

* format fix

* format fix

* fix lints

* format fix

* fix unrelatred test

* code refator

* fix format

* ut fix

* cgmanifest

* Revert "cgmanifest"

This reverts commit 2e76a6926ee0d3d4e0510f2e04e03446c2ca8c47.

* fix snap

* test fix

* formatting code

* updated xterm

* include username in command

* cloudshell add exit

* fix test

* format fix

* tets fix

* fix multiple open cloudshell calls

* socket time out after 20 min

* remove unused code

* 120 min

* Addressed comments
2025-04-30 13:19:01 -07:00
sunghyunkang1111
bb66deb3a4 Added more test cases and fix system partition key load issue (#2126)
* Added more test cases and fix system partition key load issue

* Fix unit tests and fix ci

* Updated test snapsho
2025-04-30 15:18:11 -05:00
Laurent Nguyen
fe73d0a1c6 Fix Fabric Native ReadOnly mode (#2123)
* Add FabricNativeReadOnly mode

* Hide Settings for Fabric native readonly

* Fix strict compil
2025-04-30 17:37:54 +02:00
sakshigupta12feb
e90e1fc581 Updated the Migrate data link (#2122)
* updated the Migrate data link

* updated the Migrate data link (removed en-us)

---------

Co-authored-by: Sakshi Gupta <sakshig+microsoft@microsoft.com>
2025-04-30 17:48:15 +05:30
Nishtha Ahuja
8bcad6e0e0 Emulator Quickstart Tutorials (#2121)
* updated all outdated sample apps
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-04-30 13:32:53 +05:30
SATYA SB
9f3236c29c [accessibility-3560183]:[Screen reader - Cosmos DB Query Copilot - Query Faster with Copilot>Enable Query Advisor]: Screen reader does not announce the dialog information on invoking 'Clear editor' button. (#2068)
Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2025-04-30 11:35:58 +05:30
Laurent Nguyen
2f858ecf9b Fabric native improvements: Settings pane, Partition Key settings tab, sample data and message contract (#2119)
* Hide entire Accordion of options in Settings Pane

* In PartitionKeyComponent hide "Change partition key" label when read-only.

* Create sample data container with correct pkey

* Add unit tests to PartitionKeyComponent

* Fix format

* fix unit test snapshot

* Add Fabric message to open Settings to given tab id

* Improve syntax on message contract

* Remove "(preview)" in partition key tab title in Settings Tab
2025-04-29 17:50:20 +02:00
sunghyunkang1111
274c85d2de Added document test skips (#2120) 2025-04-28 13:42:18 -05:00
asier-isayas
d9436be61b Remove references to old Portal Backend (#2109)
* remove old portal backend endpoints

* format

* fix tests

* remove Materialized Views from createResourceTokenTreeNodes

* add portal FE back to defaultAllowedBackendEndpoints

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-04-28 13:29:27 -04:00
Nishtha Ahuja
6db2536a61 fixed quickstart tab in emulator (#2115)
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-04-28 21:11:27 +05:30
Sourabh Jain
714f38a1be Mongo RU Schema Analyzer Deprecation (#2117)
* remove menu item

* remove unused import
2025-04-27 20:43:24 -05:00
asier-isayas
af4e1d10b4 GSI: Remove Unique Key Policy and Manual Throughput (#2114)
* Remove Unique Key Policy and Manual Throughput

* fix tests

* remove manual throughput option from scale & settings

* fix test

* cleanup

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-04-18 14:39:31 -04:00
Laurent Nguyen
6dbc412fa6 Implement Sample data import for Fabric Home (#2101)
* Implement dialog to import sample data

* Fix format

* Cosmetic fixes

* fix: update help link to point to the new documentation URL

---------

Co-authored-by: Sevo Kukol <sevoku@microsoft.com>
2025-04-16 14:27:22 -07:00
sunghyunkang1111
3470f56535 Pk missing fix (#2107)
* fix partition key missing not being able to load the document

* Implement E2E tests for documents with different partitionkeys

* Implement E2E tests for documents with different partitionkeys

* Implement E2E tests for documents with different partitionkeys

* Updated snapshot

* Updated tests for MongoRU and add create/delete tests

* Fixing system partition key showing up in Data Explorer
2025-04-16 13:12:53 -05:00
Vsevolod Kukol
00ec678569 fix: handle optional activeTab in tab activation logic (#2106) 2025-04-16 10:34:15 -07:00
Laurent Nguyen
27c9ea7ab6 Add tracing events for tracking query execution and upload documents (#2100)
* Add tracing for Execute (submit) query and Upload documents

* Trace query closer to iterator operation

* Add warnings to values used in Fabric

* Fix format

* Don't trace execute queries for filtering calls

* Remove tracing call for documents tab filtering

* Fix failing unit test.

---------

Co-authored-by: Jade Welton <jawelton@microsoft.com>
Co-authored-by: Sevo Kukol <sevoku@microsoft.com>
2025-04-16 10:31:43 -07:00
asier-isayas
d5fe2d9e9f Fix Unit and E2E tests (#2112)
* fix tests

* remove Materialized Views from createResourceTokenTreeNodes

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-04-16 10:05:36 -04:00
Laurent Nguyen
94b1e729d1 Enable original azure resource tree style for Fabric native and turn on Settings tab (#2103)
* Enable original azure resource tree for Fabric native and turn on Settings page

* Fix unit tests
2025-04-15 08:49:16 -07:00
Laurent Nguyen
afdbefe36c Implement refreshResourceTree message and hide refresh button above resource tree for Fabric native (#2102) 2025-04-15 08:48:44 -07:00
asier-isayas
2cff0fc3ff Global Secondary Index (#2071)
* add Materialized Views feature flag

* fetch MV properties from RP API and capture them in our data models

* AddMaterializedViewPanel

* undefined check

* subpartition keys

* Partition Key, Throughput, Unique Keys

* All views associated with a container (#2063) and Materialized View Target Container (#2065)

Identified Source container and Target container
Created tabs in Scale and Settings respectively
Changed the Icon of target container

* Add MV Panel

* format

* format

* styling

* add tests

* tests

* test files (#2074)

Co-authored-by: nishthaAhujaa

* fix type error

* fix tests

* merge conflict

* Panel Integration (#2075)

* integrated panel

* edited header text

---------

Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
Co-authored-by: Asier Isayas <aisayas@microsoft.com>

* updated tests (#2077)

Co-authored-by: nishthaAhujaa

* fix tests

* update treeNodeUtil test snap

* update settings component test snap

* fixed source container in global "New Materialized View"

* source container check (#2079)

Co-authored-by: nishthaAhujaa

* renamed Materialized Views to Global Secondary Index

* more renaming

* fix import

* fix typo

* disable materialized views for Fabric

* updated input validation

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
Co-authored-by: Nishtha Ahuja <45535788+nishthaAhujaa@users.noreply.github.com>
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-04-11 10:39:32 -04:00
sunghyunkang1111
a3bfc89318 Revert "Pk missing fix (#2094)" (#2099)
This reverts commit af0a890516.
2025-04-09 13:04:44 -05:00
Ajay Parulekar
0666e11d89 Renaming Materialized views builder blade text to Global secondary indexes for NoSql API (#1991)
* GSI changes

* GSI changes

* GSI changes

* updating GlobalSecondaryIndexesBuilder.json

* Changes

* Update cost text keys based on user context API type

* Refactor Materialized Views Builder code for improved readability and consistency in API type checks

* Update links in Materialized Views Builder for consistency and accuracy

* Update Global Secondary Indexes links and descriptions for clarity and accuracy based on API type

* Update portal notification message keys based on user context API type for Materialized Views Builder

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Fix capitalization and wording inconsistencies in Materialized Views Builder localization strings

* Fix capitalization and wording inconsistencies in localization strings for Materialized Views Builder

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

* Update src/Localization/en/MaterializedViewsBuilder.json

Co-authored-by: Justine Cocchi <justine@cocchi.org>

---------

Co-authored-by: Justine Cocchi <justine@cocchi.org>
2025-04-09 13:32:35 -04:00
bogercraig
9bb1d0bace Manual Region Selection (#2037)
* Add standin region selection to settings menu.

* Retrieve read and write regions from user context and populate dropdown menu.  Update local storage value.
Need to now connect with updating read region of primary cosmos client.

* Change to only selecting region for cosmos client.  Not setting up separate read and write clients.

* Add read and write endpoint logging to cosmos client.

* Pass changing endpoint from settings menu to client.  Encountered token issues using new endpoint in client.

* Rough implementation of region selection of endpoint for cosmos client.  Still need to:
1 - Use separate context var to track selected region.  Directly updating database account context throws off token generation by acquireMSALTokenForAccount
2 - Remove href overrides in acquireMSALTokenForAccount.

* Update region selection to include global endpoint and generate a unique list of read and write endpoints.
Need to continue with clearing out selected endpoint when global is selected again.
Write operations stall when read region is selected even though 403 returned when region rejects operation.
Need to limit feature availablility to nosql, table, gremlin (maybe).

* Update cosmos client to fix bug.
Clients continuously generate after changing RBAC setting.

* Swapping back to default endpoint value.

* Rebase on client refresh bug fix.

* Enable region selection for NoSql, Table, Gremlin

* Add logic to reset regional endpoint when global is selected.

* Fix state changing when selecting region or resetting to global.

* Rough implementation of configuring regional endpoint when DE is loaded in portal or hosted with AAD/Entra auth.

* Ininitial attempt at adding error handling, but still having issues with errors caught at proxy plugin.

* Added rough error handling in local requestPlugin used in local environments.  Passes new error to calling code.
Might need to add specific error handling for request plugin to the handleError class.

* Change how request plugin returns error so existing error handling utility can process and present error.

* Only enable region selection for nosql accounts.

* Limit region selection to portal and hosted AAD auth.  SQL accounts only.  Could possibly enable on table and gremlin later.

* Update error handling to account for generic error code.

* Refactor error code extraction.

* Update test snapshots and remove unneeded logging.

* Change error handling to use only the message rather than casting to any.

* Clean up debug logging in cosmos client.

* Remove unused storage keys.

* Use endpoint instead of region name to track selected region.  Prevents having to do endpoint lookups.

* Add initial button state update depending on region selection.
Need to update with the API and react to user context changes.

* Disable CRUD buttons when read region selected.

* Default to write enabled in react.

* Disable query saving when read region is selected.

* Patch clientWidth error on conflicts tab.

* Resolve merge conflicts from rebase.

* Make sure proxy endpoints return in all cases.

* Remove excess client logging and match main for ConflictsTab.

* Cleaning up logging and fixing endpoint discovery bug.

* Fix formatting.

* Reformatting if statements with preferred formatting.

* Migrate region selection to local persistence.  Fixes account swapping bug.
TODO: Inspect better way to reset interface elements when deleteAllStates is called.  Need to react to regional endpoint being reset.

* Relocate resetting interface context to helper function.

* Remove legacy state storage for regional endpoint selection.

* Laurent suggestion updates.
2025-04-07 09:29:11 -07:00
sunghyunkang1111
af0a890516 Pk missing fix (#2094)
* fix partition key missing not being able to load the document

* Implement E2E tests for documents with different partitionkeys

* Implement E2E tests for documents with different partitionkeys

* Implement E2E tests for documents with different partitionkeys

* Updated snapshot
2025-04-07 10:45:29 -05:00
bogercraig
e3c3a8b1b7 Hide Keys and Connection Strings in Connect Tab (#2095)
* Hide connection strings and keys by default.  Move URI above pivot since common across tabs.  Matches frontend.  Need to add scrolling of keys when window is small.  Possibly reduce URI width.

* Add vertical scrolling when window size reduces.

* Adding missing semicolon at end of connection strings.
2025-04-03 17:19:28 -07:00
sunghyunkang1111
0f6c979268 Update cleanupDBs.js (#2093) 2025-04-03 11:10:40 -05:00
asier-isayas
32576f50d3 Self Serve text render fix (#2088)
* debug

* added comment

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-03-27 14:17:06 -04:00
sunghyunkang1111
10f5a5fbfe Revert "fix partition key missing not being able to load the document (#2085)" (#2090)
This reverts commit 257256f915.
2025-03-27 12:47:14 -05:00
JustinKol
8eb53674dc Add refresh button to Mongo DB RU and adjust ellipsis so refresh button on single column container doesn't hide it (#2089)
* Moved ellipsis to the left for single column containers

* Added refresh to MongoDB RU

* prettier run
2025-03-27 13:44:22 -04:00
sunghyunkang1111
257256f915 fix partition key missing not being able to load the document (#2085) 2025-03-26 11:26:47 -05:00
jawelton74
41f5401016 Fix input validation patterns for resource ids (#2086)
* Fix input element pattern matching and add validation reporting for
cases where the element is not within a form element.

* Update test snapshots.

* Remove old code and fix trigger error message.

* Move id validation to a util class.

* Add unit tests, fix standalone function, rename constants.
2025-03-26 07:10:47 -07:00
Laurent Nguyen
a4c9a47d4e Add comments for expired token used in test (#2084) 2025-03-26 09:00:55 +01:00
JustinKol
c43132d5c0 Adding container item refresh button back to upper right corner of page (#2083)
* Moved button to upper right

* Reverted background color

* Updated test snapshot

* Added hidding refresh button on overflow

* Ran prettier and updated snapshot
2025-03-25 08:16:39 -04:00
tarazou9
6ce81099ef Handle catalog empty (#2082)
Handle UI errors caused by Catalog API calls returning no offering id.
2025-03-21 16:15:48 -04:00
Nishtha Ahuja
777e411f4f edited screenshot for vcore quickstart shell (#2080)
Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in>
2025-03-20 21:55:03 +05:30
Laurent Nguyen
63d4b4f4ef fix tab wrapping with a lil' css tweak (#2013) (#2076)
Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>
2025-03-17 11:51:59 +01:00
asier-isayas
eaf9a14e7d Cancel Phoenix container allocation on ctrl+c & ctrl+z (#2055)
* Cancel Phoenix container allocation on ctrl+c

* revert package-lock

* fix build issues

* add ctrl+z

* Close terminal when Ctrl key is pressed

* format

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2025-03-13 14:56:11 -04:00
420 changed files with 343364 additions and 5485 deletions

View File

@@ -23,8 +23,6 @@ src/Common/MongoUtility.ts
src/Common/NotificationsClientBase.ts
src/Common/QueriesClient.ts
src/Common/Splitter.ts
src/Controls/Heatmap/Heatmap.test.ts
src/Controls/Heatmap/Heatmap.ts
src/Definitions/datatables.d.ts
src/Definitions/gif.d.ts
src/Definitions/globals.d.ts

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

@@ -164,24 +164,54 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shardTotal: [16]
steps:
- uses: actions/checkout@v4
- name: "Az CLI login"
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Use Node.js 18.x
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: npm ci
- run: npx playwright install --with-deps
- name: "Az CLI login"
uses: Azure/login@v2
with:
client-id: ${{ secrets.E2E_TESTS_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# We can't use MSAL within playwright so we acquire tokens prior to running the tests
- name: "Acquire RBAC tokens for test accounts"
uses: azure/cli@v2
with:
azcliversion: latest
inlineScript: |
NOSQL_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$NOSQL_TESTACCOUNT_TOKEN"
echo NOSQL_TESTACCOUNT_TOKEN=$NOSQL_TESTACCOUNT_TOKEN >> $GITHUB_ENV
NOSQL_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-readonly.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$NOSQL_READONLY_TESTACCOUNT_TOKEN"
echo NOSQL_READONLY_TESTACCOUNT_TOKEN=$NOSQL_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
TABLE_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-tables.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$TABLE_TESTACCOUNT_TOKEN"
echo TABLE_TESTACCOUNT_TOKEN=$TABLE_TESTACCOUNT_TOKEN >> $GITHUB_ENV
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 }}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3
- name: Upload blob report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4

View File

@@ -27,7 +27,7 @@ jobs:
- name: "Az CLI login"
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
client-id: ${{ secrets.E2E_TESTS_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

2
.npmrc
View File

@@ -1,4 +1,4 @@
save-exact=true
# Ignore peer dependency conflicts
force=true # TODO: Remove this when we update to React 17 or higher!
force=true # TODO: Remove this when we update to React 17 or higher!

View File

@@ -19,6 +19,6 @@
</frameworkAssemblies>
</metadata>
<files>
<file src="**\*" target="content"/>
<file src="**\*" exclude="obj\**\*" target="content"/>
</files>
</package>

View File

@@ -1,5 +1,4 @@
{
"JUNO_ENDPOINT": "https://tools.cosmos.azure.com",
"isTerminalEnabled": true,
"isPhoenixEnabled": true
}
}

View File

@@ -1,5 +1,4 @@
{
"JUNO_ENDPOINT": "https://tools.cosmos.azure.com",
"isTerminalEnabled" : false,
"isPhoenixEnabled" : false
}
"JUNO_ENDPOINT": "https://tools.cosmos.azure.com",
"isPhoenixEnabled": false
}

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

8
images/VisualStudio.svg Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 2.1 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

8
images/golang.svg Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path fill="#8CC5E7" d="M21.4679537,3.20617761 C22.1814672,4.67953668 20.0131274,4.83706564 20.1243243,5.49498069 C20.3281853,6.68108108 20.1891892,8.44169884 20.0316602,10.1745174 C19.7629344,13.1119691 21.9590734,20.1451737 17.3814672,22.9714286 C16.5196911,23.5088803 14.4718147,23.8054054 12.4517375,23.8517375 C12.4517375,23.8517375 12.442471,23.8517375 12.442471,23.8517375 C12.442471,23.8517375 12.4332046,23.8517375 12.4332046,23.8517375 C10.4131274,23.8054054 8.08725869,23.5088803 7.22548263,22.9714286 C2.65714286,20.1451737 4.85328185,13.1119691 4.59382239,10.1745174 C4.42702703,8.44169884 4.28803089,6.68108108 4.5011583,5.49498069 C4.61235521,4.83706564 2.44401544,4.68880309 3.15752896,3.20617761 C3.76911197,1.93667954 5.27953668,3.05791506 5.65945946,2.65945946 C7.596139,0.648648649 9.94980695,0.111196911 11.8030888,0.0648648649 C11.988417,0.0648648649 12.8223938,0.0648648649 12.8223938,0.0648648649 C14.6664093,0.157528958 17.0200772,0.657915058 18.9660232,2.65945946 C19.3459459,3.05791506 20.8471042,1.93667954 21.4679537,3.20617761 Z M11.4324324,10.9065637 C11.3490347,10.9436293 11.2100386,11.8517375 11.6362934,11.8980695 C11.9235521,11.9258687 12.7111969,12.0185328 12.8965251,11.8980695 C13.2579151,11.6664093 13.2208494,11.1104247 13.0169884,10.9714286 C12.6741313,10.7490347 11.5250965,10.8602317 11.4324324,10.9065637 Z M9.07876448,4.10501931 C8.12432432,3.99382239 6.52123552,4.88339768 6.28030888,6.77374517 C6.02084942,8.73822394 8.33745174,10.6841699 10.56139,8.73822394 C11.7567568,7.69111969 12.1737452,4.46640927 9.07876448,4.10501931 Z M15.5281853,4.10501931 C12.4332046,4.46640927 12.8501931,7.69111969 14.0455598,8.73822394 C16.2694981,10.6841699 18.5861004,8.73822394 18.3266409,6.77374517 C18.0949807,4.88339768 16.4918919,3.99382239 15.5281853,4.10501931 Z"/>
<path fill="#B8937F" d="M12.3127413,8.98841699 C12.8965251,8.90501931 14.2957529,9.57220077 14.2030888,10.3598456 C14.0918919,11.2772201 10.5984556,11.3976834 10.4131274,10.3042471 C10.3019305,9.63706564 10.8301158,9.21081081 12.3127413,8.98841699 Z M20.1984556,16.3737452 C19.9111969,16.3644788 19.7258687,15.984556 19.7258687,15.7528958 C19.7258687,15.3359073 19.7814672,14.8447876 20.0872587,14.6316602 C20.7173745,14.196139 21.2177606,16.3830116 20.1984556,16.3737452 Z M4.41776062,16.3737452 C3.3984556,16.3830116 3.8988417,14.196139 4.52895753,14.6316602 C4.83474903,14.8447876 4.89034749,15.3359073 4.89034749,15.7528958 C4.89034749,15.984556 4.70501931,16.3644788 4.41776062,16.3737452 Z M18.2617761,23.0918919 C18.4471042,23.3606178 18.4563707,23.5459459 18.1598456,23.6849421 C17.0293436,24.203861 16.019305,23.5088803 16.3992278,23.3142857 C17.2054054,22.9065637 17.7057915,22.2671815 18.2617761,23.0918919 Z M6.35444015,23.184556 C6.91042471,22.3598456 7.41081081,22.9992278 8.21698842,23.4069498 C8.5969112,23.6015444 7.58687259,24.2965251 6.45637066,23.7776062 C6.15984556,23.63861 6.16911197,23.4532819 6.35444015,23.184556 Z"/>
<path fill="#000000" d="M19.7351351,3.42857143 C19.7814672,3.23397683 20.2633205,3.14131274 20.5320463,3.47490347 C20.8563707,3.87335907 20.0594595,4.42007722 20.0223938,4.1976834 C19.9297297,3.5953668 19.6795367,3.62316602 19.7351351,3.42857143 Z M4.88108108,3.42857143 C4.93667954,3.62316602 4.68648649,3.5953668 4.59382239,4.1976834 C4.55675676,4.42007722 3.75984556,3.87335907 4.08416988,3.47490347 C4.34362934,3.14131274 4.82548263,3.23397683 4.88108108,3.42857143 Z M15.7413127,7.94131274 C15.1578953,7.94131274 14.6849421,7.46835949 14.6849421,6.88494208 C14.6849421,6.30152468 15.1578953,5.82857143 15.7413127,5.82857143 C16.3247301,5.82857143 16.7976834,6.30152468 16.7976834,6.88494208 C16.7976834,7.46835949 16.3247301,7.94131274 15.7413127,7.94131274 Z M15.4633205,6.76447876 C15.6475575,6.76447876 15.7969112,6.61512511 15.7969112,6.43088803 C15.7969112,6.24665096 15.6475575,6.0972973 15.4633205,6.0972973 C15.2790834,6.0972973 15.1297297,6.24665096 15.1297297,6.43088803 C15.1297297,6.61512511 15.2790834,6.76447876 15.4633205,6.76447876 Z M11.3583012,9.43320463 C11.4694981,9.00694981 11.8586873,8.86795367 12.1737452,8.85868726 C12.9799228,8.84015444 13.2857143,9.27567568 13.3135135,9.61853282 C13.369112,10.2023166 11.1081081,10.3413127 11.3583012,9.43320463 Z M8.87490347,7.94131274 C8.29148607,7.94131274 7.81853282,7.46835949 7.81853282,6.88494208 C7.81853282,6.30152468 8.29148607,5.82857143 8.87490347,5.82857143 C9.45832088,5.82857143 9.93127413,6.30152468 9.93127413,6.88494208 C9.93127413,7.46835949 9.45832088,7.94131274 8.87490347,7.94131274 Z M9.15289575,6.76447876 C9.33713283,6.76447876 9.48648649,6.61512511 9.48648649,6.43088803 C9.48648649,6.24665096 9.33713283,6.0972973 9.15289575,6.0972973 C8.96865868,6.0972973 8.81930502,6.24665096 8.81930502,6.43088803 C8.81930502,6.61512511 8.96865868,6.76447876 9.15289575,6.76447876 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

10
images/springboot.svg Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M38.9437824,35.879008 C89.5234256,-13.1200214 170.398168,-11.8028432 219.397197,39.0402357 C224.929346,31.6640377 229.671187,23.4975328 233.095851,15.0675923 C249.165425,64.0666217 258.912543,105.162582 255.224444,137.038295 C253.380395,163.90873 242.842969,189.725423 225.456217,210.273403 C180.145286,264.014274 99.53398,270.863601 45.7931091,225.55267 L45.7931091,225.55267 L44.765,224.638 L44.7103323,224.601984 C44.5420247,224.484832 44.376007,224.362668 44.2124952,224.235492 C43.7219599,223.853965 43.2765312,223.438607 42.8762093,222.995252 L42.732,222.831 L41.0512675,221.3377 C39.4121124,219.93271 37.7729573,218.52772 36.3188215,216.93771 L35.7825547,216.332423 C-13.2164747,165.752779 -11.6358609,84.8780374 38.9437824,35.879008 Z M57.9111486,207.375611 C53.169307,203.687512 46.3199803,204.214383 42.6318814,208.956225 C39.3888978,213.125775 39.4048731,218.924805 42.6798072,222.771269 L42.732,222.831 L44.765,224.638 L44.9644841,224.773953 C49.5691585,227.80174 55.7644273,227.175885 59.2982065,222.896387 L59.4917624,222.654878 C63.1798614,217.913037 62.3895545,211.06371 57.9111486,207.375611 Z M231.778672,28.2393744 C218.60689,55.9001168 185.940871,76.9749681 157.753257,83.5608592 C131.146257,89.8833146 107.963921,84.6146018 83.4644059,94.0982849 C27.6160498,115.436572 28.6697923,181.822354 59.2283268,196.838185 L59.2283268,196.838185 L61.0723763,197.891928 C61.0723763,197.891928 83.1456487,193.50309 104.973663,187.707242 L106.843514,187.207079 C115.561826,184.857554 124.138869,182.296538 131.146257,179.714869 C167.500376,166.279651 207.542593,133.08676 220.714375,94.6251562 C213.865049,134.667374 179.35498,173.392413 144.84491,191.042601 C126.404416,200.526284 112.178891,202.633769 81.883792,213.171195 C78.195693,214.488373 75.297901,215.805551 75.297901,215.805551 C75.6675607,215.754564 76.0372203,215.70481 76.4060145,215.65629 L77.1421925,215.560893 L77.1421925,215.560893 L77.8745239,215.468787 C84.5652297,214.639554 90.5771682,214.224938 90.5771682,214.224938 C133.517178,212.117452 200.956702,226.342977 232.305544,184.45671 C264.444692,141.780136 246.531068,72.7599979 231.778672,28.2393744 Z" fill="#6DB33F">
</path>
<path d="M57.9111486,207.375611 C62.3895545,211.06371 63.1798614,217.913037 59.4917624,222.654878 C55.8036635,227.39672 48.9543368,227.923591 44.2124952,224.235492 C39.4706537,220.547393 38.9437824,213.698066 42.6318814,208.956225 C46.3199803,204.214383 53.169307,203.687512 57.9111486,207.375611 Z M231.778672,28.2393744 C246.531068,72.7599979 264.444692,141.780136 232.305544,184.45671 C200.956702,226.342977 133.517178,212.117452 90.5771682,214.224938 C90.5771682,214.224938 84.5652297,214.639554 77.8745239,215.468787 L77.1421925,215.560893 C76.5300999,215.63902 75.9140004,215.720572 75.297901,215.805551 C75.297901,215.805551 78.195693,214.488373 81.883792,213.171195 C112.178891,202.633769 126.404416,200.526284 144.84491,191.042601 C179.35498,173.392413 213.865049,134.667374 220.714375,94.6251562 C207.542593,133.08676 167.500376,166.279651 131.146257,179.714869 C106.119871,188.935116 61.0723763,197.891928 61.0723763,197.891928 L59.2283268,196.838185 C28.6697923,181.822354 27.6160498,115.436572 83.4644059,94.0982849 C107.963921,84.6146018 131.146257,89.8833146 157.753257,83.5608592 C185.940871,76.9749681 218.60689,55.9001168 231.778672,28.2393744 Z" fill="#FFFFFF">
</path>

After

Width:  |  Height:  |  Size: 3.6 KiB

1
images/vscode.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="15" height="15" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" overflow="hidden"><defs><clipPath id="clip0"><rect x="479" y="279" width="15" height="15"/></clipPath><clipPath id="clip1"><rect x="-0.287396" y="-0.171573" width="152381" height="152381"/></clipPath><image width="35" height="35" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAMAAAApB0NrAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAH4UExURQAAAASExCGs7CC//wB9vQB5uxSU1yaz8ySy9AB8uwB8vAB5uxOR1Say8yax8iaw8gB6vAB6uwB7uwB4uROQ1Cax8ySy8QCAvwB3ugB5ugB2uROP1CWw8yav8wCT2QCQ1QCP1wB2uQB1uBOO0yWv8yat8wCP1QCP1QCP1QCO1QCQ1wB1tQB2uAB3uAB1tx+k6SSt8ySt8QCN0wCO1ACM0wBzuQBztAB1twB0tgB0tiSr8SSs8gCM1ACM1ACEywBwtQBxtAB0tgBztQB0sySp8SSr8gCL0wCK0gCL0wCHzwByuABusgBztQBttiOq8gB6wQCI0QCJ0gB7wySn8SOo8gBxtABtsABwtgCFzwCI0gCG0QCJ0SKn8SKn8iKl8QBvsABvsgBvsQBtsABssgCCzACG0QCF0ACDzyKm8QBtrwBusABsrgCAzQCE0ACF0ACF0ACE0CKj8SKl8QBssQBtrwBtrwBsrgBsrwCK1QCBzwCD0ACA0B2d7CGj8QBsrABrrQBwrwCA3wCC0ACCzwCBzwB+zhGM3iGi8SKh8QCAzQCAzgCAzwB9zRCK3iCh8SGh8SCf8QB+zAB/zgB8zRCJ3SCg8SCf7wB+zQB6zBCI3CCe8CCf8CCe8CCf8SCf/wB7zAB4yxGJ3h6c8CCd7yCf7wmA0RqX60C//5CaUeMAAACodFJOUwA8XAho+//ncID/////53iM//////9wCKv/////gCiYILf///+AMPP/70wYw/+3lP+AXP+MKNv/+3uA/3z/+////+NAgP9c9/////+7HP+A///MgP9Y+////7scgP+AeP/////7/+NA/2D/iyTb//t4gP808//vUBjD/7eU/yifIAi3//////+Ar////////4CI/////3B//////+9/CGj7/+dwEDhYBCm1XqwAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAFpSURBVDhPY2AYBaiAkYkZXQgdsLCysXPgV8XJxc3Dy8vHj0eVgKCQsIioqKioGLoMDIhLSEpKSknLyMjIyKLLyckrgJUoCgsLCyspq6ioqKiiKVFT19DUYmDQ1tEFAT19AwMDA0M0NUbGxsbGJqZm5ubm5haWDFbW1tbWVmhqGGxsbW1t7ewdHB2dnBkYXFxdXV1d0NUwuLl7eHh4enn7+DIwMLj4+fn5Yaph8A8IDAwMDBIHsYNDQkJCgtFVMISGhUdERkZGRkUzMDDExMbGxsahK4lPSExKTklNTU1NS2dgiMvIyMhAV5OZlZWVlZ2Tm5eXl5dfwFBYVFRUVIimpriktKycgaGisgoEqmtqa2tr0dUw1NU3gKjGpuaWlpbWtvb29vYOdDUw0NjZ1dXd09vX39c3AV0OASZOmjR5ytSpU6dOQ5dBAtN7ZsycNXvO3HnoEshg/oKFixYvQRdFA0uXLUcXGqEAAH4FV0z+qQbjAAAAAElFTkSuQmCC" preserveAspectRatio="none" id="img2"></image><clipPath id="clip3"><path d="M44291.4 46947.4 187148 46947.4 187148 188823 44291.4 188823Z" fill-rule="evenodd" clip-rule="evenodd"/></clipPath></defs><g clip-path="url(#clip0)" transform="translate(-479 -279)"><g clip-path="url(#clip1)" transform="matrix(0.000105 0 0 0.000105 479 279)"><g clip-path="url(#clip3)" transform="matrix(1 0 0 1.00692 -44291.4 -47272.4)"><use width="100%" height="100%" xlink:href="#img2" transform="scale(6709.45 6709.45)"></use></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1914,13 +1914,20 @@ input::-webkit-calendar-picker-indicator::after {
}
.nav-tabs-margin {
height: 32px;
background-color: #f2f2f2;
.nav-tabs {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
height: 100%;
margin-bottom: -0.5px;
li {
// Override the bootstrap defaults here to align with our layout constants.
margin-bottom: 0px;
height: 32px;
}
}
}
@@ -2862,6 +2869,7 @@ a:link {
z-index: 1000;
overflow-y: auto;
overflow-x: clip;
min-height: fit-content;
}
.uniqueIndexesContainer {

View File

@@ -211,3 +211,12 @@ a:focus {
.fileImportImg img {
filter: brightness(0) saturate(100%);
}
.tabPanesContainer {
overflow: auto !important;
}
.tabs-container {
min-height: 500px;
min-width: 500px;
}

File diff suppressed because it is too large Load Diff

378
package-lock.json generated
View File

@@ -10,7 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.2.0-beta.1",
"@azure/cosmos": "4.7.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
"@azure/msal-browser": "2.14.2",
@@ -51,6 +51,8 @@
"@types/mkdirp": "1.0.1",
"@types/node-fetch": "2.5.7",
"@xmldom/xmldom": "0.7.13",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"allotment": "1.20.2",
"applicationinsights": "1.8.0",
"bootstrap": "3.4.1",
@@ -86,7 +88,7 @@
"mkdirp": "1.0.4",
"monaco-editor": "0.44.0",
"ms": "2.1.3",
"p-retry": "4.6.2",
"p-retry": "6.2.1",
"patch-package": "8.0.0",
"plotly.js-cartesian-dist-min": "1.52.3",
"post-robot": "10.0.42",
@@ -114,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": {
@@ -288,57 +291,69 @@
"version": "2.6.2",
"license": "0BSD"
},
"node_modules/@azure/core-rest-pipeline": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.18.0.tgz",
"integrity": "sha512-QSoGUp4Eq/gohEFNJaUOwTN7BCc2nHTjjbm75JT0aD7W65PWM1H/tItz0GsABn22uaKyGxiMhWQLt2r+FGU89Q==",
"node_modules/@azure/core-http-compat": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.3.0.tgz",
"integrity": "sha512-qLQujmUypBBG0gxHd0j6/Jdmul6ttl24c8WGiLXIk7IHXdBlfoBqW27hyz3Xn6xbfdyVSarl1Ttbk0AwnZBYCw==",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
"@azure/core-auth": "^1.8.0",
"@azure/core-tracing": "^1.0.1",
"@azure/core-util": "^1.11.0",
"@azure/core-client": "^1.3.0",
"@azure/core-rest-pipeline": "^1.20.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-lro": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz",
"integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
"@azure/core-util": "^1.2.0",
"@azure/logger": "^1.0.0",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-rest-pipeline/node_modules/agent-base": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
"integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
"node_modules/@azure/core-lro/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/@azure/core-paging": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz",
"integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==",
"dependencies": {
"debug": "^4.3.4"
"tslib": "^2.6.2"
},
"engines": {
"node": ">= 14"
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-rest-pipeline/node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
"node_modules/@azure/core-paging/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/@azure/core-rest-pipeline/node_modules/https-proxy-agent": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz",
"integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==",
"node_modules/@azure/core-rest-pipeline": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.20.0.tgz",
"integrity": "sha512-ASoP8uqZBS3H/8N8at/XwFr6vYrRP3syTK0EUjDXQy0Y1/AUS+QeIRThKmTNJO2RggvBBxaXDPM7YoIwDGeA0g==",
"dependencies": {
"agent-base": "^7.0.2",
"debug": "4"
"@azure/abort-controller": "^2.0.0",
"@azure/core-auth": "^1.8.0",
"@azure/core-tracing": "^1.0.1",
"@azure/core-util": "^1.11.0",
"@azure/logger": "^1.0.0",
"@typespec/ts-http-runtime": "^0.2.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">= 14"
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-rest-pipeline/node_modules/tslib": {
@@ -377,23 +392,25 @@
"license": "0BSD"
},
"node_modules/@azure/cosmos": {
"version": "4.2.0-beta.1",
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.2.0-beta.1.tgz",
"integrity": "sha512-mREONehm1DxjEKXGaNU6Wmpf9Ckb9IrhKFXhDFVs45pxmoEb3y2s/Ub0owuFmqlphpcS1zgtYQn5exn+lwnJuQ==",
"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.0.0",
"@azure/core-auth": "^1.7.1",
"@azure/core-rest-pipeline": "^1.15.1",
"@azure/core-tracing": "^1.1.1",
"@azure/core-util": "^1.8.1",
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.9.0",
"@azure/core-rest-pipeline": "^1.19.1",
"@azure/core-tracing": "^1.2.0",
"@azure/core-util": "^1.11.0",
"@azure/keyvault-keys": "^4.9.0",
"@azure/logger": "^1.1.4",
"fast-json-stable-stringify": "^2.1.0",
"jsbi": "^4.3.0",
"priorityqueuejs": "^2.0.0",
"semaphore": "^1.1.0",
"tslib": "^2.6.2"
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
}
},
"node_modules/@azure/cosmos-language-service": {
@@ -423,8 +440,9 @@
}
},
"node_modules/@azure/cosmos/node_modules/tslib": {
"version": "2.6.2",
"license": "0BSD"
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/@azure/identity": {
"version": "4.5.0",
@@ -490,14 +508,66 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/@azure/logger": {
"version": "1.0.4",
"license": "MIT",
"node_modules/@azure/keyvault-common": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.0.0.tgz",
"integrity": "sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
"@azure/core-auth": "^1.3.0",
"@azure/core-client": "^1.5.0",
"@azure/core-rest-pipeline": "^1.8.0",
"@azure/core-tracing": "^1.0.0",
"@azure/core-util": "^1.10.0",
"@azure/logger": "^1.1.4",
"tslib": "^2.2.0"
},
"engines": {
"node": ">=14.0.0"
"node": ">=18.0.0"
}
},
"node_modules/@azure/keyvault-common/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/@azure/keyvault-keys": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.9.0.tgz",
"integrity": "sha512-ZBP07+K4Pj3kS4TF4XdkqFcspWwBHry3vJSOFM5k5ZABvf7JfiMonvaFk2nBF6xjlEbMpz5PE1g45iTMme0raQ==",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
"@azure/core-auth": "^1.3.0",
"@azure/core-client": "^1.5.0",
"@azure/core-http-compat": "^2.0.1",
"@azure/core-lro": "^2.2.0",
"@azure/core-paging": "^1.1.1",
"@azure/core-rest-pipeline": "^1.8.1",
"@azure/core-tracing": "^1.0.0",
"@azure/core-util": "^1.0.0",
"@azure/keyvault-common": "^2.0.0",
"@azure/logger": "^1.0.0",
"tslib": "^2.2.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/keyvault-keys/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/@azure/logger": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.2.0.tgz",
"integrity": "sha512-0hKEzLhpw+ZTAfNJyRrn6s+V0nDWzXk9OjBr2TiGIu0OfMr5s2V4FpKLTAK3Ca5r5OKLbf4hkOGDPyiRjie/jA==",
"dependencies": {
"@typespec/ts-http-runtime": "^0.2.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/logger/node_modules/tslib": {
@@ -557,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",
@@ -616,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",
@@ -7526,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",
@@ -9056,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",
@@ -9213,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",
@@ -9328,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",
@@ -9596,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",
@@ -9819,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",
@@ -12662,7 +12796,9 @@
}
},
"node_modules/@types/retry": {
"version": "0.12.0",
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
"integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
"license": "MIT"
},
"node_modules/@types/sanitize-html": {
@@ -13070,6 +13206,56 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typespec/ts-http-runtime": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.2.2.tgz",
"integrity": "sha512-Gz/Sm64+Sq/vklJu1tt9t+4R2lvnud8NbTD/ZfpZtMiUX7YeVpCA8j6NSW8ptwcoLL+NmYANwqP8DV0q/bwl2w==",
"dependencies": {
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@typespec/ts-http-runtime/node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"engines": {
"node": ">= 14"
}
},
"node_modules/@typespec/ts-http-runtime/node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/@typespec/ts-http-runtime/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/@typespec/ts-http-runtime/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/@ungap/url-search-params": {
"version": "0.2.2",
"license": "ISC"
@@ -13238,6 +13424,19 @@
"node": ">=10.0.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"license": "BSD-3-Clause"
@@ -21799,6 +21998,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-network-error": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz",
"integrity": "sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-number": {
"version": "3.0.0",
"license": "MIT",
@@ -26273,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",
@@ -27034,11 +27254,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsbi": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
"integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
},
"node_modules/jsbn": {
"version": "0.1.1",
"license": "MIT"
@@ -30243,14 +30458,20 @@
}
},
"node_modules/p-retry": {
"version": "4.6.2",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz",
"integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==",
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.0",
"@types/retry": "0.12.2",
"is-network-error": "^1.0.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=8"
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
@@ -33606,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",
@@ -35472,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"
}
@@ -35997,6 +36228,13 @@
}
}
},
"node_modules/webpack-dev-server/node_modules/@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
"dev": true,
"license": "MIT"
},
"node_modules/webpack-dev-server/node_modules/ajv": {
"version": "8.12.0",
"dev": true,
@@ -36044,6 +36282,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/webpack-dev-server/node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/webpack-dev-server/node_modules/rimraf": {
"version": "3.0.2",
"dev": true,

View File

@@ -5,7 +5,7 @@
"main": "index.js",
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.2.0-beta.1",
"@azure/cosmos": "4.7.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
"@azure/msal-browser": "2.14.2",
@@ -46,6 +46,8 @@
"@types/mkdirp": "1.0.1",
"@types/node-fetch": "2.5.7",
"@xmldom/xmldom": "0.7.13",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"allotment": "1.20.2",
"applicationinsights": "1.8.0",
"bootstrap": "3.4.1",
@@ -81,7 +83,7 @@
"mkdirp": "1.0.4",
"monaco-editor": "0.44.0",
"ms": "2.1.3",
"p-retry": "4.6.2",
"p-retry": "6.2.1",
"patch-package": "8.0.0",
"plotly.js-cartesian-dist-min": "1.52.3",
"post-robot": "10.0.42",
@@ -109,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,5 +1,4 @@
import { defineConfig, devices } from "@playwright/test";
/**
* See https://playwright.dev/docs/test-configuration.
*/
@@ -29,24 +28,60 @@ export default defineConfig({
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
use: {
...devices["Desktop Chrome"],
launchOptions: {
args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"],
},
},
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
use: {
...devices["Desktop Firefox"],
launchOptions: {
firefoxUserPrefs: {
"security.fileuri.strict_origin_policy": false,
"network.http.referer.XOriginPolicy": 0,
"network.http.referer.trimmingPolicy": 0,
"privacy.file_unique_origin": false,
"security.csp.enable": false,
"network.cors_preflight.allow_client_cert": true,
"dom.security.https_first": false,
"network.http.cross-origin-embedder-policy": false,
"network.http.cross-origin-opener-policy": false,
"browser.tabs.remote.useCrossOriginPolicy": false,
"browser.tabs.remote.useCORP": false,
},
args: ["--disable-web-security"],
},
},
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
use: {
...devices["Desktop Safari"],
},
},
/* Test against branded browsers. */
{
name: "Google Chrome",
use: { ...devices["Desktop Chrome"], channel: "chrome" }, // or 'chrome-beta'
use: {
...devices["Desktop Chrome"],
channel: "chrome",
launchOptions: {
args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"],
},
},
},
{
name: "Microsoft Edge",
use: { ...devices["Desktop Edge"], channel: "msedge" }, // or 'msedge-dev'
use: {
...devices["Desktop Edge"],
channel: "msedge",
launchOptions: {
args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"],
},
},
},
],

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();

1102
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,13 +142,12 @@ export enum MongoBackendEndpointType {
remote,
}
export class BackendApi {
public static readonly GenerateToken: string = "GenerateToken";
public static readonly PortalSettings: string = "PortalSettings";
public static readonly AccountRestrictions: string = "AccountRestrictions";
public static readonly RuntimeProxy: string = "RuntimeProxy";
public static readonly DisallowedLocations: string = "DisallowedLocations";
public static readonly SampleData: string = "SampleData";
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 {
@@ -257,12 +260,14 @@ export class Areas {
public static ShareDialog: string = "Share Access Dialog";
public static Notebook: string = "Notebook";
public static Copilot: string = "Copilot";
public static CloudShell: string = "Cloud Shell";
}
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";
@@ -292,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 {
@@ -530,6 +536,9 @@ export class ariaLabelForLearnMoreLink {
public static readonly AzureSynapseLink = "Learn more about Azure Synapse Link.";
}
export class GlobalSecondaryIndexLabels {
public static readonly NewGlobalSecondaryIndex: string = "New Global Secondary Index";
}
export class FeedbackLabels {
public static readonly provideFeedback: string = "Provide feedback";
}
@@ -770,3 +779,10 @@ export const ShortenedQueryCopilotSampleContainerSchema = {
userPrompt: "find all products",
};
export enum MongoGuidRepresentation {
Standard = "Standard",
CSharpLegacy = "CSharpLegacy",
JavaLegacy = "JavaLegacy",
PythonLegacy = "PythonLegacy",
}

View File

@@ -4,12 +4,12 @@ import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
import { AuthorizationToken } from "Contracts/FabricMessageTypes";
import { checkDatabaseResourceTokensValidity, isFabricMirroredKey } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { useDataplaneRbacAuthorization } from "Utils/AuthorizationUtils";
import { AuthType } from "../AuthType";
import { PriorityLevel } from "../Common/Constants";
import * as Logger from "../Common/Logger";
import { Platform, configContext } from "../ConfigContext";
import { FabricArtifactInfo, updateUserContext, userContext } from "../UserContext";
import { isDataplaneRbacSupported } from "../Utils/APITypeUtils";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
@@ -20,8 +20,7 @@ const _global = typeof self === "undefined" ? window : self;
export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
const { verb, resourceId, resourceType, headers } = requestInfo;
const dataPlaneRBACOptionEnabled = userContext.dataPlaneRbacEnabled && isDataplaneRbacSupported(userContext.apiType);
if (userContext.features.enableAadDataPlane || dataPlaneRBACOptionEnabled) {
if (useDataplaneRbacAuthorization(userContext)) {
Logger.logInfo(
`AAD Data Plane Feature flag set to ${userContext.features.enableAadDataPlane} for account with disable local auth ${userContext.databaseAccount.properties.disableLocalAuth} `,
"Explorer/tokenProvider",
@@ -125,7 +124,11 @@ export const endpoint = () => {
const location = _global.parent ? _global.parent.location : _global.location;
return configContext.EMULATOR_ENDPOINT || location.origin;
}
return userContext.endpoint || userContext?.databaseAccount?.properties?.documentEndpoint;
return (
userContext.selectedRegionalEndpoint ||
userContext.endpoint ||
userContext?.databaseAccount?.properties?.documentEndpoint
);
};
export async function getTokenFromAuthService(
@@ -203,6 +206,7 @@ export function client(): Cosmos.CosmosClient {
userAgentSuffix: "Azure Portal",
defaultHeaders: _defaultHeaders,
connectionPolicy: {
enableEndpointDiscovery: !userContext.selectedRegionalEndpoint,
retryOptions: {
maxRetryAttemptCount: LocalStorageUtility.getEntryNumber(StorageKey.RetryAttempts),
fixedRetryIntervalInMilliseconds: LocalStorageUtility.getEntryNumber(StorageKey.RetryInterval),

View File

@@ -1,6 +1,7 @@
import { TagNames, WorkloadType } from "Common/Constants";
import { Tags } from "Contracts/DataModels";
import { userContext } from "../UserContext";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { ApiType, userContext } from "../UserContext";
function isVirtualNetworkFilterEnabled() {
return userContext.databaseAccount?.properties?.isVirtualNetworkFilterEnabled;
@@ -26,3 +27,39 @@ export function getWorkloadType(): WorkloadType {
}
return workloadType;
}
export function isGlobalSecondaryIndexEnabled(): boolean {
return (
!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

@@ -1,5 +1,7 @@
import { QueryOperationOptions } from "@azure/cosmos";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import * as Constants from "../Common/Constants";
import { QueryResults } from "../Contracts/ViewModels";
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
interface QueryResponse {
// [Todo] remove any
@@ -11,17 +13,15 @@ interface QueryResponse {
}
export interface MinimalQueryIterator {
fetchNext: (queryOperationOptions?: QueryOperationOptions) => Promise<QueryResponse>;
fetchNext: () => Promise<QueryResponse>;
}
// Pick<QueryIterator<any>, "fetchNext">;
export function nextPage(
documentsIterator: MinimalQueryIterator,
firstItemIndex: number,
queryOperationOptions?: QueryOperationOptions,
): Promise<QueryResults> {
return documentsIterator.fetchNext(queryOperationOptions).then((response) => {
export function nextPage(documentsIterator: MinimalQueryIterator, firstItemIndex: number): Promise<QueryResults> {
TelemetryProcessor.traceStart(Action.ExecuteQuery);
return documentsIterator.fetchNext().then((response) => {
TelemetryProcessor.traceSuccess(Action.ExecuteQuery, { dataExplorerArea: Constants.Areas.Tab });
const documents = response.resources;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const headers = (response as any).headers || {}; // TODO this is a private key. Remove any

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

@@ -65,7 +65,6 @@ describe("MongoProxyClient", () => {
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -84,7 +83,6 @@ describe("MongoProxyClient", () => {
it("builds the correct proxy URL in development", () => {
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
queryDocuments(databaseId, collection, true, "{}");
expect(window.fetch).toHaveBeenCalledWith(
@@ -101,7 +99,6 @@ describe("MongoProxyClient", () => {
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -120,7 +117,6 @@ describe("MongoProxyClient", () => {
it("builds the correct proxy URL in development", () => {
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
@@ -137,7 +133,6 @@ describe("MongoProxyClient", () => {
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -156,7 +151,6 @@ describe("MongoProxyClient", () => {
it("builds the correct proxy URL in development", () => {
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
readDocument(databaseId, collection, documentId);
expect(window.fetch).toHaveBeenCalledWith(
@@ -173,7 +167,6 @@ describe("MongoProxyClient", () => {
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -197,7 +190,6 @@ describe("MongoProxyClient", () => {
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
window.fetch = jest.fn().mockImplementation(fetchMock);
});
@@ -216,7 +208,6 @@ describe("MongoProxyClient", () => {
it("builds the correct proxy URL in development", () => {
updateConfigContext({
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
globallyEnabledMongoAPIs: [],
});
deleteDocuments(databaseId, collection, [documentId]);
expect(window.fetch).toHaveBeenCalledWith(
@@ -233,7 +224,6 @@ describe("MongoProxyClient", () => {
});
updateConfigContext({
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
globallyEnabledMongoAPIs: [],
});
});

View File

@@ -1,4 +1,5 @@
import { Constants as CosmosSDKConstants } from "@azure/cosmos";
import { getMongoGuidRepresentation } from "Shared/StorageUtility";
import { AuthType } from "../AuthType";
import { configContext } from "../ConfigContext";
import * as DataModels from "../Contracts/DataModels";
@@ -6,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";
@@ -15,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;
}
}
@@ -139,6 +148,9 @@ export function readDocument(
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey
? documentId.partitionKeyProperties?.[0]
: "",
clientSettings: {
guidRepresentation: getMongoGuidRepresentation(),
},
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
@@ -181,6 +193,9 @@ export function createDocument(
partitionKey:
collection && collection.partitionKey && !collection.partitionKey.systemKey ? partitionKeyProperty : "",
documentContent: JSON.stringify(documentContent),
clientSettings: {
guidRepresentation: getMongoGuidRepresentation(),
},
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
@@ -228,6 +243,9 @@ export function updateDocument(
? documentId.partitionKeyProperties?.[0]
: "",
documentContent,
clientSettings: {
guidRepresentation: getMongoGuidRepresentation(),
},
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
@@ -274,6 +292,9 @@ export function deleteDocuments(
subscriptionID: userContext.subscriptionId,
resourceGroup: userContext.resourceGroup,
databaseAccountName: databaseAccount.name,
clientSettings: {
guidRepresentation: getMongoGuidRepresentation(),
},
};
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);

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

@@ -1,5 +1,4 @@
import { monaco } from "Explorer/LazyMonaco";
import { getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
export enum QueryErrorSeverity {
Error = "Error",
@@ -103,20 +102,9 @@ export interface ErrorEnrichment {
learnMoreUrl?: string;
}
const REPLACEMENT_MESSAGES: Record<string, (original: string) => string> = {
OPERATION_RU_LIMIT_EXCEEDED: (original) => {
if (ruThresholdEnabled()) {
const threshold = getRUThreshold();
return `Query exceeded the Request Unit (RU) limit of ${threshold} RUs. You can change this limit in Data Explorer settings.`;
}
return original;
},
};
const REPLACEMENT_MESSAGES: Record<string, (original: string) => string> = {};
const HELP_LINKS: Record<string, string> = {
OPERATION_RU_LIMIT_EXCEEDED:
"https://learn.microsoft.com/en-us/azure/cosmos-db/data-explorer#configure-request-unit-threshold",
};
const HELP_LINKS: Record<string, string> = {};
export default class QueryError {
message: string;

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

@@ -4,13 +4,18 @@ import * as React from "react";
export interface TooltipProps {
children: string;
className?: string;
ariaLabelForTooltip?: string;
}
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children, className }: TooltipProps) => {
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({
children,
className,
ariaLabelForTooltip = children,
}: TooltipProps) => {
return (
<span className={className}>
<TooltipHost content={children}>
<Icon iconName="Info" ariaLabel={children} className="panelInfoIcon" tabIndex={0} />
<Icon iconName="Info" aria-label={ariaLabelForTooltip} className="panelInfoIcon" tabIndex={0} />
</TooltipHost>
</span>
);

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

@@ -2,7 +2,7 @@
exports[`getCommonQueryOptions builds the correct default options objects 1`] = `
{
"disableNonStreamingOrderByQuery": true,
"enableQueryControl": false,
"enableScanInQuery": true,
"forceQueryPlan": true,
"maxDegreeOfParallelism": 0,
@@ -13,7 +13,7 @@ exports[`getCommonQueryOptions builds the correct default options objects 1`] =
exports[`getCommonQueryOptions reads from localStorage 1`] = `
{
"disableNonStreamingOrderByQuery": true,
"enableQueryControl": false,
"enableScanInQuery": true,
"forceQueryPlan": true,
"maxDegreeOfParallelism": 17,

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

@@ -0,0 +1,74 @@
import { constructRpOptions } from "Common/dataAccess/createCollection";
import { handleError } from "Common/ErrorHandlingUtils";
import { Collection, CreateMaterializedViewsParams as CreateGlobalSecondaryIndexParams } from "Contracts/DataModels";
import { userContext } from "UserContext";
import { createUpdateSqlContainer } from "Utils/arm/generatedClients/cosmos/sqlResources";
import {
CreateUpdateOptions,
SqlContainerResource,
SqlDatabaseCreateUpdateParameters,
} from "Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "Utils/NotificationConsoleUtils";
export const createGlobalSecondaryIndex = async (params: CreateGlobalSecondaryIndexParams): Promise<Collection> => {
const clearMessage = logConsoleProgress(
`Creating a new global secondary index ${params.materializedViewId} for database ${params.databaseId}`,
);
const options: CreateUpdateOptions = constructRpOptions(params);
const resource: SqlContainerResource = {
id: params.materializedViewId,
};
if (params.materializedViewDefinition) {
resource.materializedViewDefinition = params.materializedViewDefinition;
}
if (params.analyticalStorageTtl) {
resource.analyticalStorageTtl = params.analyticalStorageTtl;
}
if (params.indexingPolicy) {
resource.indexingPolicy = params.indexingPolicy;
}
if (params.partitionKey) {
resource.partitionKey = params.partitionKey;
}
if (params.uniqueKeyPolicy) {
resource.uniqueKeyPolicy = params.uniqueKeyPolicy;
}
if (params.vectorEmbeddingPolicy) {
resource.vectorEmbeddingPolicy = params.vectorEmbeddingPolicy;
}
if (params.fullTextPolicy) {
resource.fullTextPolicy = params.fullTextPolicy;
}
const rpPayload: SqlDatabaseCreateUpdateParameters = {
properties: {
resource,
options,
},
};
try {
const createResponse = await createUpdateSqlContainer(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
params.databaseId,
params.materializedViewId,
rpPayload,
);
logConsoleInfo(`Successfully created global secondary index ${params.materializedViewId}`);
return createResponse && (createResponse.properties.resource as Collection);
} catch (error) {
handleError(
error,
"CreateGlobalSecondaryIndex",
`Error while creating global secondary index ${params.materializedViewId}`,
);
throw error;
} finally {
clearMessage();
}
};

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

@@ -42,6 +42,7 @@ export interface IBulkDeleteResult {
export const deleteDocuments = async (
collection: CollectionBase,
documentIds: DocumentId[],
abortSignal: AbortSignal,
): Promise<IBulkDeleteResult[]> => {
const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`);
try {
@@ -65,12 +66,16 @@ export const deleteDocuments = async (
operationType: BulkOperationType.Delete,
}));
const promise = v2Container.items.bulk(operations).then((bulkResults) => {
return bulkResults.map((bulkResult, index) => {
const documentId = documentIdsChunk[index];
return { ...bulkResult, documentId };
const promise = v2Container.items
.bulk(operations, undefined, {
abortSignal,
})
.then((bulkResults) => {
return bulkResults.map((bulkResult, index) => {
const documentId = documentIdsChunk[index];
return { ...bulkResult, documentId };
});
});
});
promiseArray.push(promise);
}

View File

@@ -1,3 +1,4 @@
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { configContext } from "../../ConfigContext";
import { userContext } from "../../UserContext";
@@ -41,7 +42,7 @@ interface MetricsResponse {
}
export const getCollectionUsageSizeInKB = async (databaseName: string, containerName: string): Promise<number> => {
if (userContext.authType !== AuthType.AAD) {
if (userContext.authType !== AuthType.AAD || isFabricNative()) {
return undefined;
}

View File

@@ -1,5 +1,4 @@
import { FeedOptions, ItemDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
import { Queries } from "../Constants";
import { client } from "../CosmosClient";
@@ -26,7 +25,7 @@ export const getCommonQueryOptions = (options: FeedOptions): FeedOptions => {
options.maxItemCount ||
(storedItemPerPageSetting !== undefined && storedItemPerPageSetting) ||
Queries.itemsPerPage;
options.enableQueryControl = LocalStorageUtility.getEntryBoolean(StorageKey.QueryControlEnabled);
options.maxDegreeOfParallelism = LocalStorageUtility.getEntryNumber(StorageKey.MaxDegreeOfParellism);
options.disableNonStreamingOrderByQuery = !isVectorSearchEnabled();
return options;
};

View File

@@ -1,4 +1,3 @@
import { QueryOperationOptions } from "@azure/cosmos";
import { QueryResults } from "../../Contracts/ViewModels";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { getEntityName } from "../DocumentUtility";
@@ -9,13 +8,12 @@ export const queryDocumentsPage = async (
resourceName: string,
documentsIterator: MinimalQueryIterator,
firstItemIndex: number,
queryOperationOptions?: QueryOperationOptions,
): Promise<QueryResults> => {
const entityName = getEntityName();
const clearMessage = logConsoleProgress(`Querying ${entityName} for container ${resourceName}`);
try {
const result: QueryResults = await nextPage(documentsIterator, firstItemIndex, queryOperationOptions);
const result: QueryResults = await nextPage(documentsIterator, firstItemIndex);
const itemCount = (result.documents && result.documents.length) || 0;
logConsoleInfo(`Successfully fetched ${itemCount} ${entityName} for container ${resourceName}`);
return result;

View File

@@ -1,3 +1,4 @@
import { isFabric } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../../AuthType";
import { Offer, ReadCollectionOfferParams } from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
@@ -11,6 +12,11 @@ import { handleError } from "../ErrorHandlingUtils";
import { readOfferWithSDK } from "./readOfferWithSDK";
export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise<Offer> => {
if (isFabric()) {
// Not exposing offers in Fabric
return undefined;
}
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
try {

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

@@ -1,21 +1,15 @@
import { CassandraProxyEndpoints, JunoEndpoints, MongoProxyEndpoints, PortalBackendEndpoints } from "Common/Constants";
import {
BackendApi,
CassandraProxyEndpoints,
JunoEndpoints,
MongoProxyEndpoints,
PortalBackendEndpoints,
} from "Common/Constants";
import {
allowedAadEndpoints,
allowedArcadiaEndpoints,
allowedEmulatorEndpoints,
allowedGraphEndpoints,
allowedHostedExplorerEndpoints,
allowedJunoOrigins,
allowedMsalRedirectEndpoints,
defaultAllowedAadEndpoints,
defaultAllowedArmEndpoints,
defaultAllowedBackendEndpoints,
defaultAllowedCassandraProxyEndpoints,
defaultAllowedGraphEndpoints,
defaultAllowedMongoProxyEndpoints,
validateEndpoint,
} from "Utils/EndpointUtils";
@@ -29,6 +23,8 @@ export enum Platform {
export interface ConfigContext {
platform: Platform;
allowedAadEndpoints: ReadonlyArray<string>;
allowedGraphEndpoints: ReadonlyArray<string>;
allowedArmEndpoints: ReadonlyArray<string>;
allowedBackendEndpoints: ReadonlyArray<string>;
allowedCassandraProxyEndpoints: ReadonlyArray<string>;
@@ -37,10 +33,8 @@ export interface ConfigContext {
gitSha?: string;
proxyPath?: string;
AAD_ENDPOINT: string;
ARM_AUTH_AREA: string;
ARM_ENDPOINT: string;
EMULATOR_ENDPOINT?: string;
ARM_API_VERSION: string;
GRAPH_ENDPOINT: string;
GRAPH_API_VERSION: string;
// This is the endpoint to get offering Ids to be used to fetch prices. Refer to this doc: https://learn.microsoft.com/en-us/rest/api/marketplacecatalog/dataplane/skus/list?view=rest-marketplacecatalog-dataplane-2023-05-01-preview&tabs=HTTP
@@ -50,27 +44,24 @@ export interface ConfigContext {
ARCADIA_ENDPOINT: string;
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
PORTAL_BACKEND_ENDPOINT: string;
NEW_BACKEND_APIS?: BackendApi[];
MONGO_PROXY_ENDPOINT: string;
CASSANDRA_PROXY_ENDPOINT: string;
NEW_CASSANDRA_APIS?: string[];
PROXY_PATH?: string;
JUNO_ENDPOINT: string;
GITHUB_CLIENT_ID: string;
GITHUB_TEST_ENV_CLIENT_ID: string;
GITHUB_CLIENT_SECRET?: string; // No need to inject secret for prod. Juno already knows it.
isTerminalEnabled: boolean;
isPhoenixEnabled: boolean;
hostedExplorerURL: string;
armAPIVersion?: string;
msalRedirectURI?: string;
globallyEnabledCassandraAPIs?: string[];
globallyEnabledMongoAPIs?: string[];
}
// Default configuration
let configContext: Readonly<ConfigContext> = {
platform: Platform.Portal,
allowedAadEndpoints: defaultAllowedAadEndpoints,
allowedGraphEndpoints: defaultAllowedGraphEndpoints,
allowedArmEndpoints: defaultAllowedArmEndpoints,
allowedBackendEndpoints: defaultAllowedBackendEndpoints,
allowedCassandraProxyEndpoints: defaultAllowedCassandraProxyEndpoints,
@@ -85,17 +76,12 @@ let configContext: Readonly<ConfigContext> = {
`^https:\\/\\/cosmos-db-dataexplorer-germanycentral\\.azurewebsites\\.de$`,
`^https:\\/\\/.*\\.fabric\\.microsoft\\.com$`,
`^https:\\/\\/.*\\.powerbi\\.com$`,
`^https:\\/\\/.*\\.analysis-df\\.net$`,
`^https:\\/\\/.*\\.analysis-df\\.windows\\.net$`,
`^https:\\/\\/.*\\.azure-test\\.net$`,
`^https:\\/\\/dataexplorer-preview\\.azurewebsites\\.net$`,
], // Webpack injects this at build time
gitSha: process.env.GIT_SHA,
hostedExplorerURL: "https://cosmos.azure.com/",
AAD_ENDPOINT: "https://login.microsoftonline.com/",
ARM_AUTH_AREA: "https://management.azure.com/",
ARM_ENDPOINT: "https://management.azure.com/",
ARM_API_VERSION: "2016-06-01",
GRAPH_ENDPOINT: "https://graph.microsoft.com",
GRAPH_API_VERSION: "1.6",
CATALOG_ENDPOINT: "https://catalogapi.azure.com/",
@@ -109,11 +95,7 @@ let configContext: Readonly<ConfigContext> = {
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
NEW_CASSANDRA_APIS: ["postQuery", "createOrDelete", "getKeys", "getSchema"],
isTerminalEnabled: false,
isPhoenixEnabled: false,
globallyEnabledCassandraAPIs: [],
globallyEnabledMongoAPIs: [],
};
export function resetConfigContext(): void {
@@ -128,19 +110,38 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
return;
}
if (!validateEndpoint(newContext.ARM_ENDPOINT, configContext.allowedArmEndpoints || defaultAllowedArmEndpoints)) {
delete newContext.ARM_ENDPOINT;
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, allowedAadEndpoints)) {
if (!validateEndpoint(newContext.AAD_ENDPOINT, configContext.allowedAadEndpoints)) {
delete newContext.AAD_ENDPOINT;
}
if (!validateEndpoint(newContext.ARM_ENDPOINT, configContext.allowedArmEndpoints)) {
delete newContext.ARM_ENDPOINT;
}
if (!validateEndpoint(newContext.EMULATOR_ENDPOINT, allowedEmulatorEndpoints)) {
delete newContext.EMULATOR_ENDPOINT;
}
if (!validateEndpoint(newContext.GRAPH_ENDPOINT, allowedGraphEndpoints)) {
if (!validateEndpoint(newContext.GRAPH_ENDPOINT, configContext.allowedGraphEndpoints)) {
delete newContext.GRAPH_ENDPOINT;
}
@@ -148,21 +149,15 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
delete newContext.ARCADIA_ENDPOINT;
}
if (
!validateEndpoint(
newContext.MONGO_PROXY_ENDPOINT,
configContext.allowedMongoProxyEndpoints || defaultAllowedMongoProxyEndpoints,
)
) {
if (!validateEndpoint(newContext.PORTAL_BACKEND_ENDPOINT, configContext.allowedBackendEndpoints)) {
delete newContext.PORTAL_BACKEND_ENDPOINT;
}
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

@@ -23,6 +23,7 @@ export enum PaneKind {
GlobalSettings,
AdHocAccess,
SwitchDirectory,
QuickStart,
}
/**

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";
@@ -18,10 +19,36 @@ 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;
params: [{ settingsId?: "About" | "Connection" }];
}
| {
type: FabricMessageTypes.RestoreContainer;
params: [];
}
| {
type: FabricMessageTypes.ContainerUpdated;
params: {
updateType: "created" | "deleted" | "settings";
};
};
export type GetCosmosTokenMessageOptions = {
export interface GetCosmosTokenMessageOptions {
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
resourceType: "" | "dbs" | "colls" | "docs" | "sprocs" | "pkranges";
resourceId: string;
};
}

View File

@@ -7,17 +7,37 @@ export interface ArmEntity {
type: string;
kind: string;
tags?: Tags;
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;
@@ -28,10 +48,13 @@ export interface DatabaseAccountExtendedProperties {
capabilities?: Capability[];
enableMultipleWriteLocations?: boolean;
mongoEndpoint?: string;
backupPolicy?: DatabaseAccountBackupPolicy;
defaultIdentity?: string;
readLocations?: DatabaseAccountResponseLocation[];
writeLocations?: DatabaseAccountResponseLocation[];
enableFreeTier?: boolean;
enableAnalyticalStorage?: boolean;
enableMaterializedViews?: boolean;
isVirtualNetworkFilterEnabled?: boolean;
ipRules?: IpRule[];
privateEndpointConnections?: unknown[];
@@ -42,6 +65,7 @@ export interface DatabaseAccountExtendedProperties {
publicNetworkAccess?: string;
enablePriorityBasedExecution?: boolean;
vcoreMongoEndpoint?: string;
enableAllVersionsAndDeletesChangeFeed?: boolean;
}
export interface DatabaseAccountResponseLocation {
@@ -99,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;
@@ -161,9 +203,12 @@ export interface Collection extends Resource {
geospatialConfig?: GeospatialConfig;
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
fullTextPolicy?: FullTextPolicy;
dataMaskingPolicy?: DataMaskingPolicy;
schema?: ISchema;
requestSchema?: () => void;
computedProperties?: ComputedProperties;
materializedViews?: MaterializedView[];
materializedViewDefinition?: MaterializedViewDefinition;
}
export interface CollectionsWithPagination {
@@ -207,7 +252,7 @@ export interface IndexingPolicy {
export interface VectorIndex {
path: string;
type: "flat" | "diskANN" | "quantizedFlat";
diskANNShardKey?: string;
vectorIndexShardKey?: string[];
indexingSearchListSize?: number;
quantizationByteSize?: number;
}
@@ -223,6 +268,28 @@ 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;
}
export interface MaterializedViewDefinition {
definition: string;
sourceCollectionId: string;
sourceCollectionRid?: string;
}
export interface PartitionKey {
paths: string[];
kind: "Hash" | "Range" | "MultiHash";
@@ -345,9 +412,7 @@ export interface CreateDatabaseParams {
offerThroughput?: number;
}
export interface CreateCollectionParams {
createNewDatabase: boolean;
collectionId: string;
export interface CreateCollectionParamsBase {
databaseId: string;
databaseLevelThroughput: boolean;
offerThroughput?: number;
@@ -361,12 +426,22 @@ export interface CreateCollectionParams {
fullTextPolicy?: FullTextPolicy;
}
export interface CreateCollectionParams extends CreateCollectionParamsBase {
createNewDatabase: boolean;
collectionId: string;
}
export interface CreateMaterializedViewsParams extends CreateCollectionParamsBase {
materializedViewId: string;
materializedViewDefinition: MaterializedViewDefinition;
}
export interface VectorEmbeddingPolicy {
vectorEmbeddings: VectorEmbedding[];
}
export interface VectorEmbedding {
dataType: "float16" | "float32" | "uint8" | "int8";
dataType: "float32" | "uint8" | "int8";
dimensions: number;
distanceFunction: "euclidean" | "cosine" | "dotproduct";
path: string;

View File

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

View File

@@ -81,6 +81,13 @@ export type FabricMessageV3 =
error: string | undefined;
data: { accessToken: string };
};
}
| {
type: "refreshResourceTree";
message: {
id: string;
error: string | undefined;
};
};
export enum CosmosDbArtifactType {

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

@@ -1,4 +1,6 @@
import {
ItemDefinition,
JSONObject,
QueryMetrics,
Resource,
StoredProcedureDefinition,
@@ -29,8 +31,11 @@ export interface UploadDetailsRecord {
numFailed: number;
numThrottled: number;
errors: string[];
resources?: ItemDefinition[];
}
export type BulkInsertResult = Omit<UploadDetailsRecord, "fileName">;
export interface QueryResultsMetadata {
hasMoreResults: boolean;
firstItemIndex: number;
@@ -45,6 +50,7 @@ export interface QueryResults extends QueryResultsMetadata {
roundTrips?: number;
headers?: any;
queryMetrics?: QueryMetrics;
ruThresholdExceeded?: boolean;
}
export interface Button {
@@ -134,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>;
@@ -143,6 +150,8 @@ export interface Collection extends CollectionBase {
geospatialConfig: ko.Observable<DataModels.GeospatialConfig>;
documentIds: ko.ObservableArray<DocumentId>;
computedProperties: ko.Observable<DataModels.ComputedProperties>;
materializedViews: ko.Observable<DataModels.MaterializedView[]>;
materializedViewDefinition: ko.Observable<DataModels.MaterializedViewDefinition>;
cassandraKeys: CassandraTableKeys;
cassandraSchema: CassandraTableKey[];
@@ -204,6 +213,12 @@ export interface Collection extends CollectionBase {
onDragOver(source: Collection, event: { originalEvent: DragEvent }): void;
onDrop(source: Collection, event: { originalEvent: DragEvent }): void;
uploadFiles(fileList: FileList): Promise<{ data: UploadDetailsRecord[] }>;
bulkInsertDocuments(documents: JSONObject[]): Promise<{
numSucceeded: number;
numFailed: number;
numThrottled: number;
errors: string[];
}>;
}
/**
@@ -429,6 +444,9 @@ export interface DataExplorerInputsFrame {
[key: string]: string;
};
feedbackPolicies?: any;
aadToken?: string;
containerCopyEnabled?: boolean;
sessionId?: string;
}
export interface SelfServeFrameInputs {

View File

@@ -1,11 +0,0 @@
<!doctype html>
<html class="no-js" lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="data:," />
</head>
<body>
<div id="heatmap"></div>
</body>
</html>

View File

@@ -1,55 +0,0 @@
@import "../../../less/Common/Constants";
html {
font-family: @DataExplorerFont;
padding: 0px;
margin: 0px;
border: 0px;
overflow: hidden;
position: fixed;
width: 100%;
height: 100%;
}
body {
font-family: @DataExplorerFont;
padding: 0px;
margin: 0px;
border: 0px;
overflow: hidden;
}
#heatmap {
.dark-theme {
color: @BaseLight;
}
.chartTitle {
position: absolute;
top: 5px;
left: 3px;
font-size: 13px;
}
.noDataMessage {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
z-index: 10000;
height: 100%;
width: 100%;
top: 0;
left: 0;
opacity: 0.97;
div {
border-color: rgba(204, 204, 204, 0.8);
box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.12);
padding: 15px 10px;
width: calc(55% - 40px);
font-size: 13px;
text-align: center;
border-width: 1px;
border-style: solid;
}
}
}

View File

@@ -1,143 +0,0 @@
import dayjs from "dayjs";
import { handleMessage, Heatmap, isDarkTheme } from "./Heatmap";
import { PortalTheme } from "./HeatmapDatatypes";
describe("The Heatmap Control", () => {
const dataPoints = {
"1": {
"2019-06-19T00:59:10Z": {
"Normalized Throughput": 0.35,
},
"2019-06-19T00:48:10Z": {
"Normalized Throughput": 0.25,
},
},
};
const chartCaptions = {
chartTitle: "chart title",
yAxisTitle: "YAxisTitle",
tooltipText: "Tooltip text",
timeWindow: 123456789,
};
let heatmap: Heatmap;
const theme: PortalTheme = 1;
const divElement = `<div id="${Heatmap.elementId}"></div>`;
describe("drawHeatmap rendering", () => {
beforeEach(() => {
heatmap = new Heatmap(dataPoints, chartCaptions, theme);
document.body.innerHTML = divElement;
});
afterEach(() => {
document.body.innerHTML = ``;
});
it("should call _getChartSettings when drawHeatmap is invoked", () => {
const _getChartSettings = jest.spyOn(heatmap, "_getChartSettings");
heatmap.drawHeatmap();
expect(_getChartSettings).toHaveBeenCalled();
});
it("should call _getLayoutSettings when drawHeatmap is invoked", () => {
const _getLayoutSettings = jest.spyOn(heatmap, "_getLayoutSettings");
heatmap.drawHeatmap();
expect(_getLayoutSettings).toHaveBeenCalled();
});
it("should call _getChartDisplaySettings when drawHeatmap is invoked", () => {
const _getChartDisplaySettings = jest.spyOn(heatmap, "_getChartDisplaySettings");
heatmap.drawHeatmap();
expect(_getChartDisplaySettings).toHaveBeenCalled();
});
it("drawHeatmap should render a Heatmap inside the div element", () => {
heatmap.drawHeatmap();
expect(document.body.innerHTML).not.toEqual(divElement);
});
});
describe("generateMatrixFromMap", () => {
it("should massage input data to match output expected", () => {
expect(heatmap.generateMatrixFromMap(dataPoints).yAxisPoints).toEqual(["1"]);
expect(heatmap.generateMatrixFromMap(dataPoints).dataPoints).toEqual([[0.25, 0.35]]);
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints.length).toEqual(2);
});
it("should output the date format to ISO8601 string format", () => {
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints[0].slice(10, 11)).toEqual("T");
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints[0].slice(-1)).toEqual("Z");
});
it("should convert the time to the user's local time", () => {
if (dayjs().utcOffset()) {
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints).not.toEqual([
"2019-06-19T00:48:10Z",
"2019-06-19T00:59:10Z",
]);
} else {
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints).toEqual([
"2019-06-19T00:48:10Z",
"2019-06-19T00:59:10Z",
]);
}
});
});
describe("isDarkTheme", () => {
it("isDarkTheme should return the correct result", () => {
expect(isDarkTheme(PortalTheme.dark)).toEqual(true);
expect(isDarkTheme(PortalTheme.azure)).not.toEqual(true);
});
});
});
describe("iframe rendering when there is no data", () => {
afterEach(() => {
document.body.innerHTML = ``;
});
it("should show a no data message with a dark theme", () => {
const data = {
data: {
signature: "pcIframe",
data: {
chartData: {},
chartSettings: {},
theme: 4,
},
},
origin: "http://localhost",
};
const divElement = `<div id="${Heatmap.elementId}"></div>`;
document.body.innerHTML = divElement;
handleMessage(data as MessageEvent);
expect(document.body.innerHTML).toContain("dark-theme");
expect(document.body.innerHTML).toContain("noDataMessage");
});
it("should show a no data message with a white theme", () => {
const data = {
data: {
signature: "pcIframe",
data: {
chartData: {},
chartSettings: {},
theme: 2,
},
},
origin: "http://localhost",
};
const divElement = `<div id="${Heatmap.elementId}"></div>`;
document.body.innerHTML = divElement;
handleMessage(data as MessageEvent);
expect(document.body.innerHTML).not.toContain("dark-theme");
expect(document.body.innerHTML).toContain("noDataMessage");
});
});

View File

@@ -1,272 +0,0 @@
import dayjs from "dayjs";
import * as Plotly from "plotly.js-cartesian-dist-min";
import { sendCachedDataMessage, sendReadyMessage } from "../../Common/MessageHandler";
import { StyleConstants } from "../../Common/StyleConstants";
import { MessageTypes } from "../../Contracts/ExplorerContracts";
import { isInvalidParentFrameOrigin } from "../../Utils/MessageValidation";
import "./Heatmap.less";
import {
ChartSettings,
DataPayload,
DisplaySettings,
FontSettings,
HeatmapCaptions,
HeatmapData,
LayoutSettings,
PartitionTimeStampToData,
PortalTheme,
} from "./HeatmapDatatypes";
export class Heatmap {
public static readonly elementId: string = "heatmap";
private _chartData: HeatmapData;
private _heatmapCaptions: HeatmapCaptions;
private _theme: PortalTheme;
private _defaultFontColor: string;
constructor(data: DataPayload, heatmapCaptions: HeatmapCaptions, theme: PortalTheme) {
this._theme = theme;
this._defaultFontColor = StyleConstants.BaseDark;
this._setThemeColorForChart();
this._chartData = this.generateMatrixFromMap(data);
this._heatmapCaptions = heatmapCaptions;
}
private _setThemeColorForChart() {
if (isDarkTheme(this._theme)) {
this._defaultFontColor = StyleConstants.BaseLight;
}
}
private _getFontStyles(size: number = StyleConstants.MediumFontSize, color = "#838383"): FontSettings {
return {
family: StyleConstants.DataExplorerFont,
size,
color,
};
}
public generateMatrixFromMap(data: DataPayload): HeatmapData {
// all keys in data payload, sorted...
const rows: string[] = Object.keys(data).sort((a: string, b: string) => {
if (parseInt(a) < parseInt(b)) {
return -1;
} else {
if (parseInt(a) > parseInt(b)) {
return 1;
} else {
return 0;
}
}
});
const output: HeatmapData = {
yAxisPoints: [],
dataPoints: [],
xAxisPoints: Object.keys(data[rows[0]]).sort((a: string, b: string) => {
if (a < b) {
return -1;
} else {
if (a > b) {
return 1;
} else {
return 0;
}
}
}),
};
// go thru all rows and create 2d matrix for heatmap...
for (let i = 0; i < rows.length; i++) {
output.yAxisPoints.push(rows[i]);
const dataPoints: number[] = [];
for (let a = 0; a < output.xAxisPoints.length; a++) {
const row: PartitionTimeStampToData = data[rows[i]];
dataPoints.push(row[output.xAxisPoints[a]]["Normalized Throughput"]);
}
output.dataPoints.push(dataPoints);
}
for (let a = 0; a < output.xAxisPoints.length; a++) {
const dateTime = output.xAxisPoints[a];
// convert to local users timezone...
const day = dayjs(new Date(dateTime)).format("YYYY-MM-DD");
const hour = dayjs(new Date(dateTime)).format("HH:mm:ss");
// coerce to ISOString format since that is what plotly wants...
output.xAxisPoints[a] = `${day}T${hour}Z`;
}
return output;
}
// public for testing purposes
public _getChartSettings(): ChartSettings[] {
return [
{
z: this._chartData.dataPoints,
type: "heatmap",
zmin: 0,
zmid: 50,
zmax: 100,
colorscale: [
[0.0, "#1FD338"],
[0.1, "#1CAD2F"],
[0.2, "#50A527"],
[0.3, "#719F21"],
[0.4, "#95991B"],
[0.5, "#CE8F11"],
[0.6, "#E27F0F"],
[0.7, "#E46612"],
[0.8, "#E64914"],
[0.9, "#B80016"],
[1.0, "#B80016"],
],
name: "",
hovertemplate: this._heatmapCaptions.tooltipText,
colorbar: {
thickness: 15,
outlinewidth: 0,
tickcolor: StyleConstants.BaseDark,
tickfont: this._getFontStyles(10, this._defaultFontColor),
},
y: this._chartData.yAxisPoints,
x: this._chartData.xAxisPoints,
},
];
}
// public for testing purposes
public _getLayoutSettings(): LayoutSettings {
return {
margin: {
l: 40,
r: 10,
b: 35,
t: 30,
pad: 0,
},
paper_bgcolor: "transparent",
plot_bgcolor: "transparent",
width: 462,
height: 240,
yaxis: {
title: this._heatmapCaptions.yAxisTitle,
titlefont: this._getFontStyles(11),
autorange: true,
showgrid: false,
zeroline: false,
showline: false,
autotick: true,
fixedrange: true,
ticks: "",
showticklabels: false,
},
xaxis: {
fixedrange: true,
title: "*White area in heatmap indicates there is no available data",
titlefont: this._getFontStyles(11),
autorange: true,
showgrid: false,
zeroline: false,
showline: false,
autotick: true,
tickformat: this._heatmapCaptions.timeWindow > 7 ? "%I:%M %p" : "%b %e",
showticklabels: true,
tickfont: this._getFontStyles(10),
},
title: {
text: this._heatmapCaptions.chartTitle,
x: 0.01,
font: this._getFontStyles(13, this._defaultFontColor),
},
};
}
// public for testing purposes
public _getChartDisplaySettings(): DisplaySettings {
return {
/* heatmap can be fully responsive however the min-height needed in that case is greater than the iframe portal height, hence explicit width + height have been set in _getLayoutSettings
responsive: true,*/
displayModeBar: false,
};
}
public drawHeatmap(): void {
// todo - create random elementId generator so multiple heatmaps can be created - ticket # 431469
Plotly.plot(
Heatmap.elementId,
this._getChartSettings(),
this._getLayoutSettings(),
this._getChartDisplaySettings(),
);
const plotDiv: any = document.getElementById(Heatmap.elementId);
plotDiv.on("plotly_click", (data: any) => {
let timeSelected: string = data.points[0].x;
timeSelected = timeSelected.replace(" ", "T");
timeSelected = `${timeSelected}Z`;
let xAxisIndex = 0;
for (let i = 0; i < this._chartData.xAxisPoints.length; i++) {
if (this._chartData.xAxisPoints[i] === timeSelected) {
xAxisIndex = i;
break;
}
}
const output = [];
for (let i = 0; i < this._chartData.dataPoints.length; i++) {
output.push(this._chartData.dataPoints[i][xAxisIndex]);
}
sendCachedDataMessage(MessageTypes.LogInfo, output);
});
}
}
export function isDarkTheme(theme: PortalTheme) {
return theme === PortalTheme.dark;
}
export function handleMessage(event: MessageEvent) {
if (isInvalidParentFrameOrigin(event)) {
return;
}
if (typeof event.data !== "object" || event.data["signature"] !== "pcIframe") {
return;
}
if (
typeof event.data.data !== "object" ||
!("chartData" in event.data.data) ||
!("chartSettings" in event.data.data)
) {
return;
}
Plotly.purge(Heatmap.elementId);
document.getElementById(Heatmap.elementId)!.innerHTML = "";
const data = event.data.data;
const chartData: DataPayload = data.chartData;
const chartSettings: HeatmapCaptions = data.chartSettings;
const chartTheme: PortalTheme = data.theme;
if (Object.keys(chartData).length) {
new Heatmap(chartData, chartSettings, chartTheme).drawHeatmap();
} else {
const chartTitleElement = document.createElement("div");
chartTitleElement.innerHTML = data.chartSettings.chartTitle;
chartTitleElement.classList.add("chartTitle");
const noDataMessageElement = document.createElement("div");
noDataMessageElement.classList.add("noDataMessage");
const noDataMessageContent = document.createElement("div");
noDataMessageContent.innerHTML = data.errorMessage;
noDataMessageElement.appendChild(noDataMessageContent);
if (isDarkTheme(chartTheme)) {
chartTitleElement.classList.add("dark-theme");
noDataMessageElement.classList.add("dark-theme");
noDataMessageContent.classList.add("dark-theme");
}
document.getElementById(Heatmap.elementId)!.appendChild(chartTitleElement);
document.getElementById(Heatmap.elementId)!.appendChild(noDataMessageElement);
}
}
window.addEventListener("message", handleMessage, false);
sendReadyMessage();

View File

@@ -1,106 +0,0 @@
type dataPoint = string | number;
export interface DataPayload {
[id: string]: PartitionTimeStampToData;
}
export enum PortalTheme {
blue = 1,
azure,
light,
dark,
}
export interface HeatmapData {
yAxisPoints: string[];
xAxisPoints: string[];
dataPoints: dataPoint[][];
}
export interface HeatmapCaptions {
chartTitle: string;
yAxisTitle: string;
tooltipText: string;
timeWindow: number;
}
export interface FontSettings {
family: string;
size: number;
color: string;
}
export interface LayoutSettings {
paper_bgcolor?: string;
plot_bgcolor?: string;
margin?: {
l: number;
r: number;
b: number;
t: number;
pad: number;
};
width?: number;
height?: number;
yaxis?: {
fixedrange: boolean;
title: HeatmapCaptions["yAxisTitle"];
titlefont: FontSettings;
autorange: boolean;
showgrid: boolean;
zeroline: boolean;
showline: boolean;
autotick: boolean;
ticks: "";
showticklabels: boolean;
};
xaxis?: {
fixedrange: boolean;
title: string;
titlefont: FontSettings;
autorange: boolean;
showgrid: boolean;
zeroline: boolean;
showline: boolean;
autotick: boolean;
showticklabels: boolean;
tickformat: string;
tickfont: FontSettings;
};
title?: {
text: HeatmapCaptions["chartTitle"];
x: number;
font?: FontSettings;
};
font?: FontSettings;
}
export interface ChartSettings {
z: HeatmapData["dataPoints"];
type: "heatmap";
zmin: number;
zmid: number;
zmax: number;
colorscale: [number, string][];
name: string;
hovertemplate: HeatmapCaptions["tooltipText"];
colorbar: {
thickness: number;
outlinewidth: number;
tickcolor: string;
tickfont: FontSettings;
};
y: HeatmapData["yAxisPoints"];
x: HeatmapData["xAxisPoints"];
}
export interface DisplaySettings {
displayModeBar: boolean;
responsive?: boolean;
}
export interface PartitionTimeStampToData {
[timeSeriesDates: string]: {
[NormalizedThroughput: string]: number;
};
}

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");
});
});

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