diff --git a/.eslintrc.js b/.eslintrc.js index 047d7c1bb..fb1ed3a73 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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"] }], diff --git a/DataExplorer.nuspec b/DataExplorer.nuspec index 34fff1ccf..fc003d7b9 100644 --- a/DataExplorer.nuspec +++ b/DataExplorer.nuspec @@ -19,6 +19,6 @@ - + \ No newline at end of file diff --git a/images/DocumentIcon.svg b/images/DocumentIcon.svg new file mode 100644 index 000000000..d9bdb3385 --- /dev/null +++ b/images/DocumentIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/images/MoonIcon.svg b/images/MoonIcon.svg new file mode 100644 index 000000000..2902376ca --- /dev/null +++ b/images/MoonIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/images/SunIcon.svg b/images/SunIcon.svg new file mode 100644 index 000000000..7c7425848 --- /dev/null +++ b/images/SunIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/images/moon-blue.svg b/images/moon-blue.svg new file mode 100644 index 000000000..d664de808 --- /dev/null +++ b/images/moon-blue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/images/sun-blue.svg b/images/sun-blue.svg new file mode 100644 index 000000000..40ed9a803 --- /dev/null +++ b/images/sun-blue.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/less/Common/Constants.less b/less/Common/Constants.less index f77e82c8b..50a21b807 100644 --- a/less/Common/Constants.less +++ b/less/Common/Constants.less @@ -128,7 +128,7 @@ @provisionDatabaseThroughputInfo: 200px; //tabs container -@ActiveTabHeight: 31px; +@ActiveTabHeight: 32px; @ActiveTabWidth: 141px; @TabsHeight: 30px; @TabsWidth: 140px; @@ -237,11 +237,11 @@ *********************************************************************************************/ .hover() { - background-color: @AccentLight; + background-color: var(--colorNeutralBackground1Hover); } .active() { - background-color: @AccentExtra; + background-color: var(--colorNeutralBackground1Hover); } .focus() { diff --git a/less/documentDB.less b/less/documentDB.less index dce940328..3950aad9c 100644 --- a/less/documentDB.less +++ b/less/documentDB.less @@ -1740,7 +1740,7 @@ input::-webkit-calendar-picker-indicator { flex: 1; padding-left: 34px; padding-right: 34px; - color: @BaseDark; + color: var(--colorNeutralForeground1); overflow-y: auto; overflow-x: auto; margin: (2 * @MediumSpace) 0px; @@ -1749,7 +1749,6 @@ input::-webkit-calendar-picker-indicator { .contextual-pane .panelMainContent { padding-left: 34px; padding-right: 34px; - color: @BaseDark; margin: (2 * @MediumSpace) 0px; } @@ -1914,7 +1913,8 @@ input::-webkit-calendar-picker-indicator::after { } .nav-tabs-margin { - background-color: #f2f2f2; + background-color: var(--colorNeutralBackground1); + color: var(--colorNeutralForeground1); .nav-tabs { display: flex; @@ -1922,11 +1922,19 @@ input::-webkit-calendar-picker-indicator::after { align-items: flex-end; height: 100%; margin-bottom: -0.5px; + background-color: var(--colorNeutralBackground1Selected); li { - // Override the bootstrap defaults here to align with our layout constants. margin-bottom: 0px; height: 32px; + + &:hover { + background-color: var(--colorNeutralBackground1Hover); + } + + &:active { + background-color: var(--colorNeutralBackground1Pressed); + } } } } @@ -1940,8 +1948,9 @@ input::-webkit-calendar-picker-indicator::after { .nav.nav-tabs.qslevel > li > a:hover { border: none; border-radius: 0; - background-color: transparent !important; + background-color: var(--colorNeutralBackground1Selected); border-color: transparent; + color: var(--colorNeutralForeground1); } .numbersize { @@ -2376,6 +2385,8 @@ a:link { display: flex; flex-direction: column; min-width: 0; // This prevents it to grow past the parent's width if its content is too wide + background-color: var(--colorNeutralBackground1); + color: var(--colorNeutralForeground1); } .tabs { @@ -2631,14 +2642,16 @@ a:link { } .tabPanesContainer { - display: flex; flex-grow: 1; overflow: hidden; + background-color: var(--colorNeutralBackground1); + color: var(--colorNeutralForeground1); } .tabs-container { height: 100%; width: 100%; + overflow-y: auto; } .paddingspan4 { @@ -2655,24 +2668,18 @@ a:link { .nav-tabs > li.active > .tabNavContentContainer, .nav-tabs > li.active > .tabNavContentContainer:focus, .nav-tabs > li.active > .tabNavContentContainer:hover { - color: #555; + color: var(--colorNeutralForeground1); cursor: default; - background-color: @BaseLight; - border-color: @BaseMedium; - border-bottom-color: @BaseLight; + background-color: var(--colorNeutralBackground1); + border-color: var(--colorNeutralStroke1); border-style: solid; border-width: 1px; height: @ActiveTabHeight; width: @ActiveTabWidth; } -.nav-tabs > li.active > .tabNavContentContainer > .tab_Content > .contentWrapper > .tabNavText { - font-weight: bolder; - border-bottom: 2px solid rgba(0, 120, 212, 1); -} - -.nav-tabs > li.active:focus > .tabNavContentContainer { - .focus(); +.nav-tabs > li.active .contentWrapper .tabNavText { + border-bottom: 2px solid var(--colorCompoundBrandBackground); } .tabNavContentContainer { @@ -2681,7 +2688,7 @@ a:link { justify-content: space-between; border-radius: 2px 2px 0 0; padding: @DefaultSpace 0px @SmallSpace 0px; - color: @BaseHigh; + color: var(--colorNeutralForeground1); width: @TabsWidth; text-align: center; position: relative; @@ -2689,19 +2696,21 @@ a:link { &:hover { text-decoration: none; - background-color: @BaseMediumLow; - border-color: @BaseMediumLow; + background-color: var(--colorNeutralBackground1Hover); + border-color: transparent; } &:active { - background-color: @BaseMediumLow; + background-color: var(--colorNeutralBackground1Pressed); } .tab_Content { .flex-display(); width: @TabsWidth; - border-right: @ButtonBorderWidth solid @BaseMedium; + border-right: @ButtonBorderWidth solid var(--colorNeutralStroke1); white-space: nowrap; + color: var(--colorNeutralForeground1); + .contentWrapper { .flex-display(); width: @ContentWrapper; @@ -2723,9 +2732,8 @@ a:link { background-image: url(../images/error_no_outline.svg); background-repeat: no-repeat; background-position: center; - background-size: 3px; display: block; - margin: 1px 0px 0px 6px; + margin: 4px 0px 0px 6px; } } @@ -2750,39 +2758,60 @@ a:link { .loadingIcon { width: @LoadingErrorIconSize; height: @LoadingErrorIconSize; - margin: 0px 0px @SmallSpace @SmallSpace; + margin-top: 1px; + } + + .warningIconContainer { + width: @ErrorIconContainer; + height: @ErrorIconContainer; + margin-top: 1px; } } .tabNavText { margin-left: @SmallSpace; margin-right: 2px; - color: @BaseDark; + color: var(--colorNeutralForeground1); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; flex-grow: 1; + font-size: 12px; } } .tabIconSection { - width: 29px; position: relative; - padding-top: 2px; .cancelButton { padding: 0px @SmallSpace 0px @SmallSpace; + color: var(--colorNeutralForeground1); + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; &:hover { - .hover(); + background-color: var(--colorNeutralBackground1Hover); + color: var(--colorNeutralForeground1); } &:focus { - .focus(); + background-color: var(--colorNeutralBackground1Pressed); + color: var(--colorNeutralForeground1); } &:active { - .active(); + background-color: var(--colorNeutralBackground1Pressed); + color: var(--colorNeutralForeground1); + } + + &::before { + content: "×"; + font-size: 16px; + line-height: 1; } } } @@ -3137,3 +3166,12 @@ a:link { .sidebarContainer { height: 100%; } + +.close-Icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + cursor: pointer; +} diff --git a/less/documentDBFabric.less b/less/documentDBFabric.less index 832679abd..7e3c15429 100644 --- a/less/documentDBFabric.less +++ b/less/documentDBFabric.less @@ -7,6 +7,7 @@ html { body { font-family: @FabricFont; background-color: #f5f5f5; + --colorCompoundBrandBackground: @FabricAccentMedium; } a { @@ -41,7 +42,7 @@ a:focus { } .nav-tabs-margin { - padding-top: 5px; + padding-top: 0px; background-color: #ffffff; } @@ -68,17 +69,20 @@ a:focus { } .nav-tabs > li > .tabNavContentContainer > .tab_Content:hover { - border-bottom: 2px solid #e0e0e0; + border-bottom: none; } .nav-tabs > li.active > .tabNavContentContainer > .tab_Content, .nav-tabs > li.active > .tabNavContentContainer > .tab_Content:hover { - border-bottom: 2px solid @FabricAccentMedium; + border-bottom: none; } .nav-tabs > li.active > .tabNavContentContainer > .tab_Content > .contentWrapper > .tabNavText { border-bottom: 0px none transparent; } +.nav-tabs > li.active .contentWrapper .tabNavText { + border-bottom: 2px solid @FabricAccentMedium; +} .tabNavContentContainer { padding: @SmallSpace 0px @SmallSpace 0px; diff --git a/less/forms.less b/less/forms.less index 572134c26..a2f5fa257 100644 --- a/less/forms.less +++ b/less/forms.less @@ -1,211 +1,227 @@ @import "./Common/Constants"; .dirty { - border: 1px solid #9b4f96; + border: 1px solid #9b4f96; } .dirty:focus { - outline: 1px solid #9b4f96; + outline: 1px solid #9b4f96; } .tabForm { - padding: 12px 20px 20px 20px; - margin-left: 3px; + padding: 12px 20px 20px 20px; + margin-left: 3px; } .storedTabForm { - padding-top: @LargeSpace; + padding-top: @LargeSpace; } .scaleSettingScrollable { - overflow-y: auto; - overflow-x: hidden; - height:100%; + overflow-y: auto; + overflow-x: hidden; + height: 100%; } .disableFocusDefaults[tabindex] { - &:focus { - outline: none; - } + &:focus { + outline: none; + } - &:active { - outline: none; - } + &:active { + outline: none; + } } .indexingPolicyEditor { - width: 100%; - height: calc(~"100vh - 400px"); + width: 100%; + height: calc(~"100vh - 400px"); } .scaleDivison { - padding: @MediumSpace 0px @DefaultSpace 0px; + padding: @MediumSpace 0px @DefaultSpace 0px; } .scaleSettingTitle { - font-size: 14px; - cursor: pointer; + font-size: 14px; + cursor: pointer; } .autoScaleThroughputTitle { - margin-bottom: @SmallSpace; + margin-bottom: @SmallSpace; } .autoScaleDescription { - margin-top: 6px; - margin-bottom: @SmallSpace; + margin-top: 6px; + margin-bottom: @SmallSpace; } .ssExpandCollapseIcon { - width: 10px; - height: 10px; + width: 10px; + height: 10px; } .ssCollapseIcon { - margin-bottom: 5px; + margin-bottom: 5px; } .ssTextAllignment { - padding-left: 19px; + padding-left: 19px; } .throughputStorageBlock { - border-top: 1px solid #bbb; - border-bottom: 1px solid #bbb; - background-color: #ccc; - padding-left: 10px; - width: 315px; + border-top: 1px solid #bbb; + border-bottom: 1px solid #bbb; + background-color: #ccc; + padding-left: 10px; + width: 315px; } .storageCapacityTitle { - padding: @LargeSpace 0px; - + padding: @LargeSpace 0px; } .throughputStorageValue { - font-size: 12px; + font-size: 12px; } -.estimatedCost, .largePartitionKeyEnabled { - padding: @SmallSpace 0px @LargeSpace; +.estimatedCost, +.largePartitionKeyEnabled { + padding: @SmallSpace 0px @LargeSpace; } .storagePadding { - padding-top: 6px; - padding-bottom: 14px; + padding-top: 6px; + padding-bottom: 14px; } .dirtyTextbox { - width: 176px; - margin-top: 7px; - padding-left: 5px; + width: 176px; + margin-top: 7px; + padding-left: 5px; } .formTitleFirst { - padding: @DefaultSpace (2 * @MediumSpace); + padding: @DefaultSpace (2 * @MediumSpace); } .formTitleTextbox { - padding: 0px 0px @DefaultSpace (2 * @MediumSpace); + padding: 0px 0px @DefaultSpace (2 * @MediumSpace); } .formTree { - border: 1px solid #969696; - color: #393939; - padding: 0px 12px 1px 8px; + border: 1px solid var(--colorNeutralStroke1); + color: var(--colorNeutralForeground1); + background-color: var(--colorNeutralBackground1); + padding: 0px 12px 1px 8px; } .formTree:hover { - border: 1px solid #969696; - background-color: #e6f8fe; + border: 1px solid var(--colorNeutralStroke1Hover); + background-color: var(--colorNeutralBackground1Hover); +} + +.formTree::placeholder { + color: var(--colorNeutralForeground2); + opacity: 1; } .formTree:active { - border: 1px solid #1ebbee; + border: 1px solid var(--colorNeutralStroke1Pressed); + background-color: var(--colorNeutralBackground1Pressed); } .scaleForm { - padding-left: 8px; - color: @BaseDark; - border: 1px solid #969696; - min-width: @ScaleFormWidth; + padding-left: 8px; + color: @BaseDark; + border: 1px solid #969696; + min-width: @ScaleFormWidth; - &:hover { - background-color: #e6f8fe; - } + &:hover { + background-color: #e6f8fe; + } } .formTitle { - margin-top: 16px; - margin-bottom: 4px; + margin-top: 16px; + margin-bottom: 4px; } - .spUdfTriggerHeader { - padding: @DefaultSpace 0px @SmallSpace (2 * @MediumSpace); + padding: @DefaultSpace 0px @SmallSpace (2 * @MediumSpace); } .storedUdfTriggerEditor { - width: 100%; - height: 100%; + width: 100%; + height: 100%; } .unselectedRadio { - background-color: white; - border-color: #EEE!important; - color: black!important; + background-color: white; + border-color: #eee !important; + color: black !important; } .disabledRadio { - background-color: #A19F9D; - border-color: #EEE!important; - color: white!important; + background-color: #a19f9d; + border-color: #eee !important; + color: white !important; } .selectedRadio { - background-color: @AccentMediumHigh; - border-color: #EEE!important; - color: white!important; - cursor: pointer; + background-color: @AccentMediumHigh; + border-color: #eee !important; + color: white !important; + cursor: pointer; } .selectedRadio:hover { - background-color: @AccentMediumHigh; - border-color: #EEE!important; - color: white!important; - cursor: pointer; + background-color: @AccentMediumHigh; + border-color: #eee !important; + color: white !important; + cursor: pointer; } .selectedRadio:active { - background-color: #0072c6; - border-color: #EEE!important; - color: white!important; - cursor: pointer; - border: 1px solid #0072c6; + background-color: #0072c6; + border-color: #eee !important; + color: white !important; + cursor: pointer; + border: 1px solid #0072c6; } .selectedRadio.dirty { - background-color: #9b4f96; + background-color: #9b4f96; } .tabs { - margin: 0; + margin: 0; } .formReadOnly { - background-color: #ddd; - border: 1px solid #969696; - min-width: 184px; - padding-left: 8px; + background-color: #ddd; + border: 1px solid #969696; + min-width: 184px; + padding-left: 8px; } .migration:disabled { - background-color: #ccc; + background-color: #ccc; } .trigger-field { - width: 40%; - margin-top: 10px + width: 40%; + margin-top: 10px; + background-color: var(--colorNeutralBackground1); + color: var(--colorNeutralForeground1); } + +.trigger-field input::placeholder { + color: var(--colorNeutralForeground3); + opacity: 1; +} + .trigger-form { - padding: 10px 30px 10px 30px; -} \ No newline at end of file + background-color: var(--colorNeutralBackground1); + color: var(--colorNeutralForeground1); + padding: 10px 30px; +} diff --git a/less/hostedexplorer.less b/less/hostedexplorer.less index 86186d366..a3de43b5a 100644 --- a/less/hostedexplorer.less +++ b/less/hostedexplorer.less @@ -255,7 +255,7 @@ body { flex: 1; padding-left: 34px; padding-right: 34px; - color: @BaseDark; + color: var(--colorNeutralForeground1); overflow-y: auto; overflow-x: hidden; margin: (2 * @MediumSpace) 0px; diff --git a/less/tree.less b/less/tree.less deleted file mode 100644 index ed0fbf71f..000000000 --- a/less/tree.less +++ /dev/null @@ -1,270 +0,0 @@ -@import "./Common/Constants"; - -.resourceTree { - height: 100%; - flex: 0 0 auto; - .main { - height: 100%; - } -} - -.resourceTreeScroll { - height: 100%; - display: flex; - overflow-y: auto; - overflow-x: hidden; - padding-right: 10px; -} - -.userSelectNone { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; -} - -.treeHovermargin { - margin-left: 16px; -} - -.highlight { - padding: @SmallSpace 2px; - outline: 0; - - &:hover { - .hover(); - } - - &:active { - .active(); - } - - &:focus { - .focus(); - } -} - -.contextmenushowing { - background-color: #eee; -} - -.collectionstree { - width: 100%; - margin-top: @DefaultSpace; - - .databaseList { - list-style-type: none; - padding-left: 0px; - - .collectionList { - padding-left: (2 * @MediumSpace); - } - - .collectionChildList { - padding-left: @LargeSpace; - } - - .databaseDocuments { - padding-left: (5 * @MediumSpace); - } - } -} - -.pointerCursor { - cursor: pointer; -} - -.menuEllipsis { - padding-right: 6px; - font-weight: bold; - font-size: 18px; - position: relative; - top: -5px; - left: 0px; - float: right; - display: none; - padding-left: 6px !important; - line-height: @TreeLineHeight; -} - -.databaseMenu { - .flex-display(); -} - -.databaseMenu:hover .menuEllipsis, -.databaseMenu:focus .menuEllipsis { - display: block; -} - -.databaseCollChildTextOverflow { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - flex: 1; -} - -.collectionMenu { - .flex-display(); -} - -.collectionMenu:hover .menuEllipsis, -.collectionMenu:focus .menuEllipsis { - display: block; -} - -.documentsMenu:hover .menuEllipsis, -.documentsMenu:focus .menuEllipsis { - display: block; -} - -.treeChildMenu { - display: flex; -} - -.storedProcedureMenu:hover .menuEllipsis, -.storedProcedureMenu:focus .menuEllipsis { - display: block; -} - -.childMenu { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding-left: (6 * @MediumSpace); - width: 100%; -} - -.storedChildMenu:hover .menuEllipsis, -.storedChildMenu:focus .menuEllipsis { - display: block; -} - -.contextmenu6 { - top: -29px; -} - -.userDefinedMenu:hover .contextmenu6 { - display: block; -} - -.userDefinedchildMenu:hover .menuEllipsis, -.userDefinedchildMenu:focus .menuEllipsis { - display: block; -} - -.triggersMenu:hover .menuEllipsis, -.triggersMenu:focus .menuEllipsis { - display: block; -} - -.triggersChildMenu:hover .menuEllipsis, -.triggersChildMenu:focus .menuEllipsis { - display: block; -} - -.databaseId { - font-size: 14px; -} - -.storedUdfTriggerMenu { - padding-left: 0px; -} - -.collectionstree img { - width: 16px; - height: 16px; - vertical-align: text-top; -} - -img.collectionsTreeCollapseExpand { - width: 10px; - height: 10px; - vertical-align: middle; - margin-bottom: 5px; -} - -.collapsed::before { - content: "\23F5"; - margin-left: 0px; - font-size: 15px; -} - -.expanded::before { - content: "\23F7"; - margin-left: 0px; - font-size: 15px; -} - -.collectionMenuChildren { - padding-left: 42px; -} - -.main-nav { - width: 100vh; - height: 40px; - background: white; - transform-origin: left top; - -webkit-transform-origin: left top; - -ms-transform-origin: left top; - transform: rotate(-90deg) translateX(-100%); - -webkit-transform: rotate(-90deg) translateX(-100%); - -ms-transform: rotate(-90deg) translateX(-100%); - border-bottom: 1px solid #ccc; -} - -.main-nav-img { - width: 16px; - height: 16px; - margin: -32px 0 0 0; - transform: rotate(-90deg) translateX(-100%); - -webkit-transform: rotate(-90deg) translateX(-100%); - -ms-transform: rotate(-90deg) translateX(-100%); -} - -.main-nav-img.main-nav-sub-img { - width: 16px; - height: 16px; - margin: 0px 0px 0 0; - transform: rotate(180deg) translateX(0%); - -webkit-transform: rotate(180deg) translateX(0%); - -ms-transform: rotate(180deg) translateX(0%); - position: absolute; - right: -8px; - top: 16px; -} - -ul.nav { - margin: 0 auto; - margin-top: 0px; - margin-left: 0px; -} - -.mini ul.nav li { - float: right; - line-height: 25px; - height: auto; - margin-top: 3px; -} - -.spancolchildstyle { - padding: 4px; -} - -.contextmenubutton { - float: right; - display: none; -} - -.highlight:hover > .contextmenubutton { - display: unset; -} - -.highlight:hover > .contextmenubutton::after { - content: "\2026"; - font-size: 12px; -} - -.showEllipsis { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} diff --git a/package-lock.json b/package-lock.json index 3969be269..0663caa6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,6 +116,7 @@ "tinykeys": "2.1.0", "underscore": "1.12.1", "utility-types": "3.10.0", + "web-vitals": "4.2.4", "uuid": "9.0.0", "zustand": "3.5.0" }, @@ -35930,6 +35931,11 @@ "defaults": "^1.0.3" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "license": "BSD-2-Clause" diff --git a/package.json b/package.json index ad7fd5e19..0f31c174b 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "tinykeys": "2.1.0", "underscore": "1.12.1", "utility-types": "3.10.0", + "web-vitals": "4.2.4", "uuid": "9.0.0", "zustand": "3.5.0" }, diff --git a/src/Common/LoadingOverlay.test.tsx b/src/Common/LoadingOverlay.test.tsx new file mode 100644 index 000000000..441304b6f --- /dev/null +++ b/src/Common/LoadingOverlay.test.tsx @@ -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(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render loading overlay with custom label", () => { + const customProps = { + isLoading: true, + label: "Processing your request...", + }; + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render loading overlay with empty label", () => { + const emptyLabelProps = { + isLoading: true, + label: "", + }; + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should return null when isLoading is false", () => { + const notLoadingProps = { + isLoading: false, + label: "Loading...", + }; + const { container } = render(); + 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(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/Common/Upload/Upload.tsx b/src/Common/Upload/Upload.tsx index 3feb4fdfd..90b9e7add 100644 --- a/src/Common/Upload/Upload.tsx +++ b/src/Common/Upload/Upload.tsx @@ -50,10 +50,33 @@ export const Upload: FunctionComponent = ({ const title = label + " to upload"; return (
- {label} + + {label} + {tooltip && {tooltip}} - + +
+
+
+ This is a very long loading message that might span multiple lines and should still render correctly in the loading overlay component +
+
+
+`; + +exports[`LoadingOverlay should render loading overlay when isLoading is true 1`] = ` +
+
+
+
+ Loading... +
+
+
+`; + +exports[`LoadingOverlay should render loading overlay with custom label 1`] = ` +
+
+
+
+ Processing your request... +
+
+
+`; + +exports[`LoadingOverlay should render loading overlay with empty label 1`] = ` +
+
+
+ +
+
+`; diff --git a/src/Contracts/MessageTypes.ts b/src/Contracts/MessageTypes.ts index 658499525..70c431501 100644 --- a/src/Contracts/MessageTypes.ts +++ b/src/Contracts/MessageTypes.ts @@ -50,4 +50,5 @@ export enum MessageTypes { OpenCESCVAFeedbackBlade, ActivateTab, OpenContainerCopyFeedbackBlade, + UpdateTheme, } diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index 99ff76f9d..a84afe9c2 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -7,7 +7,7 @@ import { TriggerDefinition, UserDefinedFunctionDefinition, } from "@azure/cosmos"; -import Explorer from "../Explorer/Explorer"; +import type Explorer from "../Explorer/Explorer"; import { ConsoleData } from "../Explorer/Menus/NotificationConsole/ConsoleData"; import { CassandraTableKey, CassandraTableKeys } from "../Explorer/Tables/TableDataClient"; import ConflictId from "../Explorer/Tree/ConflictId"; @@ -447,6 +447,9 @@ export interface DataExplorerInputsFrame { aadToken?: string; containerCopyEnabled?: boolean; sessionId?: string; + theme?: { + mode: number; + }; } export interface SelfServeFrameInputs { @@ -480,3 +483,6 @@ export interface DropdownOption { value: T; disable?: boolean; } + +// Remove the duplicate Explorer interface and export the type +export type { Explorer }; diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx new file mode 100644 index 000000000..a1885e062 --- /dev/null +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx @@ -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("Previous copy job request was cancelled."), + }); + }); + + 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(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx index 6ca871a22..e2e6b6fc7 100644 --- a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx @@ -124,8 +124,7 @@ export const getCopyJobs = async (): Promise => { 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.", + message: "Previous copy job request was cancelled.", }; } else { throw error; diff --git a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.test.tsx b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.test.tsx new file mode 100644 index 000000000..314fecc37 --- /dev/null +++ b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.test.tsx @@ -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; + let mockGetCommandBarButtons: jest.MockedFunction; + + beforeEach(() => { + mockExplorer = {} as Explorer; + + mockConvertButton = CommandBarUtil.convertButton as jest.MockedFunction; + mockGetCommandBarButtons = Utils.getCommandBarButtons as jest.MockedFunction; + + jest.clearAllMocks(); + }); + + it("should render without crashing", () => { + mockGetCommandBarButtons.mockReturnValue([]); + mockConvertButton.mockReturnValue([]); + + const { container } = render(); + expect(container.querySelector(".commandBarContainer")).toBeInTheDocument(); + }); + + it("should call getCommandBarButtons with explorer", () => { + mockGetCommandBarButtons.mockReturnValue([]); + mockConvertButton.mockReturnValue([]); + + render(); + + 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(); + + expect(mockConvertButton).toHaveBeenCalledTimes(1); + }); + + it("should render FluentCommandBar with correct aria label", () => { + mockGetCommandBarButtons.mockReturnValue([]); + mockConvertButton.mockReturnValue([]); + + const { getByRole } = render(); + + 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(); + + 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(); + + 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(); + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1); + + rerender(); + + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2); + expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx index 9f163613d..92b1107c9 100644 --- a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx +++ b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx @@ -28,4 +28,6 @@ const CopyJobCommandBar: React.FC = ({ explorer }) => { ); }; +CopyJobCommandBar.displayName = "CopyJobCommandBar"; + export default CopyJobCommandBar; diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.test.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.test.ts new file mode 100644 index 000000000..8ce248f41 --- /dev/null +++ b/src/Explorer/ContainerCopy/CommandBar/Utils.test.ts @@ -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); + }); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.ts index a1472793b..152c2dfbd 100644 --- a/src/Explorer/ContainerCopy/CommandBar/Utils.ts +++ b/src/Explorer/ContainerCopy/CommandBar/Utils.ts @@ -17,7 +17,7 @@ function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] { iconSrc: AddIcon, label: ContainerCopyMessages.createCopyJobButtonLabel, ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel, - onClick: Actions.openCreateCopyJobPanel.bind(null, explorer), + onClick: () => Actions.openCreateCopyJobPanel(explorer), }, { key: "refresh", diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts index fdc4e38c6..27175de68 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -89,7 +89,7 @@ export default { enablementTitle: "Enable system assigned managed identity", enablementDescription: (accountName: string) => accountName - ? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button. ` + ? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button.` : "", }, defaultManagedIdentity: { @@ -116,7 +116,7 @@ export default { }, 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. ", + "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", @@ -162,10 +162,10 @@ export default { viewDetails: "View Details", }, Status: { - Pending: "Pending", - InProgress: "In Progress", - Running: "In Progress", - Partitioning: "In Progress", + Pending: "Queued", + InProgress: "Running", + Running: "Running", + Partitioning: "Running", Paused: "Paused", Completed: "Completed", Failed: "Failed", diff --git a/src/Explorer/ContainerCopy/ContainerCopyPanel.test.tsx b/src/Explorer/ContainerCopy/ContainerCopyPanel.test.tsx new file mode 100644 index 000000000..0f559e026 --- /dev/null +++ b/src/Explorer/ContainerCopy/ContainerCopyPanel.test.tsx @@ -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
CopyJobCommandBar
; + }; + 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
MonitorCopyJobs
; + }); + 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(); + + const wrapper = document.querySelector("#containerCopyWrapper"); + expect(wrapper).toBeInTheDocument(); + expect(wrapper).toHaveClass("flexContainer", "hideOverflows"); + }); + + it("renders CopyJobCommandBar component", () => { + render(); + + const commandBar = screen.getByTestId("copy-job-command-bar"); + expect(commandBar).toBeInTheDocument(); + expect(commandBar).toHaveTextContent("CopyJobCommandBar"); + }); + + it("renders MonitorCopyJobs component", () => { + render(); + + const monitorCopyJobs = screen.getByTestId("monitor-copy-jobs"); + expect(monitorCopyJobs).toBeInTheDocument(); + expect(monitorCopyJobs).toHaveTextContent("MonitorCopyJobs"); + }); + + it("passes explorer prop to child components", () => { + render(); + + 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(); + + 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(); + await waitFor(() => { + expect(mockSetRef).toHaveBeenCalledTimes(1); + }); + mockSetRef.mockClear(); + rerender(); + }); + + it("handles missing explorer prop gracefully", () => { + const { container } = render(); + expect(container.querySelector("#containerCopyWrapper")).toBeInTheDocument(); + }); + + it("applies correct CSS classes to wrapper", () => { + render(); + + const wrapper = document.querySelector("#containerCopyWrapper"); + expect(wrapper).toHaveClass("flexContainer"); + expect(wrapper).toHaveClass("hideOverflows"); + }); + + it("maintains ref across re-renders", async () => { + const { rerender } = render(); + + await waitFor(() => { + expect(mockSetRef).toHaveBeenCalled(); + }); + + const firstCallRef = mockSetRef.mock.calls[0][0]; + const newExplorer = {} as Explorer; + rerender(); + expect(mockSetRef.mock.calls[0][0]).toBe(firstCallRef); + }); +}); diff --git a/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx b/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx index 2d7cccb87..1c82ad4a6 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx +++ b/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx @@ -20,4 +20,6 @@ const ContainerCopyPanel: React.FC = ({ explorer }) => { ); }; +ContainerCopyPanel.displayName = "ContainerCopyPanel"; + export default ContainerCopyPanel; diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx new file mode 100644 index 000000000..7a0e7d874 --- /dev/null +++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx @@ -0,0 +1,660 @@ +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( + +
Test Child
+
, + ); + + expect(screen.getByTestId("test-child")).toBeInTheDocument(); + expect(screen.getByTestId("test-child")).toHaveTextContent("Test Child"); + }); + + it("should initialize with default state", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue).toBeDefined(); + expect(contextValue.copyJobState).toEqual({ + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null, + account: null, + 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( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.setCopyJobState).toBeDefined(); + expect(typeof contextValue.setCopyJobState).toBe("function"); + }); + + it("should provide setFlow function", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.setFlow).toBeDefined(); + expect(typeof contextValue.setFlow).toBe("function"); + }); + + it("should provide setContextError function", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.setContextError).toBeDefined(); + expect(typeof contextValue.setContextError).toBe("function"); + }); + + it("should provide resetCopyJobState function", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + 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 ( + + ); + }; + + render( + + + , + ); + + 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 ; + }; + + render( + + + , + ); + + 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 ; + }; + + render( + + + , + ); + + 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 ( + <> + + + + ); + }; + + render( + + + , + ); + + 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( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + 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 ( + <> + + + + + ); + }; + + render( + + + , + ); + + 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 ; + }; + + render( + + + , + ); + + 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( + + + , + ); + + 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(); + }).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 ( + + ); + }; + + render( + + + , + ); + + 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 ( + <> + + + + ); + }; + + render( + + + , + ); + + 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 ( + + ); + }; + + const TestComponent2 = (): JSX.Element => { + const context = useCopyJobContext(); + contextValue2 = context; + return
Component 2
; + }; + + render( + + + + , + ); + + 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( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Offline); + }); + + it("should initialize source with userContext values", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.copyJobState.source?.subscription?.subscriptionId).toBeUndefined(); + expect(contextValue.copyJobState.source?.account?.name).toBeUndefined(); + }); + + it("should initialize target with userContext values", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + 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( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.copyJobState.sourceReadAccessFromTarget).toBe(false); + }); + + it("should initialize with empty database and container ids", () => { + let contextValue: any; + + render( + + + {(value) => { + contextValue = value; + return null; + }} + + , + ); + + expect(contextValue.copyJobState.source.databaseId).toBe(""); + expect(contextValue.copyJobState.source.containerId).toBe(""); + expect(contextValue.copyJobState.target.databaseId).toBe(""); + expect(contextValue.copyJobState.target.containerId).toBe(""); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx index dab4bd3c0..ddb936dcf 100644 --- a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx +++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx @@ -1,5 +1,4 @@ import Explorer from "Explorer/Explorer"; -import { Subscription } from "Contracts/DataModels"; import React from "react"; import { userContext } from "UserContext"; import { CopyJobMigrationType } from "../Enums/CopyJobEnums"; @@ -24,10 +23,8 @@ const getInitialCopyJobState = (): CopyJobContextState => { jobName: "", migrationType: CopyJobMigrationType.Offline, source: { - subscription: { - subscriptionId: userContext.subscriptionId || "", - } as Subscription, - account: userContext.databaseAccount || null, + subscription: null, + account: null, databaseId: "", containerId: "", }, diff --git a/src/Explorer/ContainerCopy/CopyJobUtils.test.ts b/src/Explorer/ContainerCopy/CopyJobUtils.test.ts new file mode 100644 index 000000000..5c0a2a49d --- /dev/null +++ b/src/Explorer/ContainerCopy/CopyJobUtils.test.ts @@ -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"); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CopyJobUtils.ts b/src/Explorer/ContainerCopy/CopyJobUtils.ts index 75cc4acd2..a84b3d461 100644 --- a/src/Explorer/ContainerCopy/CopyJobUtils.ts +++ b/src/Explorer/ContainerCopy/CopyJobUtils.ts @@ -147,7 +147,7 @@ export function isEqual(prevJobs: CopyJobType[], newJobs: CopyJobType[]): boolea } const truncateLength = 5; -const truncateName = (name: string, length: number = truncateLength): string => { +export const truncateName = (name: string, length: number = truncateLength): string => { return name.length <= length ? name : name.slice(0, length); }; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.test.tsx new file mode 100644 index 000000000..6022b98d4 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.test.tsx @@ -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; + +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( + + + , + ); + }; + + 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(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx new file mode 100644 index 000000000..5ef3577b8 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx @@ -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
{content}
; + }; + 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 ( +
+
{title}
+
{children}
+ + +
+ ); + }; + 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; + const mockAssignRole = assignRole as jest.MockedFunction; + const mockGetAccountDetailsFromResourceId = getAccountDetailsFromResourceId as jest.MockedFunction< + typeof getAccountDetailsFromResourceId + >; + const mockLogError = logError as jest.MockedFunction; + + 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( + + + , + ); + }; + + 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, + }); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx new file mode 100644 index 000000000..76f915c5e --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx @@ -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(), + 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 ( +
+ Loading... +
+ ); + }; + MockShimmerTree.displayName = "MockShimmerTree"; + return MockShimmerTree; +}); + +jest.mock("./AddManagedIdentity", () => { + const MockAddManagedIdentity = () => { + return
Add Managed Identity Component
; + }; + MockAddManagedIdentity.displayName = "MockAddManagedIdentity"; + return MockAddManagedIdentity; +}); + +jest.mock("./AddReadPermissionToDefaultIdentity", () => { + const MockAddReadPermissionToDefaultIdentity = () => { + return
Add Read Permission Component
; + }; + MockAddReadPermissionToDefaultIdentity.displayName = "MockAddReadPermissionToDefaultIdentity"; + return MockAddReadPermissionToDefaultIdentity; +}); + +jest.mock("./DefaultManagedIdentity", () => { + const MockDefaultManagedIdentity = () => { + return
Default Managed Identity Component
; + }; + MockDefaultManagedIdentity.displayName = "MockDefaultManagedIdentity"; + return MockDefaultManagedIdentity; +}); + +jest.mock("./OnlineCopyEnabled", () => { + const MockOnlineCopyEnabled = () => { + return
Online Copy Enabled Component
; + }; + MockOnlineCopyEnabled.displayName = "MockOnlineCopyEnabled"; + return MockOnlineCopyEnabled; +}); + +jest.mock("./PointInTimeRestore", () => { + const MockPointInTimeRestore = () => { + return
Point In Time Restore Component
; + }; + 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 => ({ + 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( + + + , + ); + }; + + 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: () =>
Add Managed Identity Component
, + disabled: false, + completed: true, + }, + { + id: "readPermissionAssigned", + title: "Read Permission Assigned", + Component: () =>
Add Read Permission Component
, + 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: () =>
Point In Time Restore Component
, + disabled: false, + completed: true, + }, + { + id: "onlineCopyEnabled", + title: "Online Copy Enabled", + Component: () =>
Online Copy Enabled Component
, + 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: () =>
Add Managed Identity Component
, + disabled: false, + completed: true, + }, + ], + }, + { + id: "onlineConfigs", + title: "Online Configuration", + description: "Configure settings for online migration", + sections: [ + { + id: "onlineCopyEnabled", + title: "Online Copy Enabled", + Component: () =>
Online Copy Enabled Component
, + 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: () =>
Completed Component
, + disabled: false, + completed: true, + }, + { + id: "incompleteSection", + title: "Incomplete Section", + Component: () =>
Incomplete Component
, + disabled: false, + completed: false, + }, + { + id: "disabledSection", + title: "Disabled Section", + Component: () =>
Disabled Component
, + 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(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.test.tsx new file mode 100644 index 000000000..93418859f --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.test.tsx @@ -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
{content}
; + }; + 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 ( +
+
{title}
+
{children}
+
{isLoading ? "Loading" : "Not Loading"}
+ + +
+ ); + }; + 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; +const mockUseToggle = useToggle as jest.MockedFunction; + +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( + + + , + ); + }; + + 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(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx index da6bd4815..69e12e72e 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx @@ -27,7 +27,7 @@ const DefaultManagedIdentity: React.FC = () => { return (
- {ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account.name)}   + {ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account?.name)}  
= () => { onCancel={() => onToggle(null, false)} onPrimary={handleAddSystemIdentity} > - {ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account.name)} + {ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account?.name)}
); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.test.tsx new file mode 100644 index 000000000..8d28f2482 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.test.tsx @@ -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 ?
{label}
: null; + }; + MockLoadingOverlay.displayName = "MockLoadingOverlay"; + return MockLoadingOverlay; +}); + +const mockFetchDatabaseAccount = fetchDatabaseAccount as jest.MockedFunction; +const mockUpdateDatabaseAccount = updateDatabaseAccount as jest.MockedFunction; +const mockLogError = logError as jest.MockedFunction; + +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( + + + , + ); + }; + + 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"); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx index ebbe77447..a6f0918c3 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx @@ -32,7 +32,7 @@ const OnlineCopyEnabled: React.FC = () => { subscriptionId: sourceSubscriptionId, resourceGroup: sourceResourceGroup, accountName: sourceAccountName, - } = getAccountDetailsFromResourceId(selectedSourceAccount?.id); + } = getAccountDetailsFromResourceId(selectedSourceAccount?.id) || {}; const handleFetchAccount = async () => { try { @@ -91,12 +91,6 @@ const OnlineCopyEnabled: React.FC = () => { }); } setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel(sourceAccountName)); - await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, { - properties: { - enableAllVersionsAndDeletesChangeFeed: true, - }, - }); - await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, { properties: { capabilities: [...sourceAccountCapabilities, { name: CapabilityNames.EnableOnlineCopyFeature }], diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.test.tsx new file mode 100644 index 000000000..de5ddcc49 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.test.tsx @@ -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; +const mockLogError = logError as jest.MockedFunction; + +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 => ({ + copyJobState: defaultCopyJobState, + setCopyJobState: mockSetCopyJobState, + flow: null, + setFlow: jest.fn(), + contextError: null, + setContextError: jest.fn(), + resetCopyJobState: jest.fn(), + ...overrides, + }); + + const renderWithContext = (contextValue: CopyJobContextProviderType) => { + return render( + + + , + ); + }; + + 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(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx index f62331677..95d10c49a 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx @@ -31,7 +31,11 @@ const PointInTimeRestore: React.FC = () => { const [showRefreshButton, setShowRefreshButton] = useState(false); const intervalRef = useRef(null); const timeoutRef = useRef(null); - const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext(); + 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; @@ -39,7 +43,7 @@ const PointInTimeRestore: React.FC = () => { subscriptionId: sourceSubscriptionId, resourceGroup: sourceResourceGroup, accountName: sourceAccountName, - } = getAccountDetailsFromResourceId(selectedSourceAccount?.id); + } = getAccountDetailsFromResourceId(selectedSourceAccount?.id) || {}; useEffect(() => { return () => { diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap new file mode 100644 index 000000000..a0d1c3033 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap @@ -0,0 +1,406 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddManagedIdentity Snapshot Tests renders initial state correctly 1`] = ` +
+ + 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 don’t have to store any credentials in code. +   + + Learn more about Managed identities. + + +   +
+
+ Information +
+ +
+
+
+
+ + +
+
+
+`; + +exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = ` +
+ + 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 don’t have to store any credentials in code. +   + + Learn more about Managed identities. + + +   +
+
+ Information +
+ +
+
+
+
+ + +
+
+
+
+
+
+
+ Please wait while we process your request... +
+
+
+ + Enable system assigned managed identity + + + Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button. + +
+ + +
+
+
+`; + +exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover visible 1`] = ` +
+ + 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 don’t have to store any credentials in code. +   + + Learn more about Managed identities. + + +   +
+
+ Information +
+ +
+
+
+
+ + +
+
+
+ + Enable system assigned managed identity + + + Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button. + +
+ + +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddReadPermissionToDefaultIdentity.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddReadPermissionToDefaultIdentity.test.tsx.snap new file mode 100644 index 000000000..e461c2058 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddReadPermissionToDefaultIdentity.test.tsx.snap @@ -0,0 +1,398 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle missing source account 1`] = ` +
+
+ + 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. +   +
+ + Learn more about +   + + Read permissions. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle missing target account identity 1`] = ` +
+
+ + 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. +   +
+ + Learn more about +   + + Read permissions. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly when sourceReadAccessFromTarget is true 1`] = ` +
+
+ + 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. +   +
+ + Learn more about +   + + Read permissions. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly when toggle is on 1`] = ` +
+
+ + 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. +   +
+ + Learn more about +   + + Read permissions. + + +
+
+
+
+ + +
+
+
+
+ Read permissions assigned to default identity. +
+
+ Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button. +
+ + +
+
+
+`; + +exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly with default state 1`] = ` +
+
+ + 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. +   +
+ + Learn more about +   + + Read permissions. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly with different context states 1`] = ` +
+
+ + 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. +   +
+ + Learn more about +   + + Read permissions. + + +
+
+
+
+ + +
+
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AssignPermissions.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AssignPermissions.test.tsx.snap new file mode 100644 index 000000000..a6d76c3f3 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AssignPermissions.test.tsx.snap @@ -0,0 +1,1301 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AssignPermissions Component Accordion Behavior should render accordion sections with proper status icons 1`] = ` +
+
+ + 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. + +
+
+
+ + Test Group + + + Test Description + +
+
+
+
+ +
+
+
+
+ +
+
+
+ Incomplete Component +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Edge Cases should calculate correct indent levels for offline migration 1`] = ` +
+
+ + 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. + +
+
+
+ + Test Group + + + Test Description + +
+
+
+
+ +
+
+
+
+ +
+
+
+ Incomplete Component +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Edge Cases should calculate correct indent levels for online migration 1`] = ` +
+
+ + Follow the steps below to enable online copy on your "Source Account" account. + +
+
+
+ + Test Group + + + Test Description + +
+
+
+
+ +
+
+
+
+ +
+
+
+ Incomplete Component +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Edge Cases should handle missing account names 1`] = ` +
+
+ + 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. + +
+
+
+ + Test Group + + + Test Description + +
+
+
+
+ +
+
+
+
+ +
+
+
+ Incomplete Component +
+
+
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Permission Groups should render multiple permission groups 1`] = ` +
+
+ + Follow the steps below to enable online copy on your "Source Account" account. + +
+
+
+ + Cross Account Configuration + + + Configure permissions for cross-account copy + +
+
+
+
+ +
+
+
+
+
+
+ + Online Configuration + + + Configure settings for online migration + +
+
+
+
+ +
+
+
+ Online Copy Enabled Component +
+
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Permission Groups should render online migration specific groups 1`] = ` +
+
+ + Follow the steps below to enable online copy on your "Source Account" account. + +
+
+
+ + Online Configuration + + + Configure settings for online migration + +
+
+
+
+ +
+
+
+
+ +
+
+
+ Online Copy Enabled Component +
+
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Permission Groups should render permission groups when available 1`] = ` +
+
+ + 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. + +
+
+
+ + Cross Account Configuration + + + Configure permissions for cross-account copy + +
+
+
+
+ +
+
+
+
+ +
+
+
+ Add Read Permission Component +
+
+
+
+
+
+
+
+`; + +exports[`AssignPermissions Component Rendering should render without crashing with offline migration 1`] = ` +
+
+ + 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. + +
+ Loading... +
+
+
+`; + +exports[`AssignPermissions Component Rendering should render without crashing with online migration 1`] = ` +
+
+ + 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. + +
+ Loading... +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/DefaultManagedIdentity.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/DefaultManagedIdentity.test.tsx.snap new file mode 100644 index 000000000..631e60100 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/DefaultManagedIdentity.test.tsx.snap @@ -0,0 +1,369 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DefaultManagedIdentity Edge Cases should handle missing account name gracefully 1`] = ` +
+
+
+ Set the system-assigned managed identity as default for "" by switching it on. +   +
+ + Learn more about +   + + Default Managed Identities. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`DefaultManagedIdentity Edge Cases should handle null account 1`] = ` +
+
+
+ Set the system-assigned managed identity as default for "undefined" by switching it on. +   +
+ + Learn more about +   + + Default Managed Identities. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`DefaultManagedIdentity Loading States should render loading state snapshot 1`] = ` +
+
+
+ Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on. +   +
+ + Learn more about +   + + Default Managed Identities. + + +
+
+
+
+ + +
+
+
+
+ System assigned managed identity set as default +
+
+ Assign the system-assigned managed identity as the default for "test-cosmos-account". To confirm, click the "Yes" button. +
+
+ Loading +
+ + +
+
+
+`; + +exports[`DefaultManagedIdentity Rendering should render correctly with default state 1`] = ` +
+
+
+ Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on. +   +
+ + Learn more about +   + + Default Managed Identities. + + +
+
+
+
+ + +
+
+
+
+`; + +exports[`DefaultManagedIdentity Toggle Interactions should render toggle with checked state when toggle is true 1`] = ` +
+
+
+ Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on. +   +
+ + Learn more about +   + + Default Managed Identities. + + +
+
+
+
+ + +
+
+
+
+ System assigned managed identity set as default +
+
+ Assign the system-assigned managed identity as the default for "test-cosmos-account". To confirm, click the "Yes" button. +
+
+ Not Loading +
+ + +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/OnlineCopyEnabled.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/OnlineCopyEnabled.test.tsx.snap new file mode 100644 index 000000000..bc2151ac8 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/OnlineCopyEnabled.test.tsx.snap @@ -0,0 +1,193 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OnlineCopyEnabled Edge Cases should handle account with existing online copy capability 1`] = ` +
+
+
+ Enable online container copy by clicking the button below on your "test-account" account. +   + + Learn more about online copy jobs + +
+
+ +
+
+
+`; + +exports[`OnlineCopyEnabled Edge Cases should handle missing account name gracefully 1`] = ` +
+
+
+ Enable online container copy by clicking the button below on your "" account. +   + + Learn more about online copy jobs + +
+
+ +
+
+
+`; + +exports[`OnlineCopyEnabled Edge Cases should handle null account 1`] = ` +
+
+
+ Enable online container copy by clicking the button below on your "" account. +   + + Learn more about online copy jobs + +
+
+ +
+
+
+`; + +exports[`OnlineCopyEnabled Rendering should render correctly with initial state 1`] = ` +
+
+
+ Enable online container copy by clicking the button below on your "test-account" account. +   + + Learn more about online copy jobs + +
+
+ +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/PointInTimeRestore.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/PointInTimeRestore.test.tsx.snap new file mode 100644 index 000000000..83fe4df8e --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/PointInTimeRestore.test.tsx.snap @@ -0,0 +1,333 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PointInTimeRestore Edge Cases should handle missing account ID gracefully 1`] = `
`; + +exports[`PointInTimeRestore Edge Cases should handle missing source account gracefully 1`] = `
`; + +exports[`PointInTimeRestore Initial Render should render correctly with default props 1`] = ` +
+
+
+ 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. + +
+
+ Information +
+ +
+
+
+ +
+
+
+`; + +exports[`PointInTimeRestore Initial Render should render with empty account name gracefully 1`] = ` +
+
+
+ To facilitate online container copy jobs, please update your "" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality. + +
+
+ Information +
+ +
+
+
+ +
+
+
+`; + +exports[`PointInTimeRestore Snapshots should match snapshot in loading state 1`] = ` +
+
+
+
+
+
+ Please wait while we process your request... +
+
+
+
+ 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. + +
+
+ Information +
+ +
+
+
+ +
+
+
+`; + +exports[`PointInTimeRestore Snapshots should match snapshot with refresh button 1`] = ` +
+
+
+ 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. + +
+
+ Information +
+ +
+
+
+ +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.test.tsx new file mode 100644 index 000000000..2a91c8c69 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.test.tsx @@ -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; + +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; + onError?: (error: string) => void; +} + +const TestComponent: React.FC = ({ 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 ( +
+ +
{loading ? "true" : "false"}
+ {contextError &&
{contextError}
} +
+ ); +}; + +const TestWrapper: React.FC = (props) => { + const mockExplorer = new Explorer(); + + return ( + + + + ); +}; + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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"); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx index 5d3ed0474..571b1898f 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx @@ -31,7 +31,7 @@ const useManagedIdentity = ( subscriptionId: targetSubscriptionId, resourceGroup: targetResourceGroup, accountName: targetAccountName, - } = getAccountDetailsFromResourceId(selectedTargetAccount?.id); + } = getAccountDetailsFromResourceId(selectedTargetAccount?.id) || {}; const updatedAccount = await updateIdentityFn(targetSubscriptionId, targetResourceGroup, targetAccountName); if (updatedAccount) { diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.test.tsx new file mode 100644 index 000000000..78935657d --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.test.tsx @@ -0,0 +1,691 @@ +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { noop } from "underscore"; +import { CapabilityNames } from "../../../../../../Common/Constants"; +import * as RbacUtils from "../../../../../../Utils/arm/RbacUtils"; +import { + BackupPolicyType, + CopyJobMigrationType, + DefaultIdentityType, + IdentityType, +} from "../../../../Enums/CopyJobEnums"; +import { CopyJobContextState } from "../../../../Types/CopyJobTypes"; +import * as CopyJobPrerequisitesCacheModule from "../../../Utils/useCopyJobPrerequisitesCache"; +import usePermissionSections, { + checkTargetHasReaderRoleOnSource, + PermissionGroupConfig, + SECTION_IDS, +} from "./usePermissionsSection"; + +jest.mock("../../../../../../Utils/arm/RbacUtils"); +jest.mock("../../../Utils/useCopyJobPrerequisitesCache"); +jest.mock("../../../../CopyJobUtils", () => ({ + getAccountDetailsFromResourceId: jest.fn(() => ({ + subscriptionId: "sub-123", + resourceGroup: "rg-test", + accountName: "account-test", + })), + getContainerIdentifiers: jest.fn((container: any) => ({ + accountId: container?.account?.id || "default-account-id", + })), + isIntraAccountCopy: jest.fn((sourceId: string, targetId: string) => sourceId === targetId), +})); + +jest.mock("../AddManagedIdentity", () => { + const MockAddManagedIdentity = () => { + return
AddManagedIdentity
; + }; + MockAddManagedIdentity.displayName = "MockAddManagedIdentity"; + return MockAddManagedIdentity; +}); + +jest.mock("../AddReadPermissionToDefaultIdentity", () => { + const MockAddReadPermissionToDefaultIdentity = () => { + return
AddReadPermissionToDefaultIdentity
; + }; + MockAddReadPermissionToDefaultIdentity.displayName = "MockAddReadPermissionToDefaultIdentity"; + return MockAddReadPermissionToDefaultIdentity; +}); + +jest.mock("../DefaultManagedIdentity", () => { + const MockDefaultManagedIdentity = () => { + return
DefaultManagedIdentity
; + }; + MockDefaultManagedIdentity.displayName = "MockDefaultManagedIdentity"; + return MockDefaultManagedIdentity; +}); + +jest.mock("../OnlineCopyEnabled", () => { + const MockOnlineCopyEnabled = () => { + return
OnlineCopyEnabled
; + }; + MockOnlineCopyEnabled.displayName = "MockOnlineCopyEnabled"; + return MockOnlineCopyEnabled; +}); + +jest.mock("../PointInTimeRestore", () => { + const MockPointInTimeRestore = () => { + return
PointInTimeRestore
; + }; + MockPointInTimeRestore.displayName = "MockPointInTimeRestore"; + return MockPointInTimeRestore; +}); + +const mockedRbacUtils = RbacUtils as jest.Mocked; +const mockedCopyJobPrerequisitesCache = CopyJobPrerequisitesCacheModule as jest.Mocked< + typeof CopyJobPrerequisitesCacheModule +>; + +interface TestWrapperProps { + state: CopyJobContextState; + onResult?: (result: PermissionGroupConfig[]) => void; +} + +const TestWrapper: React.FC = ({ state, onResult }) => { + const result = usePermissionSections(state); + + React.useEffect(() => { + if (onResult) { + onResult(result); + } + }, [result, onResult]); + + return ( +
+
{result.length}
+ {result.map((group) => ( +
+

{group.title}

+

{group.description}

+ {group.sections.map((section) => ( +
+ + {section.completed?.toString() || "undefined"} + + {section.disabled.toString()} +
+ ))} +
+ ))} +
+ ); +}; + +describe("usePermissionsSection", () => { + let mockValidationCache: Map; + let mockSetValidationCache: jest.Mock; + + const createMockState = (overrides: Partial = {}): CopyJobContextState => ({ + jobName: "test-job", + migrationType: CopyJobMigrationType.Offline, + source: { + account: { + id: "source-account-id", + name: "source-account", + properties: { + backupPolicy: { + type: BackupPolicyType.Periodic, + }, + capabilities: [], + }, + location: "", + type: "", + kind: "", + }, + subscription: undefined, + databaseId: "", + containerId: "", + }, + target: { + account: { + id: "target-account-id", + name: "target-account", + identity: { + type: IdentityType.None, + principalId: "principal-123", + }, + properties: { + defaultIdentity: DefaultIdentityType.FirstPartyIdentity, + }, + location: "", + type: "", + kind: "", + }, + subscriptionId: "", + databaseId: "", + containerId: "", + }, + ...overrides, + }); + + beforeEach(() => { + mockValidationCache = new Map(); + mockSetValidationCache = jest.fn(); + + mockedCopyJobPrerequisitesCache.useCopyJobPrerequisitesCache.mockReturnValue({ + validationCache: mockValidationCache, + setValidationCache: mockSetValidationCache, + }); + + mockedRbacUtils.fetchRoleAssignments.mockResolvedValue([]); + mockedRbacUtils.fetchRoleDefinitions.mockResolvedValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Cross-account copy scenarios", () => { + it("should return cross-account configuration for different accounts", async () => { + const state = createMockState(); + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId("groups-count")).toHaveTextContent("1"); + }); + + expect(capturedResult).toHaveLength(1); + expect(capturedResult[0].id).toBe("crossAccountConfigs"); + expect(capturedResult[0].sections).toHaveLength(3); + expect(capturedResult[0].sections.map((s) => s.id)).toEqual([ + SECTION_IDS.addManagedIdentity, + SECTION_IDS.defaultManagedIdentity, + SECTION_IDS.readPermissionAssigned, + ]); + }); + + it("should not return cross-account configuration for same account (intra-account copy)", async () => { + const state = createMockState({ + source: { + account: { + id: "same-account-id", + name: "same-account", + properties: undefined, + location: "", + type: "", + kind: "", + }, + subscription: undefined, + databaseId: "", + containerId: "", + }, + target: { + account: { + id: "same-account-id", + name: "same-account", + identity: { type: IdentityType.None, principalId: "principal-123" }, + properties: { defaultIdentity: DefaultIdentityType.FirstPartyIdentity }, + location: "", + type: "", + kind: "", + }, + subscriptionId: "", + databaseId: "", + containerId: "", + }, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId("groups-count")).toHaveTextContent("0"); + }); + + expect(capturedResult).toHaveLength(0); + }); + }); + + describe("Online copy scenarios", () => { + it("should return online configuration for online migration", async () => { + const state = createMockState({ + migrationType: CopyJobMigrationType.Online, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId("groups-count")).toHaveTextContent("2"); + }); + + const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs"); + expect(onlineGroup).toBeDefined(); + expect(onlineGroup?.sections).toHaveLength(2); + expect(onlineGroup?.sections.map((s) => s.id)).toEqual([ + SECTION_IDS.pointInTimeRestore, + SECTION_IDS.onlineCopyEnabled, + ]); + }); + + it("should not return online configuration for offline migration", async () => { + const state = createMockState({ + migrationType: CopyJobMigrationType.Offline, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId("groups-count")).toHaveTextContent("1"); + }); + + const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs"); + expect(onlineGroup).toBeUndefined(); + }); + }); + + describe("Section validation", () => { + it("should validate addManagedIdentity section correctly", async () => { + const stateWithSystemAssigned = createMockState({ + target: { + account: { + id: "target-account-id", + name: "target-account", + identity: { + type: IdentityType.SystemAssigned, + principalId: "principal-123", + }, + properties: { + defaultIdentity: DefaultIdentityType.FirstPartyIdentity, + }, + location: "", + type: "", + kind: "", + }, + subscriptionId: "", + databaseId: "", + containerId: "", + }, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.addManagedIdentity}-completed`)).toHaveTextContent("true"); + }); + + const crossAccountGroup = capturedResult.find((g) => g.id === "crossAccountConfigs"); + const addManagedIdentitySection = crossAccountGroup?.sections.find( + (s) => s.id === SECTION_IDS.addManagedIdentity, + ); + expect(addManagedIdentitySection?.completed).toBe(true); + }); + + it("should validate defaultManagedIdentity section correctly", async () => { + const stateWithSystemAssignedIdentity = createMockState({ + target: { + account: { + id: "target-account-id", + name: "target-account", + identity: { + type: IdentityType.SystemAssigned, + principalId: "principal-123", + }, + properties: { + defaultIdentity: DefaultIdentityType.SystemAssignedIdentity, + }, + location: "", + type: "", + kind: "", + }, + subscriptionId: "", + databaseId: "", + containerId: "", + }, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.defaultManagedIdentity}-completed`)).toHaveTextContent("true"); + }); + + const crossAccountGroup = capturedResult.find((g) => g.id === "crossAccountConfigs"); + const defaultManagedIdentitySection = crossAccountGroup?.sections.find( + (s) => s.id === SECTION_IDS.defaultManagedIdentity, + ); + expect(defaultManagedIdentitySection?.completed).toBe(true); + }); + + it("should validate readPermissionAssigned section with reader role", async () => { + const mockRoleDefinitions: RbacUtils.RoleDefinitionType[] = [ + { + id: "role-1", + name: "Custom Role", + permissions: [ + { + dataActions: [ + "Microsoft.DocumentDB/databaseAccounts/readMetadata", + "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read", + ], + }, + ], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + ]; + + mockedRbacUtils.fetchRoleAssignments.mockResolvedValue([{ roleDefinitionId: "role-def-1" }] as any); + mockedRbacUtils.fetchRoleDefinitions.mockResolvedValue(mockRoleDefinitions); + + const state = createMockState({ + target: { + account: { + id: "target-account-id", + name: "target-account", + identity: { + type: IdentityType.SystemAssigned, + principalId: "principal-123", + }, + properties: { + defaultIdentity: DefaultIdentityType.SystemAssignedIdentity, + }, + location: "", + type: "", + kind: "", + }, + subscriptionId: "", + databaseId: "", + containerId: "", + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.readPermissionAssigned}-completed`)).toHaveTextContent("true"); + }); + + expect(mockedRbacUtils.fetchRoleAssignments).toHaveBeenCalledWith( + "sub-123", + "rg-test", + "account-test", + "principal-123", + ); + }); + + it("should validate pointInTimeRestore section for continuous backup", async () => { + const state = createMockState({ + migrationType: CopyJobMigrationType.Online, + source: { + account: { + id: "source-account-id", + name: "source-account", + properties: { + backupPolicy: { + type: BackupPolicyType.Continuous, + }, + capabilities: [], + }, + location: "", + type: "", + kind: "", + }, + subscription: undefined, + databaseId: "", + containerId: "", + }, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.pointInTimeRestore}-completed`)).toHaveTextContent("true"); + }); + + const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs"); + const pointInTimeSection = onlineGroup?.sections.find((s) => s.id === SECTION_IDS.pointInTimeRestore); + expect(pointInTimeSection?.completed).toBe(true); + }); + + it("should validate onlineCopyEnabled section with proper capability", async () => { + const state = createMockState({ + migrationType: CopyJobMigrationType.Online, + source: { + account: { + id: "source-account-id", + name: "source-account", + properties: { + backupPolicy: { + type: BackupPolicyType.Continuous, + }, + capabilities: [ + { + name: CapabilityNames.EnableOnlineCopyFeature, + description: "", + }, + ], + }, + location: "", + type: "", + kind: "", + }, + subscription: undefined, + databaseId: "", + containerId: "", + }, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.onlineCopyEnabled}-completed`)).toHaveTextContent("true"); + }); + + const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs"); + const onlineCopySection = onlineGroup?.sections.find((s) => s.id === SECTION_IDS.onlineCopyEnabled); + expect(onlineCopySection?.completed).toBe(true); + }); + }); + + describe("Validation caching", () => { + it("should use cached validation results", async () => { + mockValidationCache.set(SECTION_IDS.addManagedIdentity, true); + mockValidationCache.set(SECTION_IDS.defaultManagedIdentity, true); + + const state = createMockState(); + render(); + + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.addManagedIdentity}-completed`)).toHaveTextContent("true"); + }); + + expect(screen.getByTestId(`section-${SECTION_IDS.defaultManagedIdentity}-completed`)).toHaveTextContent("true"); + }); + + it("should clear online job validation cache when migration type changes to offline", async () => { + mockValidationCache.set(SECTION_IDS.pointInTimeRestore, true); + mockValidationCache.set(SECTION_IDS.onlineCopyEnabled, true); + + const state = createMockState({ + migrationType: CopyJobMigrationType.Offline, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("groups-count")).toHaveTextContent("1"); + }); + + expect(mockSetValidationCache).toHaveBeenCalled(); + }); + }); + + describe("Sequential validation within groups", () => { + it("should stop validation at first failure within a group", async () => { + const state = createMockState({ + target: { + account: { + id: "target-account-id", + name: "target-account", + identity: { + type: IdentityType.None, + principalId: "principal-123", + }, + properties: { + defaultIdentity: DefaultIdentityType.FirstPartyIdentity, + }, + location: "", + type: "", + kind: "", + }, + subscriptionId: "", + databaseId: "", + containerId: "", + }, + }); + + let capturedResult: PermissionGroupConfig[] = []; + + render( (capturedResult = result)} />); + + await waitFor(() => { + expect(screen.getByTestId(`section-${SECTION_IDS.addManagedIdentity}-completed`)).toHaveTextContent("false"); + }); + + const crossAccountGroup = capturedResult.find((g) => g.id === "crossAccountConfigs"); + expect(crossAccountGroup?.sections[0].completed).toBe(false); + expect(crossAccountGroup?.sections[1].completed).toBe(false); + expect(crossAccountGroup?.sections[2].completed).toBe(false); + }); + }); +}); + +describe("checkTargetHasReaderRoleOnSource", () => { + it("should return true for built-in Reader role", () => { + const roleDefinitions: RbacUtils.RoleDefinitionType[] = [ + { + id: "role-1", + name: "00000000-0000-0000-0000-000000000001", + permissions: [], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + ]; + + const result = checkTargetHasReaderRoleOnSource(roleDefinitions); + expect(result).toBe(true); + }); + + it("should return true for custom role with required data actions", () => { + const roleDefinitions: RbacUtils.RoleDefinitionType[] = [ + { + id: "role-1", + name: "Custom Reader Role", + permissions: [ + { + dataActions: [ + "Microsoft.DocumentDB/databaseAccounts/readMetadata", + "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read", + ], + }, + ], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + ]; + + const result = checkTargetHasReaderRoleOnSource(roleDefinitions); + expect(result).toBe(true); + }); + + it("should return false for role without required permissions", () => { + const roleDefinitions: RbacUtils.RoleDefinitionType[] = [ + { + id: "role-1", + name: "Insufficient Role", + permissions: [ + { + dataActions: ["Microsoft.DocumentDB/databaseAccounts/readMetadata"], + }, + ], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + ]; + + const result = checkTargetHasReaderRoleOnSource(roleDefinitions); + expect(result).toBe(false); + }); + + it("should return false for empty role definitions", () => { + const result = checkTargetHasReaderRoleOnSource([]); + expect(result).toBe(false); + }); + + it("should return false for role definitions without permissions", () => { + const roleDefinitions: RbacUtils.RoleDefinitionType[] = [ + { + id: "role-1", + name: "No Permissions Role", + permissions: [], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + ]; + + const result = checkTargetHasReaderRoleOnSource(roleDefinitions); + expect(result).toBe(false); + }); + + it("should handle multiple roles and return true if any has sufficient permissions", () => { + const roleDefinitions: RbacUtils.RoleDefinitionType[] = [ + { + id: "role-1", + name: "Insufficient Role", + permissions: [ + { + dataActions: ["Microsoft.DocumentDB/databaseAccounts/readMetadata"], + }, + ], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + { + id: "role-2", + name: "00000000-0000-0000-0000-000000000001", + permissions: [], + assignableScopes: [], + resourceGroup: "", + roleName: "", + type: "", + typePropertiesType: "", + }, + ]; + + const result = checkTargetHasReaderRoleOnSource(roleDefinitions); + expect(result).toBe(true); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useToggle.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useToggle.test.tsx new file mode 100644 index 000000000..e74d334dc --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useToggle.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import useToggle from "./useToggle"; + +const TestToggleComponent: React.FC<{ initialState?: boolean }> = ({ initialState }) => { + const [state, onToggle] = useToggle(initialState); + + return ( +
+ {state ? "true" : "false"} + + + +
+ ); +}; + +describe("useToggle hook", () => { + it("should initialize with false as default", () => { + render(); + + const stateElement = screen.getByTestId("toggle-state"); + expect(stateElement.textContent).toBe("false"); + }); + + it("should initialize with provided initial state", () => { + render(); + + const stateElement = screen.getByTestId("toggle-state"); + expect(stateElement.textContent).toBe("true"); + }); + + it("should toggle state when onToggle is called with opposite value", () => { + render(); + + const stateElement = screen.getByTestId("toggle-state"); + const toggleButton = screen.getByTestId("toggle-button"); + + expect(stateElement.textContent).toBe("false"); + + fireEvent.click(toggleButton); + expect(stateElement.textContent).toBe("true"); + + fireEvent.click(toggleButton); + expect(stateElement.textContent).toBe("false"); + }); + + it("should handle undefined checked parameter gracefully", () => { + const TestUndefinedComponent: React.FC = () => { + const [state, onToggle] = useToggle(false); + + return ( +
+ {state ? "true" : "false"} + +
+ ); + }; + + render(); + + const stateElement = screen.getByTestId("toggle-state"); + const undefinedButton = screen.getByTestId("undefined-button"); + + expect(stateElement.textContent).toBe("false"); + + fireEvent.click(undefinedButton); + expect(stateElement.textContent).toBe("false"); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.test.tsx new file mode 100644 index 000000000..33fd7e0ce --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.test.tsx @@ -0,0 +1,251 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import FieldRow from "./FieldRow"; + +describe("FieldRow", () => { + const mockChildContent = "Test Child Content"; + const testLabel = "Test Label"; + const customClassName = "custom-label-class"; + + describe("Component Rendering", () => { + it("renders the component with correct structure", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + expect(container.firstChild).toHaveClass("flex-row"); + expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument(); + expect(screen.getByText(mockChildContent)).toBeInTheDocument(); + }); + + it("renders children content correctly", () => { + render( + + + + , + ); + + expect(screen.getByTestId("test-input")).toBeInTheDocument(); + expect(screen.getByTestId("test-button")).toBeInTheDocument(); + }); + + it("renders complex children components correctly", () => { + const ComplexChild = () => ( +
+ Nested content + +
+ ); + + render( + + + , + ); + + expect(screen.getByText("Nested content")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter value")).toBeInTheDocument(); + }); + + it("does not render label when not provided", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + expect(container.querySelector("label")).not.toBeInTheDocument(); + expect(screen.getByText(mockChildContent)).toBeInTheDocument(); + }); + + it("applies custom label className when provided", () => { + render( + +
{mockChildContent}
+
, + ); + + const label = screen.getByText(`${testLabel}:`); + expect(label).toHaveClass("field-label", customClassName); + }); + }); + + describe("CSS Classes and Styling", () => { + it("applies default CSS classes correctly", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + const mainContainer = container.firstChild as Element; + expect(mainContainer).toHaveClass("flex-row"); + + const labelContainer = container.querySelector(".flex-fixed-width"); + expect(labelContainer).toBeInTheDocument(); + + const childContainer = container.querySelector(".flex-grow-col"); + expect(childContainer).toBeInTheDocument(); + + const label = screen.getByText(`${testLabel}:`); + expect(label).toHaveClass("field-label"); + }); + }); + + describe("Layout and Structure", () => { + it("uses horizontal Stack with space-between alignment", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + const mainContainer = container.firstChild as Element; + expect(mainContainer).toHaveClass("flex-row"); + }); + + it("positions label in fixed-width container with center alignment", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + const labelContainer = container.querySelector(".flex-fixed-width"); + expect(labelContainer).toBeInTheDocument(); + expect(labelContainer).toContainElement(screen.getByText(`${testLabel}:`)); + }); + + it("positions children in grow container with center alignment", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + const childContainer = container.querySelector(".flex-grow-col"); + expect(childContainer).toBeInTheDocument(); + expect(childContainer).toContainElement(screen.getByTestId("child-content")); + }); + + it("maintains layout when no label is provided", () => { + const { container } = render( + +
{mockChildContent}
+
, + ); + + expect(container.firstChild).toHaveClass("flex-row"); + expect(container.querySelector(".flex-fixed-width")).not.toBeInTheDocument(); + + const childContainer = container.querySelector(".flex-grow-col"); + expect(childContainer).toBeInTheDocument(); + expect(childContainer).toContainElement(screen.getByTestId("child-content")); + }); + }); + + describe("Edge Cases and Error Handling", () => { + it("handles null children gracefully", () => { + render({null}); + + expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument(); + }); + + it("handles zero as children", () => { + render({0}); + + expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument(); + expect(screen.getByText("0")).toBeInTheDocument(); + }); + + it("handles empty string as children", () => { + render({""}); + + expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument(); + }); + + it("handles array of children", () => { + render({[First, Second]}); + + expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument(); + expect(screen.getByText("First")).toBeInTheDocument(); + expect(screen.getByText("Second")).toBeInTheDocument(); + }); + }); + + describe("Snapshot Testing", () => { + it("matches snapshot with minimal props", () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with label only", () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with custom className", () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with complex children", () => { + const { container } = render( + +
+ + + +
+
, + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with no label", () => { + const { container } = render( + +
+

Section Title

+

Section description goes here

+
+
, + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with empty label", () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/InfoTooltip.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/InfoTooltip.test.tsx new file mode 100644 index 000000000..c1bb62372 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/InfoTooltip.test.tsx @@ -0,0 +1,39 @@ +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import React from "react"; +import InfoTooltip from "./InfoTooltip"; + +describe("InfoTooltip", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Component Rendering", () => { + it("should render null when no content is provided", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("should render null when content is undefined", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("should render tooltip with image when content is provided", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should render with JSX element content", () => { + const jsxContent = ( +
+ Important: This is a JSX tooltip +
+ ); + + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.test.tsx new file mode 100644 index 000000000..a8cd22e48 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.test.tsx @@ -0,0 +1,112 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import NavigationControls from "./NavigationControls"; + +describe("NavigationControls", () => { + const defaultProps = { + primaryBtnText: "Next", + onPrimary: jest.fn(), + onPrevious: jest.fn(), + onCancel: jest.fn(), + isPrimaryDisabled: false, + isPreviousDisabled: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders all buttons with correct text", () => { + render(); + + expect(screen.getByText("Next")).toBeInTheDocument(); + expect(screen.getByText("Previous")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + + it("renders primary button with custom text", () => { + const customProps = { + ...defaultProps, + primaryBtnText: "Complete", + }; + render(); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + expect(screen.queryByText("Next")).not.toBeInTheDocument(); + }); + + it("calls onPrimary when primary button is clicked", () => { + render(); + + fireEvent.click(screen.getByText("Next")); + expect(defaultProps.onPrimary).toHaveBeenCalledTimes(1); + }); + + it("calls onPrevious when previous button is clicked", () => { + render(); + + fireEvent.click(screen.getByText("Previous")); + expect(defaultProps.onPrevious).toHaveBeenCalledTimes(1); + }); + + it("calls onCancel when cancel button is clicked", () => { + render(); + + fireEvent.click(screen.getByText("Cancel")); + expect(defaultProps.onCancel).toHaveBeenCalledTimes(1); + }); + + it("disables primary button when isPrimaryDisabled is true", () => { + const disabledProps = { + ...defaultProps, + isPrimaryDisabled: true, + }; + render(); + const primaryButton = screen.getByText("Next").closest("button"); + expect(primaryButton).toHaveAttribute("aria-disabled", "true"); + expect(primaryButton).toHaveAttribute("data-is-focusable", "true"); + }); + + it("disables previous button when isPreviousDisabled is true", () => { + const disabledProps = { + ...defaultProps, + isPreviousDisabled: true, + }; + render(); + + const previousButton = screen.getByText("Previous").closest("button"); + expect(previousButton).toHaveAttribute("aria-disabled", "true"); + expect(previousButton).toHaveAttribute("data-is-focusable", "true"); + }); + + it("does not call onPrimary when disabled primary button is clicked", () => { + const disabledProps = { + ...defaultProps, + isPrimaryDisabled: true, + }; + render(); + + fireEvent.click(screen.getByText("Next")); + expect(defaultProps.onPrimary).not.toHaveBeenCalled(); + }); + + it("does not call onPrevious when disabled previous button is clicked", () => { + const disabledProps = { + ...defaultProps, + isPreviousDisabled: true, + }; + render(); + + fireEvent.click(screen.getByText("Previous")); + expect(defaultProps.onPrevious).not.toHaveBeenCalled(); + }); + + it("enables both buttons when neither is disabled", () => { + render(); + + expect(screen.getByText("Next").closest("button")).not.toHaveAttribute("aria-disabled"); + expect(screen.getByText("Previous").closest("button")).not.toHaveAttribute("aria-disabled"); + expect(screen.getByText("Cancel").closest("button")).not.toHaveAttribute("aria-disabled"); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx new file mode 100644 index 000000000..597159be8 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx @@ -0,0 +1,251 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import PopoverMessage from "./PopoverContainer"; + +jest.mock("../../../../../Common/LoadingOverlay", () => { + const MockLoadingOverlay = ({ isLoading, label }: { isLoading: boolean; label: string }) => { + return isLoading ?
: null; + }; + MockLoadingOverlay.displayName = "MockLoadingOverlay"; + return MockLoadingOverlay; +}); + +describe("PopoverMessage Component", () => { + const defaultProps = { + visible: true, + title: "Test Title", + onCancel: jest.fn(), + onPrimary: jest.fn(), + children:
Test content
, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Rendering", () => { + it("should render correctly when visible", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should render correctly when not visible", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should render correctly with loading state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should render correctly with different title", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should render correctly with different children content", () => { + const customChildren = ( +
+

First paragraph

+

Second paragraph

+
+ ); + const { container } = render({customChildren}); + expect(container).toMatchSnapshot(); + }); + }); + + describe("Visibility", () => { + it("should not render anything when visible is false", () => { + render(); + expect(screen.queryByText("Test Title")).not.toBeInTheDocument(); + expect(screen.queryByText("Test content")).not.toBeInTheDocument(); + }); + + it("should render content when visible is true", () => { + render(); + expect(screen.getByText("Test Title")).toBeInTheDocument(); + expect(screen.getByText("Test content")).toBeInTheDocument(); + }); + }); + + describe("Title Display", () => { + it("should display the provided title", () => { + render(); + expect(screen.getByText("Custom Popover Title")).toBeInTheDocument(); + }); + + it("should handle empty title", () => { + render(); + expect(screen.queryByText("Test Title")).not.toBeInTheDocument(); + }); + }); + + describe("Children Content", () => { + it("should render children content", () => { + const customChildren = Custom child content; + render({customChildren}); + expect(screen.getByText("Custom child content")).toBeInTheDocument(); + }); + + it("should render complex children content", () => { + const complexChildren = ( +
+

Heading

+
    +
  • Item 1
  • +
  • Item 2
  • +
+
+ ); + render({complexChildren}); + expect(screen.getByText("Heading")).toBeInTheDocument(); + expect(screen.getByText("Item 1")).toBeInTheDocument(); + expect(screen.getByText("Item 2")).toBeInTheDocument(); + }); + }); + + describe("Button Interactions", () => { + it("should call onPrimary when Yes button is clicked", () => { + const onPrimaryMock = jest.fn(); + render(); + + const yesButton = screen.getByText("Yes"); + fireEvent.click(yesButton); + + expect(onPrimaryMock).toHaveBeenCalledTimes(1); + }); + + it("should call onCancel when No button is clicked", () => { + const onCancelMock = jest.fn(); + render(); + + const noButton = screen.getByText("No"); + fireEvent.click(noButton); + + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + + it("should not call handlers multiple times on rapid clicks", () => { + const onPrimaryMock = jest.fn(); + const onCancelMock = jest.fn(); + render(); + + const yesButton = screen.getByText("Yes"); + const noButton = screen.getByText("No"); + + fireEvent.click(yesButton); + fireEvent.click(yesButton); + fireEvent.click(noButton); + fireEvent.click(noButton); + + expect(onPrimaryMock).toHaveBeenCalledTimes(2); + expect(onCancelMock).toHaveBeenCalledTimes(2); + }); + }); + + describe("Loading State", () => { + test("should show loading overlay when isLoading is true", () => { + render(); + expect(screen.getByTestId("loading-overlay")).toBeInTheDocument(); + }); + + it("should not show loading overlay when isLoading is false", () => { + render(); + expect(screen.queryByTestId("loading-overlay")).not.toBeInTheDocument(); + }); + + it("should disable buttons when loading", () => { + render(); + + const yesButton = screen.getByText("Yes").closest("button"); + const noButton = screen.getByText("No").closest("button"); + + expect(yesButton).toHaveAttribute("aria-disabled", "true"); + expect(noButton).toHaveAttribute("aria-disabled", "true"); + }); + + it("should enable buttons when not loading", () => { + render(); + + const yesButton = screen.getByText("Yes").closest("button"); + const noButton = screen.getByText("No").closest("button"); + + expect(yesButton).not.toHaveAttribute("aria-disabled"); + expect(noButton).not.toHaveAttribute("aria-disabled"); + }); + + it("should use correct loading overlay label", () => { + render(); + const loadingOverlay = screen.getByTestId("loading-overlay"); + expect(loadingOverlay).toHaveAttribute("aria-label", ContainerCopyMessages.popoverOverlaySpinnerLabel); + }); + }); + + describe("Default Props", () => { + it("should handle missing isLoading prop (defaults to false)", () => { + const propsWithoutLoading = { ...defaultProps }; + delete (propsWithoutLoading as any).isLoading; + + render(); + + expect(screen.queryByTestId("loading-overlay")).not.toBeInTheDocument(); + expect(screen.getByText("Yes")).not.toBeDisabled(); + expect(screen.getByText("No")).not.toBeDisabled(); + }); + }); + + describe("CSS Classes and Styling", () => { + it("should apply correct CSS classes", () => { + const { container } = render(); + const popoverContainer = container.querySelector(".popover-container"); + + expect(popoverContainer).toHaveClass("foreground"); + }); + + it("should apply loading class when isLoading is true", () => { + const { container } = render(); + const popoverContainer = container.querySelector(".popover-container"); + + expect(popoverContainer).toHaveClass("loading"); + }); + + it("should not apply loading class when isLoading is false", () => { + const { container } = render(); + const popoverContainer = container.querySelector(".popover-container"); + + expect(popoverContainer).not.toHaveClass("loading"); + }); + }); + + describe("Edge Cases", () => { + it("should handle undefined children", () => { + const propsWithUndefinedChildren = { ...defaultProps, children: undefined as React.ReactNode }; + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should handle null children", () => { + const propsWithNullChildren = { ...defaultProps, children: null as React.ReactNode }; + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should handle empty string title", () => { + const propsWithEmptyTitle = { ...defaultProps, title: "" }; + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should handle very long title", () => { + const longTitle = + "This is a very long title that might cause layout issues or text wrapping in the popover component"; + const propsWithLongTitle = { ...defaultProps, title: longTitle }; + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/FieldRow.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/FieldRow.test.tsx.snap new file mode 100644 index 000000000..697d1da47 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/FieldRow.test.tsx.snap @@ -0,0 +1,145 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FieldRow Snapshot Testing matches snapshot with complex children 1`] = ` +
+
+ +
+
+
+ + + +
+
+
+`; + +exports[`FieldRow Snapshot Testing matches snapshot with custom className 1`] = ` +
+
+ +
+
+ +
+
+`; + +exports[`FieldRow Snapshot Testing matches snapshot with empty label 1`] = ` +
+ +
+ +
+
+`; + +exports[`FieldRow Snapshot Testing matches snapshot with label only 1`] = ` +
+
+ +
+
+ +
+
+`; + +exports[`FieldRow Snapshot Testing matches snapshot with minimal props 1`] = ` +
+ +
+ +
+
+`; + +exports[`FieldRow Snapshot Testing matches snapshot with no label 1`] = ` +
+ +
+
+

+ Section Title +

+

+ Section description goes here +

+
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/InfoTooltip.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/InfoTooltip.test.tsx.snap new file mode 100644 index 000000000..8b79c2b74 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/InfoTooltip.test.tsx.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InfoTooltip Component Rendering should render tooltip with image when content is provided 1`] = ` +
+
+
+ Information +
+ +
+
+`; + +exports[`InfoTooltip Component Rendering should render with JSX element content 1`] = ` +
+
+
+ Information +
+ +
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap new file mode 100644 index 000000000..978f38f5b --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap @@ -0,0 +1,552 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PopoverMessage Component Edge Cases should handle empty string title 1`] = ` +
+
+ + +
+ Test content +
+
+
+ + +
+
+
+`; + +exports[`PopoverMessage Component Edge Cases should handle null children 1`] = ` +
+
+ + Test Title + +
+ + +
+
+
+`; + +exports[`PopoverMessage Component Edge Cases should handle undefined children 1`] = ` +
+
+ + Test Title + +
+ + +
+
+
+`; + +exports[`PopoverMessage Component Edge Cases should handle very long title 1`] = ` +
+
+ + This is a very long title that might cause layout issues or text wrapping in the popover component + + +
+ Test content +
+
+
+ + +
+
+
+`; + +exports[`PopoverMessage Component Rendering should render correctly when not visible 1`] = `
`; + +exports[`PopoverMessage Component Rendering should render correctly when visible 1`] = ` +
+
+ + Test Title + + +
+ Test content +
+
+
+ + +
+
+
+`; + +exports[`PopoverMessage Component Rendering should render correctly with different children content 1`] = ` +
+
+ + Test Title + + +
+

+ First paragraph +

+

+ Second paragraph +

+
+
+
+ + +
+
+
+`; + +exports[`PopoverMessage Component Rendering should render correctly with different title 1`] = ` +
+
+ + Custom Title + + +
+ Test content +
+
+
+ + +
+
+
+`; + +exports[`PopoverMessage Component Rendering should render correctly with loading state 1`] = ` +
+
+
+ + Test Title + + +
+ Test content +
+
+
+ + +
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.test.tsx new file mode 100644 index 000000000..226ea15af --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.test.tsx @@ -0,0 +1,261 @@ +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import { CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums"; +import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes"; +import Explorer from "Explorer/Explorer"; +import { useSidePanel } from "hooks/useSidePanel"; +import React from "react"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import AddCollectionPanelWrapper from "./AddCollectionPanelWrapper"; + +jest.mock("hooks/useSidePanel"); +jest.mock("../../../Context/CopyJobContext"); + +jest.mock("../../../../Panes/AddCollectionPanel/AddCollectionPanel", () => ({ + AddCollectionPanel: ({ + explorer, + isCopyJobFlow, + onSubmitSuccess, + }: { + explorer?: Explorer; + isCopyJobFlow: boolean; + onSubmitSuccess: (data: { databaseId: string; collectionId: string }) => void; + }) => ( +
+
{explorer ? "explorer-present" : "no-explorer"}
+
{isCopyJobFlow ? "true" : "false"}
+ +
+ ), +})); + +jest.mock("immer", () => ({ + produce: jest.fn((updater) => (state: any) => { + const draft = { ...state }; + updater(draft); + return draft; + }), +})); + +const mockUseSidePanel = useSidePanel as jest.MockedFunction; +const mockUseCopyJobContext = useCopyJobContext as jest.MockedFunction; + +describe("AddCollectionPanelWrapper", () => { + const mockSetCopyJobState = jest.fn(); + const mockGoBack = jest.fn(); + const mockSetHeaderText = jest.fn(); + const mockExplorer = {} as Explorer; + + const mockSidePanelState = { + isOpen: false, + panelWidth: "440px", + hasConsole: true, + headerText: "", + setHeaderText: mockSetHeaderText, + openSidePanel: jest.fn(), + closeSidePanel: jest.fn(), + setPanelHasConsole: jest.fn(), + }; + + const mockCopyJobContextValue = { + contextError: null, + setContextError: jest.fn(), + copyJobState: { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: { subscriptionId: "" }, + account: null, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null, + databaseId: "", + containerId: "", + }, + sourceReadAccessFromTarget: false, + }, + setCopyJobState: mockSetCopyJobState, + flow: null, + setFlow: jest.fn(), + resetCopyJobState: jest.fn(), + explorer: mockExplorer, + } as unknown as CopyJobContextProviderType; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseSidePanel.mockReturnValue(mockSidePanelState); + mockUseSidePanel.getState = jest.fn().mockReturnValue(mockSidePanelState); + mockUseCopyJobContext.mockReturnValue(mockCopyJobContextValue); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("Component Rendering", () => { + it("should render correctly with all required elements", () => { + const { container } = render(); + + expect(container.querySelector(".addCollectionPanelWrapper")).toBeInTheDocument(); + expect(container.querySelector(".addCollectionPanelHeader")).toBeInTheDocument(); + expect(container.querySelector(".addCollectionPanelBody")).toBeInTheDocument(); + expect(screen.getByText(ContainerCopyMessages.createNewContainerSubHeading)).toBeInTheDocument(); + expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument(); + }); + + it("should match snapshot", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should match snapshot with explorer prop", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should match snapshot with goBack prop", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("should match snapshot with both props", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); + + describe("Side Panel Header Management", () => { + it("should set header text to create container heading on mount", () => { + render(); + + expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createContainerHeading); + }); + + it("should reset header text to create copy job panel title on unmount", () => { + const { unmount } = render(); + + unmount(); + + expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createCopyJobPanelTitle); + }); + + it("should not change header text if already set correctly", () => { + const modifiedSidePanelState = { + ...mockSidePanelState, + headerText: ContainerCopyMessages.createContainerHeading, + }; + + mockUseSidePanel.getState = jest.fn().mockReturnValue(modifiedSidePanelState); + + render(); + + expect(mockSetHeaderText).not.toHaveBeenCalled(); + }); + }); + + describe("AddCollectionPanel Integration", () => { + it("should pass explorer prop to AddCollectionPanel", () => { + render(); + + expect(screen.getByTestId("explorer-prop")).toHaveTextContent("explorer-present"); + }); + + it("should pass undefined explorer to AddCollectionPanel when not provided", () => { + render(); + + expect(screen.getByTestId("explorer-prop")).toHaveTextContent("no-explorer"); + }); + + it("should pass isCopyJobFlow as true to AddCollectionPanel", () => { + render(); + + expect(screen.getByTestId("copy-job-flow")).toHaveTextContent("true"); + }); + }); + + describe("Collection Success Handler", () => { + it("should update copy job state when handleAddCollectionSuccess is called", async () => { + render(); + + const submitButton = screen.getByTestId("submit-button"); + submitButton.click(); + + await waitFor(() => { + expect(mockSetCopyJobState).toHaveBeenCalledTimes(1); + }); + + const stateUpdater = mockSetCopyJobState.mock.calls[0][0]; + const mockState = { + target: { databaseId: "", containerId: "" }, + }; + + const updatedState = stateUpdater(mockState); + expect(updatedState.target.databaseId).toBe("test-db"); + expect(updatedState.target.containerId).toBe("test-collection"); + }); + + it("should call goBack when handleAddCollectionSuccess is called and goBack is provided", async () => { + render(); + + const submitButton = screen.getByTestId("submit-button"); + submitButton.click(); + + await waitFor(() => { + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + }); + + it("should not call goBack when handleAddCollectionSuccess is called and goBack is not provided", async () => { + render(); + + const submitButton = screen.getByTestId("submit-button"); + submitButton.click(); + + await waitFor(() => { + expect(mockSetCopyJobState).toHaveBeenCalledTimes(1); + }); + + expect(mockGoBack).not.toHaveBeenCalled(); + }); + }); + + describe("Error Handling", () => { + it("should handle missing setCopyJobState gracefully", () => { + const mockCopyJobContextValueWithoutSetState = { + ...mockCopyJobContextValue, + setCopyJobState: undefined as any, + }; + + mockUseCopyJobContext.mockReturnValue(mockCopyJobContextValueWithoutSetState); + + expect(() => render()).not.toThrow(); + }); + }); + + describe("Component Lifecycle", () => { + it("should properly cleanup on unmount", () => { + const { unmount } = render(); + expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createContainerHeading); + mockSetHeaderText.mockClear(); + unmount(); + expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createCopyJobPanelTitle); + }); + + it("should re-render correctly when props change", () => { + const { rerender } = render(); + expect(screen.getByTestId("explorer-prop")).toHaveTextContent("no-explorer"); + rerender(); + expect(screen.getByTestId("explorer-prop")).toHaveTextContent("explorer-present"); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap new file mode 100644 index 000000000..6824baba9 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap @@ -0,0 +1,165 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddCollectionPanelWrapper Component Rendering should match snapshot 1`] = ` +
+
+
+ + Select the properties for your container. + +
+
+
+
+ no-explorer +
+
+ true +
+ +
+
+
+
+`; + +exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with both props 1`] = ` +
+
+
+ + Select the properties for your container. + +
+
+
+
+ explorer-present +
+
+ true +
+ +
+
+
+
+`; + +exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with explorer prop 1`] = ` +
+
+
+ + Select the properties for your container. + +
+
+
+
+ explorer-present +
+
+ true +
+ +
+
+
+
+`; + +exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with goBack prop 1`] = ` +
+
+
+ + Select the properties for your container. + +
+
+
+
+ no-explorer +
+
+ true +
+ +
+
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.test.tsx new file mode 100644 index 000000000..bbff5758e --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.test.tsx @@ -0,0 +1,426 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import CreateCopyJobScreens from "./CreateCopyJobScreens"; + +jest.mock("../../Context/CopyJobContext", () => ({ + useCopyJobContext: jest.fn(), +})); + +jest.mock("../Utils/useCopyJobNavigation", () => ({ + useCopyJobNavigation: jest.fn(), +})); + +jest.mock("./Components/NavigationControls", () => { + const MockedNavigationControls = ({ + primaryBtnText, + onPrimary, + onPrevious, + onCancel, + isPrimaryDisabled, + isPreviousDisabled, + }: { + primaryBtnText: string; + onPrimary: () => void; + onPrevious: () => void; + onCancel: () => void; + isPrimaryDisabled: boolean; + isPreviousDisabled: boolean; + }) => ( +
+ + + +
+ ); + return MockedNavigationControls; +}); + +import { useCopyJobContext } from "../../Context/CopyJobContext"; +import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation"; + +const createMockNavigationHook = (overrides = {}) => ({ + currentScreen: { + key: "SelectAccount", + component:
Mock Screen Component
, + }, + isPrimaryDisabled: false, + isPreviousDisabled: true, + handlePrimary: jest.fn(), + handlePrevious: jest.fn(), + handleCancel: jest.fn(), + primaryBtnText: "Next", + showAddCollectionPanel: jest.fn(), + ...overrides, +}); + +const createMockContext = (overrides = {}) => ({ + contextError: "", + setContextError: jest.fn(), + copyJobState: {}, + setCopyJobState: jest.fn(), + flow: {}, + setFlow: jest.fn(), + resetCopyJobState: jest.fn(), + explorer: {}, + ...overrides, +}); + +describe("CreateCopyJobScreens", () => { + const mockNavigationHook = createMockNavigationHook(); + const mockContext = createMockContext(); + + beforeEach(() => { + jest.clearAllMocks(); + (useCopyJobNavigation as jest.Mock).mockReturnValue(mockNavigationHook); + (useCopyJobContext as jest.Mock).mockReturnValue(mockContext); + }); + + describe("Rendering", () => { + test("should render without error", () => { + render(); + expect(screen.getByTestId("mock-screen")).toBeInTheDocument(); + expect(screen.getByTestId("navigation-controls")).toBeInTheDocument(); + }); + + test("should render current screen component", () => { + const customScreen =
Custom Screen Content
; + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + currentScreen: { component: customScreen }, + }), + ); + + render(); + expect(screen.getByTestId("custom-screen")).toBeInTheDocument(); + expect(screen.getByText("Custom Screen Content")).toBeInTheDocument(); + }); + + test("should have correct CSS classes", () => { + const { container } = render(); + const mainContainer = container.querySelector(".createCopyJobScreensContainer"); + const contentContainer = container.querySelector(".createCopyJobScreensContent"); + const footerContainer = container.querySelector(".createCopyJobScreensFooter"); + + expect(mainContainer).toBeInTheDocument(); + expect(contentContainer).toBeInTheDocument(); + expect(footerContainer).toBeInTheDocument(); + }); + }); + + describe("Error Message Bar", () => { + test("should not show error message bar when no error", () => { + render(); + expect(screen.queryByRole("region")).not.toBeInTheDocument(); + }); + + test("should show error message bar when context error exists", () => { + const errorMessage = "Something went wrong"; + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: errorMessage, + }), + ); + + render(); + const messageBar = screen.getByRole("region"); + expect(messageBar).toBeInTheDocument(); + expect(messageBar).toHaveClass("createCopyJobErrorMessageBar"); + }); + + test("should have correct error message bar properties", () => { + const errorMessage = "Test error message"; + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: errorMessage, + }), + ); + + render(); + const messageBar = screen.getByRole("region"); + + expect(messageBar).toHaveClass("createCopyJobErrorMessageBar"); + }); + + test("should call setContextError when dismiss button is clicked", () => { + const mockSetContextError = jest.fn(); + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: "Test error", + setContextError: mockSetContextError, + }), + ); + + render(); + + const dismissButton = screen.getByLabelText("Close"); + fireEvent.click(dismissButton); + + expect(mockSetContextError).toHaveBeenCalledWith(null); + }); + + test("should show overflow button with correct aria label", () => { + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: "A very long error message that should trigger overflow behavior", + }), + ); + + render(); + const overflowButton = screen.getByLabelText("See more"); + expect(overflowButton).toBeInTheDocument(); + }); + }); + + describe("Navigation Controls Integration", () => { + test("should pass correct props to NavigationControls", () => { + const mockHook = createMockNavigationHook({ + primaryBtnText: "Create", + isPrimaryDisabled: true, + isPreviousDisabled: false, + }); + (useCopyJobNavigation as jest.Mock).mockReturnValue(mockHook); + + render(); + + const primaryButton = screen.getByTestId("primary-button"); + const previousButton = screen.getByTestId("previous-button"); + + expect(primaryButton).toHaveTextContent("Create"); + expect(primaryButton).toBeDisabled(); + expect(previousButton).not.toBeDisabled(); + }); + + test("should call navigation handlers when buttons are clicked", () => { + const mockHandlePrimary = jest.fn(); + const mockHandlePrevious = jest.fn(); + const mockHandleCancel = jest.fn(); + + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + handlePrimary: mockHandlePrimary, + handlePrevious: mockHandlePrevious, + handleCancel: mockHandleCancel, + isPrimaryDisabled: false, + isPreviousDisabled: false, + }), + ); + + render(); + + fireEvent.click(screen.getByTestId("primary-button")); + fireEvent.click(screen.getByTestId("previous-button")); + fireEvent.click(screen.getByTestId("cancel-button")); + + expect(mockHandlePrimary).toHaveBeenCalledTimes(1); + expect(mockHandlePrevious).toHaveBeenCalledTimes(1); + expect(mockHandleCancel).toHaveBeenCalledTimes(1); + }); + }); + + describe("Screen Component Props", () => { + test("should pass showAddCollectionPanel prop to screen component", () => { + const mockShowAddCollectionPanel = jest.fn(); + const TestScreen = ({ showAddCollectionPanel }: { showAddCollectionPanel: () => void }) => ( +
+ +
+ ); + + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + currentScreen: { component: {}} /> }, + showAddCollectionPanel: mockShowAddCollectionPanel, + }), + ); + + render(); + + const addButton = screen.getByTestId("add-collection-btn"); + expect(addButton).toBeInTheDocument(); + }); + + test("should handle screen component without props", () => { + const SimpleScreen = () =>
Simple Screen
; + + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + currentScreen: { component: }, + }), + ); + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId("simple-screen")).toBeInTheDocument(); + }); + }); + + describe("Layout and Structure", () => { + test("should maintain vertical layout with space-between alignment", () => { + const { container } = render(); + const stackContainer = container.querySelector(".createCopyJobScreensContainer"); + + expect(stackContainer).toBeInTheDocument(); + }); + + test("should have content area above navigation controls", () => { + const { container } = render(); + + const content = container.querySelector(".createCopyJobScreensContent"); + const footer = container.querySelector(".createCopyJobScreensFooter"); + + expect(content).toBeInTheDocument(); + expect(footer).toBeInTheDocument(); + + const contentIndex = Array.from(container.querySelectorAll("*")).indexOf(content!); + const footerIndex = Array.from(container.querySelectorAll("*")).indexOf(footer!); + expect(contentIndex).toBeLessThan(footerIndex); + }); + }); + + describe("Error Scenarios", () => { + test("should handle missing current screen gracefully", () => { + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + currentScreen: null, + }), + ); + expect(() => render()).toThrow(); + }); + + test("should handle missing screen component", () => { + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + currentScreen: { key: "test", component: null }, + }), + ); + expect(() => render()).toThrow(); + }); + + test("should render with valid screen component", () => { + (useCopyJobNavigation as jest.Mock).mockReturnValue( + createMockNavigationHook({ + currentScreen: { + key: "test", + component:
Valid Screen
, + }, + }), + ); + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId("valid-screen")).toBeInTheDocument(); + }); + + test("should handle context hook throwing error", () => { + (useCopyJobContext as jest.Mock).mockImplementation(() => { + throw new Error("Context not available"); + }); + + expect(() => render()).toThrow("Context not available"); + }); + + test("should handle navigation hook throwing error", () => { + (useCopyJobNavigation as jest.Mock).mockImplementation(() => { + throw new Error("Navigation not available"); + }); + + expect(() => render()).toThrow("Navigation not available"); + }); + }); + + describe("Multiple Error States", () => { + test("should handle error message changes", () => { + const mockSetContextError = jest.fn(); + const { rerender } = render(); + + expect(screen.queryByRole("region")).not.toBeInTheDocument(); + + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: "First error", + setContextError: mockSetContextError, + }), + ); + rerender(); + expect(screen.getByRole("region")).toBeInTheDocument(); + + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: "Second error", + setContextError: mockSetContextError, + }), + ); + rerender(); + expect(screen.getByRole("region")).toBeInTheDocument(); + + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: null, + setContextError: mockSetContextError, + }), + ); + rerender(); + expect(screen.queryByRole("region")).not.toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + test("should have proper ARIA labels for message bar", () => { + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: "Test error", + }), + ); + + render(); + + const dismissButton = screen.getByLabelText("Close"); + const overflowButton = screen.getByLabelText("See more"); + + expect(dismissButton).toBeInTheDocument(); + expect(overflowButton).toBeInTheDocument(); + }); + + test("should have proper region role for message bar", () => { + (useCopyJobContext as jest.Mock).mockReturnValue( + createMockContext({ + contextError: "Test error", + }), + ); + + render(); + const messageRegion = screen.getByRole("region"); + expect(messageRegion).toBeInTheDocument(); + + const alert = screen.getByRole("alert"); + expect(alert).toBeInTheDocument(); + }); + }); + + describe("Component Integration", () => { + test("should integrate with both context and navigation hooks", () => { + const mockContext = createMockContext({ + contextError: "Integration test error", + }); + const mockNavigation = createMockNavigationHook({ + primaryBtnText: "Integration Test", + isPrimaryDisabled: true, + }); + + (useCopyJobContext as jest.Mock).mockReturnValue(mockContext); + (useCopyJobNavigation as jest.Mock).mockReturnValue(mockNavigation); + + render(); + + expect(screen.getByRole("region")).toBeInTheDocument(); + expect(screen.getByText("Integration Test")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreensProvider.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreensProvider.test.tsx new file mode 100644 index 000000000..51bd5636e --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreensProvider.test.tsx @@ -0,0 +1,95 @@ +import { shallow } from "enzyme"; +import Explorer from "Explorer/Explorer"; +import React from "react"; +import CreateCopyJobScreensProvider from "./CreateCopyJobScreensProvider"; + +jest.mock("../../Context/CopyJobContext", () => ({ + __esModule: true, + default: ({ children, explorer }: { children: React.ReactNode; explorer: Explorer }) => ( +
+ {children} +
+ ), +})); + +jest.mock("./CreateCopyJobScreens", () => ({ + __esModule: true, + default: () =>
CreateCopyJobScreens
, +})); + +const mockExplorer = { + databaseAccount: { + id: "test-account", + name: "test-account-name", + location: "East US", + type: "DocumentDB", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://test-account.documents.azure.com:443/", + gremlinEndpoint: "https://test-account.gremlin.cosmosdb.azure.com:443/", + tableEndpoint: "https://test-account.table.cosmosdb.azure.com:443/", + cassandraEndpoint: "https://test-account.cassandra.cosmosdb.azure.com:443/", + }, + }, + subscriptionId: "test-subscription-id", + resourceGroup: "test-resource-group", +} as unknown as Explorer; + +describe("CreateCopyJobScreensProvider", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should render with explorer prop", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it("should render with null explorer", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it("should render with undefined explorer", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it("should not crash with minimal explorer object", () => { + const minimalExplorer = {} as Explorer; + + expect(() => { + const wrapper = shallow(); + expect(wrapper).toBeDefined(); + }).not.toThrow(); + }); + + it("should match snapshot for default render", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot("default-render"); + }); + + it("should match snapshot for edge cases", () => { + const emptyExplorer = {} as Explorer; + const wrapperEmpty = shallow(); + expect(wrapperEmpty).toMatchSnapshot("empty-explorer"); + + const partialExplorer = { + databaseAccount: { id: "partial-account" }, + } as unknown as Explorer; + const wrapperPartial = shallow(); + expect(wrapperPartial).toMatchSnapshot("partial-explorer"); + }); + + describe("Error Boundaries and Edge Cases", () => { + it("should handle React rendering errors gracefully", () => { + const edgeCases = [null, undefined, {}, { invalidProperty: "test" }]; + + edgeCases.forEach((explorerCase) => { + expect(() => { + shallow(); + }).not.toThrow(); + }); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.test.tsx new file mode 100644 index 000000000..adaea1915 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.test.tsx @@ -0,0 +1,366 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { Subscription } from "Contracts/DataModels"; +import React from "react"; +import { CopyJobContext } from "../../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; +import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes"; +import PreviewCopyJob from "./PreviewCopyJob"; + +jest.mock("./Utils/PreviewCopyJobUtils", () => ({ + getPreviewCopyJobDetailsListColumns: () => [ + { + key: "sourcedbname", + name: "Source Database", + fieldName: "sourceDatabaseName", + minWidth: 130, + maxWidth: 140, + }, + { + key: "sourcecolname", + name: "Source Container", + fieldName: "sourceContainerName", + minWidth: 130, + maxWidth: 140, + }, + { + key: "targetdbname", + name: "Destination Database", + fieldName: "targetDatabaseName", + minWidth: 130, + maxWidth: 140, + }, + { + key: "targetcolname", + name: "Destination Container", + fieldName: "targetContainerName", + minWidth: 130, + maxWidth: 140, + }, + ], +})); + +jest.mock("../../../CopyJobUtils", () => ({ + getDefaultJobName: jest.fn((selectedDatabaseAndContainers) => { + if (selectedDatabaseAndContainers.length === 1) { + const { sourceDatabaseName, sourceContainerName, targetDatabaseName, targetContainerName } = + selectedDatabaseAndContainers[0]; + return `${sourceDatabaseName}.${sourceContainerName}_${targetDatabaseName}.${targetContainerName}_123456789`; + } + return ""; + }), +})); + +describe("PreviewCopyJob", () => { + const mockSetCopyJobState = jest.fn(); + const mockSetContextError = jest.fn(); + const mockSetFlow = jest.fn(); + const mockResetCopyJobState = jest.fn(); + + const mockSubscription: Subscription = { + subscriptionId: "test-subscription-id", + displayName: "Test Subscription", + state: "Enabled", + subscriptionPolicies: { + locationPlacementId: "test", + quotaId: "test", + }, + authorizationSource: "test", + }; + + const mockDatabaseAccount = { + id: "/subscriptions/test-subscription-id/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name: "test-account", + location: "East US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://test-account.documents.azure.com:443/", + gremlinEndpoint: "https://test-account.gremlin.cosmosdb.azure.com:443/", + tableEndpoint: "https://test-account.table.cosmosdb.azure.com:443/", + cassandraEndpoint: "https://test-account.cassandra.cosmosdb.azure.com:443/", + }, + }; + + const createMockContext = (overrides: Partial = {}): CopyJobContextProviderType => { + const defaultState: CopyJobContextState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: mockSubscription, + account: mockDatabaseAccount, + databaseId: "source-database", + containerId: "source-container", + }, + target: { + subscriptionId: "test-subscription-id", + account: mockDatabaseAccount, + databaseId: "target-database", + containerId: "target-container", + }, + sourceReadAccessFromTarget: false, + ...overrides, + }; + + return { + contextError: null, + setContextError: mockSetContextError, + copyJobState: defaultState, + setCopyJobState: mockSetCopyJobState, + flow: null, + setFlow: mockSetFlow, + resetCopyJobState: mockResetCopyJobState, + explorer: {} as any, + }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should render with default state and empty job name", () => { + const mockContext = createMockContext(); + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render with pre-filled job name", () => { + const mockContext = createMockContext({ + jobName: "custom-job-name-123", + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render with missing source subscription information", () => { + const mockContext = createMockContext({ + source: { + subscription: undefined, + account: mockDatabaseAccount, + databaseId: "source-database", + containerId: "source-container", + }, + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render with missing source account information", () => { + const mockContext = createMockContext({ + source: { + subscription: mockSubscription, + account: null, + databaseId: "source-database", + containerId: "source-container", + }, + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render with undefined database and container names", () => { + const mockContext = createMockContext({ + source: { + subscription: mockSubscription, + account: mockDatabaseAccount, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "test-subscription-id", + account: mockDatabaseAccount, + databaseId: "", + containerId: "", + }, + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render with long subscription and account names", () => { + const longNameSubscription: Subscription = { + ...mockSubscription, + displayName: "This is a very long subscription name that might cause display issues if not handled properly", + }; + + const longNameAccount = { + ...mockDatabaseAccount, + name: "this-is-a-very-long-database-account-name-that-might-cause-display-issues", + }; + + const mockContext = createMockContext({ + source: { + subscription: longNameSubscription, + account: longNameAccount, + databaseId: "long-database-name-for-testing-purposes", + containerId: "long-container-name-for-testing-purposes", + }, + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render with online migration type", () => { + const mockContext = createMockContext({ + migrationType: CopyJobMigrationType.Online, + jobName: "online-migration-job", + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should handle special characters in database and container names", () => { + const mockContext = createMockContext({ + source: { + subscription: mockSubscription, + account: mockDatabaseAccount, + databaseId: "test-db_with@special#chars", + containerId: "test-container_with@special#chars", + }, + target: { + subscriptionId: "test-subscription-id", + account: mockDatabaseAccount, + databaseId: "target-db_with@special#chars", + containerId: "target-container_with@special#chars", + }, + jobName: "job-with@special#chars_123", + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render component with cross-subscription setup", () => { + const targetAccount = { + ...mockDatabaseAccount, + id: "/subscriptions/target-subscription-id/resourceGroups/target-rg/providers/Microsoft.DocumentDB/databaseAccounts/target-account", + name: "target-account", + }; + + const mockContext = createMockContext({ + target: { + subscriptionId: "target-subscription-id", + account: targetAccount, + databaseId: "target-database", + containerId: "target-container", + }, + sourceReadAccessFromTarget: true, + }); + + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should call setCopyJobState with default job name on mount", async () => { + const mockContext = createMockContext(); + + render( + + + , + ); + + await waitFor(() => { + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + it("should update job name when text field is changed", async () => { + const mockContext = createMockContext({ + jobName: "initial-job-name", + }); + + const { getByDisplayValue } = render( + + + , + ); + + const jobNameInput = getByDisplayValue("initial-job-name"); + fireEvent.change(jobNameInput, { target: { value: "updated-job-name" } }); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("should handle empty job name input", () => { + const mockContext = createMockContext({ + jobName: "existing-name", + }); + + const { getByDisplayValue } = render( + + + , + ); + + const jobNameInput = getByDisplayValue("existing-name"); + fireEvent.change(jobNameInput, { target: { value: "" } }); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("should display proper field labels from ContainerCopyMessages", () => { + const mockContext = createMockContext(); + + const { getByText } = render( + + + , + ); + + expect(getByText(/Job name/i)).toBeInTheDocument(); + expect(getByText(/Source subscription/i)).toBeInTheDocument(); + expect(getByText(/Source account/i)).toBeInTheDocument(); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.test.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.test.ts new file mode 100644 index 000000000..ba0219a8f --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.test.ts @@ -0,0 +1,8 @@ +import { getPreviewCopyJobDetailsListColumns } from "./PreviewCopyJobUtils"; + +describe("PreviewCopyJobUtils", () => { + it("should return correctly formatted columns for preview copy job details list", () => { + const columns = getPreviewCopyJobDetailsListColumns(); + expect(columns).toMatchSnapshot(); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/__snapshots__/PreviewCopyJobUtils.test.ts.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/__snapshots__/PreviewCopyJobUtils.test.ts.snap new file mode 100644 index 000000000..49c73a93d --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/__snapshots__/PreviewCopyJobUtils.test.ts.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PreviewCopyJobUtils should return correctly formatted columns for preview copy job details list 1`] = ` +[ + { + "fieldName": "sourceDatabaseName", + "key": "sourcedbname", + "maxWidth": 140, + "minWidth": 130, + "name": "Source database", + "styles": { + "root": { + "lineHeight": "1.2", + "whiteSpace": "normal", + "wordBreak": "break-word", + }, + }, + }, + { + "fieldName": "sourceContainerName", + "key": "sourcecolname", + "maxWidth": 140, + "minWidth": 130, + "name": "Source container", + "styles": { + "root": { + "lineHeight": "1.2", + "whiteSpace": "normal", + "wordBreak": "break-word", + }, + }, + }, + { + "fieldName": "targetDatabaseName", + "key": "targetdbname", + "maxWidth": 140, + "minWidth": 130, + "name": "Destination database", + "styles": { + "root": { + "lineHeight": "1.2", + "whiteSpace": "normal", + "wordBreak": "break-word", + }, + }, + }, + { + "fieldName": "targetContainerName", + "key": "targetcolname", + "maxWidth": 140, + "minWidth": 130, + "name": "Destination container", + "styles": { + "root": { + "lineHeight": "1.2", + "whiteSpace": "normal", + "wordBreak": "break-word", + }, + }, + }, +] +`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/__snapshots__/PreviewCopyJob.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/__snapshots__/PreviewCopyJob.test.tsx.snap new file mode 100644 index 000000000..df45dbab7 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/__snapshots__/PreviewCopyJob.test.tsx.snap @@ -0,0 +1,2845 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PreviewCopyJob should handle special characters in database and container names 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render component with cross-subscription setup 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with default state and empty job name 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with long subscription and account names 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + This is a very long subscription name that might cause display issues if not handled properly + +
+
+ + Source account + + + this-is-a-very-long-database-account-name-that-might-cause-display-issues + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with missing source account information 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with missing source subscription information 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with online migration type 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with pre-filled job name 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+
+
+
+
+`; + +exports[`PreviewCopyJob should render with undefined database and container names 1`] = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + Source subscription + + + Test Subscription + +
+
+ + Source account + + + test-account + +
+
+
+
+
+ + +
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx new file mode 100644 index 000000000..a25663813 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx @@ -0,0 +1,409 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { configContext, Platform } from "../../../../../../ConfigContext"; +import { DatabaseAccount } from "../../../../../../Contracts/DataModels"; +import * as useDatabaseAccountsHook from "../../../../../../hooks/useDatabaseAccounts"; +import { apiType, userContext } from "../../../../../../UserContext"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; +import { CopyJobContext } from "../../../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; +import { CopyJobContextProviderType, CopyJobContextState } from "../../../../Types/CopyJobTypes"; +import { AccountDropdown } from "./AccountDropdown"; + +jest.mock("../../../../../../hooks/useDatabaseAccounts"); +jest.mock("../../../../../../UserContext", () => ({ + userContext: { + databaseAccount: null as DatabaseAccount | null, + }, + apiType: jest.fn(), +})); +jest.mock("../../../../../../ConfigContext", () => ({ + configContext: { + platform: "Portal", + }, + Platform: { + Portal: "Portal", + Hosted: "Hosted", + }, +})); + +const mockUseDatabaseAccounts = useDatabaseAccountsHook.useDatabaseAccounts as jest.MockedFunction< + typeof useDatabaseAccountsHook.useDatabaseAccounts +>; + +describe("AccountDropdown", () => { + const mockSetCopyJobState = jest.fn(); + const mockCopyJobState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: { + subscriptionId: "test-subscription-id", + displayName: "Test Subscription", + }, + account: null, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null, + databaseId: "", + containerId: "", + }, + sourceReadAccessFromTarget: false, + } as CopyJobContextState; + + const mockCopyJobContextValue = { + copyJobState: mockCopyJobState, + setCopyJobState: mockSetCopyJobState, + flow: null, + setFlow: jest.fn(), + contextError: null, + setContextError: jest.fn(), + resetCopyJobState: jest.fn(), + } as CopyJobContextProviderType; + + const mockDatabaseAccount1: DatabaseAccount = { + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDb/databaseAccounts/account1", + name: "test-account-1", + kind: "GlobalDocumentDB", + location: "East US", + type: "Microsoft.DocumentDB/databaseAccounts", + tags: {}, + properties: { + documentEndpoint: "https://account1.documents.azure.com:443/", + capabilities: [], + enableMultipleWriteLocations: false, + }, + }; + + const mockDatabaseAccount2: DatabaseAccount = { + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDb/databaseAccounts/account2", + name: "test-account-2", + kind: "GlobalDocumentDB", + location: "West US", + type: "Microsoft.DocumentDB/databaseAccounts", + tags: {}, + properties: { + documentEndpoint: "https://account2.documents.azure.com:443/", + capabilities: [], + enableMultipleWriteLocations: false, + }, + }; + + const mockNonSqlAccount: DatabaseAccount = { + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDb/databaseAccounts/mongo-account", + name: "mongo-account", + kind: "MongoDB", + location: "Central US", + type: "Microsoft.DocumentDB/databaseAccounts", + tags: {}, + properties: { + documentEndpoint: "https://mongo-account.documents.azure.com:443/", + capabilities: [], + enableMultipleWriteLocations: false, + }, + }; + + const renderWithContext = (contextValue = mockCopyJobContextValue) => { + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + (apiType as jest.MockedFunction).mockImplementation((account: DatabaseAccount) => { + return account.kind === "MongoDB" ? "MongoDB" : "SQL"; + }); + }); + + describe("Rendering", () => { + it("should render dropdown with correct label and placeholder", () => { + mockUseDatabaseAccounts.mockReturnValue([]); + + renderWithContext(); + + expect( + screen.getByText(`${ContainerCopyMessages.sourceAccountDropdownLabel}:`, { exact: true }), + ).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toHaveAttribute( + "aria-label", + ContainerCopyMessages.sourceAccountDropdownLabel, + ); + }); + + it("should render disabled dropdown when no subscription is selected", () => { + mockUseDatabaseAccounts.mockReturnValue([]); + const contextWithoutSubscription = { + ...mockCopyJobContextValue, + copyJobState: { + ...mockCopyJobState, + source: { + ...mockCopyJobState.source, + subscription: null, + }, + } as CopyJobContextState, + }; + + renderWithContext(contextWithoutSubscription); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveAttribute("aria-disabled", "true"); + }); + + it("should render disabled dropdown when no accounts are available", () => { + mockUseDatabaseAccounts.mockReturnValue([]); + + renderWithContext(); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveAttribute("aria-disabled", "true"); + }); + + it("should render enabled dropdown when accounts are available", () => { + mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1, mockDatabaseAccount2]); + + renderWithContext(); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveAttribute("aria-disabled", "false"); + }); + }); + + describe("Account filtering", () => { + it("should filter accounts to only show SQL API accounts", () => { + const allAccounts = [mockDatabaseAccount1, mockDatabaseAccount2, mockNonSqlAccount]; + mockUseDatabaseAccounts.mockReturnValue(allAccounts); + + renderWithContext(); + + expect(mockUseDatabaseAccounts).toHaveBeenCalledWith("test-subscription-id"); + + expect(apiType as jest.MockedFunction).toHaveBeenCalledWith(mockDatabaseAccount1); + expect(apiType as jest.MockedFunction).toHaveBeenCalledWith(mockDatabaseAccount2); + expect(apiType as jest.MockedFunction).toHaveBeenCalledWith(mockNonSqlAccount); + }); + }); + + describe("Account selection", () => { + it("should auto-select the first SQL account when no account is currently selected", async () => { + mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1, mockDatabaseAccount2]); + + renderWithContext(); + + await waitFor(() => { + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + + const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0]; + const newState = stateUpdateFunction(mockCopyJobState); + expect(newState.source.account).toBe(mockDatabaseAccount1); + }); + + it("should auto-select predefined account from userContext if available", async () => { + const userContextAccount = { + ...mockDatabaseAccount2, + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDb/databaseAccounts/account2", + }; + + (userContext as any).databaseAccount = userContextAccount; + + mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1, mockDatabaseAccount2]); + + renderWithContext(); + + await waitFor(() => { + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + + const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0]; + const newState = stateUpdateFunction(mockCopyJobState); + expect(newState.source.account).toBe(mockDatabaseAccount2); + }); + + it("should keep current account if it exists in the filtered list", async () => { + const contextWithSelectedAccount = { + ...mockCopyJobContextValue, + copyJobState: { + ...mockCopyJobState, + source: { + ...mockCopyJobState.source, + account: mockDatabaseAccount1, + }, + }, + }; + + mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1, mockDatabaseAccount2]); + + renderWithContext(contextWithSelectedAccount); + + await waitFor(() => { + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + + const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0]; + const newState = stateUpdateFunction(contextWithSelectedAccount.copyJobState); + expect(newState).toBe(contextWithSelectedAccount.copyJobState); + }); + + it("should handle account change when user selects different account", async () => { + mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1, mockDatabaseAccount2]); + + renderWithContext(); + + const dropdown = screen.getByRole("combobox"); + fireEvent.click(dropdown); + + await waitFor(() => { + const option = screen.getByText("test-account-2"); + fireEvent.click(option); + }); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + describe("ID normalization", () => { + it("should normalize account ID for Portal platform", () => { + const portalAccount = { + ...mockDatabaseAccount1, + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDb/databaseAccounts/account1", + }; + + (configContext as any).platform = Platform.Portal; + mockUseDatabaseAccounts.mockReturnValue([portalAccount]); + + const contextWithSelectedAccount = { + ...mockCopyJobContextValue, + copyJobState: { + ...mockCopyJobState, + source: { + ...mockCopyJobState.source, + account: portalAccount, + }, + }, + }; + + renderWithContext(contextWithSelectedAccount); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toMatchSnapshot(); + }); + + it("should normalize account ID for Hosted platform", () => { + const hostedAccount = { + ...mockDatabaseAccount1, + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/account1", + }; + + (configContext as any).platform = Platform.Hosted; + mockUseDatabaseAccounts.mockReturnValue([hostedAccount]); + + const contextWithSelectedAccount = { + ...mockCopyJobContextValue, + copyJobState: { + ...mockCopyJobState, + source: { + ...mockCopyJobState.source, + account: hostedAccount, + }, + }, + }; + + renderWithContext(contextWithSelectedAccount); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toBeInTheDocument(); + }); + }); + + describe("Edge cases", () => { + it("should handle empty account list gracefully", () => { + mockUseDatabaseAccounts.mockReturnValue([]); + + renderWithContext(); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveAttribute("aria-disabled", "true"); + }); + + it("should handle null account list gracefully", () => { + mockUseDatabaseAccounts.mockReturnValue(null as any); + + renderWithContext(); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveAttribute("aria-disabled", "true"); + }); + + it("should handle undefined subscription ID", () => { + const contextWithoutSubscription = { + ...mockCopyJobContextValue, + copyJobState: { + ...mockCopyJobState, + source: { + ...mockCopyJobState.source, + subscription: null, + }, + } as CopyJobContextState, + }; + + mockUseDatabaseAccounts.mockReturnValue([]); + + renderWithContext(contextWithoutSubscription); + + expect(mockUseDatabaseAccounts).toHaveBeenCalledWith(undefined); + }); + + it("should not update state if account is already selected and the same", async () => { + const selectedAccount = mockDatabaseAccount1; + const contextWithSelectedAccount = { + ...mockCopyJobContextValue, + copyJobState: { + ...mockCopyJobState, + source: { + ...mockCopyJobState.source, + account: selectedAccount, + }, + }, + }; + + mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1, mockDatabaseAccount2]); + + renderWithContext(contextWithSelectedAccount); + + await waitFor(() => { + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + }); + + const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0]; + const newState = stateUpdateFunction(contextWithSelectedAccount.copyJobState); + expect(newState).toBe(contextWithSelectedAccount.copyJobState); + }); + }); + + describe("Accessibility", () => { + it("should have proper aria-label", () => { + mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1]); + + renderWithContext(); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveAttribute("aria-label", ContainerCopyMessages.sourceAccountDropdownLabel); + }); + + it("should have required attribute", () => { + mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1]); + + renderWithContext(); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveAttribute("aria-required", "true"); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx index b24aed7b3..f585c860f 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx @@ -1,31 +1,91 @@ /* eslint-disable react/prop-types */ /* eslint-disable react/display-name */ import { Dropdown } from "@fluentui/react"; -import React from "react"; +import { configContext, Platform } from "ConfigContext"; +import React, { useEffect } from "react"; +import { DatabaseAccount } from "../../../../../../Contracts/DataModels"; +import { useDatabaseAccounts } from "../../../../../../hooks/useDatabaseAccounts"; +import { apiType, userContext } from "../../../../../../UserContext"; import ContainerCopyMessages from "../../../../ContainerCopyMessages"; -import { DropdownOptionType } from "../../../../Types/CopyJobTypes"; +import { useCopyJobContext } from "../../../../Context/CopyJobContext"; import FieldRow from "../../Components/FieldRow"; -interface AccountDropdownProps { - options: DropdownOptionType[]; - selectedKey?: string; - disabled: boolean; - onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void; -} +interface AccountDropdownProps {} -export const AccountDropdown: React.FC = React.memo( - ({ options, selectedKey, disabled, onChange }) => ( +const normalizeAccountId = (id: string) => { + if (configContext.platform === Platform.Portal) { + return id.replace("/Microsoft.DocumentDb/", "/Microsoft.DocumentDB/"); + } else if (configContext.platform === Platform.Hosted) { + return id.replace("/Microsoft.DocumentDB/", "/Microsoft.DocumentDb/"); + } else { + return id; + } +}; + +export const AccountDropdown: React.FC = () => { + const { copyJobState, setCopyJobState } = useCopyJobContext(); + + const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId; + const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId); + const sqlApiOnlyAccounts: DatabaseAccount[] = (allAccounts || []).filter((account) => apiType(account) === "SQL"); + + const updateCopyJobState = (newAccount: DatabaseAccount) => { + setCopyJobState((prevState) => { + if (prevState.source?.account?.id !== newAccount.id) { + return { + ...prevState, + source: { + ...prevState.source, + account: newAccount, + }, + }; + } + return prevState; + }); + }; + + useEffect(() => { + if (sqlApiOnlyAccounts && sqlApiOnlyAccounts.length > 0 && selectedSubscriptionId) { + const currentAccountId = copyJobState?.source?.account?.id; + const predefinedAccountId = userContext.databaseAccount?.id; + const selectedAccountId = currentAccountId || predefinedAccountId; + + const targetAccount: DatabaseAccount | null = + sqlApiOnlyAccounts.find((account) => account.id === selectedAccountId) || null; + updateCopyJobState(targetAccount || sqlApiOnlyAccounts[0]); + } + }, [sqlApiOnlyAccounts?.length, selectedSubscriptionId]); + + const accountOptions = + sqlApiOnlyAccounts?.map((account) => ({ + key: normalizeAccountId(account.id), + text: account.name, + data: account, + })) || []; + + const handleAccountChange = (_ev?: React.FormEvent, option?: (typeof accountOptions)[0]) => { + const selectedAccount = option?.data as DatabaseAccount; + + if (selectedAccount) { + updateCopyJobState(selectedAccount); + } + }; + + const isAccountDropdownDisabled = !selectedSubscriptionId || accountOptions.length === 0; + const selectedAccountId = normalizeAccountId(copyJobState?.source?.account?.id ?? ""); + + return ( - ), - (prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey, -); + ); +}; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.test.tsx new file mode 100644 index 000000000..67289fe39 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.test.tsx @@ -0,0 +1,72 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { MigrationTypeCheckbox } from "./MigrationTypeCheckbox"; + +describe("MigrationTypeCheckbox", () => { + const mockOnChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Component Rendering", () => { + it("should render with default props (unchecked state)", () => { + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should render in checked state", () => { + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("should display the correct label text", () => { + render(); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + + const label = screen.getByText("Copy container in offline mode"); + expect(label).toBeInTheDocument(); + }); + + it("should have correct accessibility attributes when checked", () => { + render(); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toBeChecked(); + expect(checkbox).toHaveAttribute("checked"); + }); + }); + + describe("FluentUI Integration", () => { + it("should render FluentUI Checkbox component correctly", () => { + render(); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).toHaveAttribute("type", "checkbox"); + }); + + it("should render FluentUI Stack component correctly", () => { + render(); + + const stackContainer = document.querySelector(".migrationTypeRow"); + expect(stackContainer).toBeInTheDocument(); + }); + + it("should apply FluentUI Stack horizontal alignment correctly", () => { + const { container } = render(); + + const stackContainer = container.querySelector(".migrationTypeRow"); + expect(stackContainer).toBeInTheDocument(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.test.tsx new file mode 100644 index 000000000..c9356b8e5 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.test.tsx @@ -0,0 +1,295 @@ +import "@testing-library/jest-dom"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { Subscription } from "../../../../../../Contracts/DataModels"; +import Explorer from "../../../../../Explorer"; +import CopyJobContextProvider from "../../../../Context/CopyJobContext"; +import { SubscriptionDropdown } from "./SubscriptionDropdown"; + +jest.mock("../../../../../../hooks/useSubscriptions"); +jest.mock("../../../../../../UserContext"); +jest.mock("../../../../ContainerCopyMessages"); + +const mockUseSubscriptions = jest.requireMock("../../../../../../hooks/useSubscriptions").useSubscriptions; +const mockUserContext = jest.requireMock("../../../../../../UserContext").userContext; +const mockContainerCopyMessages = jest.requireMock("../../../../ContainerCopyMessages").default; + +mockContainerCopyMessages.subscriptionDropdownLabel = "Subscription"; +mockContainerCopyMessages.subscriptionDropdownPlaceholder = "Select a subscription"; + +describe("SubscriptionDropdown", () => { + let mockExplorer: Explorer; + const mockSubscriptions: Subscription[] = [ + { + subscriptionId: "sub-1", + displayName: "Subscription One", + state: "Enabled", + tenantId: "tenant-1", + }, + { + subscriptionId: "sub-2", + displayName: "Subscription Two", + state: "Enabled", + tenantId: "tenant-1", + }, + { + subscriptionId: "sub-3", + displayName: "Another Subscription", + state: "Enabled", + tenantId: "tenant-1", + }, + ]; + + const renderWithProvider = (children: React.ReactNode) => { + return render({children}); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockExplorer = {} as Explorer; + + mockUseSubscriptions.mockReturnValue(mockSubscriptions); + mockUserContext.subscriptionId = "sub-1"; + }); + + describe("Rendering", () => { + it("should render subscription dropdown with correct attributes", () => { + renderWithProvider(); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toBeInTheDocument(); + expect(dropdown).toHaveAttribute("aria-label", "Subscription"); + expect(dropdown).toHaveAttribute("data-test", "subscription-dropdown"); + expect(dropdown).toBeRequired(); + }); + + it("should render field label correctly", () => { + renderWithProvider(); + + expect(screen.getByText("Subscription:")).toBeInTheDocument(); + }); + + it("should show placeholder when no subscription is selected", async () => { + mockUserContext.subscriptionId = ""; + mockUseSubscriptions.mockReturnValue([]); + + renderWithProvider(); + + await waitFor(() => { + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveTextContent("Select a subscription"); + }); + }); + }); + + describe("Subscription Options", () => { + it("should populate dropdown with available subscriptions", async () => { + renderWithProvider(); + + const dropdown = screen.getByRole("combobox"); + fireEvent.click(dropdown); + + await waitFor(() => { + expect(screen.getByText("Subscription One", { selector: ".ms-Dropdown-optionText" })).toBeInTheDocument(); + expect(screen.getByText("Subscription Two", { selector: ".ms-Dropdown-optionText" })).toBeInTheDocument(); + expect(screen.getByText("Another Subscription", { selector: ".ms-Dropdown-optionText" })).toBeInTheDocument(); + }); + }); + + it("should handle empty subscriptions list", () => { + mockUseSubscriptions.mockReturnValue([]); + + renderWithProvider(); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toBeInTheDocument(); + expect(dropdown).toHaveTextContent("Select a subscription"); + }); + + it("should handle undefined subscriptions", () => { + mockUseSubscriptions.mockReturnValue(undefined); + + renderWithProvider(); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toBeInTheDocument(); + expect(dropdown).toHaveTextContent("Select a subscription"); + }); + }); + + describe("Selection Logic", () => { + it("should auto-select subscription based on userContext.subscriptionId on mount", async () => { + mockUserContext.subscriptionId = "sub-2"; + + renderWithProvider(); + + await waitFor(() => { + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveTextContent("Subscription Two"); + }); + }); + + it("should maintain current selection when subscriptions list updates with same subscription", async () => { + renderWithProvider(); + + await waitFor(() => { + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveTextContent("Subscription One"); + }); + + act(() => { + mockUseSubscriptions.mockReturnValue([...mockSubscriptions]); + }); + + await waitFor(() => { + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveTextContent("Subscription One"); + }); + }); + + it("should prioritize current copyJobState subscription over userContext subscription", async () => { + mockUserContext.subscriptionId = "sub-2"; + + const { rerender } = renderWithProvider(); + + await waitFor(() => { + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveTextContent("Subscription Two"); + }); + + const dropdown = screen.getByRole("combobox"); + fireEvent.click(dropdown); + + await waitFor(() => { + const option = screen.getByText("Another Subscription"); + fireEvent.click(option); + }); + + rerender( + + + , + ); + + await waitFor(() => { + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveTextContent("Another Subscription"); + }); + }); + + it("should handle subscription selection change", async () => { + renderWithProvider(); + + const dropdown = screen.getByRole("combobox"); + fireEvent.click(dropdown); + + await waitFor(() => { + const option = screen.getByText("Subscription Two"); + fireEvent.click(option); + }); + + await waitFor(() => { + expect(dropdown).toHaveTextContent("Subscription Two"); + }); + }); + + it("should not auto-select if target subscription not found in list", async () => { + mockUserContext.subscriptionId = "non-existent-sub"; + + renderWithProvider(); + + await waitFor(() => { + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveTextContent("Select a subscription"); + }); + }); + }); + + describe("Context State Management", () => { + it("should update copyJobState when subscription is selected", async () => { + renderWithProvider(); + + const dropdown = screen.getByRole("combobox"); + fireEvent.click(dropdown); + + await waitFor(() => { + const option = screen.getByText("Subscription Two"); + fireEvent.click(option); + }); + await waitFor(() => { + expect(dropdown).toHaveTextContent("Subscription Two"); + }); + }); + + it("should reset account when subscription changes", async () => { + renderWithProvider(); + + await waitFor(() => { + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveTextContent("Subscription One"); + }); + const dropdown = screen.getByRole("combobox"); + fireEvent.click(dropdown); + + await waitFor(() => { + const option = screen.getByText("Subscription Two"); + fireEvent.click(option); + }); + + await waitFor(() => { + expect(dropdown).toHaveTextContent("Subscription Two"); + }); + }); + + it("should not update state if same subscription is selected", async () => { + renderWithProvider(); + + await waitFor(() => { + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toHaveTextContent("Subscription One"); + }); + + const dropdown = screen.getByRole("combobox"); + fireEvent.click(dropdown); + + await waitFor(() => { + const option = screen.getByText("Subscription One", { selector: ".ms-Dropdown-optionText" }); + fireEvent.click(option); + }); + + await waitFor(() => { + expect(dropdown).toHaveTextContent("Subscription One"); + }); + }); + }); + + describe("Edge Cases", () => { + it("should handle subscription change event with option missing data", async () => { + renderWithProvider(); + + const dropdown = screen.getByRole("combobox"); + fireEvent.click(dropdown); + expect(dropdown).toBeInTheDocument(); + }); + + it("should handle subscriptions loading state", () => { + mockUseSubscriptions.mockReturnValue(undefined); + + renderWithProvider(); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toBeInTheDocument(); + expect(dropdown).toHaveTextContent("Select a subscription"); + }); + + it("should work when both userContext.subscriptionId and copyJobState subscription are null", () => { + mockUserContext.subscriptionId = ""; + + renderWithProvider(); + + const dropdown = screen.getByRole("combobox"); + expect(dropdown).toBeInTheDocument(); + expect(dropdown).toHaveTextContent("Select a subscription"); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx index 2627918a6..9d38c2f57 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx @@ -1,29 +1,79 @@ /* eslint-disable react/prop-types */ /* eslint-disable react/display-name */ import { Dropdown } from "@fluentui/react"; -import React from "react"; +import React, { useEffect } from "react"; +import { Subscription } from "../../../../../../Contracts/DataModels"; +import { useSubscriptions } from "../../../../../../hooks/useSubscriptions"; +import { userContext } from "../../../../../../UserContext"; import ContainerCopyMessages from "../../../../ContainerCopyMessages"; -import { DropdownOptionType } from "../../../../Types/CopyJobTypes"; +import { useCopyJobContext } from "../../../../Context/CopyJobContext"; import FieldRow from "../../Components/FieldRow"; -interface SubscriptionDropdownProps { - options: DropdownOptionType[]; - selectedKey?: string; - onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void; -} +interface SubscriptionDropdownProps {} -export const SubscriptionDropdown: React.FC = React.memo( - ({ options, selectedKey, onChange }) => ( +export const SubscriptionDropdown: React.FC = React.memo(() => { + const { copyJobState, setCopyJobState } = useCopyJobContext(); + const subscriptions: Subscription[] = useSubscriptions(); + + const updateCopyJobState = (newSubscription: Subscription) => { + setCopyJobState((prevState) => { + if (prevState.source?.subscription?.subscriptionId !== newSubscription.subscriptionId) { + return { + ...prevState, + source: { + ...prevState.source, + subscription: newSubscription, + account: null, + }, + }; + } + return prevState; + }); + }; + + useEffect(() => { + if (subscriptions && subscriptions.length > 0) { + const currentSubscriptionId = copyJobState?.source?.subscription?.subscriptionId; + const predefinedSubscriptionId = userContext.subscriptionId; + const selectedSubscriptionId = currentSubscriptionId || predefinedSubscriptionId; + + const targetSubscription: Subscription | null = + subscriptions.find((sub) => sub.subscriptionId === selectedSubscriptionId) || null; + + if (targetSubscription) { + updateCopyJobState(targetSubscription); + } + } + }, [subscriptions?.length]); + + const subscriptionOptions = + subscriptions?.map((sub) => ({ + key: sub.subscriptionId, + text: sub.displayName, + data: sub, + })) || []; + + const handleSubscriptionChange = (_ev?: React.FormEvent, option?: (typeof subscriptionOptions)[0]) => { + const selectedSubscription = option?.data as Subscription; + + if (selectedSubscription) { + updateCopyJobState(selectedSubscription); + } + }; + + const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId; + + return ( - ), - (prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey, -); + ); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/AccountDropdown.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/AccountDropdown.test.tsx.snap new file mode 100644 index 000000000..6c33d4711 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/AccountDropdown.test.tsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountDropdown ID normalization should normalize account ID for Portal platform 1`] = ` + +`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationTypeCheckbox.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationTypeCheckbox.test.tsx.snap new file mode 100644 index 000000000..4e1c653ef --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationTypeCheckbox.test.tsx.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MigrationTypeCheckbox Component Rendering should render in checked state 1`] = ` +
+
+ + +
+
+`; + +exports[`MigrationTypeCheckbox Component Rendering should render with default props (unchecked state) 1`] = ` +
+
+ + +
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx new file mode 100644 index 000000000..5fb556c3c --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx @@ -0,0 +1,170 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; +import { CopyJobContextProviderType } from "../../../Types/CopyJobTypes"; +import SelectAccount from "./SelectAccount"; + +jest.mock("../../../Context/CopyJobContext", () => ({ + useCopyJobContext: jest.fn(), +})); + +jest.mock("./Components/SubscriptionDropdown", () => ({ + SubscriptionDropdown: jest.fn(() =>
Subscription Dropdown
), +})); + +jest.mock("./Components/AccountDropdown", () => ({ + AccountDropdown: jest.fn(() =>
Account Dropdown
), +})); + +jest.mock("./Components/MigrationTypeCheckbox", () => ({ + MigrationTypeCheckbox: jest.fn(({ checked, onChange }: { checked: boolean; onChange: () => void }) => ( +
+ + Copy container in offline mode +
+ )), +})); + +describe("SelectAccount", () => { + const mockSetCopyJobState = jest.fn(); + + const defaultContextValue: CopyJobContextProviderType = { + copyJobState: { + jobName: "", + migrationType: CopyJobMigrationType.Online, + source: { + subscription: null as any, + account: null as any, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "", + containerId: "", + }, + sourceReadAccessFromTarget: false, + }, + setCopyJobState: mockSetCopyJobState, + flow: { currentScreen: "selectAccount" }, + setFlow: jest.fn(), + contextError: null, + setContextError: jest.fn(), + explorer: {} as any, + resetCopyJobState: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useCopyJobContext as jest.Mock).mockReturnValue(defaultContextValue); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Component Rendering", () => { + it("should render the component with all required elements", () => { + const { container } = render(); + + expect(container.firstChild).toHaveAttribute("data-test", "Panel:SelectAccountContainer"); + expect(container.firstChild).toHaveClass("selectAccountContainer"); + + expect(screen.getByText(/Please select a source account from which to copy/i)).toBeInTheDocument(); + + expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument(); + expect(screen.getByTestId("account-dropdown")).toBeInTheDocument(); + expect(screen.getByTestId("migration-type-checkbox")).toBeInTheDocument(); + }); + + it("should render correctly with snapshot", () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + }); + + describe("Migration Type Functionality", () => { + it("should display migration type checkbox as unchecked when migrationType is Online", () => { + (useCopyJobContext as jest.Mock).mockReturnValue({ + ...defaultContextValue, + copyJobState: { + ...defaultContextValue.copyJobState, + migrationType: CopyJobMigrationType.Online, + }, + }); + + render(); + + const checkbox = screen.getByTestId("migration-checkbox-input"); + expect(checkbox).not.toBeChecked(); + }); + + it("should display migration type checkbox as checked when migrationType is Offline", () => { + (useCopyJobContext as jest.Mock).mockReturnValue({ + ...defaultContextValue, + copyJobState: { + ...defaultContextValue.copyJobState, + migrationType: CopyJobMigrationType.Offline, + }, + }); + + render(); + + const checkbox = screen.getByTestId("migration-checkbox-input"); + expect(checkbox).toBeChecked(); + }); + + it("should call setCopyJobState with Online migration type when checkbox is unchecked", () => { + (useCopyJobContext as jest.Mock).mockReturnValue({ + ...defaultContextValue, + copyJobState: { + ...defaultContextValue.copyJobState, + migrationType: CopyJobMigrationType.Offline, + }, + }); + + render(); + + const checkbox = screen.getByTestId("migration-checkbox-input"); + fireEvent.click(checkbox); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + + const updateFunction = mockSetCopyJobState.mock.calls[0][0]; + const previousState = { + ...defaultContextValue.copyJobState, + migrationType: CopyJobMigrationType.Offline, + }; + const result = updateFunction(previousState); + + expect(result).toEqual({ + ...previousState, + migrationType: CopyJobMigrationType.Online, + }); + }); + }); + + describe("Performance and Optimization", () => { + it("should maintain referential equality of handler functions between renders", async () => { + const { rerender } = render(); + + const migrationCheckbox = (await import("./Components/MigrationTypeCheckbox")).MigrationTypeCheckbox as jest.Mock; + const firstRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange; + + rerender(); + + const secondRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange; + + expect(firstRenderHandler).toBe(secondRenderHandler); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx index 17f323413..ba1072de7 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx @@ -1,52 +1,37 @@ -/* eslint-disable react/display-name */ -import { Stack } from "@fluentui/react"; +import { Stack, Text } from "@fluentui/react"; import React from "react"; -import { apiType } from "UserContext"; -import { DatabaseAccount, Subscription } from "../../../../../Contracts/DataModels"; -import { useDatabaseAccounts } from "../../../../../hooks/useDatabaseAccounts"; -import { useSubscriptions } from "../../../../../hooks/useSubscriptions"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; import { AccountDropdown } from "./Components/AccountDropdown"; import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox"; import { SubscriptionDropdown } from "./Components/SubscriptionDropdown"; -import { useDropdownOptions, useEventHandlers } from "./Utils/selectAccountUtils"; const SelectAccount = React.memo(() => { const { copyJobState, setCopyJobState } = useCopyJobContext(); - const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId; - const selectedSourceAccountId = copyJobState?.source?.account?.id; - const subscriptions: Subscription[] = useSubscriptions(); - const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId); - const sqlApiOnlyAccounts: DatabaseAccount[] = allAccounts?.filter((account) => apiType(account) === "SQL"); - - const { subscriptionOptions, accountOptions } = useDropdownOptions(subscriptions, sqlApiOnlyAccounts); - const { handleSelectSourceAccount, handleMigrationTypeChange } = useEventHandlers(setCopyJobState); + const handleMigrationTypeChange = (_ev?: React.FormEvent, checked?: boolean) => { + setCopyJobState((prevState) => ({ + ...prevState, + migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online, + })); + }; const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline; return ( - - {ContainerCopyMessages.selectAccountDescription} + + {ContainerCopyMessages.selectAccountDescription} - handleSelectSourceAccount("subscription", option?.data)} - /> + - handleSelectSourceAccount("account", option?.data)} - /> + ); }); +SelectAccount.displayName = "SelectAccount"; + export default SelectAccount; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx deleted file mode 100644 index de9b0c976..000000000 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from "react"; -import { DatabaseAccount, Subscription } from "../../../../../../Contracts/DataModels"; -import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; -import { CopyJobContextProviderType, CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes"; -import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache"; - -export function useDropdownOptions( - subscriptions: Subscription[], - accounts: DatabaseAccount[], -): { - subscriptionOptions: DropdownOptionType[]; - accountOptions: DropdownOptionType[]; -} { - const subscriptionOptions = - subscriptions?.map((sub) => ({ - key: sub.subscriptionId, - text: sub.displayName, - data: sub, - })) || []; - - const accountOptions = - accounts?.map((account) => ({ - key: account.id, - text: account.name, - data: account, - })) || []; - - return { subscriptionOptions, accountOptions }; -} - -type setCopyJobStateType = CopyJobContextProviderType["setCopyJobState"]; - -export function useEventHandlers(setCopyJobState: setCopyJobStateType) { - const { setValidationCache } = useCopyJobPrerequisitesCache(); - const handleSelectSourceAccount = ( - type: "subscription" | "account", - data: (Subscription & DatabaseAccount) | undefined, - ) => { - setCopyJobState((prevState: CopyJobContextState) => { - if (type === "subscription") { - return { - ...prevState, - source: { - ...prevState.source, - subscription: data || null, - account: null, - }, - }; - } - if (type === "account") { - return { - ...prevState, - source: { - ...prevState.source, - account: data || null, - }, - }; - } - return prevState; - }); - setValidationCache(new Map()); - }; - - const handleMigrationTypeChange = React.useCallback((_ev?: React.FormEvent, checked?: boolean) => { - setCopyJobState((prevState: CopyJobContextState) => ({ - ...prevState, - migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online, - })); - setValidationCache(new Map()); - }, []); - - return { handleSelectSourceAccount, handleMigrationTypeChange }; -} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap new file mode 100644 index 000000000..90a8ddc2b --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SelectAccount Component Rendering should render correctly with snapshot 1`] = ` +
+ + Please select a source account from which to copy. + +
+ Subscription Dropdown +
+
+ Account Dropdown +
+
+ + Copy container in offline mode +
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.test.tsx new file mode 100644 index 000000000..4cf137ea4 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.test.tsx @@ -0,0 +1,330 @@ +import { fireEvent, render } from "@testing-library/react"; +import React from "react"; +import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; +import { CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes"; +import { dropDownChangeHandler } from "./DropDownChangeHandler"; + +const createMockInitialState = (): CopyJobContextState => ({ + jobName: "test-job", + migrationType: CopyJobMigrationType.Offline, + sourceReadAccessFromTarget: false, + source: { + subscription: { + subscriptionId: "source-sub-id", + displayName: "Source Subscription", + state: "Enabled", + subscriptionPolicies: { + locationPlacementId: "test", + quotaId: "test", + spendingLimit: "Off", + }, + authorizationSource: "test", + }, + account: { + id: "source-account-id", + name: "source-account", + location: "East US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "DocumentDB", + properties: { + documentEndpoint: "https://source.documents.azure.com:443/", + cassandraEndpoint: undefined, + gremlinEndpoint: undefined, + tableEndpoint: undefined, + writeLocations: [], + readLocations: [], + enableMultipleWriteLocations: false, + isVirtualNetworkFilterEnabled: false, + enableFreeTier: false, + enableAnalyticalStorage: false, + backupPolicy: undefined, + disableLocalAuth: false, + capacity: undefined, + enablePriorityBasedExecution: false, + publicNetworkAccess: "Enabled", + enableMaterializedViews: false, + }, + systemData: undefined, + }, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "target-sub-id", + account: { + id: "target-account-id", + name: "target-account", + location: "West US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "DocumentDB", + properties: { + documentEndpoint: "https://target.documents.azure.com:443/", + cassandraEndpoint: undefined, + gremlinEndpoint: undefined, + tableEndpoint: undefined, + writeLocations: [], + readLocations: [], + enableMultipleWriteLocations: false, + isVirtualNetworkFilterEnabled: false, + enableFreeTier: false, + enableAnalyticalStorage: false, + backupPolicy: undefined, + disableLocalAuth: false, + capacity: undefined, + enablePriorityBasedExecution: false, + publicNetworkAccess: "Enabled", + enableMaterializedViews: false, + }, + systemData: undefined, + }, + databaseId: "target-db", + containerId: "target-container", + }, +}); + +interface TestComponentProps { + initialState: CopyJobContextState; + onStateChange: (state: CopyJobContextState) => void; +} + +const TestComponent: React.FC = ({ initialState, onStateChange }) => { + const [state, setState] = React.useState(initialState); + const handler = dropDownChangeHandler(setState); + + React.useEffect(() => { + onStateChange(state); + }, [state, onStateChange]); + + return ( +
+ + + + +
+ ); +}; + +describe("dropDownChangeHandler", () => { + let capturedState: CopyJobContextState; + let initialState: CopyJobContextState; + + beforeEach(() => { + initialState = createMockInitialState(); + capturedState = initialState; + }); + + const renderTestComponent = () => { + return render( + { + capturedState = state; + }} + />, + ); + }; + + describe("sourceDatabase dropdown change", () => { + it("should update source database and reset source container", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("source-database-btn")); + + expect(capturedState.source.databaseId).toBe("new-source-db"); + expect(capturedState.source.containerId).toBeUndefined(); + expect(capturedState.source.subscription).toEqual(initialState.source.subscription); + expect(capturedState.source.account).toEqual(initialState.source.account); + expect(capturedState.target).toEqual(initialState.target); + }); + + it("should maintain other state properties when updating source database", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("source-database-btn")); + + expect(capturedState.jobName).toBe(initialState.jobName); + expect(capturedState.migrationType).toBe(initialState.migrationType); + expect(capturedState.sourceReadAccessFromTarget).toBe(initialState.sourceReadAccessFromTarget); + }); + }); + + describe("sourceContainer dropdown change", () => { + it("should update source container only", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("source-container-btn")); + + expect(capturedState.source.containerId).toBe("new-source-container"); + expect(capturedState.source.databaseId).toBe(initialState.source.databaseId); + expect(capturedState.source.subscription).toEqual(initialState.source.subscription); + expect(capturedState.source.account).toEqual(initialState.source.account); + expect(capturedState.target).toEqual(initialState.target); + }); + + it("should not affect database selection when updating container", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("source-container-btn")); + + expect(capturedState.source.databaseId).toBe("source-db"); + }); + }); + + describe("targetDatabase dropdown change", () => { + it("should update target database and reset target container", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("target-database-btn")); + + expect(capturedState.target.databaseId).toBe("new-target-db"); + expect(capturedState.target.containerId).toBeUndefined(); + expect(capturedState.target.subscriptionId).toBe(initialState.target.subscriptionId); + expect(capturedState.target.account).toEqual(initialState.target.account); + expect(capturedState.source).toEqual(initialState.source); + }); + + it("should maintain other state properties when updating target database", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("target-database-btn")); + + expect(capturedState.jobName).toBe(initialState.jobName); + expect(capturedState.migrationType).toBe(initialState.migrationType); + expect(capturedState.sourceReadAccessFromTarget).toBe(initialState.sourceReadAccessFromTarget); + }); + }); + + describe("targetContainer dropdown change", () => { + it("should update target container only", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("target-container-btn")); + + expect(capturedState.target.containerId).toBe("new-target-container"); + expect(capturedState.target.databaseId).toBe(initialState.target.databaseId); + expect(capturedState.target.subscriptionId).toBe(initialState.target.subscriptionId); + expect(capturedState.target.account).toEqual(initialState.target.account); + expect(capturedState.source).toEqual(initialState.source); + }); + + it("should not affect database selection when updating container", () => { + const { getByTestId } = renderTestComponent(); + + fireEvent.click(getByTestId("target-container-btn")); + + expect(capturedState.target.databaseId).toBe("target-db"); + }); + }); + + describe("edge cases and error scenarios", () => { + it("should handle empty string keys", () => { + renderTestComponent(); + + const handler = dropDownChangeHandler((updater) => { + const newState = typeof updater === "function" ? updater(capturedState) : updater; + capturedState = newState; + return capturedState; + }); + + const mockEvent = {} as React.FormEvent; + const mockOption: DropdownOptionType = { key: "", text: "Empty Option", data: {} }; + + handler("sourceDatabase")(mockEvent, mockOption); + + expect(capturedState.source.databaseId).toBe(""); + expect(capturedState.source.containerId).toBeUndefined(); + }); + + it("should handle special characters in keys", () => { + renderTestComponent(); + + const handler = dropDownChangeHandler((updater) => { + const newState = typeof updater === "function" ? updater(capturedState) : updater; + capturedState = newState; + return capturedState; + }); + + const mockEvent = {} as React.FormEvent; + const mockOption: DropdownOptionType = { + key: "test-db-with-special-chars-@#$%", + text: "Special DB", + data: {}, + }; + + handler("sourceDatabase")(mockEvent, mockOption); + + expect(capturedState.source.databaseId).toBe("test-db-with-special-chars-@#$%"); + expect(capturedState.source.containerId).toBeUndefined(); + }); + + it("should handle numeric keys", () => { + renderTestComponent(); + + const handler = dropDownChangeHandler((updater) => { + const newState = typeof updater === "function" ? updater(capturedState) : updater; + capturedState = newState; + return capturedState; + }); + + const mockEvent = {} as React.FormEvent; + const mockOption: DropdownOptionType = { key: "12345", text: "Numeric Option", data: {} }; + + handler("targetContainer")(mockEvent, mockOption); + + expect(capturedState.target.containerId).toBe("12345"); + }); + + it.skip("should handle invalid dropdown type gracefully", () => { + const handler = dropDownChangeHandler((updater) => { + const newState = typeof updater === "function" ? updater(capturedState) : updater; + capturedState = newState; + return capturedState; + }); + + const mockEvent = {} as React.FormEvent; + const mockOption: DropdownOptionType = { key: "test-value", text: "Test Option", data: {} }; + + const invalidHandler = handler as any; + invalidHandler("invalidType")(mockEvent, mockOption); + + expect(capturedState).toEqual(initialState); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.test.tsx new file mode 100644 index 000000000..5b7a6b13f --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.test.tsx @@ -0,0 +1,484 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { DatabaseModel } from "Contracts/DataModels"; +import React from "react"; +import Explorer from "../../../../../Explorer/Explorer"; +import CopyJobContextProvider from "../../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; +import SelectSourceAndTargetContainers from "./SelectSourceAndTargetContainers"; + +jest.mock("../../../../../hooks/useDatabases", () => ({ + useDatabases: jest.fn(), +})); + +jest.mock("../../../../../hooks/useDataContainers", () => ({ + useDataContainers: jest.fn(), +})); + +jest.mock("../../../ContainerCopyMessages", () => ({ + __esModule: true, + default: { + selectSourceAndTargetContainersDescription: "Select source and target containers for migration", + sourceContainerSubHeading: "Source Container", + targetContainerSubHeading: "Target Container", + }, +})); + +jest.mock("./Events/DropDownChangeHandler", () => ({ + dropDownChangeHandler: jest.fn(() => () => jest.fn()), +})); + +jest.mock("./memoizedData", () => ({ + useSourceAndTargetData: jest.fn(), +})); + +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", + }, + }, +})); + +import { useDatabases } from "../../../../../hooks/useDatabases"; +import { useDataContainers } from "../../../../../hooks/useDataContainers"; +import { dropDownChangeHandler } from "./Events/DropDownChangeHandler"; +import { useSourceAndTargetData } from "./memoizedData"; + +const mockUseDatabases = useDatabases as jest.MockedFunction; +const mockUseDataContainers = useDataContainers as jest.MockedFunction; +const mockDropDownChangeHandler = dropDownChangeHandler as jest.MockedFunction; +const mockUseSourceAndTargetData = useSourceAndTargetData as jest.MockedFunction; + +describe("SelectSourceAndTargetContainers", () => { + let mockExplorer: Explorer; + let mockShowAddCollectionPanel: jest.Mock; + let mockOnDropdownChange: jest.Mock; + + const mockDatabases: DatabaseModel[] = [ + { id: "db1", name: "Database1" } as DatabaseModel, + { id: "db2", name: "Database2" } as DatabaseModel, + ]; + + const mockContainers: DatabaseModel[] = [ + { id: "container1", name: "Container1" } as DatabaseModel, + { id: "container2", name: "Container2" } as DatabaseModel, + ]; + + const mockCopyJobState = { + 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", + }, + databaseId: "db1", + containerId: "container1", + }, + target: { + subscriptionId: "test-subscription-id", + account: { + id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name: "test-account", + }, + databaseId: "db2", + containerId: "container2", + }, + sourceReadAccessFromTarget: false, + }; + + const mockMemoizedData = { + source: mockCopyJobState.source, + target: mockCopyJobState.target, + sourceDbParams: ["test-sub", "test-rg", "test-account", "SQL"] as const, + sourceContainerParams: ["test-sub", "test-rg", "test-account", "db1", "SQL"] as const, + targetDbParams: ["test-sub", "test-rg", "test-account", "SQL"] as const, + targetContainerParams: ["test-sub", "test-rg", "test-account", "db2", "SQL"] as const, + }; + + beforeEach(() => { + mockExplorer = {} as Explorer; + mockShowAddCollectionPanel = jest.fn(); + mockOnDropdownChange = jest.fn(); + + mockUseDatabases.mockReturnValue(mockDatabases); + mockUseDataContainers.mockReturnValue(mockContainers); + mockUseSourceAndTargetData.mockReturnValue(mockMemoizedData as ReturnType); + mockDropDownChangeHandler.mockReturnValue(() => mockOnDropdownChange); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const renderWithContext = (component: React.ReactElement) => { + return render({component}); + }; + + describe("Component Rendering", () => { + it("should render without crashing", () => { + renderWithContext(); + expect(screen.getByText("Select source and target containers for migration")).toBeInTheDocument(); + }); + + it("should render description text", () => { + renderWithContext(); + expect(screen.getByText("Select source and target containers for migration")).toBeInTheDocument(); + }); + + it("should render source container section", () => { + renderWithContext(); + expect(screen.getByText("Source Container")).toBeInTheDocument(); + }); + + it("should render target container section", () => { + renderWithContext(); + expect(screen.getByText("Target Container")).toBeInTheDocument(); + }); + + it("should return null when source is not available", () => { + mockUseSourceAndTargetData.mockReturnValue({ + ...mockMemoizedData, + source: null, + } as ReturnType); + + const { container } = renderWithContext(); + expect(container.firstChild).toBeNull(); + }); + + it("should call useDatabases hooks with correct parameters", () => { + renderWithContext(); + + expect(mockUseDatabases).toHaveBeenCalledWith(...mockMemoizedData.sourceDbParams); + expect(mockUseDatabases).toHaveBeenCalledWith(...mockMemoizedData.targetDbParams); + }); + + it("should call useDataContainers hooks with correct parameters", () => { + renderWithContext(); + + expect(mockUseDataContainers).toHaveBeenCalledWith(...mockMemoizedData.sourceContainerParams); + expect(mockUseDataContainers).toHaveBeenCalledWith(...mockMemoizedData.targetContainerParams); + }); + }); + + describe("Database Options", () => { + it("should create source database options from useDatabases data", () => { + renderWithContext(); + expect(mockUseDatabases).toHaveBeenCalled(); + }); + + it("should create target database options from useDatabases data", () => { + renderWithContext(); + + expect(mockUseDatabases).toHaveBeenCalled(); + }); + + it("should handle empty database list", () => { + mockUseDatabases.mockReturnValue([]); + + renderWithContext(); + expect(mockUseDatabases).toHaveBeenCalled(); + }); + + it("should handle undefined database list", () => { + mockUseDatabases.mockReturnValue(undefined); + + renderWithContext(); + expect(mockUseDatabases).toHaveBeenCalled(); + }); + }); + + describe("Container Options", () => { + it("should create source container options from useDataContainers data", () => { + renderWithContext(); + + expect(mockUseDataContainers).toHaveBeenCalled(); + }); + + it("should create target container options from useDataContainers data", () => { + renderWithContext(); + + expect(mockUseDataContainers).toHaveBeenCalled(); + }); + + it("should handle empty container list", () => { + mockUseDataContainers.mockReturnValue([]); + + renderWithContext(); + expect(mockUseDataContainers).toHaveBeenCalled(); + }); + + it("should handle undefined container list", () => { + mockUseDataContainers.mockReturnValue(undefined); + + renderWithContext(); + expect(mockUseDataContainers).toHaveBeenCalled(); + }); + }); + + describe("Event Handlers", () => { + it("should call dropDownChangeHandler with setCopyJobState", () => { + renderWithContext(); + + expect(mockDropDownChangeHandler).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("should create dropdown change handlers for different types", () => { + renderWithContext(); + expect(mockDropDownChangeHandler).toHaveBeenCalled(); + }); + }); + + describe("Component Props", () => { + it("should pass showAddCollectionPanel to DatabaseContainerSection", () => { + renderWithContext(); + expect(screen.getByText("Target Container")).toBeInTheDocument(); + }); + + it("should render without showAddCollectionPanel prop", () => { + renderWithContext(); + + expect(screen.getByText("Source Container")).toBeInTheDocument(); + expect(screen.getByText("Target Container")).toBeInTheDocument(); + }); + }); + + describe("Memoization", () => { + it("should memoize source database options", () => { + const { rerender } = renderWithContext(); + + expect(mockUseDatabases).toHaveBeenCalled(); + rerender( + + + , + ); + + expect(mockUseDatabases).toHaveBeenCalled(); + }); + + it("should memoize target database options", () => { + const { rerender } = renderWithContext(); + + expect(mockUseDatabases).toHaveBeenCalled(); + + rerender( + + + , + ); + + expect(mockUseDatabases).toHaveBeenCalled(); + }); + + it("should memoize source container options", () => { + const { rerender } = renderWithContext(); + + expect(mockUseDataContainers).toHaveBeenCalled(); + + rerender( + + + , + ); + + expect(mockUseDataContainers).toHaveBeenCalled(); + }); + + it("should memoize target container options", () => { + const { rerender } = renderWithContext(); + + expect(mockUseDataContainers).toHaveBeenCalled(); + + rerender( + + + , + ); + + expect(mockUseDataContainers).toHaveBeenCalled(); + }); + }); + + describe("Database Container Section Props", () => { + it("should pass correct props to source DatabaseContainerSection", () => { + renderWithContext(); + + expect(screen.getByText("Source Container")).toBeInTheDocument(); + }); + + it("should pass correct props to target DatabaseContainerSection", () => { + renderWithContext(); + + expect(screen.getByText("Target Container")).toBeInTheDocument(); + }); + + it("should disable source container dropdown when no database is selected", () => { + mockUseSourceAndTargetData.mockReturnValue({ + ...mockMemoizedData, + source: { + ...mockMemoizedData.source, + databaseId: "", + }, + } as ReturnType); + + renderWithContext(); + expect(screen.getByText("Source Container")).toBeInTheDocument(); + }); + + it("should disable target container dropdown when no database is selected", () => { + mockUseSourceAndTargetData.mockReturnValue({ + ...mockMemoizedData, + target: { + ...mockMemoizedData.target, + databaseId: "", + }, + } as ReturnType); + + renderWithContext(); + expect(screen.getByText("Target Container")).toBeInTheDocument(); + }); + }); + + describe("Error Handling", () => { + it("should handle hooks returning null gracefully", () => { + mockUseDatabases.mockReturnValue(null); + mockUseDataContainers.mockReturnValue(null); + + renderWithContext(); + + expect(screen.getByText("Select source and target containers for migration")).toBeInTheDocument(); + }); + + it("should handle hooks throwing errors gracefully", () => { + const originalError = console.error; + console.error = jest.fn(); + + mockUseDatabases.mockImplementation(() => { + throw new Error("Database fetch error"); + }); + + expect(() => { + renderWithContext(); + }).toThrow(); + + console.error = originalError; + }); + + it("should handle missing source data gracefully", () => { + mockUseSourceAndTargetData.mockReturnValue({ + ...mockMemoizedData, + source: undefined, + } as ReturnType); + + const { container } = renderWithContext(); + expect(container.firstChild).toBeNull(); + }); + }); + + describe("Integration with CopyJobContext", () => { + it("should use CopyJobContext for state management", () => { + renderWithContext(); + + expect(mockUseSourceAndTargetData).toHaveBeenCalled(); + }); + + it("should respond to context state changes", () => { + const { rerender } = renderWithContext(); + + mockUseSourceAndTargetData.mockReturnValue({ + ...mockMemoizedData, + source: { + ...mockMemoizedData.source, + databaseId: "different-db", + }, + } as ReturnType); + + rerender( + + + , + ); + + expect(mockUseSourceAndTargetData).toHaveBeenCalled(); + }); + }); + + describe("Stack Layout", () => { + it("should render with correct Stack className", () => { + const { container } = renderWithContext(); + + const stackElement = container.querySelector(".selectSourceAndTargetContainers"); + expect(stackElement).toBeInTheDocument(); + }); + + it("should apply correct spacing tokens", () => { + renderWithContext(); + + expect(screen.getByText("Select source and target containers for migration")).toBeInTheDocument(); + }); + }); + + describe("Component Structure", () => { + it("should render description, source section, and target section in correct order", () => { + renderWithContext(); + + const description = screen.getByText("Select source and target containers for migration"); + const sourceSection = screen.getByText("Source Container"); + const targetSection = screen.getByText("Target Container"); + + expect(description).toBeInTheDocument(); + expect(sourceSection).toBeInTheDocument(); + expect(targetSection).toBeInTheDocument(); + }); + + it("should maintain component hierarchy", () => { + const { container } = renderWithContext(); + + const mainContainer = container.querySelector(".selectSourceAndTargetContainers"); + expect(mainContainer).toBeInTheDocument(); + }); + }); + + describe("Performance", () => { + it("should not cause unnecessary re-renders when props don't change", () => { + const { rerender } = renderWithContext(); + + rerender( + + + , + ); + + expect(mockUseSourceAndTargetData).toHaveBeenCalled(); + }); + + it("should handle rapid state changes efficiently", () => { + const { rerender } = renderWithContext(); + + for (let i = 0; i < 5; i++) { + mockUseSourceAndTargetData.mockReturnValue({ + ...mockMemoizedData, + source: { + ...mockMemoizedData.source, + databaseId: `db-${i}`, + }, + } as ReturnType); + + rerender( + + + , + ); + } + + expect(mockUseSourceAndTargetData).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx new file mode 100644 index 000000000..95cffb6e0 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx @@ -0,0 +1,452 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; +import { DatabaseContainerSectionProps, DropdownOptionType } from "../../../../Types/CopyJobTypes"; +import { DatabaseContainerSection } from "./DatabaseContainerSection"; + +describe("DatabaseContainerSection", () => { + const mockDatabaseOnChange = jest.fn(); + const mockContainerOnChange = jest.fn(); + const mockHandleOnDemandCreateContainer = jest.fn(); + + const mockDatabaseOptions: DropdownOptionType[] = [ + { key: "db1", text: "Database 1", data: { id: "db1" } }, + { key: "db2", text: "Database 2", data: { id: "db2" } }, + { key: "db3", text: "Database 3", data: { id: "db3" } }, + ]; + + const mockContainerOptions: DropdownOptionType[] = [ + { key: "container1", text: "Container 1", data: { id: "container1" } }, + { key: "container2", text: "Container 2", data: { id: "container2" } }, + { key: "container3", text: "Container 3", data: { id: "container3" } }, + ]; + + const defaultProps: DatabaseContainerSectionProps = { + heading: "Source container", + databaseOptions: mockDatabaseOptions, + selectedDatabase: "db1", + databaseDisabled: false, + databaseOnChange: mockDatabaseOnChange, + containerOptions: mockContainerOptions, + selectedContainer: "container1", + containerDisabled: false, + containerOnChange: mockContainerOnChange, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Component Rendering", () => { + it("renders the component with correct structure", () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass("databaseContainerSection"); + expect(screen.getByText("Source container")).toBeInTheDocument(); + }); + + it("renders heading correctly", () => { + render(); + + const heading = screen.getByText("Source container"); + expect(heading).toBeInTheDocument(); + expect(heading.tagName).toBe("LABEL"); + expect(heading).toHaveClass("subHeading"); + }); + + it("renders database dropdown with correct properties", () => { + render(); + + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + + expect(databaseDropdown).toBeInTheDocument(); + expect(databaseDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.databaseDropdownLabel); + expect(databaseDropdown).not.toBeDisabled(); + }); + + it("renders container dropdown with correct properties", () => { + render(); + + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + expect(containerDropdown).toBeInTheDocument(); + expect(containerDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.containerDropdownLabel); + expect(containerDropdown).not.toBeDisabled(); + }); + + it("renders database label correctly", () => { + render(); + + expect(screen.getByText(`${ContainerCopyMessages.databaseDropdownLabel}:`)).toBeInTheDocument(); + }); + + it("renders container label correctly", () => { + render(); + + expect(screen.getByText(`${ContainerCopyMessages.containerDropdownLabel}:`)).toBeInTheDocument(); + }); + + it("does not render create container button when handleOnDemandCreateContainer is not provided", () => { + render(); + + expect(screen.queryByText(ContainerCopyMessages.createContainerButtonLabel)).not.toBeInTheDocument(); + }); + + it("renders create container button when handleOnDemandCreateContainer is provided", () => { + const propsWithCreateHandler = { + ...defaultProps, + handleOnDemandCreateContainer: mockHandleOnDemandCreateContainer, + }; + const { container } = render(); + const createButton = container.querySelector(".create-container-link-btn"); + + expect(createButton).toBeInTheDocument(); + expect(createButton).toHaveTextContent(ContainerCopyMessages.createContainerButtonLabel); + }); + }); + + describe("Dropdown States", () => { + it("renders database dropdown as disabled when databaseDisabled is true", () => { + const propsWithDisabledDatabase = { + ...defaultProps, + databaseDisabled: true, + }; + + render(); + + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + + expect(databaseDropdown).toHaveAttribute("aria-disabled", "true"); + }); + + it("renders container dropdown as disabled when containerDisabled is true", () => { + const propsWithDisabledContainer = { + ...defaultProps, + containerDisabled: true, + }; + + render(); + + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + expect(containerDropdown).toHaveAttribute("aria-disabled", "true"); + }); + + it("handles falsy values for disabled props correctly", () => { + const propsWithFalsyDisabled = { + ...defaultProps, + databaseDisabled: undefined, + containerDisabled: null, + } as DatabaseContainerSectionProps; + + render(); + + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + expect(databaseDropdown).not.toHaveAttribute("aria-disabled", "true"); + expect(containerDropdown).not.toHaveAttribute("aria-disabled", "true"); + }); + }); + + describe("User Interactions", () => { + it("calls databaseOnChange when database dropdown selection changes", () => { + render(); + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + + fireEvent.click(databaseDropdown); + expect(databaseDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.databaseDropdownLabel); + }); + + it("calls containerOnChange when container dropdown selection changes", () => { + render(); + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + fireEvent.click(containerDropdown); + expect(containerDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.containerDropdownLabel); + }); + + it("calls handleOnDemandCreateContainer when create container button is clicked", () => { + const propsWithCreateHandler = { + ...defaultProps, + handleOnDemandCreateContainer: mockHandleOnDemandCreateContainer, + }; + + render(); + + const createButton = screen.getByText(ContainerCopyMessages.createContainerButtonLabel); + fireEvent.click(createButton); + + expect(mockHandleOnDemandCreateContainer).toHaveBeenCalledTimes(1); + expect(mockHandleOnDemandCreateContainer).toHaveBeenCalledWith(); + }); + }); + + describe("Props Validation", () => { + it("renders with different heading text", () => { + const propsWithDifferentHeading = { + ...defaultProps, + heading: "Target container", + }; + + render(); + + expect(screen.getByText("Target container")).toBeInTheDocument(); + expect(screen.queryByText("Source container")).not.toBeInTheDocument(); + }); + + it("renders with different selected values", () => { + const propsWithDifferentSelections = { + ...defaultProps, + selectedDatabase: "db2", + selectedContainer: "container3", + }; + + render(); + + expect(screen.getByText("Source container")).toBeInTheDocument(); + }); + + it("renders with empty options arrays", () => { + const propsWithEmptyOptions = { + ...defaultProps, + databaseOptions: [], + containerOptions: [], + } as DatabaseContainerSectionProps; + + render(); + + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + expect(databaseDropdown).toBeInTheDocument(); + expect(containerDropdown).toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + it("has proper ARIA labels for dropdowns", () => { + render(); + + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + expect(databaseDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.databaseDropdownLabel); + expect(containerDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.containerDropdownLabel); + }); + + it("has proper required attributes for dropdowns", () => { + render(); + + const databaseDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.databaseDropdownLabel, + }); + const containerDropdown = screen.getByRole("combobox", { + name: ContainerCopyMessages.containerDropdownLabel, + }); + + expect(databaseDropdown).toHaveAttribute("aria-required", "true"); + expect(containerDropdown).toHaveAttribute("aria-required", "true"); + }); + + it("maintains proper label associations", () => { + render(); + + expect(screen.getByText(`${ContainerCopyMessages.databaseDropdownLabel}:`)).toBeInTheDocument(); + expect(screen.getByText(`${ContainerCopyMessages.containerDropdownLabel}:`)).toBeInTheDocument(); + }); + }); + + describe("Edge Cases", () => { + it("handles undefined optional props gracefully", () => { + const minimalProps: DatabaseContainerSectionProps = { + heading: "Test Heading", + databaseOptions: mockDatabaseOptions, + selectedDatabase: "db1", + databaseOnChange: mockDatabaseOnChange, + containerOptions: mockContainerOptions, + selectedContainer: "container1", + containerOnChange: mockContainerOnChange, + }; + + render(); + + expect(screen.getByText("Test Heading")).toBeInTheDocument(); + expect(screen.queryByText(ContainerCopyMessages.createContainerButtonLabel)).not.toBeInTheDocument(); + }); + + it("handles empty string selections", () => { + const propsWithEmptySelections = { + ...defaultProps, + selectedDatabase: "", + selectedContainer: "", + }; + + render(); + + expect(screen.getByText("Source container")).toBeInTheDocument(); + }); + + it("renders correctly with long option texts", () => { + const longOptions = [ + { + key: "long1", + text: "This is a very long database name that might wrap to multiple lines in the dropdown", + data: { id: "long1" }, + }, + ]; + + const propsWithLongOptions = { + ...defaultProps, + databaseOptions: longOptions, + containerOptions: longOptions, + selectedDatabase: "long1", + selectedContainer: "long1", + }; + + render(); + + expect(screen.getByText("Source container")).toBeInTheDocument(); + }); + }); + + describe("Component Structure", () => { + it("has correct CSS classes applied", () => { + const { container } = render(); + + const mainContainer = container.querySelector(".databaseContainerSection"); + expect(mainContainer).toBeInTheDocument(); + + const subHeading = screen.getByText("Source container"); + expect(subHeading).toHaveClass("subHeading"); + }); + + it("maintains proper component hierarchy", () => { + const { container } = render(); + + const mainStack = container.querySelector(".databaseContainerSection"); + expect(mainStack).toBeInTheDocument(); + + const fieldRows = container.querySelectorAll(".flex-row"); + expect(fieldRows.length).toBe(2); + }); + + it("renders create button in correct position when provided", () => { + const propsWithCreateHandler = { + ...defaultProps, + handleOnDemandCreateContainer: mockHandleOnDemandCreateContainer, + }; + + const { container } = render(); + + const createButton = screen.getByText(ContainerCopyMessages.createContainerButtonLabel); + expect(createButton).toBeInTheDocument(); + + const containerSection = container.querySelector(".databaseContainerSection"); + expect(containerSection).toContainElement(createButton); + }); + + it("displays correct create container button label", () => { + const propsWithCreateHandler = { + ...defaultProps, + handleOnDemandCreateContainer: mockHandleOnDemandCreateContainer, + }; + + render(); + + expect(screen.getByText(ContainerCopyMessages.createContainerButtonLabel)).toBeInTheDocument(); + }); + }); + + describe("Snapshot Testing", () => { + it("matches snapshot with minimal props", () => { + const minimalProps: DatabaseContainerSectionProps = { + heading: "Source Container", + databaseOptions: [{ key: "db1", text: "Database 1", data: { id: "db1" } }], + selectedDatabase: "db1", + databaseOnChange: jest.fn(), + containerOptions: [{ key: "c1", text: "Container 1", data: { id: "c1" } }], + selectedContainer: "c1", + containerOnChange: jest.fn(), + }; + + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with all props including create container handler", () => { + const fullProps: DatabaseContainerSectionProps = { + heading: "Target Container", + databaseOptions: mockDatabaseOptions, + selectedDatabase: "db2", + databaseDisabled: false, + databaseOnChange: jest.fn(), + containerOptions: mockContainerOptions, + selectedContainer: "container2", + containerDisabled: false, + containerOnChange: jest.fn(), + handleOnDemandCreateContainer: jest.fn(), + }; + + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with disabled states", () => { + const disabledProps: DatabaseContainerSectionProps = { + heading: "Disabled Section", + databaseOptions: mockDatabaseOptions, + selectedDatabase: "db1", + databaseDisabled: true, + databaseOnChange: jest.fn(), + containerOptions: mockContainerOptions, + selectedContainer: "container1", + containerDisabled: true, + containerOnChange: jest.fn(), + }; + + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("matches snapshot with empty options", () => { + const emptyOptionsProps: DatabaseContainerSectionProps = { + heading: "Empty Options", + databaseOptions: [], + selectedDatabase: "", + databaseOnChange: jest.fn(), + containerOptions: [], + selectedContainer: "", + containerOnChange: jest.fn(), + }; + + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/__snapshots__/DatabaseContainerSection.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/__snapshots__/DatabaseContainerSection.test.tsx.snap new file mode 100644 index 000000000..09582b12b --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/__snapshots__/DatabaseContainerSection.test.tsx.snap @@ -0,0 +1,518 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DatabaseContainerSection Snapshot Testing matches snapshot with all props including create container handler 1`] = ` +
+ +
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+
+`; + +exports[`DatabaseContainerSection Snapshot Testing matches snapshot with disabled states 1`] = ` +
+ +
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+`; + +exports[`DatabaseContainerSection Snapshot Testing matches snapshot with empty options 1`] = ` +
+ +
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+`; + +exports[`DatabaseContainerSection Snapshot Testing matches snapshot with minimal props 1`] = ` +
+ +
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.test.tsx new file mode 100644 index 000000000..85de409cb --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.test.tsx @@ -0,0 +1,387 @@ +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import React from "react"; +import { DatabaseAccount, Subscription } from "../../../../../Contracts/DataModels"; +import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; +import { CopyJobContextState } from "../../../Types/CopyJobTypes"; +import { useSourceAndTargetData } from "./memoizedData"; + +jest.mock("../../../CopyJobUtils", () => ({ + getAccountDetailsFromResourceId: jest.fn(), +})); + +import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils"; + +const mockGetAccountDetailsFromResourceId = getAccountDetailsFromResourceId as jest.MockedFunction< + typeof getAccountDetailsFromResourceId +>; + +interface TestComponentProps { + copyJobState: CopyJobContextState | null; + onResult?: (result: any) => void; +} + +const TestComponent: React.FC = ({ copyJobState, onResult }) => { + const result = useSourceAndTargetData(copyJobState); + + React.useEffect(() => { + onResult?.(result); + }, [result, onResult]); + + return
Test Component
; +}; + +describe("useSourceAndTargetData", () => { + const mockSubscription: Subscription = { + subscriptionId: "test-subscription-id", + displayName: "Test Subscription", + state: "Enabled", + subscriptionPolicies: null, + authorizationSource: "RoleBased", + }; + + const mockSourceAccount: DatabaseAccount = { + id: "/subscriptions/source-sub-id/resourceGroups/source-rg/providers/Microsoft.DocumentDB/databaseAccounts/source-account", + name: "source-account", + location: "East US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://source-account.documents.azure.com:443/", + capabilities: [], + locations: [], + }, + }; + + const mockTargetAccount: DatabaseAccount = { + id: "/subscriptions/target-sub-id/resourceGroups/target-rg/providers/Microsoft.DocumentDB/databaseAccounts/target-account", + name: "target-account", + location: "West US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + properties: { + documentEndpoint: "https://target-account.documents.azure.com:443/", + capabilities: [], + locations: [], + }, + }; + + const mockCopyJobState: CopyJobContextState = { + jobName: "test-job", + migrationType: CopyJobMigrationType.Offline, + sourceReadAccessFromTarget: false, + source: { + subscription: mockSubscription, + account: mockSourceAccount, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "target-subscription-id", + account: mockTargetAccount, + databaseId: "target-db", + containerId: "target-container", + }, + }; + + beforeEach(() => { + mockGetAccountDetailsFromResourceId.mockImplementation((accountId) => { + if (accountId === mockSourceAccount.id) { + return { + subscriptionId: "source-sub-id", + resourceGroup: "source-rg", + accountName: "source-account", + }; + } else if (accountId === mockTargetAccount.id) { + return { + subscriptionId: "target-sub-id", + resourceGroup: "target-rg", + accountName: "target-account", + }; + } + return null; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Hook Execution", () => { + it("should return correct data structure when copyJobState is provided", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(onResult).toHaveBeenCalled(); + expect(hookResult).toBeDefined(); + expect(hookResult).toHaveProperty("source"); + expect(hookResult).toHaveProperty("target"); + expect(hookResult).toHaveProperty("sourceDbParams"); + expect(hookResult).toHaveProperty("sourceContainerParams"); + expect(hookResult).toHaveProperty("targetDbParams"); + expect(hookResult).toHaveProperty("targetContainerParams"); + }); + + it("should call getAccountDetailsFromResourceId with correct parameters", () => { + render(); + + expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledWith(mockSourceAccount.id); + expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledWith(mockTargetAccount.id); + expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledTimes(2); + }); + + it("should return source and target objects from copyJobState", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.source).toEqual(mockCopyJobState.source); + expect(hookResult.target).toEqual(mockCopyJobState.target); + }); + + it("should construct sourceDbParams array correctly", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.sourceDbParams).toEqual(["source-sub-id", "source-rg", "source-account", "SQL"]); + }); + + it("should construct sourceContainerParams array correctly", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.sourceContainerParams).toEqual([ + "source-sub-id", + "source-rg", + "source-account", + "source-db", + "SQL", + ]); + }); + + it("should construct targetDbParams array correctly", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.targetDbParams).toEqual(["target-sub-id", "target-rg", "target-account", "SQL"]); + }); + + it("should construct targetContainerParams array correctly", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.targetContainerParams).toEqual([ + "target-sub-id", + "target-rg", + "target-account", + "target-db", + "SQL", + ]); + }); + }); + + describe("Memoization and Performance", () => { + it("should work with React strict mode (double invocation)", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + const { rerender } = render(); + const firstResult = { ...hookResult }; + + rerender(); + const secondResult = { ...hookResult }; + + expect(firstResult).toEqual(secondResult); + }); + + it("should handle component re-renders gracefully", () => { + let renderCount = 0; + const onResult = jest.fn(() => { + renderCount++; + }); + + const { rerender } = render(); + + for (let i = 0; i < 5; i++) { + rerender(); + } + + expect(renderCount).toBeGreaterThan(0); + expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalled(); + }); + + it("should recalculate when copyJobState changes", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + const { rerender } = render(); + const firstResult = { ...hookResult }; + + const updatedState = { + ...mockCopyJobState, + source: { + ...mockCopyJobState.source, + databaseId: "updated-source-db", + }, + }; + + rerender(); + const secondResult = { ...hookResult }; + + expect(firstResult.sourceContainerParams[3]).toBe("source-db"); + expect(secondResult.sourceContainerParams[3]).toBe("updated-source-db"); + }); + }); + + describe("Complex State Scenarios", () => { + it("should handle state with only source defined", () => { + const sourceOnlyState = { + ...mockCopyJobState, + target: undefined as any, + }; + + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.source).toBeDefined(); + expect(hookResult.target).toBeUndefined(); + expect(hookResult.sourceDbParams).toEqual(["source-sub-id", "source-rg", "source-account", "SQL"]); + expect(hookResult.targetDbParams).toEqual([undefined, undefined, undefined, "SQL"]); + }); + + it("should handle state with only target defined", () => { + const targetOnlyState = { + ...mockCopyJobState, + source: undefined as any, + }; + + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.source).toBeUndefined(); + expect(hookResult.target).toBeDefined(); + expect(hookResult.sourceDbParams).toEqual([undefined, undefined, undefined, "SQL"]); + expect(hookResult.targetDbParams).toEqual(["target-sub-id", "target-rg", "target-account", "SQL"]); + }); + + it("should handle state with missing database IDs", () => { + const stateWithoutDbIds = { + ...mockCopyJobState, + source: { + ...mockCopyJobState.source, + databaseId: undefined as any, + }, + target: { + ...mockCopyJobState.target, + databaseId: undefined as any, + }, + }; + + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.sourceContainerParams[3]).toBeUndefined(); + expect(hookResult.targetContainerParams[3]).toBeUndefined(); + }); + + it("should handle state with missing accounts", () => { + const stateWithoutAccounts = { + ...mockCopyJobState, + source: { + ...mockCopyJobState.source, + account: undefined as any, + }, + target: { + ...mockCopyJobState.target, + account: undefined as any, + }, + }; + + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledWith(undefined); + expect(hookResult.sourceDbParams).toEqual([undefined, undefined, undefined, "SQL"]); + expect(hookResult.targetDbParams).toEqual([undefined, undefined, undefined, "SQL"]); + }); + }); + + describe("Hook Return Value Structure", () => { + it("should return an object with exactly 6 properties", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + const keys = Object.keys(hookResult); + expect(keys).toHaveLength(6); + expect(keys).toContain("source"); + expect(keys).toContain("target"); + expect(keys).toContain("sourceDbParams"); + expect(keys).toContain("sourceContainerParams"); + expect(keys).toContain("targetDbParams"); + expect(keys).toContain("targetContainerParams"); + }); + + it("should not return undefined properties when state is valid", () => { + let hookResult: any = null; + const onResult = jest.fn((result) => { + hookResult = result; + }); + + render(); + + expect(hookResult.source).toBeDefined(); + expect(hookResult.target).toBeDefined(); + expect(hookResult.sourceDbParams).toBeDefined(); + expect(hookResult.sourceContainerParams).toBeDefined(); + expect(hookResult.targetDbParams).toBeDefined(); + expect(hookResult.targetContainerParams).toBeDefined(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx index 99977ed3a..76fa3dc7a 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx @@ -9,12 +9,12 @@ export function useSourceAndTargetData(copyJobState: CopyJobContextState) { subscriptionId: sourceSubscriptionId, resourceGroup: sourceResourceGroup, accountName: sourceAccountName, - } = getAccountDetailsFromResourceId(selectedSourceAccount?.id); + } = getAccountDetailsFromResourceId(selectedSourceAccount?.id) || {}; const { subscriptionId: targetSubscriptionId, resourceGroup: targetResourceGroup, accountName: targetAccountName, - } = getAccountDetailsFromResourceId(selectedTargetAccount?.id); + } = getAccountDetailsFromResourceId(selectedTargetAccount?.id) || {}; const sourceDbParams = [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams; const sourceContainerParams = [ diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/__snapshots__/CreateCopyJobScreensProvider.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/__snapshots__/CreateCopyJobScreensProvider.test.tsx.snap new file mode 100644 index 000000000..31aab621f --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/__snapshots__/CreateCopyJobScreensProvider.test.tsx.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreateCopyJobScreensProvider should match snapshot for default render: default-render 1`] = ` + + + +`; + +exports[`CreateCopyJobScreensProvider should match snapshot for edge cases: empty-explorer 1`] = ` + + + +`; + +exports[`CreateCopyJobScreensProvider should match snapshot for edge cases: partial-explorer 1`] = ` + + + +`; + +exports[`CreateCopyJobScreensProvider should render with explorer prop 1`] = ` + + + +`; + +exports[`CreateCopyJobScreensProvider should render with null explorer 1`] = ` + + + +`; + +exports[`CreateCopyJobScreensProvider should render with undefined explorer 1`] = ` + + + +`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.test.tsx new file mode 100644 index 000000000..737d9c78f --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.test.tsx @@ -0,0 +1,324 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { CopyJobMigrationType } from "../../Enums/CopyJobEnums"; +import { CopyJobContextState } from "../../Types/CopyJobTypes"; +import { useCopyJobNavigation } from "./useCopyJobNavigation"; + +jest.mock("../../../../hooks/useSidePanel", () => ({ + useSidePanel: { + getState: jest.fn(() => ({ + closeSidePanel: jest.fn(), + })), + }, +})); + +jest.mock("../../Actions/CopyJobActions", () => ({ + submitCreateCopyJob: jest.fn(), +})); + +jest.mock("../../Context/CopyJobContext", () => ({ + useCopyJobContext: jest.fn(), +})); + +jest.mock("./useCopyJobPrerequisitesCache", () => ({ + useCopyJobPrerequisitesCache: jest.fn(), +})); + +jest.mock("./useCreateCopyJobScreensList", () => ({ + SCREEN_KEYS: { + SelectAccount: "SelectAccount", + AssignPermissions: "AssignPermissions", + SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers", + CreateCollection: "CreateCollection", + PreviewCopyJob: "PreviewCopyJob", + }, + useCreateCopyJobScreensList: jest.fn(), +})); + +jest.mock("../../CopyJobUtils", () => ({ + getContainerIdentifiers: jest.fn(), + isIntraAccountCopy: jest.fn(), +})); + +import { useSidePanel } from "../../../../hooks/useSidePanel"; +import { submitCreateCopyJob } from "../../Actions/CopyJobActions"; +import { useCopyJobContext } from "../../Context/CopyJobContext"; +import { getContainerIdentifiers, isIntraAccountCopy } from "../../CopyJobUtils"; +import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache"; +import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList"; + +const TestComponent: React.FC<{ + onHookResult?: (result: ReturnType) => void; +}> = ({ onHookResult }) => { + const hookResult = useCopyJobNavigation(); + + React.useEffect(() => { + onHookResult?.(hookResult); + }, [hookResult, onHookResult]); + + return ( +
+
{hookResult.currentScreen?.key}
+
{hookResult.isPrimaryDisabled.toString()}
+
{hookResult.isPreviousDisabled.toString()}
+
{hookResult.primaryBtnText}
+ + + + {hookResult.currentScreen?.key === SCREEN_KEYS.SelectSourceAndTargetContainers && ( + + )} +
+ ); +}; + +describe("useCopyJobNavigation", () => { + const createMockCopyJobState = (overrides?: Partial): CopyJobContextState => ({ + jobName: "test-job", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: { subscriptionId: "source-sub-id" } as any, + account: { id: "source-account-id", name: "Account-1" } as any, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "target-sub-id", + account: { id: "target-account-id", name: "Account-2" } as any, + databaseId: "target-db", + containerId: "target-container", + }, + ...overrides, + }); + + const createMockScreen = (key: string, validations: any[] = []) => ({ + key, + component:
{key} Screen
, + validations, + }); + + const mockResetCopyJobState = jest.fn(); + const mockSetContextError = jest.fn(); + const mockCloseSidePanel = jest.fn(); + const mockCopyJobState = createMockCopyJobState(); + const mockValidationCache = new Map([ + ["validation1", true], + ["validation2", true], + ]); + + const setupMocks = (screensList: any[] = [], isIntraAccount = false) => { + (useCopyJobContext as jest.Mock).mockReturnValue({ + copyJobState: mockCopyJobState, + resetCopyJobState: mockResetCopyJobState, + setContextError: mockSetContextError, + }); + + (useCopyJobPrerequisitesCache as unknown as jest.Mock).mockReturnValue({ + validationCache: mockValidationCache, + }); + + (useCreateCopyJobScreensList as jest.Mock).mockReturnValue( + screensList.length > 0 ? screensList : [createMockScreen(SCREEN_KEYS.SelectAccount)], + ); + + (useSidePanel.getState as jest.Mock).mockReturnValue({ + closeSidePanel: mockCloseSidePanel, + }); + + (getContainerIdentifiers as jest.Mock).mockImplementation((container) => ({ + accountId: container.account?.id, + databaseId: container.databaseId, + containerId: container.containerId, + })); + + (isIntraAccountCopy as jest.Mock).mockReturnValue(isIntraAccount); + }; + + const clickPrimaryButton = () => fireEvent.click(screen.getByTestId("primary-btn")); + const clickPreviousButton = () => fireEvent.click(screen.getByTestId("previous-btn")); + + const expectScreen = (screenKey: string) => { + expect(screen.getByTestId("current-screen")).toHaveTextContent(screenKey); + }; + + const expectPrimaryButtonText = (text: string) => { + expect(screen.getByTestId("primary-btn-text")).toHaveTextContent(text); + }; + + const expectPrimaryDisabled = (disabled: boolean) => { + expect(screen.getByTestId("primary-disabled")).toHaveTextContent(disabled.toString()); + }; + + const navigateToScreen = (screenKey: string, clicks: number) => { + for (let i = 0; i < clicks; i++) { + clickPrimaryButton(); + } + expectScreen(screenKey); + }; + + beforeEach(() => { + jest.clearAllMocks(); + setupMocks(); + }); + + describe("Initial state and navigation", () => { + test("should start with SelectAccount screen and disable previous button", () => { + render(); + + expectScreen(SCREEN_KEYS.SelectAccount); + expect(screen.getByTestId("previous-disabled")).toHaveTextContent("true"); + }); + + test("should show Next button text by default", () => { + render(); + expectPrimaryButtonText("Next"); + }); + + test("should navigate through screens and show Create button for CreateCollection", () => { + const screens = [ + createMockScreen(SCREEN_KEYS.SelectAccount), + createMockScreen(SCREEN_KEYS.SelectSourceAndTargetContainers), + createMockScreen(SCREEN_KEYS.CreateCollection), + ]; + setupMocks(screens, true); + + render(); + + expectScreen(SCREEN_KEYS.SelectAccount); + clickPrimaryButton(); + + expectScreen(SCREEN_KEYS.SelectSourceAndTargetContainers); + expectPrimaryButtonText("Next"); + + fireEvent.click(screen.getByTestId("add-collection-btn")); + expectScreen(SCREEN_KEYS.CreateCollection); + expectPrimaryButtonText("Create"); + + clickPreviousButton(); + expectScreen(SCREEN_KEYS.SelectSourceAndTargetContainers); + expectPrimaryButtonText("Next"); + }); + }); + + describe("Validation logic", () => { + test("should disable primary button when validations fail", () => { + const invalidScreen = createMockScreen(SCREEN_KEYS.SelectAccount, [ + { validate: () => false, message: "Invalid state" }, + ]); + setupMocks([invalidScreen]); + + render(); + expectPrimaryDisabled(true); + }); + + test("should enable primary button when all validations pass", () => { + const validScreen = createMockScreen(SCREEN_KEYS.SelectAccount, [ + { validate: () => true, message: "Valid state" }, + ]); + setupMocks([validScreen]); + + render(); + expectPrimaryDisabled(false); + }); + + test("should prevent navigation when source and target containers are identical", () => { + const screens = [ + createMockScreen(SCREEN_KEYS.SelectAccount), + createMockScreen(SCREEN_KEYS.SelectSourceAndTargetContainers, [ + { validate: () => true, message: "Valid containers" }, + ]), + ]; + setupMocks(screens, true); + + (getContainerIdentifiers as jest.Mock).mockImplementation(() => ({ + accountId: "same-account", + databaseId: "same-db", + containerId: "same-container", + })); + + render(); + + navigateToScreen(SCREEN_KEYS.SelectSourceAndTargetContainers, 1); + clickPrimaryButton(); + + expectScreen(SCREEN_KEYS.SelectSourceAndTargetContainers); + expect(mockSetContextError).toHaveBeenCalledWith( + "Source and destination containers cannot be the same. Please select different containers to proceed.", + ); + }); + }); + + describe("Copy job submission", () => { + const setupToPreviewScreen = () => { + const screens = [ + createMockScreen(SCREEN_KEYS.SelectAccount), + createMockScreen(SCREEN_KEYS.SelectSourceAndTargetContainers), + createMockScreen(SCREEN_KEYS.PreviewCopyJob), + ]; + setupMocks(screens, true); + + render(); + navigateToScreen(SCREEN_KEYS.PreviewCopyJob, 2); + clickPrimaryButton(); + }; + + test("should handle successful copy job submission", async () => { + (submitCreateCopyJob as jest.Mock).mockResolvedValue(undefined); + + setupToPreviewScreen(); + + await waitFor(() => { + expect(submitCreateCopyJob).toHaveBeenCalledWith(mockCopyJobState, expect.any(Function)); + }); + }); + + test("should handle copy job submission error", async () => { + const error = new Error("Submission failed"); + (submitCreateCopyJob as jest.Mock).mockRejectedValue(error); + setupToPreviewScreen(); + + await waitFor(() => { + expect(mockSetContextError).toHaveBeenCalledWith("Submission failed"); + }); + }); + + test("should handle unknown error during submission", async () => { + (submitCreateCopyJob as jest.Mock).mockRejectedValue("Unknown error"); + + setupToPreviewScreen(); + + await waitFor(() => { + expect(mockSetContextError).toHaveBeenCalledWith("Failed to create copy job. Please try again later."); + }); + }); + + test("should disable buttons during loading", async () => { + let resolveSubmission: () => void; + const submissionPromise = new Promise((resolve) => { + resolveSubmission = resolve; + }); + (submitCreateCopyJob as jest.Mock).mockReturnValue(submissionPromise); + + setupToPreviewScreen(); + + await waitFor(() => { + expectPrimaryDisabled(true); + }); + + resolveSubmission!(); + + await waitFor(() => { + expectPrimaryDisabled(false); + }); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts index dd8059547..6419f9471 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts @@ -39,6 +39,7 @@ export function useCopyJobNavigation() { const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] }); const handlePrevious = useCallback(() => { + setContextError(null); dispatch({ type: "PREVIOUS" }); }, [dispatch]); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.test.tsx new file mode 100644 index 000000000..5b17d1c3b --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.test.tsx @@ -0,0 +1,334 @@ +import "@testing-library/jest-dom"; +import { act, render, screen } from "@testing-library/react"; +import React from "react"; +import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache"; + +describe("useCopyJobPrerequisitesCache", () => { + let hookResult: any; + + const TestComponent = ({ onHookUpdate }: { onHookUpdate?: () => void }): JSX.Element => { + hookResult = useCopyJobPrerequisitesCache(); + + React.useEffect(() => { + if (onHookUpdate) { + onHookUpdate(); + } + }, [onHookUpdate]); + + return ( +
+ {hookResult.validationCache.size} + + +
+ ); + }; + + afterEach(() => { + if (hookResult) { + act(() => { + hookResult.setValidationCache(new Map()); + }); + } + }); + + it("should initialize with an empty validation cache", () => { + render(); + + expect(hookResult.validationCache).toBeInstanceOf(Map); + expect(hookResult.validationCache.size).toBe(0); + expect(screen.getByTestId("cache-size")).toHaveTextContent("0"); + }); + + it("should provide a setValidationCache function", () => { + render(); + + expect(typeof hookResult.setValidationCache).toBe("function"); + }); + + it("should update validation cache when setValidationCache is called", () => { + render(); + + const testCache = new Map(); + testCache.set("test-key", true); + testCache.set("another-key", false); + + act(() => { + hookResult.setValidationCache(testCache); + }); + + expect(hookResult.validationCache).toBe(testCache); + expect(hookResult.validationCache.size).toBe(2); + expect(hookResult.validationCache.get("test-key")).toBe(true); + expect(hookResult.validationCache.get("another-key")).toBe(false); + expect(screen.getByTestId("cache-size")).toHaveTextContent("2"); + }); + + it("should replace the entire validation cache when setValidationCache is called", () => { + render(); + + const initialCache = new Map(); + initialCache.set("initial-key", true); + + act(() => { + hookResult.setValidationCache(initialCache); + }); + + expect(hookResult.validationCache.get("initial-key")).toBe(true); + expect(screen.getByTestId("cache-size")).toHaveTextContent("1"); + + const newCache = new Map(); + newCache.set("new-key", false); + + act(() => { + hookResult.setValidationCache(newCache); + }); + + expect(hookResult.validationCache.get("initial-key")).toBeUndefined(); + expect(hookResult.validationCache.get("new-key")).toBe(false); + expect(hookResult.validationCache.size).toBe(1); + expect(screen.getByTestId("cache-size")).toHaveTextContent("1"); + }); + + it("should handle empty Map updates", () => { + render(); + + const initialCache = new Map(); + initialCache.set("test-key", true); + + act(() => { + hookResult.setValidationCache(initialCache); + }); + + expect(hookResult.validationCache.size).toBe(1); + expect(screen.getByTestId("cache-size")).toHaveTextContent("1"); + + act(() => { + screen.getByTestId("clear-cache-button").click(); + }); + + expect(hookResult.validationCache.size).toBe(0); + expect(screen.getByTestId("cache-size")).toHaveTextContent("0"); + }); + + it("should maintain state across multiple hook instances (global store behavior)", () => { + let firstHookResult: any; + let secondHookResult: any; + + const FirstComponent = (): JSX.Element => { + firstHookResult = useCopyJobPrerequisitesCache(); + return
First
; + }; + + const SecondComponent = (): JSX.Element => { + secondHookResult = useCopyJobPrerequisitesCache(); + return
Second
; + }; + + render( +
+ + +
, + ); + + const testCache = new Map(); + testCache.set("shared-key", true); + + act(() => { + firstHookResult.setValidationCache(testCache); + }); + + expect(secondHookResult.validationCache.get("shared-key")).toBe(true); + expect(secondHookResult.validationCache.size).toBe(1); + expect(firstHookResult.validationCache.get("shared-key")).toBe(true); + expect(firstHookResult.validationCache.size).toBe(1); + }); + + it("should allow updates from different hook instances", () => { + let firstHookResult: any; + let secondHookResult: any; + + const FirstComponent = (): JSX.Element => { + firstHookResult = useCopyJobPrerequisitesCache(); + return ( + + ); + }; + + const SecondComponent = (): JSX.Element => { + secondHookResult = useCopyJobPrerequisitesCache(); + return ( + + ); + }; + + render( +
+ + +
, + ); + + act(() => { + screen.getByTestId("first-update").click(); + }); + + expect(secondHookResult.validationCache.get("key-from-first")).toBe(true); + + act(() => { + screen.getByTestId("second-update").click(); + }); + + expect(firstHookResult.validationCache.get("key-from-second")).toBe(false); + expect(firstHookResult.validationCache.get("key-from-first")).toBeUndefined(); + }); + + it("should handle complex validation scenarios", () => { + const ComplexTestComponent = (): JSX.Element => { + hookResult = useCopyJobPrerequisitesCache(); + + const handleComplexUpdate = () => { + const complexCache = new Map(); + complexCache.set("database-validation", true); + complexCache.set("container-validation", true); + complexCache.set("network-validation", false); + complexCache.set("authentication-validation", true); + complexCache.set("permission-validation", false); + hookResult.setValidationCache(complexCache); + }; + + return ( + + ); + }; + + render(); + + act(() => { + screen.getByTestId("complex-update").click(); + }); + + expect(hookResult.validationCache.size).toBe(5); + expect(hookResult.validationCache.get("database-validation")).toBe(true); + expect(hookResult.validationCache.get("container-validation")).toBe(true); + expect(hookResult.validationCache.get("network-validation")).toBe(false); + expect(hookResult.validationCache.get("authentication-validation")).toBe(true); + expect(hookResult.validationCache.get("permission-validation")).toBe(false); + }); + + it("should handle edge case keys", () => { + const EdgeCaseTestComponent = (): JSX.Element => { + hookResult = useCopyJobPrerequisitesCache(); + + const handleEdgeCaseUpdate = () => { + const edgeCaseCache = new Map(); + edgeCaseCache.set("", true); + edgeCaseCache.set(" ", false); + edgeCaseCache.set("special-chars!@#$%^&*()", true); + edgeCaseCache.set("very-long-key-".repeat(10), false); + edgeCaseCache.set("unicode-key-🔑", true); + hookResult.setValidationCache(edgeCaseCache); + }; + + return ( + + ); + }; + + render(); + + act(() => { + screen.getByTestId("edge-case-update").click(); + }); + + expect(hookResult.validationCache.size).toBe(5); + expect(hookResult.validationCache.get("")).toBe(true); + expect(hookResult.validationCache.get(" ")).toBe(false); + expect(hookResult.validationCache.get("special-chars!@#$%^&*()")).toBe(true); + expect(hookResult.validationCache.get("very-long-key-".repeat(10))).toBe(false); + expect(hookResult.validationCache.get("unicode-key-🔑")).toBe(true); + }); + + it("should handle setting the same cache reference without errors", () => { + let testCache: Map; + + const SameReferenceTestComponent = (): JSX.Element => { + hookResult = useCopyJobPrerequisitesCache(); + + const handleFirstUpdate = () => { + testCache = new Map(); + testCache.set("test-key", true); + hookResult.setValidationCache(testCache); + }; + + const handleSecondUpdate = () => { + hookResult.setValidationCache(testCache); + }; + + return ( +
+ + + {hookResult.validationCache.get("test-key")?.toString()} +
+ ); + }; + + render(); + act(() => { + screen.getByTestId("first-update").click(); + }); + expect(hookResult.validationCache.get("test-key")).toBe(true); + expect(screen.getByTestId("cache-content")).toHaveTextContent("true"); + + act(() => { + screen.getByTestId("second-update").click(); + }); + expect(hookResult.validationCache).toBe(testCache); + expect(hookResult.validationCache.get("test-key")).toBe(true); + expect(screen.getByTestId("cache-content")).toHaveTextContent("true"); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.test.tsx new file mode 100644 index 000000000..3768ef5f1 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.test.tsx @@ -0,0 +1,477 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import Explorer from "../../../Explorer"; +import CopyJobContextProvider, { useCopyJobContext } from "../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../Enums/CopyJobEnums"; +import { CopyJobContextState } from "../../Types/CopyJobTypes"; +import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList"; + +jest.mock("../../Context/CopyJobContext", () => { + const actual = jest.requireActual("../../Context/CopyJobContext"); + return { + __esModule: true, + ...actual, + default: actual.default, + useCopyJobContext: jest.fn(), + }; +}); + +jest.mock("../Screens/AssignPermissions/AssignPermissions", () => { + const MockAssignPermissions = () => { + return
AssignPermissions
; + }; + MockAssignPermissions.displayName = "MockAssignPermissions"; + return MockAssignPermissions; +}); + +jest.mock("../Screens/CreateContainer/AddCollectionPanelWrapper", () => { + const MockAddCollectionPanelWrapper = () => { + return
AddCollectionPanelWrapper
; + }; + MockAddCollectionPanelWrapper.displayName = "MockAddCollectionPanelWrapper"; + return MockAddCollectionPanelWrapper; +}); + +jest.mock("../Screens/PreviewCopyJob/PreviewCopyJob", () => { + const MockPreviewCopyJob = () => { + return
PreviewCopyJob
; + }; + MockPreviewCopyJob.displayName = "MockPreviewCopyJob"; + return MockPreviewCopyJob; +}); + +jest.mock("../Screens/SelectAccount/SelectAccount", () => { + const MockSelectAccount = () => { + return
SelectAccount
; + }; + MockSelectAccount.displayName = "MockSelectAccount"; + return MockSelectAccount; +}); + +jest.mock("../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers", () => { + const MockSelectSourceAndTargetContainers = () => { + return
SelectSourceAndTargetContainers
; + }; + MockSelectSourceAndTargetContainers.displayName = "MockSelectSourceAndTargetContainers"; + return MockSelectSourceAndTargetContainers; +}); + +const TestHookComponent: React.FC<{ goBack: () => void }> = ({ goBack }) => { + const screens = useCreateCopyJobScreensList(goBack); + + return ( +
+ {screens.map((screen, index) => ( +
+
{screen.key}
+
{screen.component}
+
+ {JSON.stringify(screen.validations.map((v) => v.message))} +
+
+ ))} +
+ ); +}; + +describe("useCreateCopyJobScreensList", () => { + const mockExplorer = {} as Explorer; + const mockGoBack = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + (useCopyJobContext as jest.Mock).mockReturnValue({ + explorer: mockExplorer, + }); + }); + + const renderWithContext = (component: React.ReactElement) => { + return render({component}); + }; + + describe("Hook behavior", () => { + it("should return screens list with correct keys and components", () => { + renderWithContext(); + + expect(screen.getByTestId("test-hook-component")).toBeInTheDocument(); + expect(screen.getByTestId("screen-key-0")).toHaveTextContent(SCREEN_KEYS.SelectAccount); + expect(screen.getByTestId("screen-key-1")).toHaveTextContent(SCREEN_KEYS.SelectSourceAndTargetContainers); + expect(screen.getByTestId("screen-key-2")).toHaveTextContent(SCREEN_KEYS.CreateCollection); + expect(screen.getByTestId("screen-key-3")).toHaveTextContent(SCREEN_KEYS.PreviewCopyJob); + expect(screen.getByTestId("screen-key-4")).toHaveTextContent(SCREEN_KEYS.AssignPermissions); + + expect(screen.getByTestId("select-account")).toBeInTheDocument(); + expect(screen.getByTestId("select-source-target")).toBeInTheDocument(); + expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument(); + expect(screen.getByTestId("preview-copy-job")).toBeInTheDocument(); + expect(screen.getByTestId("assign-permissions")).toBeInTheDocument(); + }); + + it("should return exactly 5 screens in the correct order", () => { + renderWithContext(); + + const screens = screen.getAllByTestId(/screen-\d+/); + expect(screens).toHaveLength(5); + }); + + it("should memoize results based on explorer dependency", () => { + const { rerender } = renderWithContext(); + const initialScreens = screen.getAllByTestId(/screen-key-\d+/).map((el) => el.textContent); + rerender( + + + , + ); + + const rerenderScreens = screen.getAllByTestId(/screen-key-\d+/).map((el) => el.textContent); + expect(rerenderScreens).toEqual(initialScreens); + }); + }); + + describe("Screen validations", () => { + describe("SelectAccount screen validation", () => { + it("should validate subscription and account presence", () => { + renderWithContext(); + + const validationMessages = JSON.parse(screen.getByTestId("screen-validations-0").textContent || "[]"); + expect(validationMessages).toContain("Please select a subscription and account to proceed"); + }); + + it("should pass validation when subscription and account are present", () => { + const mockState: CopyJobContextState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: { subscriptionId: "test-sub" } as any, + account: { name: "test-account" } as any, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "", + containerId: "", + }, + }; + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const selectAccountScreen = screens.find((s) => s.key === SCREEN_KEYS.SelectAccount); + const isValid = selectAccountScreen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("valid"); + }); + + it("should fail validation when subscription is missing", () => { + const mockState: CopyJobContextState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null as any, + account: { name: "test-account" } as any, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "", + containerId: "", + }, + }; + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const selectAccountScreen = screens.find((s) => s.key === SCREEN_KEYS.SelectAccount); + const isValid = selectAccountScreen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid"); + }); + }); + + describe("SelectSourceAndTargetContainers screen validation", () => { + it("should validate source and target containers", () => { + renderWithContext(); + + const validationMessages = JSON.parse(screen.getByTestId("screen-validations-1").textContent || "[]"); + expect(validationMessages).toContain("Please select source and target containers to proceed"); + }); + + it("should pass validation when all required fields are present", () => { + const mockState: CopyJobContextState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null as any, + account: null as any, + databaseId: "source-db", + containerId: "source-container", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "target-db", + containerId: "target-container", + }, + }; + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.SelectSourceAndTargetContainers); + const isValid = screen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("valid"); + }); + + it("should fail validation when source database is missing", () => { + const mockState: CopyJobContextState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null as any, + account: null as any, + databaseId: "", + containerId: "source-container", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "target-db", + containerId: "target-container", + }, + }; + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.SelectSourceAndTargetContainers); + const isValid = screen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid"); + }); + }); + + describe("CreateCollection screen", () => { + it("should have no validations", () => { + renderWithContext(); + + const validationMessages = JSON.parse(screen.getByTestId("screen-validations-2").textContent || "[]"); + expect(validationMessages).toEqual([]); + }); + }); + + describe("PreviewCopyJob screen validation", () => { + it("should validate job name format", () => { + renderWithContext(); + + const validationMessages = JSON.parse(screen.getByTestId("screen-validations-3").textContent || "[]"); + expect(validationMessages).toContain("Please enter a job name to proceed"); + }); + + it("should pass validation with valid job name", () => { + const mockState: CopyJobContextState = { + jobName: "valid-job-name_123", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null as any, + account: null as any, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "", + containerId: "", + }, + }; + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.PreviewCopyJob); + const isValid = screen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("valid"); + }); + + it("should fail validation with invalid job name characters", () => { + const mockState: CopyJobContextState = { + jobName: "invalid job name with spaces!", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null as any, + account: null as any, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "", + containerId: "", + }, + }; + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.PreviewCopyJob); + const isValid = screen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid"); + }); + + it("should fail validation with empty job name", () => { + const mockState: CopyJobContextState = { + jobName: "", + migrationType: CopyJobMigrationType.Offline, + source: { + subscription: null as any, + account: null as any, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "", + containerId: "", + }, + }; + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.PreviewCopyJob); + const isValid = screen?.validations[0]?.validate(mockState); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid"); + }); + }); + + describe("AssignPermissions screen validation", () => { + it("should validate cache values", () => { + renderWithContext(); + + const validationMessages = JSON.parse(screen.getByTestId("screen-validations-4").textContent || "[]"); + expect(validationMessages).toContain("Please ensure all previous steps are valid to proceed"); + }); + + it("should pass validation when all cache values are true", () => { + const mockCache = new Map([ + ["step1", true], + ["step2", true], + ["step3", true], + ]); + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.AssignPermissions); + const isValid = screen?.validations[0]?.validate(mockCache); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("valid"); + }); + + it("should fail validation when cache is empty", () => { + const mockCache = new Map(); + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.AssignPermissions); + const isValid = screen?.validations[0]?.validate(mockCache); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid"); + }); + + it("should fail validation when any cache value is false", () => { + const mockCache = new Map([ + ["step1", true], + ["step2", false], + ["step3", true], + ]); + + const ValidationTestComponent = () => { + const screens = useCreateCopyJobScreensList(mockGoBack); + const screen = screens.find((s) => s.key === SCREEN_KEYS.AssignPermissions); + const isValid = screen?.validations[0]?.validate(mockCache); + + return
{isValid ? "valid" : "invalid"}
; + }; + + renderWithContext(); + expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid"); + }); + }); + }); + + describe("SCREEN_KEYS constant", () => { + it("should export correct screen keys", () => { + expect(SCREEN_KEYS.CreateCollection).toBe("CreateCollection"); + expect(SCREEN_KEYS.SelectAccount).toBe("SelectAccount"); + expect(SCREEN_KEYS.SelectSourceAndTargetContainers).toBe("SelectSourceAndTargetContainers"); + expect(SCREEN_KEYS.PreviewCopyJob).toBe("PreviewCopyJob"); + expect(SCREEN_KEYS.AssignPermissions).toBe("AssignPermissions"); + }); + }); + + describe("Component props", () => { + it("should pass explorer to AddCollectionPanelWrapper", () => { + renderWithContext(); + expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument(); + }); + + it("should pass goBack function to AddCollectionPanelWrapper", () => { + renderWithContext(); + expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument(); + }); + }); + + describe("Error handling", () => { + it("should handle context provider error gracefully", () => { + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + + (useCopyJobContext as jest.Mock).mockImplementation(() => { + throw new Error("Context not found"); + }); + + expect(() => { + render(); + }).toThrow("Context not found"); + + consoleError.mockRestore(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx index acb17f602..0b5283558 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx @@ -8,11 +8,11 @@ import SelectAccount from "../Screens/SelectAccount/SelectAccount"; import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers"; const SCREEN_KEYS = { - CreateCollection: "CreateCollection", SelectAccount: "SelectAccount", - SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers", - PreviewCopyJob: "PreviewCopyJob", AssignPermissions: "AssignPermissions", + SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers", + CreateCollection: "CreateCollection", + PreviewCopyJob: "PreviewCopyJob", }; type Validation = { diff --git a/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts b/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts index 9be43bcc8..10548f05f 100644 --- a/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts +++ b/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts @@ -11,6 +11,7 @@ export enum IdentityType { export enum DefaultIdentityType { SystemAssignedIdentity = "systemassignedidentity", + FirstPartyIdentity = "FirstPartyIdentity", } export enum BackupPolicyType { diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx new file mode 100644 index 000000000..f8cad1cd5 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx @@ -0,0 +1,611 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums"; +import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; +import CopyJobActionMenu from "./CopyJobActionMenu"; + +jest.mock("../../ContainerCopyMessages", () => ({ + __esModule: true, + default: { + MonitorJobs: { + Columns: { + actions: "Actions", + }, + Actions: { + pause: "Pause", + resume: "Resume", + cancel: "Cancel", + complete: "Complete", + }, + }, + }, +})); + +describe("CopyJobActionMenu", () => { + const createMockJob = (overrides: Partial = {}): CopyJobType => + ({ + ID: "test-job-id", + Mode: CopyJobMigrationType.Offline, + Name: "Test Job", + Status: CopyJobStatusType.InProgress, + CompletionPercentage: 50, + Duration: "00:10:30", + LastUpdatedTime: "2025-01-01T10:00:00Z", + timestamp: Date.now(), + Source: { + databaseName: "sourceDb", + collectionName: "sourceContainer", + component: "source", + }, + Destination: { + databaseName: "targetDb", + collectionName: "targetContainer", + component: "destination", + }, + ...overrides, + }) as CopyJobType; + + const mockHandleClick: HandleJobActionClickType = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Component Rendering", () => { + it("should render the action menu button for active jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + expect(actionButton).toBeInTheDocument(); + expect(actionButton).toHaveAttribute("aria-label", "Actions"); + expect(actionButton).toHaveAttribute("title", "Actions"); + }); + + it("should not render anything for completed jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Completed }); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("should not render anything for cancelled jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Cancelled }); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("should not render anything for failed jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Failed }); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("should not render anything for faulted jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Faulted }); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + }); + + describe("Menu Items for Different Job Statuses", () => { + it("should show pause and cancel actions for InProgress jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Resume")).not.toBeInTheDocument(); + }); + + it("should show resume and cancel actions for Paused jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Paused }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Resume")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Pause")).not.toBeInTheDocument(); + }); + + it("should show pause and cancel actions for Pending jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Pending }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Resume")).not.toBeInTheDocument(); + }); + + it("should show only resume action for Skipped jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Skipped }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Resume")).toBeInTheDocument(); + expect(screen.queryByText("Pause")).not.toBeInTheDocument(); + expect(screen.queryByText("Cancel")).not.toBeInTheDocument(); + }); + + it("should show pause and cancel actions for Running jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Running }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Resume")).not.toBeInTheDocument(); + }); + + it("should show pause and cancel actions for Partitioning jobs", () => { + const job = createMockJob({ Status: CopyJobStatusType.Partitioning }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Resume")).not.toBeInTheDocument(); + }); + }); + + describe("Online Mode Complete Action", () => { + it("should show complete action for online InProgress jobs", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + + it("should show complete action for online Running jobs", () => { + const job = createMockJob({ + Status: CopyJobStatusType.Running, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + it("should show complete action for online Partitioning jobs", () => { + const job = createMockJob({ + Status: CopyJobStatusType.Partitioning, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + it("should not show complete action for offline jobs", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: CopyJobMigrationType.Offline, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.queryByText("Complete")).not.toBeInTheDocument(); + }); + + it("should handle case-insensitive online mode detection", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: "ONLINE", + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + }); + + describe("Action Click Handling", () => { + it("should call handleClick when pause action is clicked", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const pauseButton = screen.getByText("Pause"); + fireEvent.click(pauseButton); + + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function)); + }); + + it("should call handleClick when cancel action is clicked", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const cancelButton = screen.getByText("Cancel"); + fireEvent.click(cancelButton); + + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function)); + }); + + it("should call handleClick when resume action is clicked", () => { + const job = createMockJob({ Status: CopyJobStatusType.Paused }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const resumeButton = screen.getByText("Resume"); + fireEvent.click(resumeButton); + + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.resume, expect.any(Function)); + }); + + it("should call handleClick when complete action is clicked", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const completeButton = screen.getByText("Complete"); + fireEvent.click(completeButton); + + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function)); + }); + }); + + describe("Disabled States During Updates", () => { + const TestComponentWrapper: React.FC<{ + job: CopyJobType; + initialUpdatingState?: { jobName: string; action: string } | null; + }> = ({ job, initialUpdatingState = null }) => { + const stateUpdater = React.useState(initialUpdatingState); + const setUpdatingJobAction = stateUpdater[1]; + + const testHandleClick: HandleJobActionClickType = (job, action, setUpdatingJobActionCallback) => { + setUpdatingJobActionCallback({ jobName: job.Name, action }); + setUpdatingJobAction({ jobName: job.Name, action }); + }; + + return ; + }; + + it("should disable pause action when job is being paused", async () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const pauseButton = screen.getByText("Pause"); + fireEvent.click(pauseButton); + fireEvent.click(actionButton); + const pauseButtonAfterClick = screen.getByText("Pause"); + expect(pauseButtonAfterClick).toBeInTheDocument(); + }); + + it("should not disable actions for different jobs when one is updating", () => { + const job1 = createMockJob({ Name: "Job1", Status: CopyJobStatusType.InProgress }); + const job2 = createMockJob({ Name: "Job2", Status: CopyJobStatusType.InProgress }); + + const { rerender } = render(); + let actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + fireEvent.click(screen.getByText("Pause")); + rerender(); + + actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + + it("should properly handle multiple action types being disabled for the same job", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + render(); + const actionButton = screen.getByRole("button", { name: "Actions" }); + + fireEvent.click(actionButton); + fireEvent.click(screen.getByText("Pause")); + + fireEvent.click(actionButton); + fireEvent.click(screen.getByText("Cancel")); + + fireEvent.click(actionButton); + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + + it("should handle complete action disabled state for online jobs", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const completeButton = screen.getByText("Complete"); + fireEvent.click(completeButton); + + fireEvent.click(actionButton); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + }); + + describe("Edge Cases", () => { + it("should handle undefined mode gracefully", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: undefined as any, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.queryByText("Complete")).not.toBeInTheDocument(); + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + + it("should handle null mode gracefully", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: null as any, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.queryByText("Complete")).not.toBeInTheDocument(); + }); + + it("should handle empty string mode gracefully", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: "", + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.queryByText("Complete")).not.toBeInTheDocument(); + }); + + it("should return all base items for unknown status", () => { + const job = createMockJob({ Status: "UnknownStatus" as any }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.getByText("Resume")).toBeInTheDocument(); + }); + }); + + describe("Icon and Accessibility", () => { + it("should have correct icon and accessibility attributes", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + render(); + const actionButton = screen.getByRole("button", { name: "Actions" }); + + expect(actionButton).toHaveAttribute("aria-label", "Actions"); + expect(actionButton).toHaveAttribute("title", "Actions"); + + const moreIcon = actionButton.querySelector('[data-icon-name="More"]'); + expect(moreIcon || actionButton).toBeInTheDocument(); + }); + + it("should have correct menu item icons", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + render(); + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + }); + + describe("Component State Management", () => { + it("should manage updating job action state correctly", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + const mockHandleClickWithState: HandleJobActionClickType = jest.fn((job, action, setUpdatingJobAction) => { + setUpdatingJobAction({ jobName: job.Name, action }); + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const pauseButton = screen.getByText("Pause"); + fireEvent.click(pauseButton); + + expect(mockHandleClickWithState).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function)); + }); + + it("should handle rapid successive clicks properly", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + const pauseButton = screen.getByText("Pause"); + fireEvent.click(pauseButton); + + fireEvent.click(actionButton); + const pauseButton2 = screen.getByText("Pause"); + fireEvent.click(pauseButton2); + + fireEvent.click(actionButton); + const pauseButton3 = screen.getByText("Pause"); + fireEvent.click(pauseButton3); + + expect(mockHandleClick).toHaveBeenCalledTimes(3); + }); + }); + + describe("Integration Tests", () => { + it("should work correctly with different job names", () => { + const jobWithLongName = createMockJob({ + Name: "Very Long Job Name That Might Cause UI Issues", + Status: CopyJobStatusType.InProgress, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const pauseButton = screen.getByText("Pause"); + fireEvent.click(pauseButton); + + expect(mockHandleClick).toHaveBeenCalledWith(jobWithLongName, CopyJobActions.pause, expect.any(Function)); + }); + + it("should handle special characters in job names", () => { + const jobWithSpecialChars = createMockJob({ + Name: "Job-Name_With$pecial#Characters!@", + Status: CopyJobStatusType.Paused, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const resumeButton = screen.getByText("Resume"); + fireEvent.click(resumeButton); + + expect(mockHandleClick).toHaveBeenCalledWith(jobWithSpecialChars, CopyJobActions.resume, expect.any(Function)); + }); + + it("should maintain consistent behavior across re-renders", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + const { rerender } = render(); + + let actionButton = screen.getByRole("button", { name: "Actions" }); + expect(actionButton).toBeInTheDocument(); + + rerender(); + + actionButton = screen.getByRole("button", { name: "Actions" }); + expect(actionButton).toBeInTheDocument(); + + fireEvent.click(actionButton); + expect(screen.getByText("Pause")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + }); + + it("should handle prop changes correctly", () => { + const job1 = createMockJob({ Status: CopyJobStatusType.InProgress }); + const job2 = createMockJob({ Status: CopyJobStatusType.Paused }); + + const { rerender } = render(); + + let actionButton = screen.getByRole("button", { name: "Actions" }); + expect(actionButton).toBeInTheDocument(); + + rerender(); + + actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + expect(screen.getByText("Resume")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Pause")).not.toBeInTheDocument(); + }); + }); + + describe("Performance and Memory", () => { + it("should not create memory leaks with multiple renders", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + const { unmount } = render(); + expect(() => unmount()).not.toThrow(); + }); + + it("should handle null/undefined props gracefully", () => { + const incompleteJob = { + ...createMockJob({ Status: CopyJobStatusType.InProgress }), + Name: undefined as any, + }; + + expect(() => { + render(); + }).not.toThrow(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobColumns.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobColumns.test.tsx new file mode 100644 index 000000000..0dc436e15 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobColumns.test.tsx @@ -0,0 +1,449 @@ +import { IColumn } from "@fluentui/react"; +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import React from "react"; +import ContainerCopyMessages from "../../ContainerCopyMessages"; +import { CopyJobStatusType } from "../../Enums/CopyJobEnums"; +import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; +import { getColumns } from "./CopyJobColumns"; + +jest.mock("./CopyJobActionMenu", () => { + const MockCopyJobActionMenu = ({ job }: { job: CopyJobType }) => { + return
Action Menu
; + }; + MockCopyJobActionMenu.displayName = "MockCopyJobActionMenu"; + return MockCopyJobActionMenu; +}); + +jest.mock("./CopyJobStatusWithIcon", () => { + const MockCopyJobStatusWithIcon = ({ status }: { status: CopyJobStatusType }) => { + return
Status: {status}
; + }; + MockCopyJobStatusWithIcon.displayName = "MockCopyJobStatusWithIcon"; + return MockCopyJobStatusWithIcon; +}); + +describe("CopyJobColumns", () => { + type OnColumnClickType = IColumn & { onColumnClick: () => void }; + const mockHandleSort = jest.fn(); + const mockHandleActionClick: HandleJobActionClickType = jest.fn(); + + const mockJob = { + ID: "test-job-id", + Mode: "Online", + Name: "Test Job Name", + Status: CopyJobStatusType.InProgress, + CompletionPercentage: 75, + Duration: "00:05:30", + LastUpdatedTime: "2024-12-01T10:30:00Z", + timestamp: 1701426600000, + Source: { + databaseName: "test-source-db", + containerName: "test-source-container", + component: "CosmosDBSql", + }, + Destination: { + databaseName: "test-dest-db", + containerName: "test-dest-container", + component: "CosmosDBSql", + }, + } as CopyJobType; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("getColumns", () => { + it("should return an array of IColumn objects", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + expect(columns).toBeDefined(); + expect(Array.isArray(columns)).toBe(true); + expect(columns.length).toBe(6); + + columns.forEach((column: IColumn) => { + expect(column).toHaveProperty("key"); + expect(column).toHaveProperty("name"); + expect(column).toHaveProperty("minWidth"); + expect(column).toHaveProperty("maxWidth"); + expect(column).toHaveProperty("isResizable"); + }); + }); + + it("should have correct column keys", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + const expectedKeys = ["LastUpdatedTime", "Name", "Mode", "CompletionPercentage", "CopyJobStatus", "Actions"]; + const actualKeys = columns.map((column) => column.key); + + expect(actualKeys).toEqual(expectedKeys); + }); + + it("should have correct column names from ContainerCopyMessages", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + expect(columns[0].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime); + expect(columns[1].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.name); + expect(columns[2].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.mode); + expect(columns[3].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.completionPercentage); + expect(columns[4].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.status); + expect(columns[5].name).toBe(""); + }); + + it("should configure sortable columns correctly when no sort is applied", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + expect(columns[0].isSorted).toBe(false); // LastUpdatedTime + expect(columns[1].isSorted).toBe(false); // Name + expect(columns[2].isSorted).toBe(false); // Mode + expect(columns[3].isSorted).toBe(false); // CompletionPercentage + expect(columns[4].isSorted).toBe(false); // CopyJobStatus + }); + + it("should configure sorted column correctly when sort is applied", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "Name", true); + + expect(columns[1].isSorted).toBe(true); + expect(columns[1].isSortedDescending).toBe(true); + + expect(columns[0].isSorted).toBe(false); + expect(columns[2].isSorted).toBe(false); + expect(columns[3].isSorted).toBe(false); + expect(columns[4].isSorted).toBe(false); + }); + + it("should handle timestamp sorting for LastUpdatedTime column", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "timestamp", false); + + expect(columns[0].isSorted).toBe(true); + expect(columns[0].isSortedDescending).toBe(false); + }); + + it("should call handleSort with correct column keys when column headers are clicked", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + (columns[0] as OnColumnClickType).onColumnClick?.(); + expect(mockHandleSort).toHaveBeenCalledWith("timestamp"); + + (columns[1] as OnColumnClickType).onColumnClick(); + expect(mockHandleSort).toHaveBeenCalledWith("Name"); + + (columns[2] as OnColumnClickType).onColumnClick(); + expect(mockHandleSort).toHaveBeenCalledWith("Mode"); + + (columns[3] as OnColumnClickType).onColumnClick(); + expect(mockHandleSort).toHaveBeenCalledWith("CompletionPercentage"); + + (columns[4] as OnColumnClickType).onColumnClick(); + expect(mockHandleSort).toHaveBeenCalledWith("Status"); + + expect(mockHandleSort).toHaveBeenCalledTimes(5); + }); + + it("should have correct column widths and resizability", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + expect(columns[0].minWidth).toBe(140); // LastUpdatedTime + expect(columns[0].maxWidth).toBe(300); + expect(columns[0].isResizable).toBe(true); + + expect(columns[1].minWidth).toBe(140); // Name + expect(columns[1].maxWidth).toBe(300); + expect(columns[1].isResizable).toBe(true); + + expect(columns[2].minWidth).toBe(90); // Mode + expect(columns[2].maxWidth).toBe(200); + expect(columns[2].isResizable).toBe(true); + + expect(columns[3].minWidth).toBe(110); // CompletionPercentage + expect(columns[3].maxWidth).toBe(200); + expect(columns[3].isResizable).toBe(true); + + expect(columns[4].minWidth).toBe(130); // CopyJobStatus + expect(columns[4].maxWidth).toBe(200); + expect(columns[4].isResizable).toBe(true); + + expect(columns[5].minWidth).toBe(80); // Actions + expect(columns[5].maxWidth).toBe(200); + expect(columns[5].isResizable).toBe(true); + }); + }); + + describe("Column Render Functions", () => { + let columns: IColumn[]; + + beforeEach(() => { + columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + }); + + describe("Name column render function", () => { + it("should render job name with correct styling", () => { + const nameColumn = columns.find((col) => col.key === "Name"); + expect(nameColumn?.onRender).toBeDefined(); + + const rendered = nameColumn?.onRender?.(mockJob); + const { container } = render(
{rendered}
); + + const jobNameElement = container.querySelector(".jobNameLink"); + expect(jobNameElement).toBeInTheDocument(); + expect(jobNameElement).toHaveTextContent("Test Job Name"); + }); + + it("should handle empty job name", () => { + const nameColumn = columns.find((col) => col.key === "Name"); + const jobWithEmptyName = { ...mockJob, Name: "" }; + + const rendered = nameColumn?.onRender?.(jobWithEmptyName); + const { container } = render(
{rendered}
); + + const jobNameElement = container.querySelector(".jobNameLink"); + expect(jobNameElement).toBeInTheDocument(); + expect(jobNameElement).toHaveTextContent(""); + }); + + it("should handle special characters in job name", () => { + const nameColumn = columns.find((col) => col.key === "Name"); + const jobWithSpecialName = { ...mockJob, Name: "Test & 'Name' \"With\" Special Characters" }; + + const rendered = nameColumn?.onRender?.(jobWithSpecialName); + const { container } = render(
{rendered}
); + + const jobNameElement = container.querySelector(".jobNameLink"); + expect(jobNameElement).toBeInTheDocument(); + expect(jobNameElement).toHaveTextContent("Test & 'Name' \"With\" Special Characters"); + }); + }); + + describe("CompletionPercentage column render function", () => { + it("should render completion percentage with % symbol", () => { + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + expect(completionColumn?.onRender).toBeDefined(); + + const result = completionColumn?.onRender?.(mockJob); + expect(result).toBe("75%"); + }); + + it("should handle 0% completion", () => { + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + const jobWithZeroCompletion = { ...mockJob, CompletionPercentage: 0 }; + + const result = completionColumn?.onRender?.(jobWithZeroCompletion); + expect(result).toBe("0%"); + }); + + it("should handle 100% completion", () => { + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + const jobWithFullCompletion = { ...mockJob, CompletionPercentage: 100 }; + + const result = completionColumn?.onRender?.(jobWithFullCompletion); + expect(result).toBe("100%"); + }); + + it("should handle decimal completion percentages", () => { + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + const jobWithDecimalCompletion = { ...mockJob, CompletionPercentage: 75.5 }; + + const result = completionColumn?.onRender?.(jobWithDecimalCompletion); + expect(result).toBe("75.5%"); + }); + + it("should handle negative completion percentages", () => { + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + const jobWithNegativeCompletion = { ...mockJob, CompletionPercentage: -5 }; + + const result = completionColumn?.onRender?.(jobWithNegativeCompletion); + expect(result).toBe("-5%"); + }); + }); + + describe("CopyJobStatus column render function", () => { + it("should render CopyJobStatusWithIcon component", () => { + const statusColumn = columns.find((col) => col.key === "CopyJobStatus"); + expect(statusColumn?.onRender).toBeDefined(); + + const rendered = statusColumn?.onRender?.(mockJob); + const { container } = render(
{rendered}
); + + const statusIcon = container.querySelector(`[data-testid="status-icon-${mockJob.Status}"]`); + expect(statusIcon).toBeInTheDocument(); + expect(statusIcon).toHaveTextContent(`Status: ${mockJob.Status}`); + }); + + it("should handle different job statuses", () => { + const statusColumn = columns.find((col) => col.key === "CopyJobStatus"); + + Object.values(CopyJobStatusType).forEach((status) => { + const jobWithStatus = { ...mockJob, Status: status }; + const rendered = statusColumn?.onRender?.(jobWithStatus); + const { container } = render(
{rendered}
); + + const statusIcon = container.querySelector(`[data-testid="status-icon-${status}"]`); + expect(statusIcon).toBeInTheDocument(); + }); + }); + }); + + describe("Actions column render function", () => { + it("should render CopyJobActionMenu component", () => { + const actionsColumn = columns.find((col) => col.key === "Actions"); + expect(actionsColumn?.onRender).toBeDefined(); + + const rendered = actionsColumn?.onRender?.(mockJob); + const { container } = render(
{rendered}
); + + const actionMenu = container.querySelector(`[data-testid="action-menu-${mockJob.Name}"]`); + expect(actionMenu).toBeInTheDocument(); + expect(actionMenu).toHaveTextContent("Action Menu"); + }); + + it("should pass correct props to CopyJobActionMenu", () => { + const actionsColumn = columns.find((col) => col.key === "Actions"); + const rendered = actionsColumn?.onRender?.(mockJob); + + expect(rendered).toBeDefined(); + expect(React.isValidElement(rendered)).toBe(true); + }); + }); + }); + + describe("Column Field Names", () => { + it("should have correct fieldName properties", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + expect(columns[0].fieldName).toBe("LastUpdatedTime"); + expect(columns[1].fieldName).toBe("Name"); + expect(columns[2].fieldName).toBe("Mode"); + expect(columns[3].fieldName).toBe("CompletionPercentage"); + expect(columns[4].fieldName).toBe("Status"); + expect(columns[5].fieldName).toBeUndefined(); // Actions column doesn't have fieldName + }); + }); + + describe("Different Sort Configurations", () => { + it("should handle ascending sort", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "Name", false); + + const nameColumn = columns.find((col) => col.key === "Name"); + expect(nameColumn?.isSorted).toBe(true); + expect(nameColumn?.isSortedDescending).toBe(false); + }); + + it("should handle descending sort", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "Mode", true); + + const modeColumn = columns.find((col) => col.key === "Mode"); + expect(modeColumn?.isSorted).toBe(true); + expect(modeColumn?.isSortedDescending).toBe(true); + }); + + it("should handle sort on CompletionPercentage column", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "CompletionPercentage", false); + + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + expect(completionColumn?.isSorted).toBe(true); + expect(completionColumn?.isSortedDescending).toBe(false); + + const nameColumn = columns.find((col) => col.key === "Name"); + expect(nameColumn?.isSorted).toBe(false); + }); + + it("should handle sort on Status column", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "Status", true); + + const statusColumn = columns.find((col) => col.key === "CopyJobStatus"); + expect(statusColumn?.isSorted).toBe(true); + expect(statusColumn?.isSortedDescending).toBe(true); + }); + }); + + describe("Edge Cases", () => { + it("should handle undefined sortedColumnKey", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + const sortableColumns = columns.filter((col) => col.key !== "Actions"); + sortableColumns.forEach((column) => { + expect(column.isSorted).toBe(false); + }); + }); + + it("should handle null job object in render functions gracefully", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + const nameColumn = columns.find((col) => col.key === "Name"); + expect(() => { + nameColumn?.onRender?.(null as any); + }).toThrow(); + + const completionColumn = columns.find((col) => col.key === "CompletionPercentage"); + expect(() => { + completionColumn?.onRender?.(null as any); + }).toThrow(); + }); + + it("should handle job object with missing properties", () => { + const incompleteJob = { + Name: "Incomplete Job", + } as CopyJobType; + + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + const nameColumn = columns.find((col) => col.key === "Name"); + const rendered = nameColumn?.onRender?.(incompleteJob); + const { container } = render(
{rendered}
); + + const jobNameElement = container.querySelector(".jobNameLink"); + expect(jobNameElement).toHaveTextContent("Incomplete Job"); + }); + + it("should handle unknown sortedColumnKey", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, "UnknownColumn", false); + + const sortableColumns = columns.filter((col) => col.key !== "Actions"); + sortableColumns.forEach((column) => { + expect(column.isSorted).toBe(false); + }); + }); + }); + + describe("Accessibility", () => { + it("should have Actions column without name for accessibility", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + const actionsColumn = columns.find((col) => col.key === "Actions"); + expect(actionsColumn?.name).toBe(""); + }); + + it("should maintain column structure for screen readers", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + const columnsWithNames = columns.filter((col) => col.key !== "Actions"); + columnsWithNames.forEach((column) => { + expect(column.name).toBeTruthy(); + expect(typeof column.name).toBe("string"); + expect(column.name.length).toBeGreaterThan(0); + }); + }); + }); + + describe("Function References", () => { + it("should maintain function reference stability", () => { + const columns1 = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + const columns2 = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + + (columns1[0] as OnColumnClickType).onColumnClick?.(); + (columns2[0] as OnColumnClickType).onColumnClick?.(); + + expect(mockHandleSort).toHaveBeenCalledTimes(2); + expect(mockHandleSort).toHaveBeenNthCalledWith(1, "timestamp"); + expect(mockHandleSort).toHaveBeenNthCalledWith(2, "timestamp"); + }); + + it("should call handleActionClick when action menu is rendered", () => { + const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false); + const actionsColumn = columns.find((col) => col.key === "Actions"); + + const rendered = actionsColumn?.onRender?.(mockJob); + expect(React.isValidElement(rendered)).toBe(true); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.test.tsx new file mode 100644 index 000000000..1aa57ebca --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.test.tsx @@ -0,0 +1,383 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { CopyJobStatusType } from "../../Enums/CopyJobEnums"; +import { CopyJobType } from "../../Types/CopyJobTypes"; +import CopyJobDetails from "./CopyJobDetails"; + +jest.mock("./CopyJobStatusWithIcon", () => { + const MockCopyJobStatusWithIcon = ({ status }: { status: CopyJobStatusType }) => { + return {status}; + }; + MockCopyJobStatusWithIcon.displayName = "MockCopyJobStatusWithIcon"; + return MockCopyJobStatusWithIcon; +}); + +jest.mock("../../ContainerCopyMessages", () => ({ + errorTitle: "Error Details", + sourceDatabaseLabel: "Source Database", + sourceContainerLabel: "Source Container", + targetDatabaseLabel: "Destination Database", + targetContainerLabel: "Destination Container", + sourceAccountLabel: "Source Account", + MonitorJobs: { + Columns: { + lastUpdatedTime: "Date & time", + status: "Status", + mode: "Mode", + }, + }, +})); + +describe("CopyJobDetails", () => { + const mockBasicJob: CopyJobType = { + ID: "test-job-1", + Mode: "Offline", + Name: "test-job-1", + Status: CopyJobStatusType.InProgress, + CompletionPercentage: 50, + Duration: "10 minutes", + LastUpdatedTime: "2024-01-01T10:00:00Z", + timestamp: 1704110400000, + Source: { + component: "CosmosDBSql", + databaseName: "sourceDb", + containerName: "sourceContainer", + remoteAccountName: "sourceAccount", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "targetDb", + containerName: "targetContainer", + remoteAccountName: "targetAccount", + }, + }; + + const mockJobWithError: CopyJobType = { + ...mockBasicJob, + ID: "test-job-error", + Status: CopyJobStatusType.Failed, + Error: { + message: "Failed to connect to source database", + code: "CONNECTION_ERROR", + }, + }; + + const mockJobWithNullValues: CopyJobType = { + ...mockBasicJob, + ID: "test-job-null", + Source: { + component: "CosmosDBSql", + databaseName: undefined, + containerName: undefined, + remoteAccountName: undefined, + }, + Destination: { + component: "CosmosDBSql", + databaseName: undefined, + containerName: undefined, + remoteAccountName: undefined, + }, + }; + + describe("Basic Rendering", () => { + it("renders the component with correct structure", () => { + render(); + + const container = screen.getByTestId("copy-job-details"); + expect(container).toBeInTheDocument(); + expect(container).toHaveClass("copyJobDetailsContainer"); + }); + + it("displays job details without error when no error exists", () => { + render(); + + expect(screen.queryByTestId("error-stack")).not.toBeInTheDocument(); + expect(screen.getByTestId("selectedcollection-stack")).toBeInTheDocument(); + }); + + it("renders all required job information fields", () => { + render(); + + expect(screen.getByText("Date & time")).toBeInTheDocument(); + expect(screen.getByText("2024-01-01T10:00:00Z")).toBeInTheDocument(); + + expect(screen.getByText("Source Account")).toBeInTheDocument(); + expect(screen.getByText("sourceAccount")).toBeInTheDocument(); + + expect(screen.getByText("Mode")).toBeInTheDocument(); + expect(screen.getByText("Offline")).toBeInTheDocument(); + }); + + it("renders the DetailsList with correct job data", () => { + render(); + + expect(screen.getByText("sourceDb")).toBeInTheDocument(); + expect(screen.getByText("sourceContainer")).toBeInTheDocument(); + + expect(screen.getByText("targetDb")).toBeInTheDocument(); + expect(screen.getByText("targetContainer")).toBeInTheDocument(); + + expect(screen.getByTestId("copy-job-status-icon")).toBeInTheDocument(); + expect(screen.getByTestId("copy-job-status-icon")).toHaveTextContent("InProgress"); + }); + }); + + describe("Error Handling", () => { + it("displays error section when job has error", () => { + render(); + + const errorStack = screen.getByTestId("error-stack"); + expect(errorStack).toBeInTheDocument(); + + expect(screen.getByText("Error Details")).toBeInTheDocument(); + expect(screen.getByText("Failed to connect to source database")).toBeInTheDocument(); + }); + + it("does not display error section when job has no error", () => { + render(); + + expect(screen.queryByTestId("error-stack")).not.toBeInTheDocument(); + expect(screen.queryByText("Error Details")).not.toBeInTheDocument(); + }); + }); + + describe("Null/Undefined Value Handling", () => { + it("displays 'N/A' for null or undefined source values", () => { + render(); + + const nATexts = screen.getAllByText("N/A"); + expect(nATexts).toHaveLength(4); + }); + + it("handles null remote account name gracefully", () => { + render(); + expect(screen.getByTestId("copy-job-details")).toBeInTheDocument(); + }); + + it("handles empty status gracefully", () => { + const jobWithEmptyStatus: CopyJobType = { + ...mockBasicJob, + Status: "" as CopyJobStatusType, + }; + + render(); + + expect(screen.getByTestId("copy-job-status-icon")).toHaveTextContent(""); + }); + }); + + describe("Different Job Statuses", () => { + const statusTestCases = [ + CopyJobStatusType.Pending, + CopyJobStatusType.Running, + CopyJobStatusType.Paused, + CopyJobStatusType.Completed, + CopyJobStatusType.Failed, + CopyJobStatusType.Cancelled, + ]; + + statusTestCases.forEach((status) => { + it(`renders correctly for ${status} status`, () => { + const jobWithStatus: CopyJobType = { + ...mockBasicJob, + Status: status, + }; + + render(); + + expect(screen.getByTestId("copy-job-status-icon")).toHaveTextContent(status); + }); + }); + }); + + describe("Component Memoization", () => { + it("re-renders when job ID changes", () => { + render(); + + expect(screen.getByText(CopyJobStatusType.InProgress)).toBeInTheDocument(); + + const updatedJob: CopyJobType = { + ...mockBasicJob, + Status: CopyJobStatusType.Completed, + }; + + render(); + + expect(screen.getByText(CopyJobStatusType.Completed)).toBeInTheDocument(); + }); + + it("re-renders when error changes", () => { + const { rerender } = render(); + + expect(screen.queryByTestId("error-stack")).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByTestId("error-stack")).toBeInTheDocument(); + }); + + it("does not re-render when other props change but ID and Error stay same", () => { + const jobWithSameIdAndError = { + ...mockBasicJob, + Mode: "Online", + CompletionPercentage: 75, + }; + + const { rerender } = render(); + rerender(); + expect(screen.getByTestId("copy-job-details")).toBeInTheDocument(); + }); + }); + + describe("Data Transformation", () => { + it("correctly transforms job data for DetailsList items", () => { + render(); + expect(screen.getByText("sourceContainer")).toBeInTheDocument(); + expect(screen.getByText("sourceDb")).toBeInTheDocument(); + expect(screen.getByText("targetContainer")).toBeInTheDocument(); + expect(screen.getByText("targetDb")).toBeInTheDocument(); + expect(screen.getByTestId("copy-job-status-icon")).toHaveTextContent("InProgress"); + }); + + it("handles complex job data structure", () => { + const complexJob: CopyJobType = { + ...mockBasicJob, + Source: { + component: "CosmosDBSql", + databaseName: "complex-source-db-with-hyphens", + containerName: "complex_source_container_with_underscores", + remoteAccountName: "complex.source.account", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "complex-target-db-with-hyphens", + containerName: "complex_target_container_with_underscores", + remoteAccountName: "complex.target.account", + }, + }; + + render(); + + expect(screen.getByText("complex-source-db-with-hyphens")).toBeInTheDocument(); + expect(screen.getByText("complex_source_container_with_underscores")).toBeInTheDocument(); + expect(screen.getByText("complex-target-db-with-hyphens")).toBeInTheDocument(); + expect(screen.getByText("complex_target_container_with_underscores")).toBeInTheDocument(); + expect(screen.getByText("complex.source.account")).toBeInTheDocument(); + }); + }); + + describe("DetailsList Configuration", () => { + it("configures DetailsList with correct layout mode", () => { + render(); + expect(screen.getByText("sourceContainer")).toBeInTheDocument(); + }); + + it("renders all expected column data", () => { + render(); + expect(screen.getByText("sourceDb")).toBeInTheDocument(); + expect(screen.getByText("sourceContainer")).toBeInTheDocument(); + expect(screen.getByText("targetDb")).toBeInTheDocument(); + expect(screen.getByText("targetContainer")).toBeInTheDocument(); + expect(screen.getByTestId("copy-job-status-icon")).toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + it("has proper data-testid attributes", () => { + render(); + + expect(screen.getByTestId("copy-job-details")).toBeInTheDocument(); + expect(screen.getByTestId("error-stack")).toBeInTheDocument(); + expect(screen.getByTestId("selectedcollection-stack")).toBeInTheDocument(); + }); + + it("renders semantic HTML structure", () => { + render(); + + const container = screen.getByTestId("copy-job-details"); + expect(container).toBeInTheDocument(); + + const nestedStack = screen.getByTestId("selectedcollection-stack"); + expect(nestedStack).toBeInTheDocument(); + }); + }); + + describe("CSS and Styling", () => { + it("applies correct CSS classes", () => { + render(); + + const container = screen.getByTestId("copy-job-details"); + expect(container).toHaveClass("copyJobDetailsContainer"); + }); + + it("applies correct styling to error text", () => { + render(); + + const errorText = screen.getByText("Failed to connect to source database"); + expect(errorText).toHaveStyle({ whiteSpace: "pre-wrap" }); + }); + + it("applies bold styling to heading texts", () => { + render(); + + const dateTimeHeading = screen.getByText("Date & time"); + const sourceAccountHeading = screen.getByText("Source Account"); + const modeHeading = screen.getByText("Mode"); + + expect(dateTimeHeading).toHaveClass("bold"); + expect(sourceAccountHeading).toHaveClass("bold"); + expect(modeHeading).toHaveClass("bold"); + }); + }); + + describe("Edge Cases", () => { + it("handles job with minimal required data", () => { + const minimalJob = { + ID: "minimal", + Mode: "", + Name: "", + Status: CopyJobStatusType.Pending, + CompletionPercentage: 0, + Duration: "", + LastUpdatedTime: "", + timestamp: 0, + Source: { + component: "CosmosDBSql", + }, + Destination: { + component: "CosmosDBSql", + }, + } as CopyJobType; + + render(); + + expect(screen.getByTestId("copy-job-details")).toBeInTheDocument(); + expect(screen.getAllByText("N/A")).toHaveLength(4); + }); + + it("handles very long text values", () => { + const longTextJob: CopyJobType = { + ...mockBasicJob, + Source: { + ...mockBasicJob.Source, + databaseName: "very-long-database-name-that-might-cause-layout-issues-in-the-ui-component", + containerName: "very-long-container-name-that-might-cause-layout-issues-in-the-ui-component", + remoteAccountName: "very-long-account-name-that-might-cause-layout-issues-in-the-ui-component", + }, + Error: { + message: + "This is a very long error message that contains multiple sentences and might span several lines when displayed in the user interface. It should handle line breaks and maintain readability even with extensive content.", + code: "LONG_ERROR", + }, + }; + + render(); + + expect( + screen.getByText("very-long-database-name-that-might-cause-layout-issues-in-the-ui-component"), + ).toBeInTheDocument(); + expect(screen.getByText(/This is a very long error message/)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.test.tsx new file mode 100644 index 000000000..836ce3643 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.test.tsx @@ -0,0 +1,162 @@ +import "@testing-library/jest-dom"; +import { render } from "@testing-library/react"; +import React from "react"; +import { CopyJobStatusType } from "../../Enums/CopyJobEnums"; +import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon"; + +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; + }, +})); + +describe("CopyJobStatusWithIcon", () => { + describe("Static Icon Status Types - Snapshot Tests", () => { + const staticIconStatuses = [ + CopyJobStatusType.Pending, + CopyJobStatusType.Paused, + CopyJobStatusType.Skipped, + CopyJobStatusType.Cancelled, + CopyJobStatusType.Failed, + CopyJobStatusType.Faulted, + CopyJobStatusType.Completed, + ]; + + test.each(staticIconStatuses)("renders %s status correctly", (status) => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + }); + + describe("Spinner Status Types", () => { + const spinnerStatuses = [CopyJobStatusType.Running, CopyJobStatusType.InProgress, CopyJobStatusType.Partitioning]; + + test.each(spinnerStatuses)("renders %s with spinner and expected text", (status) => { + const { container } = render(); + + const spinner = container.querySelector('[class*="ms-Spinner"]'); + expect(spinner).toBeInTheDocument(); + expect(container).toHaveTextContent("Running"); + expect(container.firstChild).toMatchSnapshot(); + }); + }); + + describe("PropTypes Validation", () => { + it("has correct display name", () => { + expect(CopyJobStatusWithIcon.displayName).toBe("CopyJobStatusWithIcon"); + }); + it("accepts all valid CopyJobStatusType values", () => { + const allStatuses = Object.values(CopyJobStatusType); + + allStatuses.forEach((status) => { + expect(() => { + render(); + }).not.toThrow(); + }); + }); + }); + + describe("Accessibility", () => { + it("provides proper aria-label for icon elements", () => { + const { container } = render(); + + const icon = container.querySelector('[class*="ms-Icon"]'); + expect(icon).toHaveAttribute("aria-label", CopyJobStatusType.Failed); + }); + + it("provides meaningful text content for screen readers", () => { + const { container } = render(); + + expect(container).toHaveTextContent("Running"); + }); + }); + + describe("Icon and Status Mapping", () => { + it("renders correct status text based on mapping", () => { + const statusMappings = [ + { status: CopyJobStatusType.Pending, expectedText: "Queued" }, + { status: CopyJobStatusType.Paused, expectedText: "Paused" }, + { status: CopyJobStatusType.Failed, expectedText: "Failed" }, + { status: CopyJobStatusType.Completed, expectedText: "Completed" }, + { status: CopyJobStatusType.Running, expectedText: "Running" }, + ]; + + statusMappings.forEach(({ status, expectedText }) => { + const { container, unmount } = render(); + expect(container).toHaveTextContent(expectedText); + unmount(); + }); + }); + + it("renders icons for static status types", () => { + const staticStatuses = [ + CopyJobStatusType.Pending, + CopyJobStatusType.Paused, + CopyJobStatusType.Failed, + CopyJobStatusType.Completed, + ]; + + staticStatuses.forEach((status) => { + const { container, unmount } = render(); + const icon = container.querySelector('[class*="ms-Icon"]'); + const spinner = container.querySelector('[class*="ms-Spinner"]'); + + expect(icon).toBeInTheDocument(); + expect(spinner).not.toBeInTheDocument(); + + unmount(); + }); + }); + + it("renders spinners for progress status types", () => { + const progressStatuses = [ + CopyJobStatusType.Running, + CopyJobStatusType.InProgress, + CopyJobStatusType.Partitioning, + ]; + + progressStatuses.forEach((status) => { + const { container, unmount } = render(); + const icon = container.querySelector('[class*="ms-Icon"]'); + const spinner = container.querySelector('[class*="ms-Spinner"]'); + + expect(spinner).toBeInTheDocument(); + expect(icon).not.toBeInTheDocument(); + + unmount(); + }); + }); + }); + + describe("Performance", () => { + it("does not cause unnecessary re-renders with same props", () => { + const renderSpy = jest.fn(); + const TestWrapper = ({ status }: { status: CopyJobStatusType }) => { + renderSpy(); + return ; + }; + + const { rerender } = render(); + expect(renderSpy).toHaveBeenCalledTimes(1); + + rerender(); + expect(renderSpy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.test.tsx new file mode 100644 index 000000000..d81f64289 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.test.tsx @@ -0,0 +1,73 @@ +jest.mock("../../Actions/CopyJobActions"); + +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import Explorer from "Explorer/Explorer"; +import React from "react"; +import * as Actions from "../../Actions/CopyJobActions"; +import ContainerCopyMessages from "../../ContainerCopyMessages"; +import CopyJobsNotFound from "./CopyJobs.NotFound"; + +describe("CopyJobsNotFound", () => { + let mockExplorer: Explorer; + + beforeEach(() => { + mockExplorer = {} as Explorer; + jest.clearAllMocks(); + }); + + it("should render the component with correct elements", () => { + const { container, getByText } = render(); + + const image = container.querySelector(".notFoundContainer .ms-Image"); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute("style", "width: 100px; height: 100px;"); + expect(getByText(ContainerCopyMessages.noCopyJobsTitle)).toBeInTheDocument(); + + const button = screen.getByRole("button", { + name: ContainerCopyMessages.createCopyJobButtonText, + }); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass("createCopyJobButton"); + }); + + it("should render with correct container classes", () => { + const { container } = render(); + + const notFoundContainer = container.querySelector(".notFoundContainer"); + expect(notFoundContainer).toBeInTheDocument(); + expect(notFoundContainer).toHaveClass("flexContainer", "centerContent"); + }); + + it("should call openCreateCopyJobPanel when button is clicked", () => { + const openCreateCopyJobPanelSpy = jest.spyOn(Actions, "openCreateCopyJobPanel"); + + render(); + + const button = screen.getByRole("button", { + name: ContainerCopyMessages.createCopyJobButtonText, + }); + + fireEvent.click(button); + + expect(openCreateCopyJobPanelSpy).toHaveBeenCalledTimes(1); + expect(openCreateCopyJobPanelSpy).toHaveBeenCalledWith(mockExplorer); + }); + + it("should render ActionButton with correct props", () => { + render(); + + const button = screen.getByRole("button", { + name: ContainerCopyMessages.createCopyJobButtonText, + }); + + expect(button).toBeInTheDocument(); + expect(button.textContent).toBe(ContainerCopyMessages.createCopyJobButtonText); + }); + + it("should use memo to prevent unnecessary re-renders", () => { + const { rerender } = render(); + rerender(); + expect(screen.getByRole("heading", { level: 4 })).toBeInTheDocument(); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx index d9ad5bfa0..3c9658d62 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx @@ -17,7 +17,7 @@ const CopyJobsNotFound: React.FC = ({ explorer }) => { Actions.openCreateCopyJobPanel(explorer)} > {ContainerCopyMessages.createCopyJobButtonText} diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx new file mode 100644 index 000000000..c3b723265 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx @@ -0,0 +1,446 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { CopyJobStatusType } from "../../Enums/CopyJobEnums"; +import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; +import CopyJobsList from "./CopyJobsList"; + +jest.mock("../../Actions/CopyJobActions", () => ({ + openCopyJobDetailsPanel: jest.fn(), +})); + +jest.mock("./CopyJobColumns", () => ({ + getColumns: jest.fn(() => [ + { + key: "Name", + name: "Name", + fieldName: "Name", + minWidth: 140, + maxWidth: 300, + isResizable: true, + onRender: (job: CopyJobType) => {job.Name}, + }, + { + key: "Status", + name: "Status", + fieldName: "Status", + minWidth: 130, + maxWidth: 200, + isResizable: true, + onRender: (job: CopyJobType) => {job.Status}, + }, + { + key: "CompletionPercentage", + name: "Progress", + fieldName: "CompletionPercentage", + minWidth: 110, + maxWidth: 200, + isResizable: true, + onRender: (job: CopyJobType) => {job.CompletionPercentage}%, + }, + { + key: "Actions", + name: "Actions", + minWidth: 80, + maxWidth: 200, + isResizable: true, + onRender: (job: CopyJobType) => , + }, + ]), +})); + +// Sample test data +const mockJobs: CopyJobType[] = [ + { + ID: "job-1", + Mode: "Live", + Name: "Test Job 1", + Status: CopyJobStatusType.Running, + CompletionPercentage: 45, + Duration: "00:05:30", + LastUpdatedTime: "2025-01-01 10:00:00", + timestamp: 1704110400000, + Source: { + component: "CosmosDBSql", + remoteAccountName: "source-account", + databaseName: "sourceDb", + containerName: "sourceContainer", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "targetDb", + containerName: "targetContainer", + }, + }, + { + ID: "job-2", + Mode: "Offline", + Name: "Test Job 2", + Status: CopyJobStatusType.Completed, + CompletionPercentage: 100, + Duration: "00:15:45", + LastUpdatedTime: "2025-01-01 11:00:00", + timestamp: 1704114000000, + Source: { + component: "CosmosDBSql", + remoteAccountName: "source-account-2", + databaseName: "sourceDb2", + containerName: "sourceContainer2", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "targetDb2", + containerName: "targetContainer2", + }, + }, + { + ID: "job-3", + Mode: "Live", + Name: "Test Job 3", + Status: CopyJobStatusType.Failed, + CompletionPercentage: 25, + Duration: "00:02:15", + LastUpdatedTime: "2025-01-01 09:30:00", + timestamp: 1704108600000, + Error: { + message: "Connection timeout", + code: "TIMEOUT_ERROR", + }, + Source: { + component: "CosmosDBSql", + remoteAccountName: "source-account-3", + databaseName: "sourceDb3", + containerName: "sourceContainer3", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "targetDb3", + containerName: "targetContainer3", + }, + }, +]; + +const mockHandleActionClick: HandleJobActionClickType = jest.fn(); + +describe("CopyJobsList", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Rendering", () => { + it("renders empty list when no jobs provided", () => { + render(); + + expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument(); + }); + + it("renders jobs list with provided jobs", () => { + render(); + + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.getByText("Test Job 2")).toBeInTheDocument(); + expect(screen.getByText("Test Job 3")).toBeInTheDocument(); + }); + + it("renders job statuses correctly", () => { + render(); + + expect(screen.getByText(CopyJobStatusType.Running)).toBeInTheDocument(); + expect(screen.getByText(CopyJobStatusType.Completed)).toBeInTheDocument(); + expect(screen.getByText(CopyJobStatusType.Failed)).toBeInTheDocument(); + }); + + it("renders completion percentages correctly", () => { + render(); + + expect(screen.getByText("45%")).toBeInTheDocument(); + expect(screen.getByText("100%")).toBeInTheDocument(); + expect(screen.getByText("25%")).toBeInTheDocument(); + }); + + it("renders action menus for each job", () => { + render(); + + expect(screen.getByTestId("action-menu-job-1")).toBeInTheDocument(); + expect(screen.getByTestId("action-menu-job-2")).toBeInTheDocument(); + expect(screen.getByTestId("action-menu-job-3")).toBeInTheDocument(); + }); + }); + + describe("Pagination", () => { + it("shows pager when jobs exceed page size", () => { + const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + expect(screen.getByLabelText("Go to first page")).toBeInTheDocument(); + expect(screen.getByLabelText("Go to previous page")).toBeInTheDocument(); + expect(screen.getByLabelText("Go to next page")).toBeInTheDocument(); + expect(screen.getByLabelText("Go to last page")).toBeInTheDocument(); + }); + + it("does not show pager when jobs are within page size", () => { + render(); + + expect(screen.queryByLabelText("Go to first page")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Go to previous page")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Go to last page")).not.toBeInTheDocument(); + }); + + it("displays correct page information", () => { + const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + expect(screen.getByText("Showing 1 - 10 of 25 items")).toBeInTheDocument(); + expect(screen.getByText("Page 1 of 3")).toBeInTheDocument(); + }); + + it("navigates to next page correctly", async () => { + const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.getByText("Test Job 10")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 11")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("Go to next page")); + + await waitFor(() => { + expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument(); + expect(screen.getByText("Test Job 11")).toBeInTheDocument(); + expect(screen.getByText("Test Job 15")).toBeInTheDocument(); + }); + }); + + it("uses custom page size when provided", () => { + const manyJobs: CopyJobType[] = Array.from({ length: 8 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + expect(screen.getByLabelText("Go to next page")).toBeInTheDocument(); + expect(screen.getByText("Showing 1 - 5 of 8 items")).toBeInTheDocument(); + }); + }); + + describe("Sorting", () => { + it("sorts jobs by name in ascending order", async () => { + const unsortedJobs = [ + { ...mockJobs[0], Name: "Z Job" }, + { ...mockJobs[1], Name: "A Job" }, + { ...mockJobs[2], Name: "M Job" }, + ]; + + render(); + + const rows = screen.getAllByText(/Job$/); + expect(rows[0]).toHaveTextContent("Z Job"); + expect(rows[1]).toHaveTextContent("A Job"); + expect(rows[2]).toHaveTextContent("M Job"); + }); + + it("resets pagination to first page after sorting", async () => { + const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Job ${String.fromCharCode(90 - i)}`, + })); + + render(); + + fireEvent.click(screen.getByLabelText("Go to next page")); + + await waitFor(() => { + expect(screen.getByText("Showing 11 - 15 of 15 items")).toBeInTheDocument(); + }); + }); + + it("updates jobs list when jobs prop changes", async () => { + const { rerender } = render(); + + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.getByText("Test Job 2")).toBeInTheDocument(); + expect(screen.getByText("Test Job 3")).toBeInTheDocument(); + }); + + it("resets start index when jobs change", async () => { + const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByLabelText("Go to next page")); + + await waitFor(() => { + expect(screen.getByText("Showing 11 - 15 of 15 items")).toBeInTheDocument(); + }); + + const newJobs = [mockJobs[0], mockJobs[1]]; + rerender(); + + expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument(); + }); + }); + + describe("Row Interactions", () => { + it("calls openCopyJobDetailsPanel when row is clicked", async () => { + const { openCopyJobDetailsPanel } = await import("../../Actions/CopyJobActions"); + + render(); + + const jobNameElement = screen.getByText("Test Job 1"); + const rowElement = jobNameElement.closest('[role="row"]') || jobNameElement.closest("div"); + + if (rowElement) { + fireEvent.click(rowElement); + } else { + fireEvent.click(jobNameElement); + } + + await waitFor(() => { + expect(openCopyJobDetailsPanel).toHaveBeenCalledWith(mockJobs[0]); + }); + }); + + it("applies cursor pointer style to rows", () => { + render(); + + const jobNameElement = screen.getByText("Test Job 1"); + const rowElement = jobNameElement.closest("div"); + + expect(rowElement).toBeInTheDocument(); + }); + }); + + describe("Component Props", () => { + it("uses default page size when not provided", () => { + const manyJobs: CopyJobType[] = Array.from({ length: 12 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + expect(screen.getByLabelText("Go to next page")).toBeInTheDocument(); + expect(screen.getByText("Showing 1 - 10 of 12 items")).toBeInTheDocument(); + }); + + it("passes correct props to getColumns function", async () => { + const { getColumns } = await import("./CopyJobColumns"); + + render(); + + expect(getColumns).toHaveBeenCalledWith( + expect.any(Function), // handleSort + mockHandleActionClick, // handleActionClick + undefined, // sortedColumnKey + false, // isSortedDescending + ); + }); + }); + + describe("Accessibility", () => { + it("renders with proper ARIA attributes", () => { + render(); + + const detailsList = screen.getByRole("grid"); + expect(detailsList).toBeInTheDocument(); + }); + + it("has accessible pager controls", () => { + const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + expect(screen.getByLabelText("Go to first page")).toBeInTheDocument(); + expect(screen.getByLabelText("Go to previous page")).toBeInTheDocument(); + expect(screen.getByLabelText("Go to next page")).toBeInTheDocument(); + expect(screen.getByLabelText("Go to last page")).toBeInTheDocument(); + }); + }); + + describe("Error Handling", () => { + it("handles empty jobs array gracefully", () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it("handles jobs with missing optional properties", () => { + const incompleteJob: CopyJobType = { + ID: "incomplete-job", + Mode: "Live", + Name: "Incomplete Job", + Status: CopyJobStatusType.Running, + CompletionPercentage: 0, + Duration: "00:00:00", + LastUpdatedTime: "2025-01-01 12:00:00", + timestamp: 1704117600000, + Source: { + component: "CosmosDBSql", + remoteAccountName: "source-account", + databaseName: "sourceDb", + containerName: "sourceContainer", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "targetDb", + containerName: "targetContainer", + }, + }; + + expect(() => { + render(); + }).not.toThrow(); + + expect(screen.getByText("Incomplete Job")).toBeInTheDocument(); + }); + + it("handles very large job lists", () => { + const largeJobsList: CopyJobType[] = Array.from({ length: 1000 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Job ${i + 1}`, + })); + + expect(() => { + render(); + }).not.toThrow(); + + expect(screen.getByText("Showing 1 - 10 of 1000 items")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap new file mode 100644 index 000000000..9940ee7e9 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap @@ -0,0 +1,208 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CopyJobStatusWithIcon Spinner Status Types renders InProgress with spinner and expected text 1`] = ` +
+
+
+
+ + Running + +
+`; + +exports[`CopyJobStatusWithIcon Spinner Status Types renders Partitioning with spinner and expected text 1`] = ` +
+
+
+
+ + Running + +
+`; + +exports[`CopyJobStatusWithIcon Spinner Status Types renders Running with spinner and expected text 1`] = ` +
+
+
+
+ + Running + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Cancelled status correctly 1`] = ` +
+ +  + + + Cancelled + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Completed status correctly 1`] = ` +
+ +  + + + Completed + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Failed status correctly 1`] = ` +
+ +  + + + Failed + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Faulted status correctly 1`] = ` +
+ +  + + + Failed + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Paused status correctly 1`] = ` +
+ +  + + + Paused + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Pending status correctly 1`] = ` +
+ +  + + + Queued + +
+`; + +exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Skipped status correctly 1`] = ` +
+ +  + + + Cancelled + +
+`; diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState.test.tsx new file mode 100644 index 000000000..afdb419df --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState.test.tsx @@ -0,0 +1,122 @@ +import { MonitorCopyJobsRefState } from "./MonitorCopyJobRefState"; +import { MonitorCopyJobsRef } from "./MonitorCopyJobs"; + +describe("MonitorCopyJobsRefState", () => { + beforeEach(() => { + MonitorCopyJobsRefState.setState({ ref: null }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize with null ref", () => { + const state = MonitorCopyJobsRefState.getState(); + expect(state.ref).toBeNull(); + }); + + it("should set ref using setRef", () => { + const mockRef: MonitorCopyJobsRef = { + refreshJobList: jest.fn(), + }; + + const state = MonitorCopyJobsRefState.getState(); + state.setRef(mockRef); + + const updatedState = MonitorCopyJobsRefState.getState(); + expect(updatedState.ref).toBe(mockRef); + expect(updatedState.ref).toEqual(mockRef); + }); + + it("should allow setting ref to null", () => { + const mockRef: MonitorCopyJobsRef = { + refreshJobList: jest.fn(), + }; + + MonitorCopyJobsRefState.getState().setRef(mockRef); + expect(MonitorCopyJobsRefState.getState().ref).toBe(mockRef); + + MonitorCopyJobsRefState.getState().setRef(null); + expect(MonitorCopyJobsRefState.getState().ref).toBeNull(); + }); + + it("should call refreshJobList method on the stored ref", () => { + const mockRefreshJobList = jest.fn(); + const mockRef: MonitorCopyJobsRef = { + refreshJobList: mockRefreshJobList, + }; + + MonitorCopyJobsRefState.getState().setRef(mockRef); + + const state = MonitorCopyJobsRefState.getState(); + state.ref?.refreshJobList(); + + expect(mockRefreshJobList).toHaveBeenCalledTimes(1); + }); + + it("should handle calling refreshJobList when ref is null", () => { + MonitorCopyJobsRefState.setState({ ref: null }); + + const state = MonitorCopyJobsRefState.getState(); + expect(state.ref).toBeNull(); + + expect(() => { + state.ref?.refreshJobList(); + }).not.toThrow(); + }); + + it("should allow partial state updates", () => { + const mockRef: MonitorCopyJobsRef = { + refreshJobList: jest.fn(), + }; + + MonitorCopyJobsRefState.setState({ ref: mockRef }); + const state1 = MonitorCopyJobsRefState.getState(); + expect(state1.ref).toBe(mockRef); + expect(state1.setRef).toBeDefined(); + + const newMockRef: MonitorCopyJobsRef = { + refreshJobList: jest.fn(), + }; + MonitorCopyJobsRefState.setState({ ref: newMockRef }); + const state2 = MonitorCopyJobsRefState.getState(); + expect(state2.ref).toBe(newMockRef); + expect(state2.setRef).toBeDefined(); + }); + + it("should handle multiple subscribers", () => { + const mockSubscriber1 = jest.fn(); + const mockSubscriber2 = jest.fn(); + + const unsubscribe1 = MonitorCopyJobsRefState.subscribe(mockSubscriber1); + const unsubscribe2 = MonitorCopyJobsRefState.subscribe(mockSubscriber2); + + const mockRef: MonitorCopyJobsRef = { + refreshJobList: jest.fn(), + }; + + MonitorCopyJobsRefState.getState().setRef(mockRef); + + expect(mockSubscriber1).toHaveBeenCalled(); + expect(mockSubscriber2).toHaveBeenCalled(); + + unsubscribe1(); + unsubscribe2(); + }); + + it("should not notify unsubscribed listeners", () => { + const mockSubscriber = jest.fn(); + + const unsubscribe = MonitorCopyJobsRefState.subscribe(mockSubscriber); + unsubscribe(); + + const mockRef: MonitorCopyJobsRef = { + refreshJobList: jest.fn(), + }; + + mockSubscriber.mockClear(); + MonitorCopyJobsRefState.getState().setRef(mockRef); + + expect(mockSubscriber).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.test.tsx new file mode 100644 index 000000000..a59ebf687 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.test.tsx @@ -0,0 +1,435 @@ +import "@testing-library/jest-dom"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { DataTransferJobGetResults } from "Utils/arm/generatedClients/dataTransferService/types"; +import Explorer from "../../Explorer"; +import * as CopyJobActions from "../Actions/CopyJobActions"; +import { CopyJobStatusType } from "../Enums/CopyJobEnums"; +import { CopyJobType } from "../Types/CopyJobTypes"; +import MonitorCopyJobs from "./MonitorCopyJobs"; + +jest.mock("Common/ShimmerTree/ShimmerTree", () => { + const MockShimmerTree = () => { + return
Loading...
; + }; + MockShimmerTree.displayName = "MockShimmerTree"; + return MockShimmerTree; +}); + +jest.mock("./Components/CopyJobsList", () => { + const MockCopyJobsList = ({ jobs }: any) => { + return
Jobs: {jobs.length}
; + }; + MockCopyJobsList.displayName = "MockCopyJobsList"; + return MockCopyJobsList; +}); + +jest.mock("./Components/CopyJobs.NotFound", () => { + const MockCopyJobsNotFound = () => { + return
No jobs found
; + }; + MockCopyJobsNotFound.displayName = "MockCopyJobsNotFound"; + return MockCopyJobsNotFound; +}); + +jest.mock("../Actions/CopyJobActions", () => ({ + getCopyJobs: jest.fn(), + updateCopyJobStatus: jest.fn(), +})); + +describe("MonitorCopyJobs", () => { + let mockExplorer: Explorer; + const mockGetCopyJobs = CopyJobActions.getCopyJobs as jest.MockedFunction; + const mockUpdateCopyJobStatus = CopyJobActions.updateCopyJobStatus as jest.MockedFunction< + typeof CopyJobActions.updateCopyJobStatus + >; + + const mockJobs: CopyJobType[] = [ + { + ID: "1", + Mode: "Offline", + Name: "test-job-1", + Status: CopyJobStatusType.InProgress, + CompletionPercentage: 50, + Duration: "10 minutes", + LastUpdatedTime: "1/1/2024, 10:00:00 AM", + timestamp: 1704110400000, + Source: { + component: "CosmosDBSql", + databaseName: "db1", + containerName: "container1", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "db2", + containerName: "container2", + }, + }, + { + ID: "2", + Mode: "Online", + Name: "test-job-2", + Status: CopyJobStatusType.Completed, + CompletionPercentage: 100, + Duration: "20 minutes", + LastUpdatedTime: "1/1/2024, 11:00:00 AM", + timestamp: 1704114000000, + Source: { + component: "CosmosDBSql", + databaseName: "db3", + containerName: "container3", + }, + Destination: { + component: "CosmosDBSql", + databaseName: "db4", + containerName: "container4", + }, + }, + ]; + + beforeEach(() => { + mockExplorer = {} as Explorer; + mockGetCopyJobs.mockResolvedValue(mockJobs); + mockUpdateCopyJobStatus.mockResolvedValue({ + id: "test-id", + type: "Microsoft.DocumentDB/databaseAccounts/dataTransferJobs", + properties: { + jobName: "test-job-1", + status: "Paused", + lastUpdatedUtcTime: "2024-01-01T10:00:00Z", + processedCount: 500, + totalCount: 1000, + mode: "Offline", + duration: "00:10:00", + source: { + databaseName: "db1", + containerName: "container1", + component: "CosmosDBSql", + }, + destination: { + databaseName: "db2", + containerName: "container2", + component: "CosmosDBSql", + }, + error: { + message: "", + code: "", + }, + }, + } as DataTransferJobGetResults); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Initial Rendering", () => { + it("renders the component with correct structure", async () => { + render(); + + const container = document.querySelector(".monitorCopyJobs"); + expect(container).toBeInTheDocument(); + expect(container).toHaveClass("flexContainer"); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + }); + + it("displays shimmer while loading initially", () => { + render(); + + expect(screen.getByTestId("shimmer-tree")).toBeInTheDocument(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("fetches jobs on mount", async () => { + render(); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe("Job List Display", () => { + it("displays job list when jobs are loaded", async () => { + render(); + + await waitFor( + () => { + expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + + expect(screen.getByText("Jobs: 2")).toBeInTheDocument(); + expect(screen.queryByTestId("shimmer-tree")).not.toBeInTheDocument(); + }); + + it("displays not found component when no jobs exist", async () => { + mockGetCopyJobs.mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-not-found")).toBeInTheDocument(); + }); + + expect(screen.getByText("No jobs found")).toBeInTheDocument(); + expect(screen.queryByTestId("copy-jobs-list")).not.toBeInTheDocument(); + }); + + it("passes correct jobs to CopyJobsList component", async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument(); + }); + expect(screen.getByText("Jobs: 2")).toBeInTheDocument(); + }); + + it("updates job status when action is triggered", async () => { + const ref = React.createRef(); + render(); + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument(); + }); + expect(mockJobs[0].Status).toBe(CopyJobStatusType.InProgress); + }); + }); + + describe("Error Handling", () => { + it("displays error message when fetch fails", async () => { + const errorMessage = "Failed to load copy jobs. Please try again later."; + mockGetCopyJobs.mockRejectedValue(new Error(errorMessage)); + + render(); + + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("shimmer-tree")).not.toBeInTheDocument(); + }); + + it("allows dismissing error message", async () => { + mockGetCopyJobs.mockRejectedValue(new Error("Failed to load copy jobs")); + const { container } = render(); + await waitFor(() => { + expect(screen.getByText(/Failed to load copy jobs/)).toBeInTheDocument(); + }); + + const dismissButton = container.querySelector('[aria-label="Close"]'); + if (dismissButton) { + dismissButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + } + await waitFor(() => { + expect(screen.queryByText(/Failed to load copy jobs/)).not.toBeInTheDocument(); + }); + }); + + it("displays custom error message from getCopyJobs", async () => { + const customError = { message: "Custom error occurred" }; + mockGetCopyJobs.mockRejectedValue(customError); + + render(); + + await waitFor(() => { + expect(screen.getByText("Custom error occurred")).toBeInTheDocument(); + }); + }); + + it("displays error when job action update fails", async () => { + mockUpdateCopyJobStatus.mockRejectedValue(new Error("Update failed")); + + const ref = React.createRef(); + render(); + + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument(); + }); + + const mockHandleActionClick = jest.fn(async (job, action, setUpdatingJobAction) => { + setUpdatingJobAction({ jobName: job.Name, action }); + await mockUpdateCopyJobStatus(job, action); + }); + + await expect(mockHandleActionClick(mockJobs[0], "pause", jest.fn())).rejects.toThrow("Update failed"); + }); + }); + + describe("Polling and Refresh", () => { + it.skip("polls for jobs at regular intervals", async () => { + render(); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + + act(() => { + jest.advanceTimersByTime(30000); + }); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(2); + }); + + act(() => { + jest.advanceTimersByTime(30000); + }); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(3); + }); + }); + + it("stops polling when component unmounts", async () => { + const { unmount } = render(); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + + unmount(); + + act(() => { + jest.advanceTimersByTime(60000); + }); + + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + + it("refreshes job list via ref", async () => { + const ref = React.createRef(); + render(); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + + act(() => { + ref.current?.refreshJobList(); + }); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(2); + }); + }); + + it("prevents refresh when update is in progress", async () => { + const ref = React.createRef(); + render(); + + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument(); + }); + + mockUpdateCopyJobStatus.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + id: "test-id", + type: "Microsoft.DocumentDB/databaseAccounts/dataTransferJobs", + properties: { + jobName: "test-job-1", + status: "Paused", + lastUpdatedUtcTime: "2024-01-01T10:00:00Z", + processedCount: 500, + totalCount: 1000, + mode: "Offline", + duration: "00:10:00", + source: { + databaseName: "db1", + collectionName: "container1", + component: "CosmosDBSql", + }, + destination: { + databaseName: "db2", + collectionName: "container2", + component: "CosmosDBSql", + }, + error: { + message: "", + code: "", + }, + }, + } as DataTransferJobGetResults), + 5000, + ), + ), + ); + + expect(ref.current).toHaveProperty("refreshJobList"); + expect(typeof ref.current.refreshJobList).toBe("function"); + }); + }); + + describe("Edge Cases", () => { + it("handles empty job array", async () => { + mockGetCopyJobs.mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-not-found")).toBeInTheDocument(); + }); + }); + + it("handles null response from getCopyJobs gracefully", async () => { + mockGetCopyJobs.mockResolvedValue(null as any); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-not-found")).toBeInTheDocument(); + }); + }); + + it("handles explorer prop correctly", () => { + const { rerender } = render(); + + const newExplorer = {} as Explorer; + rerender(); + + expect(document.querySelector(".monitorCopyJobs")).toBeInTheDocument(); + }); + }); + + describe("Ref Handle", () => { + it("exposes refreshJobList method through ref", () => { + const ref = React.createRef(); + render(); + + expect(ref.current).toBeDefined(); + expect(ref.current).toHaveProperty("refreshJobList"); + expect(typeof ref.current.refreshJobList).toBe("function"); + }); + + it("refreshJobList triggers getCopyJobs", async () => { + const ref = React.createRef(); + render(); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(1); + }); + + ref.current?.refreshJobList(); + + await waitFor(() => { + expect(mockGetCopyJobs).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe("Action Callback", () => { + it("provides handleActionClick callback to CopyJobsList", async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx index 1e38dc18a..56ec498f8 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx @@ -39,11 +39,14 @@ const MonitorCopyJobs = forwardRef(({ setError(null); const response = await getCopyJobs(); + const normalizedResponse = response || []; setJobs((prevJobs) => { - return isEqual(prevJobs, response) ? prevJobs : response; + return isEqual(prevJobs, normalizedResponse) ? prevJobs : normalizedResponse; }); } catch (error) { - setError(error.message || "Failed to load copy jobs. Please try again later."); + if (error.message !== "Previous copy job request was cancelled.") { + setError(error.message || "Failed to load copy jobs. Please try again later."); + } } finally { if (isFirstFetchRef.current) { setLoading(false); @@ -97,29 +100,27 @@ const MonitorCopyJobs = forwardRef(({ [], ); - const renderJobsList = () => { - if (loading) { - return null; - } - if (jobs.length > 0) { - return ; - } - return ; - }; - return ( {loading && ( )} {error && ( - setError(null)}> + setError(null)} + dismissButtonAriaLabel="Close" + > {error} )} - {renderJobsList()} + {!loading && jobs.length > 0 && } + {!loading && jobs.length === 0 && } ); }); +MonitorCopyJobs.displayName = "MonitorCopyJobs"; + export default MonitorCopyJobs; diff --git a/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts index e9ebbd0da..85ef612fb 100644 --- a/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts +++ b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts @@ -56,14 +56,14 @@ export interface CopyJobContextState { migrationType: CopyJobMigrationType; sourceReadAccessFromTarget?: boolean; source: { - subscription: Subscription; - account: DatabaseAccount; + subscription: Subscription | null; + account: DatabaseAccount | null; databaseId: string; containerId: string; }; target: { subscriptionId: string; - account: DatabaseAccount; + account: DatabaseAccount | null; databaseId: string; containerId: string; }; diff --git a/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx b/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx index 3f0fa6d2c..7d7510626 100644 --- a/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx +++ b/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx @@ -58,7 +58,7 @@ export class CollapsibleSectionComponent extends React.Component - + {this.props.tooltipContent && ( { event.stopPropagation(); this.props.onDelete(); diff --git a/src/Explorer/Controls/CollapsiblePanel/__snapshots__/CollapsibleSectionComponent.test.tsx.snap b/src/Explorer/Controls/CollapsiblePanel/__snapshots__/CollapsibleSectionComponent.test.tsx.snap index 538ab00e7..ed981c5d6 100644 --- a/src/Explorer/Controls/CollapsiblePanel/__snapshots__/CollapsibleSectionComponent.test.tsx.snap +++ b/src/Explorer/Controls/CollapsiblePanel/__snapshots__/CollapsibleSectionComponent.test.tsx.snap @@ -20,7 +20,15 @@ exports[`CollapsibleSectionComponent renders 1`] = ` - + Sample title diff --git a/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx b/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx index b6e847d54..168962312 100644 --- a/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx +++ b/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx @@ -58,6 +58,26 @@ export interface CommandButtonComponentProps { */ tooltipText?: string; + /** + * Custom styles to apply to the button using Fluent UI theme tokens + */ + styles?: { + root?: { + backgroundColor?: string; + color?: string; + selectors?: { + ":hover"?: { + backgroundColor?: string; + color?: string; + }; + ":active"?: { + backgroundColor?: string; + color?: string; + }; + }; + }; + }; + /** * tabindex for the command button */ @@ -250,6 +270,8 @@ export class CommandButtonComponent extends React.Component ) => this.commandClickCallback(e)} >
diff --git a/src/Explorer/Controls/Dialog.tsx b/src/Explorer/Controls/Dialog.tsx index a4c50a3fd..d02eb1120 100644 --- a/src/Explorer/Controls/Dialog.tsx +++ b/src/Explorer/Controls/Dialog.tsx @@ -179,8 +179,18 @@ export const Dialog: FC = () => { title, subText, styles: { - title: { fontSize: DIALOG_TITLE_FONT_SIZE, fontWeight: DIALOG_TITLE_FONT_WEIGHT }, - subText: { fontSize: DIALOG_SUBTEXT_FONT_SIZE }, + title: { + fontSize: DIALOG_TITLE_FONT_SIZE, + fontWeight: DIALOG_TITLE_FONT_WEIGHT, + }, + subText: { + fontSize: DIALOG_SUBTEXT_FONT_SIZE, + color: "var(--colorNeutralForeground2)", + }, + content: { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + }, }, showCloseButton: showCloseButton || false, onDismiss, @@ -188,18 +198,60 @@ export const Dialog: FC = () => { modalProps: { isBlocking: isModal, isDarkOverlay: false }, minWidth: DIALOG_MIN_WIDTH, maxWidth: DIALOG_MAX_WIDTH, + styles: { + main: { + backgroundColor: "var(--colorNeutralBackground1)", + selectors: { + ".ms-Dialog-title": { color: "var(--colorNeutralForeground1)" }, + }, + }, + }, }; const primaryButtonProps: IButtonProps = { text: primaryButtonText, disabled: primaryButtonDisabled || false, onClick: onPrimaryButtonClick, + styles: { + root: { + backgroundColor: "var(--colorBrandBackground)", + color: "var(--colorNeutralForegroundOnBrand)", + selectors: { + ":hover": { + backgroundColor: "var(--colorBrandBackgroundHover)", + color: "var(--colorNeutralForegroundOnBrand)", + }, + ":active": { + backgroundColor: "var(--colorBrandBackgroundPressed)", + color: "var(--colorNeutralForegroundOnBrand)", + }, + }, + }, + }, }; const secondaryButtonProps: IButtonProps = secondaryButtonText && onSecondaryButtonClick ? { text: secondaryButtonText, onClick: onSecondaryButtonClick, + styles: { + root: { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground1)", + borderColor: "var(--colorNeutralStroke1)", + selectors: { + ":hover": { + backgroundColor: "var(--colorNeutralBackground3)", + color: "var(--colorNeutralForeground1)", + }, + ":active": { + backgroundColor: "var(--colorNeutralBackground3)", + color: "var(--colorNeutralForeground1)", + borderColor: "var(--colorCompoundBrandStroke1)", + }, + }, + }, + }, } : undefined; return visible ? ( diff --git a/src/Explorer/Controls/Editor/EditorReact.tsx b/src/Explorer/Controls/Editor/EditorReact.tsx index 9ca544631..bfd3f103a 100644 --- a/src/Explorer/Controls/Editor/EditorReact.tsx +++ b/src/Explorer/Controls/Editor/EditorReact.tsx @@ -1,4 +1,5 @@ import { Spinner, SpinnerSize } from "@fluentui/react"; +import { monacoTheme, useThemeStore } from "hooks/useTheme"; import * as React from "react"; import { loadMonaco, monaco } from "../../LazyMonaco"; // import "./EditorReact.less"; @@ -66,6 +67,7 @@ export class EditorReact extends React.Component void; monacoApi: { default: typeof monaco; Emitter: typeof monaco.Emitter; @@ -94,6 +96,13 @@ export class EditorReact extends React.Component { + if (this.editor) { + const newTheme = state.isDarkMode ? "vs-dark" : "vs"; + this.monacoApi?.editor.setTheme(newTheme); + } + }); + setTimeout(() => { const suggestionWidget = this.editor?.getDomNode()?.querySelector(".suggest-widget") as HTMLElement; if (suggestionWidget) { @@ -128,6 +137,7 @@ export class EditorReact extends React.Component = { fieldGroup: { height: 27, + backgroundColor: "var(--colorNeutralBackground2)", + borderColor: "var(--colorNeutralStroke1)", }, field: { fontSize: 12, padding: "0 8px", + color: "var(--colorNeutralForeground1)", + backgroundColor: "var(--colorNeutralBackground2)", + }, + root: { + selectors: { + input: { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground1)", + }, + "input:hover": { + backgroundColor: "var(--colorNeutralBackground2)", + borderColor: "var(--colorNeutralStroke1)", + }, + "input:focus": { + backgroundColor: "var(--colorNeutralBackground2)", + borderColor: "var(--colorBrandBackground)", + }, + }, }, }; -const dropdownStyles = { - title: { - height: 27, - lineHeight: "24px", - fontSize: 12, +const dropdownStyles: Partial = { + root: { + width: "40%", + marginTop: "10px", + selectors: { + "&:hover .ms-Dropdown-title": { + color: "var(--colorNeutralForeground1)", + backgroundColor: "var(--colorNeutralBackground2)", + borderColor: "var(--colorNeutralStroke1)", + }, + "&:hover span.ms-Dropdown-title": { + color: "var(--colorNeutralForeground1)", + }, + "&:focus .ms-Dropdown-title": { + color: "var(--colorNeutralForeground1)", + backgroundColor: "var(--colorNeutralBackground2)", + }, + "&:focus span.ms-Dropdown-title": { + color: "var(--colorNeutralForeground1)", + }, + }, + }, + label: { + color: "var(--colorNeutralForeground1)", }, dropdown: { - height: 27, - lineHeight: "24px", + backgroundColor: "var(--colorNeutralBackground2)", + borderColor: "var(--colorNeutralStroke1)", + color: "var(--colorNeutralForeground1)", + }, + title: { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground1)", + borderColor: "var(--colorNeutralStroke1)", + selectors: { + "&:hover": { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground1)", + }, + "&:focus": { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground1)", + }, + "&:hover .ms-Dropdown-titleText": { + color: "var(--colorNeutralForeground1)", + }, + "&:focus .ms-Dropdown-titleText": { + color: "var(--colorNeutralForeground1)", + }, + "& .ms-Dropdown-titleText": { + color: "var(--colorNeutralForeground1)", + }, + "&.ms-Dropdown-title--hasPlaceholder": { + color: "var(--colorNeutralForeground2)", + }, + }, + }, + errorMessage: { + color: "var(--colorNeutralForeground1)", + }, + caretDown: { + color: "var(--colorNeutralForeground1)", + }, + callout: { + backgroundColor: "var(--colorNeutralBackground2)", + border: "1px solid var(--colorNeutralStroke1)", + }, + dropdownItems: { + backgroundColor: "var(--colorNeutralBackground2)", }, dropdownItem: { - fontSize: 12, + backgroundColor: "transparent", + color: "var(--colorNeutralForeground1)", + minHeight: "36px", + lineHeight: "36px", + selectors: { + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.1)", + color: "var(--colorNeutralForeground1)", + }, + "&:hover .ms-Dropdown-optionText": { + color: "var(--colorNeutralForeground1)", + }, + "&:focus": { + backgroundColor: "rgba(255, 255, 255, 0.1)", + color: "var(--colorNeutralForeground1)", + }, + "&:active": { + backgroundColor: "rgba(255, 255, 255, 0.15)", + color: "var(--colorNeutralForeground1)", + }, + "& .ms-Dropdown-optionText": { + color: "var(--colorNeutralForeground1)", + }, + }, + }, + dropdownItemSelected: { + backgroundColor: "rgba(255, 255, 255, 0.08)", + color: "var(--colorNeutralForeground1)", + minHeight: "36px", + lineHeight: "36px", + selectors: { + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.1)", + color: "var(--colorNeutralForeground1)", + }, + "&:hover .ms-Dropdown-optionText": { + color: "var(--colorNeutralForeground1)", + }, + "&:focus": { + backgroundColor: "rgba(255, 255, 255, 0.1)", + color: "var(--colorNeutralForeground1)", + }, + "&:active": { + backgroundColor: "rgba(255, 255, 255, 0.15)", + color: "var(--colorNeutralForeground1)", + }, + "& .ms-Dropdown-optionText": { + color: "var(--colorNeutralForeground1)", + }, + }, + }, + dropdownOptionText: { + color: "var(--colorNeutralForeground1)", + }, + dropdownItemHeader: { + color: "var(--colorNeutralForeground1)", }, }; @@ -226,7 +363,32 @@ export const FullTextPoliciesComponent: React.FunctionComponent ))} - + Add full text path diff --git a/src/Explorer/Controls/Settings/SettingsComponent.less b/src/Explorer/Controls/Settings/SettingsComponent.less index 5eb2b13be..85b4b4373 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.less +++ b/src/Explorer/Controls/Settings/SettingsComponent.less @@ -4,6 +4,8 @@ height: 100%; overflow-y: auto; width: 100%; + background-color: var(--colorNeutralBackground1); + color: var(--colorNeutralForeground1); } .settingsV2ToolTip { @@ -23,6 +25,8 @@ overflow-y: auto; width: 100%; font-family: @DataExplorerFont; + background-color: var(--colorNeutralBackground1); + color: var(--colorNeutralForeground1); } .settingsV2Editor { diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 9a87f5b0c..0802fc863 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -1,4 +1,4 @@ -import { IPivotItemProps, IPivotProps, Pivot, PivotItem } from "@fluentui/react"; +import { IPivotItemProps, IPivotProps, Pivot, PivotItem, Stack } from "@fluentui/react"; import { sendMessage } from "Common/MessageHandler"; import { FabricMessageTypes } from "Contracts/FabricMessageTypes"; import { @@ -1477,31 +1477,111 @@ export class SettingsComponent extends React.Component { - const pivotItemProps: IPivotItemProps = { - itemKey: SettingsV2TabTypes[tab.tab], - style: { marginTop: 20 }, - headerText: getTabTitle(tab.tab), - headerButtonProps: { - "data-test": `settings-tab-header/${SettingsV2TabTypes[tab.tab]}`, + const pivotStyles = { + root: { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + selectors: { + "& .ms-Pivot-link": { + color: "var(--colorNeutralForeground1)", + }, + "& .ms-Pivot-link.is-selected::before": { + backgroundColor: "var(--colorCompoundBrandBackground)", + }, }, - }; + }, + link: { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + selectors: { + "&:hover": { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + }, + "&:active": { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + }, + '&[aria-selected="true"]': { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + selectors: { + "&:hover": { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + }, + "&:active": { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + }, + }, + }, + }, + }, - return ( - - {tab.content} - - ); - }); + itemContainer: { + // padding: '20px 24px', + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + }, + }; + + const contentStyles = { + root: { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + // padding: '20px 24px' + }, + }; return ( -
+
{this.shouldShowKeyspaceSharedThroughputMessage() && (
This table shared throughput is configured at the keyspace
)} -
- {pivotItems} +
+ + {tabs.map((tab) => { + const pivotItemProps: IPivotItemProps = { + itemKey: SettingsV2TabTypes[tab.tab], + style: { + marginTop: 20, + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + }, + headerText: getTabTitle(tab.tab), + headerButtonProps: { + "data-test": `settings-tab-header/${SettingsV2TabTypes[tab.tab]}`, + }, + }; + + return ( + + {tab.content} + + ); + })} +
); diff --git a/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx b/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx index a703771c7..730372b8b 100644 --- a/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx +++ b/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx @@ -63,7 +63,7 @@ export interface PriceBreakdown { export type editorType = "indexPolicy" | "computedProperties" | "dataMasking"; -export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14, color: "windowtext" } }; +export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14, color: "var(--colorNeutralForeground1)" } }; export const noLeftPaddingCheckBoxStyle: ICheckboxStyles = { label: { @@ -119,15 +119,89 @@ export const addMongoIndexSubElementsTokens: IStackTokens = { export const mediumWidthStackStyles: IStackStyles = { root: { width: 600 } }; -export const shortWidthTextFieldStyles: Partial = { root: { paddingLeft: 10, width: 210 } }; +export const shortWidthTextFieldStyles: Partial = { + root: { paddingLeft: 10, width: 210 }, + fieldGroup: { + backgroundColor: "var(--colorNeutralBackground2)", + borderColor: "var(--colorNeutralStroke1)", + }, + field: { + color: "var(--colorNeutralForeground1)", + backgroundColor: "var(--colorNeutralBackground2)", + }, +}; -export const shortWidthDropDownStyles: Partial = { dropdown: { paddingleft: 10, width: 202 } }; +export const shortWidthDropDownStyles: Partial = { + dropdown: { paddingLeft: 10, width: 202 }, + title: { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground1)", + borderColor: "var(--colorNeutralStroke1)", + }, + caretDown: { + color: "var(--colorNeutralForeground1)", + }, + callout: { + backgroundColor: "var(--colorNeutralBackground2)", + border: "1px solid var(--colorNeutralStroke1)", + }, + dropdownItems: { + backgroundColor: "var(--colorNeutralBackground2)", + }, + dropdownItem: { + backgroundColor: "transparent", + color: "var(--colorNeutralForeground1)", + selectors: { + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.1)", + color: "var(--colorNeutralForeground1)", + }, + "&:focus": { + backgroundColor: "rgba(255, 255, 255, 0.1)", + color: "var(--colorNeutralForeground1)", + }, + }, + }, + dropdownItemSelected: { + backgroundColor: "rgba(255, 255, 255, 0.08)", + color: "var(--colorNeutralForeground1)", + selectors: { + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.1)", + color: "var(--colorNeutralForeground1)", + }, + }, + }, + dropdownOptionText: { + color: "var(--colorNeutralForeground1)", + }, +}; export const transparentDetailsRowStyles: Partial = { root: { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", selectors: { ":hover": { - background: "transparent", + backgroundColor: "var(--colorNeutralBackground1Hover)", + color: "var(--colorNeutralForeground1)", + }, + ":hover .ms-DetailsRow-cell": { + backgroundColor: "var(--colorNeutralBackground1Hover)", + color: "var(--colorNeutralForeground1)", + }, + "&.ms-DetailsRow": { + backgroundColor: "var(--colorNeutralBackground1)", + }, + }, + }, + cell: { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + selectors: { + ":hover": { + backgroundColor: "var(--colorNeutralBackground1Hover)", + color: "var(--colorNeutralForeground1)", }, }, }, @@ -135,9 +209,11 @@ export const transparentDetailsRowStyles: Partial = { export const transparentDetailsHeaderStyle: Partial = { root: { + color: "var(--colorNeutralForeground1)", selectors: { ":hover": { - background: "transparent", + background: "var(--colorNeutralBackground1Hover)", + color: "var(--colorNeutralForeground1)", }, }, }, @@ -149,6 +225,35 @@ export const customDetailsListStyles: Partial = { ".ms-FocusZone": { paddingTop: 0, }, + ".ms-DetailsHeader": { + backgroundColor: "var(--colorNeutralBackground1)", + }, + ".ms-DetailsHeader-cell": { + color: "var(--colorNeutralForeground1)", + backgroundColor: "var(--colorNeutralBackground1)", + selectors: { + ":hover": { + backgroundColor: "var(--colorNeutralBackground1Hover)", + color: "var(--colorNeutralForeground1)", + }, + }, + }, + ".ms-DetailsHeader-cellTitle": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-DetailsRow": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-DetailsRow-cell": { + color: "var(--colorNeutralForeground1)", + }, + // Tooltip styling for cells + ".ms-TooltipHost": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-DetailsRow-cell .ms-TooltipHost": { + color: "var(--colorNeutralForeground1)", + }, }, }, }; @@ -166,7 +271,18 @@ export const separatorStyles: Partial = { }; export const messageBarStyles: Partial = { - root: { marginTop: "5px", backgroundColor: "white" }, + root: { + marginTop: "5px", + backgroundColor: "var(--colorNeutralBackground1)", + selectors: { + "&.ms-MessageBar--severeWarning": { + backgroundColor: "var(--colorNeutralBackground4)", + }, + "&.ms-MessageBar--warning": { + backgroundColor: "var(--colorNeutralBackground3)", + }, + }, + }, text: { fontSize: 14 }, }; @@ -222,9 +338,11 @@ export const getEstimatedSpendingElement = ( const ruRange: string = isAutoscale ? throughput / 10 + " RU/s - " : ""; return ( - Cost estimate* + Cost estimate* {costElement} - How we calculate this + + How we calculate this + {numberOfRegions} region{numberOfRegions > 1 && s} @@ -238,7 +356,7 @@ export const getEstimatedSpendingElement = ( {priceBreakdown.pricePerRu}/RU - + *{estimatedCostDisclaimer} @@ -285,7 +403,7 @@ export const updateThroughputDelayedApplyWarningMessage: JSX.Element = ( export const getUpdateThroughputBeyondInstantLimitMessage = (instantMaximumThroughput: number): JSX.Element => { return ( - + Scaling up will take 4-6 hours as it exceeds what Azure Cosmos DB can instantly support currently based on your number of physical partitions. You can increase your throughput to {instantMaximumThroughput} instantly or proceed with this value and wait until the scale-up is completed. @@ -303,7 +421,7 @@ export const getUpdateThroughputBeyondSupportLimitMessage = ( Your request to increase throughput exceeds the pre-allocated capacity which may take longer than expected. There are three options you can choose from to proceed: -
    +
    1. You can instantly scale up to {instantMaximumThroughput} RU/s.
    2. {instantMaximumThroughput < maximumThroughput && (
    3. You can asynchronously scale up to any value under {maximumThroughput} RU/s in 4-6 hours.
    4. @@ -339,7 +457,7 @@ export const getUpdateThroughputBelowMinimumMessage = (minimum: number): JSX.Ele }; export const saveThroughputWarningMessage: JSX.Element = ( - + Your bill will be affected as you update your throughput settings. Please review the updated cost estimate below before saving your changes @@ -459,9 +577,13 @@ export const changeFeedPolicyToolTip: JSX.Element = ( ); export const mongoIndexingPolicyDisclaimer: JSX.Element = ( - + For queries that filter on multiple properties, create multiple single field indexes instead of a compound index. - + {` Compound indexes `} are only used for sorting query results. If you need to add a compound index, you can create one using the Mongo @@ -470,7 +592,7 @@ export const mongoIndexingPolicyDisclaimer: JSX.Element = ( ); export const mongoCompoundIndexNotSupportedMessage: JSX.Element = ( - + Collections with compound indexes are not yet supported in the indexing editor. To modify indexing policy for this collection, use the Mongo Shell. @@ -519,14 +641,50 @@ export const getTextFieldStyles = (current: isDirtyTypes, baseline: isDirtyTypes fieldGroup: { height: 25, width: 300, - borderColor: isDirty(current, baseline) ? StyleConstants.Dirty : "", + backgroundColor: "var(--colorNeutralBackground2)", + borderColor: isDirty(current, baseline) ? StyleConstants.Dirty : "var(--colorNeutralStroke1)", selectors: { ":disabled": { - backgroundColor: StyleConstants.BaseMedium, - borderColor: StyleConstants.BaseMediumHigh, + backgroundColor: "var(--colorNeutralBackground2)", + borderColor: "var(--colorNeutralStroke1)", + color: "var(--colorNeutralForeground2)", + }, + input: { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground1)", + }, + "input:disabled": { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground2)", + }, + "input#autopilotInput": { + backgroundColor: "var(--colorNeutralBackground4)", + color: "var(--colorNeutralForeground1)", }, }, }, + field: { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground1)", + selectors: { + ":disabled": { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground2)", + }, + }, + }, + subComponentStyles: { + label: { + root: { + color: "var(--colorNeutralForeground1)", + }, + }, + }, + suffix: { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground1)", + border: "1px solid var(--colorNeutralStroke1)", + }, }); export const getChoiceGroupStyles = ( @@ -534,6 +692,28 @@ export const getChoiceGroupStyles = ( baseline: isDirtyTypes, isHorizontal?: boolean, ): Partial => ({ + label: { + color: "var(--colorNeutralForeground1)", + }, + root: { + selectors: { + ".ms-ChoiceFieldLabel": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + color: "var(--colorNeutralForeground1)", + }, + }, + }, flexContainer: [ { selectors: { @@ -548,6 +728,16 @@ export const getChoiceGroupStyles = ( fontSize: 14, fontFamily: StyleConstants.DataExplorerFont, padding: "2px 5px", + color: "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + color: "var(--colorNeutralForeground1)", }, }, display: isHorizontal ? "inline-flex" : "default", diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx index 8a2c8f19e..039a66304 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx @@ -1,11 +1,11 @@ -import { FontIcon, Link, MessageBar, MessageBarType, Stack, Text } from "@fluentui/react"; +import { FontIcon, IMessageBarStyles, Link, MessageBar, MessageBarType, Stack, Text } from "@fluentui/react"; import * as DataModels from "Contracts/DataModels"; import { titleAndInputStackProps, unsavedEditorWarningMessage } from "Explorer/Controls/Settings/SettingsRenderUtils"; import { isDirty } from "Explorer/Controls/Settings/SettingsUtils"; import { loadMonaco } from "Explorer/LazyMonaco"; +import { monacoTheme, useThemeStore } from "hooks/useTheme"; import * as monaco from "monaco-editor"; import * as React from "react"; - export interface ComputedPropertiesComponentProps { computedPropertiesContent: DataModels.ComputedProperties; computedPropertiesContentBaseline: DataModels.ComputedProperties; @@ -27,6 +27,24 @@ export class ComputedPropertiesComponent extends React.Component< private shouldCheckComponentIsDirty = true; private computedPropertiesDiv = React.createRef(); private computedPropertiesEditor: monaco.editor.IStandaloneCodeEditor; + private themeUnsubscribe: () => void; + + private darkThemeMessageBarStyles: Partial = { + root: { + selectors: { + "&.ms-MessageBar--warning": { + backgroundColor: "var(--colorStatusWarningBackground1)", + border: "1px solid var(--colorStatusWarningBorder1)", + }, + ".ms-MessageBar-icon": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-MessageBar-text": { + color: "var(--colorNeutralForeground1)", + }, + }, + }, + }; constructor(props: ComputedPropertiesComponentProps) { super(props); @@ -48,6 +66,10 @@ export class ComputedPropertiesComponent extends React.Component< this.onComponentUpdate(); } + componentWillUnmount(): void { + this.themeUnsubscribe && this.themeUnsubscribe(); + } + public resetComputedPropertiesEditor = (): void => { if (!this.computedPropertiesEditor) { this.createComputedPropertiesEditor(); @@ -86,8 +108,16 @@ export class ComputedPropertiesComponent extends React.Component< value: value, language: "json", ariaLabel: "Computed properties", + theme: monacoTheme(), }); if (this.computedPropertiesEditor) { + // Subscribe to theme changes + this.themeUnsubscribe = useThemeStore.subscribe(() => { + if (this.computedPropertiesEditor) { + monaco.editor.setTheme(monacoTheme()); + } + }); + const computedPropertiesEditorModel = this.computedPropertiesEditor.getModel(); computedPropertiesEditorModel.onDidChangeContent(this.onEditorContentChange.bind(this)); this.props.logComputedPropertiesSuccessMessage(); @@ -111,11 +141,15 @@ export class ComputedPropertiesComponent extends React.Component< return ( {isDirty(this.props.computedPropertiesContent, this.props.computedPropertiesContentBaseline) && ( - + {unsavedEditorWarningMessage("computedProperties")} )} - + {"Learn more"} diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ConflictResolutionComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ConflictResolutionComponent.tsx index 46d1d675a..422b61bfe 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ConflictResolutionComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ConflictResolutionComponent.tsx @@ -6,7 +6,6 @@ import { conflictResolutionCustomToolTip, conflictResolutionLwwTooltip, getChoiceGroupStyles, - getTextFieldStyles, subComponentStackProps, } from "../SettingsRenderUtils"; import { isDirty } from "../SettingsUtils"; @@ -106,10 +105,46 @@ export class ConflictResolutionComponent extends React.Component @@ -119,19 +154,57 @@ export class ConflictResolutionComponent extends React.Component ); - private getConflictResolutionCustomComponent = (): JSX.Element => ( - - ); + private getConflictResolutionCustomComponent = (): JSX.Element => { + return ( + + ); + }; public render(): JSX.Element { return ( diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent.tsx index 1257d4a65..5fade0420 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent.tsx @@ -102,11 +102,57 @@ export const ContainerPolicyComponent: React.FC = return (
      - + {isVectorSearchEnabled && ( @@ -128,7 +174,7 @@ export const ContainerPolicyComponent: React.FC = {isFullTextSearchEnabled && ( @@ -144,7 +190,27 @@ export const ContainerPolicyComponent: React.FC = ) : ( { checkAndSendFullTextPolicyToSettings({ defaultLanguage: getFullTextLanguageOptions()[0].key as never, diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyComponent.tsx index b2e7e12c2..8990f7c93 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyComponent.tsx @@ -1,4 +1,5 @@ -import { MessageBar, MessageBarType, Stack } from "@fluentui/react"; +import { IMessageBarStyles, MessageBar, MessageBarType, Stack } from "@fluentui/react"; +import { monacoTheme, useThemeStore } from "hooks/useTheme"; import * as monaco from "monaco-editor"; import * as React from "react"; import * as DataModels from "../../../../Contracts/DataModels"; @@ -6,7 +7,6 @@ import { loadMonaco } from "../../../LazyMonaco"; import { titleAndInputStackProps, unsavedEditorWarningMessage } from "../SettingsRenderUtils"; import { isDirty, isIndexTransforming } from "../SettingsUtils"; import { IndexingPolicyRefreshComponent } from "./IndexingPolicyRefresh/IndexingPolicyRefreshComponent"; - export interface IndexingPolicyComponentProps { shouldDiscardIndexingPolicy: boolean; resetShouldDiscardIndexingPolicy: () => void; @@ -31,6 +31,24 @@ export class IndexingPolicyComponent extends React.Component< private shouldCheckComponentIsDirty = true; private indexingPolicyDiv = React.createRef(); private indexingPolicyEditor: monaco.editor.IStandaloneCodeEditor; + private themeUnsubscribe: () => void; + + private darkThemeMessageBarStyles: Partial = { + root: { + selectors: { + "&.ms-MessageBar--warning": { + backgroundColor: "var(--colorStatusWarningBackground1)", + border: "1px solid var(--colorStatusWarningBorder1)", + }, + ".ms-MessageBar-icon": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-MessageBar-text": { + color: "var(--colorNeutralForeground1)", + }, + }, + }, + }; constructor(props: IndexingPolicyComponentProps) { super(props); @@ -52,6 +70,10 @@ export class IndexingPolicyComponent extends React.Component< this.onComponentUpdate(); } + componentWillUnmount(): void { + this.themeUnsubscribe && this.themeUnsubscribe(); + } + public resetIndexingPolicyEditor = (): void => { if (!this.indexingPolicyEditor) { this.createIndexingPolicyEditor(); @@ -87,18 +109,30 @@ export class IndexingPolicyComponent extends React.Component< }; private async createIndexingPolicyEditor(): Promise { + if (!this.indexingPolicyDiv.current) { + return; + } const value: string = JSON.stringify(this.props.indexingPolicyContent, undefined, 4); const monaco = await loadMonaco(); - this.indexingPolicyEditor = monaco.editor.create(this.indexingPolicyDiv.current, { - value: value, - language: "json", - readOnly: isIndexTransforming(this.props.indexTransformationProgress), - ariaLabel: "Indexing Policy", - }); - if (this.indexingPolicyEditor) { - const indexingPolicyEditorModel = this.indexingPolicyEditor.getModel(); - indexingPolicyEditorModel.onDidChangeContent(this.onEditorContentChange.bind(this)); - this.props.logIndexingPolicySuccessMessage(); + if (this.indexingPolicyDiv.current) { + this.indexingPolicyEditor = monaco.editor.create(this.indexingPolicyDiv.current, { + value: value, + language: "json", + readOnly: isIndexTransforming(this.props.indexTransformationProgress), + ariaLabel: "Indexing Policy", + theme: monacoTheme(), + }); + if (this.indexingPolicyEditor) { + this.themeUnsubscribe = useThemeStore.subscribe(() => { + if (this.indexingPolicyEditor) { + monaco.editor.setTheme(monacoTheme()); + } + }); + + const indexingPolicyEditorModel = this.indexingPolicyEditor.getModel(); + indexingPolicyEditorModel.onDidChangeContent(this.onEditorContentChange.bind(this)); + this.props.logIndexingPolicySuccessMessage(); + } } } @@ -121,7 +155,13 @@ export class IndexingPolicyComponent extends React.Component< refreshIndexTransformationProgress={this.props.refreshIndexTransformationProgress} /> {isDirty(this.props.indexingPolicyContent, this.props.indexingPolicyContentBaseline) && ( - {unsavedEditorWarningMessage("indexPolicy")} + + {unsavedEditorWarningMessage("indexPolicy")} + )}
      diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/__snapshots__/IndexingPolicyRefreshComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/__snapshots__/IndexingPolicyRefreshComponent.test.tsx.snap index c658dd978..813b86e05 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/__snapshots__/IndexingPolicyRefreshComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/__snapshots__/IndexingPolicyRefreshComponent.test.tsx.snap @@ -8,7 +8,7 @@ exports[`IndexingPolicyRefreshComponent renders 1`] = ` styles={ { "root": { - "color": "windowtext", + "color": "var(--colorNeutralForeground1)", "fontSize": 14, }, } diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/MongoIndexingPolicyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/MongoIndexingPolicyComponent.tsx index a55630532..36e999049 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/MongoIndexingPolicyComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/MongoIndexingPolicyComponent.tsx @@ -3,6 +3,7 @@ import { DetailsListLayoutMode, IColumn, IconButton, + IMessageBarStyles, MessageBar, MessageBarType, SelectionMode, @@ -30,12 +31,12 @@ import { } from "../../SettingsRenderUtils"; import { AddMongoIndexProps, - MongoIndexIdField, - MongoIndexTypes, - MongoNotificationType, getMongoIndexType, getMongoIndexTypeText, isIndexTransforming, + MongoIndexIdField, + MongoIndexTypes, + MongoNotificationType, } from "../../SettingsUtils"; import { IndexingPolicyRefreshComponent } from "../IndexingPolicyRefresh/IndexingPolicyRefreshComponent"; import { AddMongoIndexComponent } from "./AddMongoIndexComponent"; @@ -63,6 +64,24 @@ interface MongoIndexDisplayProps { export class MongoIndexingPolicyComponent extends React.Component { private shouldCheckComponentIsDirty = true; private addMongoIndexComponentRefs: React.RefObject[] = []; + + private darkThemeMessageBarStyles: Partial = { + root: { + selectors: { + "&.ms-MessageBar--warning": { + backgroundColor: "var(--colorStatusWarningBackground1)", + border: "1px solid var(--colorStatusWarningBorder1)", + }, + ".ms-MessageBar-icon": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-MessageBar-text": { + color: "var(--colorNeutralForeground1)", + }, + }, + }, + }; + private initialIndexesColumns: IColumn[] = [ { key: "definition", name: "Definition", fieldName: "definition", minWidth: 100, maxWidth: 200, isResizable: true }, { key: "type", name: "Type", fieldName: "type", minWidth: 100, maxWidth: 200, isResizable: true }, @@ -171,8 +190,8 @@ export class MongoIndexingPolicyComponent extends React.Component{definition}, - type: {getMongoIndexTypeText(type)}, + definition: {definition}, + type: {getMongoIndexTypeText(type)}, actionButton: definition === MongoIndexIdField ? <> : this.getActionButton(arrayPosition, isCurrentIndex), }; } @@ -306,7 +325,15 @@ export class MongoIndexingPolicyComponent extends React.Component - {warningMessage && {warningMessage}} + {warningMessage && ( + + {warningMessage} + + )} ); }; diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/AddMongoIndexComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/AddMongoIndexComponent.test.tsx.snap index 32fd68bfb..dbe7d78f4 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/AddMongoIndexComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/AddMongoIndexComponent.test.tsx.snap @@ -22,6 +22,14 @@ exports[`AddMongoIndexComponent renders 1`] = ` onChange={[Function]} styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "fieldGroup": { + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + }, "root": { "paddingLeft": 10, "width": 210, @@ -49,10 +57,52 @@ exports[`AddMongoIndexComponent renders 1`] = ` selectedKey="Single" styles={ { + "callout": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + }, + "caretDown": { + "color": "var(--colorNeutralForeground1)", + }, "dropdown": { - "paddingleft": 10, + "paddingLeft": 10, "width": 202, }, + "dropdownItem": { + "backgroundColor": "transparent", + "color": "var(--colorNeutralForeground1)", + "selectors": { + "&:focus": { + "backgroundColor": "rgba(255, 255, 255, 0.1)", + "color": "var(--colorNeutralForeground1)", + }, + "&:hover": { + "backgroundColor": "rgba(255, 255, 255, 0.1)", + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "dropdownItemSelected": { + "backgroundColor": "rgba(255, 255, 255, 0.08)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + "&:hover": { + "backgroundColor": "rgba(255, 255, 255, 0.1)", + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "dropdownItems": { + "backgroundColor": "var(--colorNeutralBackground2)", + }, + "dropdownOptionText": { + "color": "var(--colorNeutralForeground1)", + }, + "title": { + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } /> diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/MongoIndexingPolicyComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/MongoIndexingPolicyComponent.test.tsx.snap index 7a200006c..10b87a214 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/MongoIndexingPolicyComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/MongoIndexingPolicyComponent.test.tsx.snap @@ -1,7 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`MongoIndexingPolicyComponent error shown for collection with compound indexes 1`] = ` - + Collections with compound indexes are not yet supported in the indexing editor. To modify indexing policy for this collection, use the Mongo Shell. `; @@ -17,10 +23,21 @@ exports[`MongoIndexingPolicyComponent renders 1`] = ` - + For queries that filter on multiple properties, create multiple single field indexes instead of a compound index. Compound indexes @@ -83,9 +100,37 @@ exports[`MongoIndexingPolicyComponent renders 1`] = ` { "root": { "selectors": { + ".ms-DetailsHeader": { + "backgroundColor": "var(--colorNeutralBackground1)", + }, + ".ms-DetailsHeader-cell": { + "backgroundColor": "var(--colorNeutralBackground1)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":hover": { + "backgroundColor": "var(--colorNeutralBackground1Hover)", + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + ".ms-DetailsHeader-cellTitle": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-DetailsRow": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-DetailsRow-cell": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-DetailsRow-cell .ms-TooltipHost": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-FocusZone": { "paddingTop": 0, }, + ".ms-TooltipHost": { + "color": "var(--colorNeutralForeground1)", + }, }, }, } diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx index 89810bcb6..a58bf50cd 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx @@ -1,6 +1,7 @@ import { DefaultButton, FontWeights, + IMessageBarStyles, Link, MessageBar, MessageBarType, @@ -32,6 +33,23 @@ export interface PartitionKeyComponentProps { isReadOnly?: boolean; // true: cannot change partition key } +const darkThemeMessageBarStyles: Partial = { + root: { + selectors: { + "&.ms-MessageBar--warning": { + backgroundColor: "var(--colorStatusWarningBackground1)", + border: "1px solid var(--colorStatusWarningBorder1)", + }, + ".ms-MessageBar-icon": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-MessageBar-text": { + color: "var(--colorNeutralForeground1)", + }, + }, + }, +}; + export const PartitionKeyComponent: React.FC = ({ database, collection, @@ -66,13 +84,15 @@ export const PartitionKeyComponent: React.FC = ({ const partitionKeyValue = getPartitionKeyValue(); const textHeadingStyle = { - root: { fontWeight: FontWeights.semibold, fontSize: 16 }, + root: { fontWeight: FontWeights.semibold, fontSize: 16, color: "var(--colorNeutralForeground1)" }, }; const textSubHeadingStyle = { - root: { fontWeight: FontWeights.semibold }, + root: { fontWeight: FontWeights.semibold, color: "var(--colorNeutralForeground1)" }, + }; + const textSubHeadingStyle1 = { + root: { color: "var(--colorNeutralForeground1)" }, }; - const startPollingforUpdate = (currentJob: DataTransferJobGetResults) => { if (isCurrentJobInProgress(currentJob)) { const jobName = currentJob?.properties?.jobName; @@ -168,26 +188,33 @@ export const PartitionKeyComponent: React.FC = ({ Partitioning
      - {partitionKeyValue} - {isHierarchicalPartitionedContainer() ? "Hierarchical" : "Non-hierarchical"} + {partitionKeyValue} + + {isHierarchicalPartitionedContainer() ? "Hierarchical" : "Non-hierarchical"} + {!isReadOnly && ( <> - + To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the source container for the entire duration of the partition key change process. Learn more - + To change the partition key, a new destination container must be created or an existing destination container selected. Data will then be copied to the destination container. diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx index c2a0322c5..e2da4db0b 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx @@ -1,4 +1,15 @@ -import { ChoiceGroup, IChoiceGroupOption, Label, Link, MessageBar, Stack, Text, TextField } from "@fluentui/react"; +import { + ChoiceGroup, + IChoiceGroupOption, + Label, + Link, + MessageBar, + Stack, + Text, + TextField, + TooltipHost, + mergeStyleSets, +} from "@fluentui/react"; import * as React from "react"; import * as ViewModels from "../../../../Contracts/ViewModels"; import { userContext } from "../../../../UserContext"; @@ -25,6 +36,11 @@ import { } from "../SettingsUtils"; import { ToolTipLabelComponent } from "./ToolTipLabelComponent"; +const classNames = mergeStyleSets({ + hintText: { + color: "var(--colorNeutralForeground1)", // theme-aware + }, +}); export interface SubSettingsComponentProps { collection: ViewModels.Collection; timeToLive: TtlType; @@ -185,13 +201,31 @@ export class SubSettingsComponent extends React.Component - To enable time-to-live (TTL) for your collection/documents, - - create a TTL index - - . + + To enable time-to-live (TTL) for your collection/documents,{" "} + + create a TTL index + + . + ) : ( @@ -319,23 +353,34 @@ export class SubSettingsComponent extends React.Component ( {this.getPartitionKeyVisible() && ( - + + + )} {userContext.apiType === "SQL" && this.isLargePartitionKeyEnabled() && ( - Large {this.partitionKeyName.toLowerCase()} has been enabled. + Large {this.partitionKeyName.toLowerCase()} has been enabled. )} {userContext.apiType === "SQL" && (this.isHierarchicalPartitionedContainer() ? ( - Hierarchically partitioned container. + Hierarchically partitioned container. ) : ( - Non-hierarchically partitioned container. + Non-hierarchically partitioned container. ))} ); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx index a0b85cabf..f49d60967 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx @@ -65,7 +65,7 @@ export const ThroughputBucketsComponent: FC = ( return ( - + {throughputBuckets?.map((bucket) => ( @@ -77,7 +77,15 @@ export const ThroughputBucketsComponent: FC = ( onChange={(newValue) => handleBucketChange(bucket.id, newValue)} showValue={false} label={`Bucket ${bucket.id}${bucket.id === 1 ? " (Data Explorer Query Bucket)" : ""}`} - styles={{ root: { flex: 2, maxWidth: 400 } }} + styles={{ + root: { flex: 2, maxWidth: 400 }, + titleLabel: { + color: + bucket.maxThroughputPercentage === 100 + ? "var(--colorNeutralForeground4)" + : "var(--colorNeutralForeground1)", + }, + }} disabled={bucket.maxThroughputPercentage === 100} /> - Updated cost per month + + Updated cost per month + - + {newPrices.currencySign} {calculateEstimateNumber(newPrices.monthlyPrice / 10)} min - + {newPrices.currencySign} {calculateEstimateNumber(newPrices.monthlyPrice)} max @@ -254,12 +273,24 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< return ( {newThroughput && newThroughputCostElement()} - Current cost per month - - + + Current cost per month + + + {prices.currencySign} {calculateEstimateNumber(prices.monthlyPrice / 10)} min - + {prices.currencySign} {calculateEstimateNumber(prices.monthlyPrice)} max @@ -269,7 +300,12 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< return getEstimatedSpendingElement(costElement(), newThroughput ?? throughput, numberOfRegions, prices, true); }; - + settingsAndScaleStyle = { + root: { + width: ThroughputInputAutoPilotV3Component.TEXT_WIDTH_33, + color: ThroughputInputAutoPilotV3Component.TEXT_COLOR_PRIMARY, + }, + }; private getEstimatedManualSpendElement = ( throughput: number, serverId: string, @@ -289,15 +325,17 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< ); return (
      - Updated cost per month + + Updated cost per month + - + {newPrices.currencySign} {calculateEstimateNumber(newPrices.hourlyPrice)}/hr - + {newPrices.currencySign} {calculateEstimateNumber(newPrices.dailyPrice)}/day - + {newPrices.currencySign} {calculateEstimateNumber(newPrices.monthlyPrice)}/mo @@ -310,15 +348,17 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< return ( {newThroughput && newThroughputCostElement()} - Current cost per month + + Current cost per month + - + {prices.currencySign} {calculateEstimateNumber(prices.hourlyPrice)}/hr - + {prices.currencySign} {calculateEstimateNumber(prices.dailyPrice)}/day - + {prices.currencySign} {calculateEstimateNumber(prices.monthlyPrice)}/mo @@ -381,7 +421,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< {this.overrideWithProvisionedThroughputSettings() && ( {manualToAutoscaleDisclaimerElement} @@ -407,8 +447,8 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< const capacity: string = this.props.isFixed ? "Fixed" : "Unlimited"; return ( - - {capacity} + + {capacity} ); }; @@ -418,7 +458,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< { selectors: { "::before": { - backgroundColor: "rgb(200, 200, 200)", + backgroundColor: "var(--colorNeutralStroke2)", height: "3px", marginTop: "-1px", }, @@ -457,10 +497,10 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< { backgroundColor: this.getCurrentRuRange() === "instant" - ? "rgb(0, 120, 212)" + ? "var(--colorBrandBackground)" : this.getCurrentRuRange() === "delayed" - ? "rgb(255 216 109)" - : "rgb(251, 217, 203)", + ? "var(--colorStatusWarningBackground1)" + : "var(--colorStatusDangerBackground1)", }, ], }); @@ -497,14 +537,17 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< - {this.props.minimum.toLocaleString()} + {this.props.minimum.toLocaleString(ThroughputInputAutoPilotV3Component.LOCALE_EN_US)} - {this.props.instantMaximumThroughput.toLocaleString()} + {this.props.instantMaximumThroughput.toLocaleString(ThroughputInputAutoPilotV3Component.LOCALE_EN_US)} + + + {this.props.softAllowedMaximumThroughput.toLocaleString(ThroughputInputAutoPilotV3Component.LOCALE_EN_US)} - {this.props.softAllowedMaximumThroughput.toLocaleString()} + {this.props.softAllowedMaximumThroughput.toLocaleString(ThroughputInputAutoPilotV3Component.LOCALE_EN_US)} @@ -547,12 +590,41 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< } }; + private darkThemeMessageBarStyles: Partial = { + root: { + marginTop: "5px", + selectors: { + "&.ms-MessageBar--severeWarning": { + backgroundColor: "var(--colorStatusDangerBackground1)", + border: "1px solid var(--colorStatusDangerBorder1)", + }, + "&.ms-MessageBar--warning": { + backgroundColor: "var(--colorStatusWarningBackground1)", + border: "1px solid var(--colorStatusWarningBorder1)", + }, + "&.ms-MessageBar--info": { + backgroundColor: "var(--colorNeutralBackground3)", + border: "1px solid var(--colorNeutralStroke1)", + }, + ".ms-MessageBar-icon": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-MessageBar-text": { + color: "var(--colorNeutralForeground1)", + }, + }, + }, + }; + private getThroughputWarningMessageBar = (): JSX.Element => { const isSevereWarning: boolean = this.currentThroughputValue() > this.props.softAllowedMaximumThroughput || this.currentThroughputValue() < this.props.minimum; return ( - + {this.getThroughputWarningMessageText()} ); @@ -565,10 +637,13 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< {/* Column 1: Minimum RU/s */} - + Minimum RU/s - + {AutoPilotUtils.getMinRUsBasedOnUserInput(this.props.maxAutoPilotThroughput)} @@ -596,6 +672,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< fontSize: 12, fontWeight: 400, paddingBottom: 6, + color: "var(--colorNeutralForeground1)", }} > x 10 = @@ -604,10 +681,13 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< {/* Column 3: Maximum RU/s */} - + Maximum RU/s - + )} {this.props.isAutoPilotSelected ? ( - + Based on usage, your {this.props.collectionName ? "container" : "database"} throughput will scale from{" "} {AutoPilotUtils.getMinRUsBasedOnUserInput(this.props.maxAutoPilotThroughput)} RU/s (10% of max RU/s) -{" "} @@ -687,7 +784,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< {this.state.exceedFreeTierThroughput && ( {`Billing will apply if you provision more than ${SharedConstants.FreeTierLimits.RU} RU/s of manual throughput, or if the resource scales beyond ${SharedConstants.FreeTierLimits.RU} RU/s with autoscale.`} @@ -696,7 +793,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< )} {!this.overrideWithProvisionedThroughputSettings() && ( - + Estimate your required RU/s with {` capacity calculator`} @@ -737,6 +834,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< {warningMessage && ( {warningMessage} diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/__snapshots__/ThroughputInputAutoPilotV3Component.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/__snapshots__/ThroughputInputAutoPilotV3Component.test.tsx.snap index 7b144745e..8955b7589 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/__snapshots__/ThroughputInputAutoPilotV3Component.test.tsx.snap +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/__snapshots__/ThroughputInputAutoPilotV3Component.test.tsx.snap @@ -16,17 +16,35 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = ` } } role="alert" - > - + } + > + Your bill will be affected as you update your throughput settings. Please review the updated cost estimate below before saving your changes @@ -41,7 +59,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = ` styles={ { "root": { - "color": "windowtext", + "color": "var(--colorNeutralForeground1)", "fontSize": 14, }, } @@ -62,11 +80,27 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = ` styles={ { "root": { - "backgroundColor": "white", "marginTop": "5px", - }, - "text": { - "fontSize": 14, + "selectors": { + "&.ms-MessageBar--info": { + "backgroundColor": "var(--colorNeutralBackground3)", + "border": "1px solid var(--colorNeutralStroke1)", + }, + "&.ms-MessageBar--severeWarning": { + "backgroundColor": "var(--colorStatusDangerBackground1)", + "border": "1px solid var(--colorStatusDangerBorder1)", + }, + "&.ms-MessageBar--warning": { + "backgroundColor": "var(--colorStatusWarningBackground1)", + "border": "1px solid var(--colorStatusWarningBorder1)", + }, + ".ms-MessageBar-icon": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-MessageBar-text": { + "color": "var(--colorNeutralForeground1)", + }, + }, }, } } @@ -76,7 +110,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = ` styles={ { "root": { - "color": "windowtext", + "color": "var(--colorNeutralForeground1)", "fontSize": 14, }, } @@ -121,15 +155,47 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": undefined, }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -185,6 +251,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = ` 1,000,000 + + 1,000,000 + - + Storage capacity - + Unlimited @@ -500,6 +643,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = ` @@ -765,17 +950,53 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = ` step={100} styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } type="number" @@ -824,6 +1045,16 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = ` > 1,000,000 + + 1,000,000 + - + Estimate your required RU/s with - + Storage capacity - + Unlimited @@ -991,6 +1267,7 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = ` @@ -1222,17 +1537,53 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = ` step={100} styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } type="number" @@ -1281,6 +1632,16 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = ` > 1,000,000 + + 1,000,000 + - + Estimate your required RU/s with - + Storage capacity - + Unlimited @@ -1431,6 +1837,7 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = ` - {this.props.label && {this.props.label}} + {this.props.label && ( + {this.props.label} + )} {this.props.toolTipElement && ( @@ -56,17 +88,44 @@ exports[`ConflictResolutionComponent Path text field displayed 1`] = ` onRenderLabel={[Function]} styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } value="" @@ -111,15 +170,47 @@ exports[`ConflictResolutionComponent Sproc text field displayed 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -130,17 +221,44 @@ exports[`ConflictResolutionComponent Sproc text field displayed 1`] = ` onRenderLabel={[Function]} styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } value="" diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap index 95d87da3d..7347176fd 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap @@ -26,6 +26,7 @@ exports[`PartitionKeyComponent renders default component and matches snapshot 1` styles={ { "root": { + "color": "var(--colorNeutralForeground1)", "fontSize": 16, "fontWeight": 600, }, @@ -54,6 +55,7 @@ exports[`PartitionKeyComponent renders default component and matches snapshot 1` styles={ { "root": { + "color": "var(--colorNeutralForeground1)", "fontWeight": 600, }, } @@ -66,6 +68,7 @@ exports[`PartitionKeyComponent renders default component and matches snapshot 1` styles={ { "root": { + "color": "var(--colorNeutralForeground1)", "fontWeight": 600, }, } @@ -81,26 +84,79 @@ exports[`PartitionKeyComponent renders default component and matches snapshot 1` } } > - - + + Non-hierarchical To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the source container for the entire duration of the partition key change process. Learn more - + To change the partition key, a new destination container must be created or an existing destination container selected. Data will then be copied to the destination container. - - + + Non-hierarchical diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap index e6b66c4c9..10bf3b17a 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap @@ -52,15 +52,47 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -74,17 +106,53 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = ` required={true} styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } suffix="second(s)" @@ -122,15 +190,47 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -145,7 +245,7 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = ` styles={ { "root": { - "color": "windowtext", + "color": "var(--colorNeutralForeground1)", "fontSize": 14, }, } @@ -186,15 +286,47 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -206,32 +338,83 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = ` } } > - - + > + + + Large partition key has been enabled. - + Non-hierarchically partitioned container. @@ -248,17 +431,53 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = ` label="Unique keys" styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } /> @@ -318,15 +537,47 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -340,17 +591,53 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = ` required={true} styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } suffix="second(s)" @@ -388,15 +675,47 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -442,15 +761,47 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": undefined, }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -466,7 +817,7 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = ` styles={ { "root": { - "color": "windowtext", + "color": "var(--colorNeutralForeground1)", "fontSize": 14, }, } @@ -507,15 +858,47 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -527,32 +910,83 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = ` } } > - - + > + + + Large partition key has been enabled. - + Non-hierarchically partitioned container. @@ -569,17 +1003,53 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = ` label="Unique keys" styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } /> @@ -639,15 +1109,47 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -661,17 +1163,53 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = ` required={true} styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } suffix="second(s)" @@ -709,15 +1247,47 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -763,15 +1333,47 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -783,17 +1385,53 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = ` required={true} styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } suffix="second(s)" @@ -808,32 +1446,83 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = ` } } > - - + > + + + Large partition key has been enabled. - + Non-hierarchically partitioned container. @@ -850,17 +1539,53 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = ` label="Unique keys" styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } /> @@ -920,15 +1645,47 @@ exports[`SubSettingsComponent renders 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -942,17 +1699,53 @@ exports[`SubSettingsComponent renders 1`] = ` required={true} styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } suffix="second(s)" @@ -990,15 +1783,47 @@ exports[`SubSettingsComponent renders 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -1044,15 +1869,47 @@ exports[`SubSettingsComponent renders 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -1064,17 +1921,53 @@ exports[`SubSettingsComponent renders 1`] = ` required={true} styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } suffix="second(s)" @@ -1093,7 +1986,7 @@ exports[`SubSettingsComponent renders 1`] = ` styles={ { "root": { - "color": "windowtext", + "color": "var(--colorNeutralForeground1)", "fontSize": 14, }, } @@ -1134,15 +2027,47 @@ exports[`SubSettingsComponent renders 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -1154,32 +2079,83 @@ exports[`SubSettingsComponent renders 1`] = ` } } > - - + > + + + Large partition key has been enabled. - + Non-hierarchically partitioned container. @@ -1196,17 +2172,53 @@ exports[`SubSettingsComponent renders 1`] = ` label="Unique keys" styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } /> @@ -1266,15 +2278,47 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": undefined, }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -1309,15 +2353,47 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -1363,15 +2439,47 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -1383,17 +2491,53 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = ` required={true} styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } suffix="second(s)" @@ -1412,7 +2556,7 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = ` styles={ { "root": { - "color": "windowtext", + "color": "var(--colorNeutralForeground1)", "fontSize": 14, }, } @@ -1453,15 +2597,47 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = ` ".ms-ChoiceField-field.is-checked::before": { "borderColor": "", }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, ".ms-ChoiceField-wrapper label": { + "color": "var(--colorNeutralForeground1)", "fontFamily": undefined, "fontSize": 14, "padding": "2px 5px", "whiteSpace": "nowrap", }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel:hover": { + "color": "var(--colorNeutralForeground1)", + }, }, }, ], + "label": { + "color": "var(--colorNeutralForeground1)", + }, + "root": { + "selectors": { + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceField-innerField": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceFieldLabel": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, } } /> @@ -1473,32 +2649,83 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = ` } } > - - + > + + + Large partition key has been enabled. - + Non-hierarchically partitioned container. @@ -1515,17 +2742,53 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = ` label="Unique keys" styles={ { + "field": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + "selectors": { + ":disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", + }, + }, + }, "fieldGroup": { - "borderColor": "", + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", "height": 25, "selectors": { ":disabled": { - "backgroundColor": undefined, - "borderColor": undefined, + "backgroundColor": "var(--colorNeutralBackground2)", + "borderColor": "var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground2)", + }, + "input": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground1)", + }, + "input#autopilotInput": { + "backgroundColor": "var(--colorNeutralBackground4)", + "color": "var(--colorNeutralForeground1)", + }, + "input:disabled": { + "backgroundColor": "var(--colorNeutralBackground2)", + "color": "var(--colorNeutralForeground2)", }, }, "width": 300, }, + "subComponentStyles": { + "label": { + "root": { + "color": "var(--colorNeutralForeground1)", + }, + }, + }, + "suffix": { + "backgroundColor": "var(--colorNeutralBackground2)", + "border": "1px solid var(--colorNeutralStroke1)", + "color": "var(--colorNeutralForeground1)", + }, } } /> diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ToolTipLabelComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ToolTipLabelComponent.test.tsx.snap index fa801675f..1c082a553 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ToolTipLabelComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ToolTipLabelComponent.test.tsx.snap @@ -14,6 +14,7 @@ exports[`ToolTipLabelComponent renders 1`] = `
      - + > + + - + > + + - + + + - + + indexingPolicyContentBaseline={ + { + "automatic": true, + "excludedPaths": [], + "includedPaths": [], + "indexingMode": "consistent", + } + } + isVectorSearchEnabled={false} + logIndexingPolicySuccessMessage={[Function]} + onIndexingPolicyContentChange={[Function]} + onIndexingPolicyDirtyChange={[Function]} + refreshIndexTransformationProgress={[Function]} + resetShouldDiscardIndexingPolicy={[Function]} + shouldDiscardIndexingPolicy={false} + /> + - + + isReadOnly={false} + /> + - + > + + - + + /> +
      diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsRenderUtils.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsRenderUtils.test.tsx.snap index 3399ab9a3..baae4ef02 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsRenderUtils.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsRenderUtils.test.tsx.snap @@ -6,6 +6,7 @@ exports[`SettingsUtils functions render 1`] = ` Enable change feed log retention policy to retain last 10 minutes of history for items in the container by default. To support this, the request unit (RU) charge for this container will be multiplied by a factor of two for writes. Reads are unaffected. - + For queries that filter on multiple properties, create multiple single field indexes instead of a compound index. Compound indexes @@ -256,7 +270,7 @@ exports[`SettingsUtils functions render 1`] = ` styles={ { "root": { - "color": "windowtext", + "color": "var(--colorNeutralForeground1)", "fontSize": 14, }, } @@ -272,7 +286,7 @@ exports[`SettingsUtils functions render 1`] = ` styles={ { "root": { - "color": "windowtext", + "color": "var(--colorNeutralForeground1)", "fontSize": 14, }, } @@ -289,7 +303,7 @@ exports[`SettingsUtils functions render 1`] = ` styles={ { "root": { - "color": "windowtext", + "color": "var(--colorNeutralForeground1)", "fontSize": 14, }, } diff --git a/src/Explorer/Controls/ThroughputInput/CostEstimateText/CostEstimateText.tsx b/src/Explorer/Controls/ThroughputInput/CostEstimateText/CostEstimateText.tsx index 4667a1a74..d5a4b9291 100644 --- a/src/Explorer/Controls/ThroughputInput/CostEstimateText/CostEstimateText.tsx +++ b/src/Explorer/Controls/ThroughputInput/CostEstimateText/CostEstimateText.tsx @@ -54,8 +54,8 @@ export const CostEstimateText: FunctionComponent = ({ if (isAutoscale) { return ( - - + + {estimatedMonthlyCost} ({currency}){iconWithEstimatedCostDisclaimer}:{" "} {currencySign + calculateEstimateNumber(monthlyPrice / 10)} -{" "} @@ -70,7 +70,7 @@ export const CostEstimateText: FunctionComponent = ({ return ( - + Estimated cost ({currency}){iconWithEstimatedCostDisclaimer}:{" "} {currencySign + calculateEstimateNumber(hourlyPrice)} hourly /{" "} diff --git a/src/Explorer/Controls/ThroughputInput/ThroughputInput.less b/src/Explorer/Controls/ThroughputInput/ThroughputInput.less index 059b210f6..816145a00 100644 --- a/src/Explorer/Controls/ThroughputInput/ThroughputInput.less +++ b/src/Explorer/Controls/ThroughputInput/ThroughputInput.less @@ -10,9 +10,13 @@ font-size: @mediumFontSize; padding: 0 @LargeSpace 0 @SmallSpace; } +// .throughputInputSpacing{ +// color: "var(--colorNeutralForeground1)"; +// } .throughputInputSpacing > :not(:last-child) { margin-bottom: @DefaultSpace; + color: "var(--colorNeutralForeground1)"; } .capacitycalculator-link:focus { @@ -28,3 +32,16 @@ .deleteQuery:focus::after { outline: none !important; } + +// Override Fluent UI TextField focus styles +.throughputInputContainer { + :global { + .ms-TextField { + .ms-TextField-fieldGroup { + &:focus-within { + border-color: var(--colorCompoundBrandStroke1, @SelectionColor); + } + } + } + } +} diff --git a/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx b/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx index 02b6853ca..6d71eb9c2 100644 --- a/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx +++ b/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx @@ -193,7 +193,11 @@ export const ThroughputInput: FunctionComponent = ({
      - + {getThroughputLabelText()} {PricingUtils.getRuToolTipText()} @@ -236,14 +240,17 @@ export const ThroughputInput: FunctionComponent = ({ {isAutoscaleSelected && ( - + Your container throughput will automatically scale up to the maximum value you select, from a minimum of 10% of that value. - + Minimum RU/s The minimum RU/s your container will scale to @@ -260,6 +267,7 @@ export const ThroughputInput: FunctionComponent = ({ display: "flex", alignItems: "center", justifyContent: "center", + color: "var(--colorNeutralForeground1)", }} > {Math.round(throughput / 10).toString()} @@ -272,6 +280,7 @@ export const ThroughputInput: FunctionComponent = ({ fontSize: 12, fontWeight: 400, paddingBottom: 6, + color: "var(--colorNeutralForeground1)", }} > x 10 = @@ -279,7 +288,10 @@ export const ThroughputInput: FunctionComponent = ({ - + Maximum RU/s {getAutoScaleTooltip()} @@ -290,7 +302,7 @@ export const ThroughputInput: FunctionComponent = ({ type="number" styles={{ fieldGroup: { width: 100, height: 27, flexShrink: 0 }, - field: { fontSize: 14, fontWeight: 400 }, + field: { fontSize: 14, fontWeight: 400, color: "var(--colorNeutralForeground1)" }, }} onChange={(_event, newInput?: string) => onThroughputValueChange(newInput)} step={AutoPilotUtils.autoPilotIncrementStep} @@ -306,7 +318,7 @@ export const ThroughputInput: FunctionComponent = ({ - + Estimate your required RU/s with  = ({ {!isAutoscaleSelected && ( - + Estimate your required RU/s with  = ({ . - + {isDatabase ? "Database" : getCollectionName()} Required RU/s {getAutoScaleTooltip()} diff --git a/src/Explorer/Controls/ThroughputInput/__snapshots__/ThroughputInput.test.tsx.snap b/src/Explorer/Controls/ThroughputInput/__snapshots__/ThroughputInput.test.tsx.snap index 35028368a..db631aae0 100644 --- a/src/Explorer/Controls/ThroughputInput/__snapshots__/ThroughputInput.test.tsx.snap +++ b/src/Explorer/Controls/ThroughputInput/__snapshots__/ThroughputInput.test.tsx.snap @@ -31,6 +31,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` key=".0:$.$.1" style={ { + "color": "var(--colorNeutralForeground1)", "fontWeight": 600, "lineHeight": "20px", } @@ -42,6 +43,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` className="css-110" style={ { + "color": "var(--colorNeutralForeground1)", "fontWeight": 600, "lineHeight": "20px", } @@ -724,6 +726,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` key=".0:$.$.0" style={ { + "color": "var(--colorNeutralForeground1)", "fontSize": 12, "marginTop": -2, } @@ -733,6 +736,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` className="css-117" style={ { + "color": "var(--colorNeutralForeground1)", "fontSize": 12, "marginTop": -2, } @@ -782,6 +786,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` key=".0:$.$.0" style={ { + "color": "var(--colorNeutralForeground1)", "fontWeight": 600, "lineHeight": "20px", } @@ -792,6 +797,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` className="css-110" style={ { + "color": "var(--colorNeutralForeground1)", "fontWeight": 600, "lineHeight": "20px", } @@ -1423,6 +1429,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` "alignItems": "center", "backgroundColor": "transparent", "border": "none", + "color": "var(--colorNeutralForeground1)", "display": "flex", "fontFamily": "Segoe UI", "fontSize": 14, @@ -1440,6 +1447,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` "alignItems": "center", "backgroundColor": "transparent", "border": "none", + "color": "var(--colorNeutralForeground1)", "display": "flex", "fontFamily": "Segoe UI", "fontSize": 14, @@ -1459,6 +1467,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` key=".0:$.$.1" style={ { + "color": "var(--colorNeutralForeground1)", "fontFamily": "Segoe UI", "fontSize": 12, "fontWeight": 400, @@ -1470,6 +1479,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` className="css-117" style={ { + "color": "var(--colorNeutralForeground1)", "fontFamily": "Segoe UI", "fontSize": 12, "fontWeight": 400, @@ -1508,6 +1518,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` key=".0:$.$.0" style={ { + "color": "var(--colorNeutralForeground1)", "fontWeight": 600, "lineHeight": "20px", } @@ -1518,6 +1529,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` className="css-110" style={ { + "color": "var(--colorNeutralForeground1)", "fontWeight": 600, "lineHeight": "20px", } @@ -2156,6 +2168,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` styles={ { "field": { + "color": "var(--colorNeutralForeground1)", "fontSize": 14, "fontWeight": 400, }, @@ -2509,11 +2522,21 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` Estimate your required RU/s with  ) => void; + disabled?: boolean; + type?: "button" | "submit" | "reset"; +}; + +const useStyles = makeStyles({ + button: { + backgroundColor: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground1, + "&:hover": { + backgroundColor: tokens.colorNeutralBackground1Hover, + color: tokens.colorNeutralForeground1Hover, + }, + "&:active": { + backgroundColor: tokens.colorNeutralBackground1Pressed, + color: tokens.colorNeutralForeground1Pressed, + }, + }, + primary: { + backgroundColor: tokens.colorBrandBackground, + color: tokens.colorNeutralForegroundOnBrand, + "&:hover": { + backgroundColor: tokens.colorBrandBackgroundHover, + }, + "&:active": { + backgroundColor: tokens.colorBrandBackgroundPressed, + }, + }, +}); + +export const Button = React.forwardRef(({ primary, ...props }, ref) => { + const baseStyles = useStyles(); + const buttonClassName = primary ? baseStyles.primary : baseStyles.button; + + return ( + + ); +}); + +Button.displayName = "Button"; diff --git a/src/Explorer/DataExplorer.tsx b/src/Explorer/DataExplorer.tsx new file mode 100644 index 000000000..ac0c399da --- /dev/null +++ b/src/Explorer/DataExplorer.tsx @@ -0,0 +1,26 @@ +import { makeStyles } from "@fluentui/react-components"; +import React from "react"; +import type { Explorer } from "../Contracts/ViewModels"; + +interface DataExplorerProps { + dataExplorer?: Explorer; +} + +const useStyles = makeStyles({ + root: { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + height: "100%", + width: "100%", + }, +}); + +export const DataExplorer: React.FC = () => { + const styles = useStyles(); + + return ( +
      +
      Data Explorer Content
      +
      + ); +}; diff --git a/src/Explorer/ErrorBoundary.tsx b/src/Explorer/ErrorBoundary.tsx new file mode 100644 index 000000000..d92325b59 --- /dev/null +++ b/src/Explorer/ErrorBoundary.tsx @@ -0,0 +1,38 @@ +import React, { Component, ReactNode } from "react"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error): void { + console.error("Error caught in boundary:", error); + } + + public render() { + if (this.state.hasError) { + return ( +
      +

      Something went wrong.

      +
      {this.state.error && this.state.error.toString()}
      +
      + ); + } + + return this.props.children; + } +} diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index 75abd2f47..0b32b3ded 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -38,6 +38,9 @@ import { ContainerConnectionInfo, IPhoenixServiceInfo, IProvisionData, IResponse import * as ViewModels from "../Contracts/ViewModels"; import { UploadDetailsRecord } from "../Contracts/ViewModels"; import { GitHubOAuthService } from "../GitHub/GitHubOAuthService"; +import MetricScenario from "../Metrics/MetricEvents"; +import { ApplicationMetricPhase } from "../Metrics/ScenarioConfig"; +import { scenarioMonitor } from "../Metrics/ScenarioMonitor"; import { PhoenixClient } from "../Phoenix/PhoenixClient"; import * as ExplorerSettings from "../Shared/ExplorerSettings"; import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants"; @@ -402,7 +405,9 @@ export default class Explorer { updatedDatabases = [...updatedDatabases, ...deltaDatabases.toAdd].sort((db1, db2) => db1.id().localeCompare(db2.id()), ); - useDatabases.setState({ databases: updatedDatabases }); + useDatabases.setState({ databases: updatedDatabases, databasesFetchedSuccessfully: true }); + scenarioMonitor.completePhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched); + await this.refreshAndExpandNewDatabases(deltaDatabases.toAdd, updatedDatabases); } catch (error) { const errorMessage = getErrorMessage(error); @@ -416,6 +421,8 @@ export default class Explorer { startKey, ); logConsoleError(`Error while refreshing databases: ${errorMessage}`); + useDatabases.setState({ databasesFetchedSuccessfully: false }); + scenarioMonitor.failPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched); } } @@ -1183,6 +1190,11 @@ export default class Explorer { } public async refreshExplorer(): Promise { + // Start DatabaseLoad scenario before fetching databases + if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") { + scenarioMonitor.start(MetricScenario.DatabaseLoad); + } + if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") { userContext.authType === AuthType.ResourceToken ? this.refreshDatabaseForResourceToken() diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponent.less b/src/Explorer/Menus/CommandBar/CommandBarComponent.less index b94c0b265..8c83d4e87 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponent.less +++ b/src/Explorer/Menus/CommandBar/CommandBarComponent.less @@ -4,11 +4,10 @@ padding: @SmallSpace 0px @SmallSpace 0px; .flex-display(); span { - border-left: @ButtonBorderWidth solid @BaseMediumHigh; margin: 0 10px 0 10px; } } .commandBarContainer { - border-bottom: 1px solid @BaseMedium; + border-bottom: 1px solid var(--colorNeutralStroke1); } diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx index b65310691..675d00564 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx @@ -4,6 +4,7 @@ * and update any knockout observables passed from the parent. */ import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react"; +import { makeStyles, useFluent } from "@fluentui/react-components"; import { useNotebook } from "Explorer/Notebook/useNotebook"; import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane"; import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; @@ -12,7 +13,6 @@ import { userContext } from "UserContext"; import * as React from "react"; import create, { UseStore } from "zustand"; import { ConnectionStatusType, PoolIdType } from "../../../Common/Constants"; -import { StyleConstants } from "../../../Common/StyleConstants"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import Explorer from "../../Explorer"; import { useSelectedNode } from "../../useSelectedNode"; @@ -37,12 +37,49 @@ export const useCommandBar: UseStore = create((set) => ({ setIsHidden: (isHidden: boolean) => set((state) => ({ ...state, isHidden })), })); +const useStyles = makeStyles({ + commandBarContainer: { + borderBottom: "1px solid var(--colorNeutralStroke1)", + // backgroundColor: "var(--colorNeutralBackground1)", + }, + toolbarButton: { + backgroundColor: "transparent", + "&:hover": { + backgroundColor: "var(--colorNeutralBackground2)", + }, + "&:active": { + backgroundColor: "var(--colorNeutralBackground3)", + }, + }, + buttonIcon: { + width: "16px", + height: "16px", + display: "flex", + alignItems: "center", + justifyContent: "center", + "& img": { + width: "100%", + height: "100%", + objectFit: "contain", + }, + }, +}); + export const CommandBar: React.FC = ({ container }: Props) => { const selectedNodeState = useSelectedNode(); const buttons = useCommandBar((state) => state.contextButtons); const isHidden = useCommandBar((state) => state.isHidden); - const backgroundColor = StyleConstants.BaseLight; + // targetDocument is used by referenced components + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { targetDocument } = useFluent(); const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR); + const styles = useStyles(); + + const { connectionInfo, isPhoenixNotebooks, isPhoenixFeatures } = useNotebook((state) => ({ + connectionInfo: state.connectionInfo, + isPhoenixNotebooks: state.isPhoenixNotebooks, + isPhoenixFeatures: state.isPhoenixFeatures, + })); // Subscribe to the store changes that affect button creation const dataPlaneRbacEnabled = useDataPlaneRbac((state) => state.dataPlaneRbacEnabled); @@ -59,12 +96,15 @@ export const CommandBar: React.FC = ({ container }: Props) => { ? CommandBarComponentButtonFactory.createPostgreButtons(container) : CommandBarComponentButtonFactory.createVCoreMongoButtons(container); return ( -
      +
      @@ -77,50 +117,69 @@ export const CommandBar: React.FC = ({ container }: Props) => { ); const controlButtons = CommandBarComponentButtonFactory.createControlCommandBarButtons(container); - const uiFabricStaticButtons = CommandBarUtil.convertButton(staticButtons, backgroundColor); + const uiFabricStaticButtons = CommandBarUtil.convertButton(staticButtons, "var(--colorNeutralBackground1)"); if (buttons && buttons.length > 0) { uiFabricStaticButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true)); } - const uiFabricTabsButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(contextButtons, backgroundColor); + const uiFabricTabsButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton( + contextButtons, + "var(--colorNeutralBackground1)", + ); if (uiFabricTabsButtons.length > 0) { uiFabricStaticButtons.push(CommandBarUtil.createDivider("commandBarDivider")); } - const uiFabricControlButtons = CommandBarUtil.convertButton(controlButtons, backgroundColor); + const uiFabricControlButtons = CommandBarUtil.convertButton(controlButtons, "var(--colorNeutralBackground1)"); uiFabricControlButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true)); - const connectionInfo = useNotebook((state) => state.connectionInfo); - - if ( - (useNotebook.getState().isPhoenixNotebooks || useNotebook.getState().isPhoenixFeatures) && - connectionInfo?.status !== ConnectionStatusType.Connect - ) { + // Add connection status if needed (using the hook values we got at the top level) + if ((isPhoenixNotebooks || isPhoenixFeatures) && connectionInfo?.status !== ConnectionStatusType.Connect) { uiFabricControlButtons.unshift( CommandBarUtil.createConnectionStatus(container, PoolIdType.DefaultPoolId, "connectionStatus"), ); } - const rootStyle = isFabric() - ? { - root: { - backgroundColor: "transparent", - padding: "2px 8px 0px 8px", + const rootStyle = { + root: { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + padding: isFabric() ? "2px 8px 0px 8px" : undefined, + }, + button: { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + selectors: { + ":hover": { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground1)", }, - } - : { - root: { - backgroundColor: backgroundColor, + ":active": { + backgroundColor: "var(--colorNeutralBackground3)", + color: "var(--colorNeutralForeground1)", }, - }; + }, + }, + menuIcon: { + color: "var(--colorNeutralForeground1)", + }, + item: { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + }, + link: { + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + }, + }; const allButtons = staticButtons.concat(contextButtons).concat(controlButtons); const keyboardHandlers = CommandBarUtil.createKeyboardHandlers(allButtons); setKeyboardHandlers(keyboardHandlers); return ( -
      +
      { + const [darkMode, setDarkMode] = React.useState(useThemeStore.getState().isDarkMode); + + React.useEffect(() => { + const unsubscribe = useThemeStore.subscribe((state) => { + setDarkMode(state.isDarkMode); + }); + return unsubscribe; + }, []); + + const tooltipText = darkMode ? "Switch to Light Theme" : "Switch to Dark Theme"; + + return { + iconSrc: darkMode ? SunIcon : MoonIcon, + iconAlt: "Theme Toggle", + onCommandClick: useThemeStore.getState().toggleTheme, + commandButtonLabel: undefined, + ariaLabel: tooltipText, + tooltipText: tooltipText, + hasPopup: false, + disabled: false, + }; +}; diff --git a/src/Explorer/Menus/NotificationConsole/NotificationConsole.less b/src/Explorer/Menus/NotificationConsole/NotificationConsole.less index fb5aed51c..3277e7234 100644 --- a/src/Explorer/Menus/NotificationConsole/NotificationConsole.less +++ b/src/Explorer/Menus/NotificationConsole/NotificationConsole.less @@ -30,11 +30,11 @@ flex-shrink:0; &:hover { - background-color:@NotificationHigh; + background-color: @NotificationHigh; } &:active { - background-color:@NotificationHigh; + background-color: @NotificationHigh; } &:focus { @@ -189,4 +189,44 @@ } } } +} + +// Dark theme specific overrides +body.isDarkMode { + .notificationConsoleContainer { + .notificationConsoleHeader { + background-color: var(--colorNeutralBackground2); + color: var(--colorNeutralForeground1); + + &:hover { + background-color: var(--colorNeutralBackground3); + color: var(--colorNeutralForeground1); + } + + &:active { + background-color: var(--colorNeutralBackground4); + color: var(--colorNeutralForeground1); + } + } + + .notificationConsoleContents { + background-color: var(--colorNeutralBackground1); + color: var(--colorNeutralForeground1); + + .clearNotificationsButton { + border: @ButtonBorderWidth solid var(--colorNeutralStroke1); + + &:hover { + background-color: var(--colorNeutralBackground3); + color: var(--colorNeutralForeground1); + } + + &:active { + border: @ButtonBorderWidth dashed var(--colorBrandForeground1); + background-color: var(--colorBrandBackground); + color: var(--colorNeutralForegroundOnBrand); + } + } + } + } } \ No newline at end of file diff --git a/src/Explorer/Menus/NotificationConsole/NotificationConsoleComponent.tsx b/src/Explorer/Menus/NotificationConsole/NotificationConsoleComponent.tsx index e3dda1418..98cc63758 100644 --- a/src/Explorer/Menus/NotificationConsole/NotificationConsoleComponent.tsx +++ b/src/Explorer/Menus/NotificationConsole/NotificationConsoleComponent.tsx @@ -152,6 +152,82 @@ export class NotificationConsoleComponent extends React.Component< selectedKey={this.state.selectedFilter} options={NotificationConsoleComponent.FilterOptions} onChange={this.onFilterSelected.bind(this)} + styles={{ + root: { + color: "var(--colorNeutralForeground1)", + }, + label: { + color: "var(--colorNeutralForeground1)", + }, + dropdown: { + backgroundColor: "var(--colorNeutralBackground2)", + borderColor: "var(--colorNeutralStroke1)", + color: "var(--colorNeutralForeground1)", + }, + title: { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground1)", + borderColor: "var(--colorNeutralStroke1)", + fontSize: "14px", + selectors: { + "&:hover": { + backgroundColor: "var(--colorNeutralBackground3)", + color: "var(--colorNeutralForeground1)", + borderColor: "var(--colorNeutralStroke1)", + }, + "&:focus": { + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground1)", + borderColor: "var(--colorBrandStroke1)", + }, + "&:after": { + borderColor: "var(--colorNeutralStroke1)", + }, + span: { + color: "var(--colorNeutralForeground1)", + }, + }, + }, + caretDown: { + color: "var(--colorNeutralForeground1)", + }, + callout: { + backgroundColor: "var(--colorNeutralBackground2)", + border: "1px solid var(--colorNeutralStroke1)", + }, + dropdownItems: { + backgroundColor: "var(--colorNeutralBackground2)", + }, + dropdownItem: { + backgroundColor: "transparent", + color: "var(--colorNeutralForeground1)", + selectors: { + "&:hover": { + backgroundColor: "var(--colorNeutralBackground4)", + color: "var(--colorNeutralForeground1)", + }, + "&:focus": { + backgroundColor: "var(--colorNeutralBackground4)", + color: "var(--colorNeutralForeground1)", + }, + ".ms-Dropdown-optionText": { + color: "var(--colorNeutralForeground1)", + }, + }, + }, + dropdownItemSelected: { + backgroundColor: "var(--colorBrandBackground)", + color: "var(--colorNeutralForegroundOnBrand)", + selectors: { + ".ms-Dropdown-optionText": { + color: "var(--colorNeutralForegroundOnBrand)", + }, + }, + }, + dropdownOptionText: { + color: "var(--colorNeutralForeground1)", + }, + }} /> { <>
      - + Open this database account in a new browser tab with Cosmos DB Explorer. You can connect using your Microsoft account or a connection string. diff --git a/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx index 58d7037b6..4a1009ba2 100644 --- a/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx +++ b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx @@ -350,9 +350,14 @@ export class AddCollectionPanel extends React.Component, isChecked: boolean) => this.setState({ isSharedThroughputChecked: isChecked }) @@ -649,7 +654,27 @@ export class AddCollectionPanel extends React.Component {subPartitionKeys.length > 0 && ( - + This feature allows you to partition your data with up to three levels of keys for better data distribution. Requires .NET V3, Java V4 SDK, or preview JavaScript V3 SDK.{" "} diff --git a/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx index 7d619b995..fbe5f8a1a 100644 --- a/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx +++ b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx @@ -135,7 +135,7 @@ export const ChangePartitionKeyPane: React.FC = ({ return ( - + When changing a container’s partition key, you will need to create a destination container with the correct partition key. You may also select an existing destination container.  = ({ Add hierarchical partition key {subPartitionKeys.length > 0 && ( - + This feature allows you to partition your data with up to three levels of keys for better data distribution. Requires .NET V3, Java V4 SDK, or preview JavaScript V3 SDK.{" "} diff --git a/src/Explorer/Panes/LoadQueryPane/LoadQueryPane.tsx b/src/Explorer/Panes/LoadQueryPane/LoadQueryPane.tsx index 6ace63e2e..52214c308 100644 --- a/src/Explorer/Panes/LoadQueryPane/LoadQueryPane.tsx +++ b/src/Explorer/Panes/LoadQueryPane/LoadQueryPane.tsx @@ -94,7 +94,14 @@ export const LoadQueryPane: FunctionComponent = (): JSX.Element => { value={selectedFileName} autoFocus readOnly - styles={{ fieldGroup: { width: 300 } }} + styles={{ + fieldGroup: { width: 300, color: "var(--colorNeutralForeground1)" }, + subComponentStyles: { + label: { + root: { color: "var(--colorNeutralForeground1)" }, + }, + }, + }} />
      @@ -997,7 +1082,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ ariaLabel="Enable cross partition query" checked={crossPartitionQueryEnabled} onChange={() => setCrossPartitionQueryEnabled(!crossPartitionQueryEnabled)} - label="Enable cross-partition query" + onRenderLabel={() => ( + Enable cross-partition query + )} />
      @@ -1029,7 +1116,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ ariaLabel="EnableQueryControl" checked={queryControlEnabled} onChange={() => setQueryControlEnabled(!queryControlEnabled)} - label="Enable query control" + onRenderLabel={() => ( + Enable query control + )} />
      @@ -1063,6 +1152,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ onValidate={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) || maxDegreeOfParallelism)} ariaLabel="Max degree of parallelism" label="Max degree of parallelism" + styles={spinButtonStyles} />
      @@ -1132,7 +1222,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ ariaLabel="Enable sample db for query exploration" checked={copilotSampleDBEnabled} onChange={handleSampleDatabaseChange} - label="Enable sample database" + onRenderLabel={() => ( + Enable sample database + )} />
      @@ -1167,7 +1259,29 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
      = ({
      { useDialog.getState().showOkCancelModalDialog( "Clear History", @@ -1223,7 +1353,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
      -
      +
      Explorer Version
      {explorerVersion}
      diff --git a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap index ca0243ec7..af4dbbd11 100644 --- a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap +++ b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap @@ -19,14 +19,14 @@ exports[`Settings Pane should render Default properly 1`] = ` >
      Page Options
      @@ -112,14 +176,14 @@ exports[`Settings Pane should render Default properly 1`] = ` >
      Query Timeout
      RU Limit
      @@ -224,14 +312,14 @@ exports[`Settings Pane should render Default properly 1`] = ` >
      Default Query Results View
      Retry Settings
      Enable container pagination
      Enable cross-partition query
      Enhanced query control
      Max degree of parallelism
      @@ -580,14 +799,14 @@ exports[`Settings Pane should render Default properly 1`] = ` >
      Advanced Settings
      @@ -630,6 +867,24 @@ exports[`Settings Pane should render Default properly 1`] = ` > Clear History @@ -639,7 +894,7 @@ exports[`Settings Pane should render Default properly 1`] = ` className="settingsSection" >
      Retry Settings
      Enable container pagination
      Display Gremlin query results as: 
      Advanced Settings
      @@ -955,6 +1295,24 @@ exports[`Settings Pane should render Gremlin properly 1`] = ` > Clear History @@ -964,7 +1322,7 @@ exports[`Settings Pane should render Gremlin properly 1`] = ` className="settingsSection" >