Compare commits
96 Commits
users/aisa
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42e230b88b | ||
|
|
6196ba4722 | ||
|
|
2c31ec2a8d | ||
|
|
bc7e8a71ca | ||
|
|
d67c1a0464 | ||
|
|
5b7d1a74af | ||
|
|
8c0e6da377 | ||
|
|
a714ef02c0 | ||
|
|
ca858c08fb | ||
|
|
fa18b85364 | ||
|
|
d060f22357 | ||
|
|
9a6f090374 | ||
|
|
63cddeb4b8 | ||
|
|
bb0bbd8a6e | ||
|
|
a33429fd85 | ||
|
|
784dadce30 | ||
|
|
490309b403 | ||
|
|
0fac59967a | ||
|
|
c72d921866 | ||
|
|
125b1c86b7 | ||
|
|
beccab02e7 | ||
|
|
a2e90b3a38 | ||
|
|
33a7412cf3 | ||
|
|
6b150dbfa0 | ||
|
|
bbdf0ce57e | ||
|
|
2417da152d | ||
|
|
3718f5a16a | ||
|
|
08f55ded3d | ||
|
|
74cd4b2ff4 | ||
|
|
27e07bcd01 | ||
|
|
18ecaaba78 | ||
|
|
0578910b9e | ||
|
|
ff1eb6a78e | ||
|
|
31ec3c08bc | ||
|
|
abf4b3bd0f | ||
|
|
d0d615a85a | ||
|
|
2996120235 | ||
|
|
3cd6d5a65d | ||
|
|
d924824536 | ||
|
|
cd27814fad | ||
|
|
909957a9a1 | ||
|
|
569e5ed1fc | ||
|
|
a5c3e6bea0 | ||
|
|
76e63818d3 | ||
|
|
cfb5db4df6 | ||
|
|
922ca5c523 | ||
|
|
bafe002fa3 | ||
|
|
0817acf404 | ||
|
|
8e2c46301d | ||
|
|
012d043c78 | ||
|
|
3afd74a957 | ||
|
|
0ef4399ba4 | ||
|
|
870863a723 | ||
|
|
e3815734db | ||
|
|
5ea78f9abf | ||
|
|
8a56214ec2 | ||
|
|
e3ae006100 | ||
|
|
589b61afaf | ||
|
|
eb3f6bc93f | ||
|
|
6ec909a97b | ||
|
|
08a51ca6b1 | ||
|
|
30a3b5c7a4 | ||
|
|
f370507a27 | ||
|
|
e0edaf405c | ||
|
|
f8231600d6 | ||
|
|
45c8d70c77 | ||
|
|
70d7ee755b | ||
|
|
0a4aed4f47 | ||
|
|
a7d007e0dd | ||
|
|
5f4a4e5c4c | ||
|
|
1b64827c24 | ||
|
|
a6ae784a45 | ||
|
|
7458107efd | ||
|
|
64533b445f | ||
|
|
d7bdd0032e | ||
|
|
372ac6921f | ||
|
|
c6eda097fc | ||
|
|
05d02f08fa | ||
|
|
ab4f02f74a | ||
|
|
0fc6647627 | ||
|
|
c5ed537109 | ||
|
|
db322ccb59 | ||
|
|
2d7631c358 | ||
|
|
e401c88df6 | ||
|
|
f14b574527 | ||
|
|
45513e5e1b | ||
|
|
15154dfd6a | ||
|
|
7aeb682bea | ||
|
|
35051bace5 | ||
|
|
5fc53a7f89 | ||
|
|
ed83bf47e4 | ||
|
|
d657c4919e | ||
|
|
95d33356c3 | ||
|
|
1081432bbd | ||
|
|
44d815454c | ||
|
|
6d604490d3 |
@@ -23,8 +23,6 @@ src/Common/MongoUtility.ts
|
||||
src/Common/NotificationsClientBase.ts
|
||||
src/Common/QueriesClient.ts
|
||||
src/Common/Splitter.ts
|
||||
src/Controls/Heatmap/Heatmap.test.ts
|
||||
src/Controls/Heatmap/Heatmap.ts
|
||||
src/Definitions/datatables.d.ts
|
||||
src/Definitions/gif.d.ts
|
||||
src/Definitions/globals.d.ts
|
||||
|
||||
@@ -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"] }],
|
||||
|
||||
32
.github/workflows/ci.yml
vendored
@@ -177,9 +177,39 @@ jobs:
|
||||
- name: "Az CLI login"
|
||||
uses: Azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
client-id: ${{ secrets.E2E_TESTS_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
# We can't use MSAL within playwright so we acquire tokens prior to running the tests
|
||||
- name: "Acquire RBAC tokens for test accounts"
|
||||
uses: azure/cli@v2
|
||||
with:
|
||||
azcliversion: latest
|
||||
inlineScript: |
|
||||
NOSQL_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$NOSQL_TESTACCOUNT_TOKEN"
|
||||
echo NOSQL_TESTACCOUNT_TOKEN=$NOSQL_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
NOSQL_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-readonly.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$NOSQL_READONLY_TESTACCOUNT_TOKEN"
|
||||
echo NOSQL_READONLY_TESTACCOUNT_TOKEN=$NOSQL_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
TABLE_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-tables.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$TABLE_TESTACCOUNT_TOKEN"
|
||||
echo TABLE_TESTACCOUNT_TOKEN=$TABLE_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
GREMLIN_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-gremlin.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$GREMLIN_TESTACCOUNT_TOKEN"
|
||||
echo GREMLIN_TESTACCOUNT_TOKEN=$GREMLIN_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
|
||||
echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
|
||||
echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
|
||||
echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
|
||||
echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
|
||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3
|
||||
- name: Upload blob report to GitHub Actions Artifacts
|
||||
|
||||
2
.github/workflows/cleanup.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: "Az CLI login"
|
||||
uses: azure/login@v1
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
client-id: ${{ secrets.E2E_TESTS_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
|
||||
@@ -19,6 +19,6 @@
|
||||
</frameworkAssemblies>
|
||||
</metadata>
|
||||
<files>
|
||||
<file src="**\*" target="content"/>
|
||||
<file src="**\*" exclude="obj\**\*" target="content"/>
|
||||
</files>
|
||||
</package>
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"JUNO_ENDPOINT": "https://tools.cosmos.azure.com",
|
||||
"isTerminalEnabled": true,
|
||||
"isPhoenixEnabled": true
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"JUNO_ENDPOINT": "https://tools.cosmos.azure.com",
|
||||
"isTerminalEnabled" : false,
|
||||
"isPhoenixEnabled": false
|
||||
}
|
||||
1
images/AzureOpenAi.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="uuid-adbdae8e-5a41-46d1-8c18-aa73cdbfee32" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><path d="m0,2.7v12.6c0,1.491,1.209,2.7,2.7,2.7h12.6c1.491,0,2.7-1.209,2.7-2.7V2.7c0-1.491-1.209-2.7-2.7-2.7H2.7C1.209,0,0,1.209,0,2.7ZM10.8,0v3.6c0,3.976,3.224,7.2,7.2,7.2h-3.6c-3.976,0-7.199,3.222-7.2,7.198v-3.598c0-3.976-3.224-7.2-7.2-7.2h3.6c3.976,0,7.2-3.224,7.2-7.2Z" fill="#000000" stroke-width="0" /></svg>
|
||||
|
After Width: | Height: | Size: 443 B |
17
images/ContainerCopy/copy-jobs.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg width="96" height="104" viewBox="0 0 96 104" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path opacity="0.2" d="M80.5008 81.2203L41.2637 58.2012L35.7705 61.9941L74.6152 84.6208L80.5008 81.2203Z" fill="#AAAAAA"/>
|
||||
<path opacity="0.2" d="M60.2283 92.5992L20.9912 69.5801L15.498 73.373L54.3428 95.9997L60.2283 92.5992Z" fill="#AAAAAA"/>
|
||||
<path d="M63.7596 30.9969L74.8768 37.4057L74.746 82.1359L35.7705 59.7708L35.9013 3.00781L63.7596 19.095V30.9969Z" fill="#C9C9C9"/>
|
||||
<path d="M35.9014 3.00818L41.0022 0L68.8605 16.0872L63.7597 19.0954L35.9014 3.00818Z" fill="#AAAAAA"/>
|
||||
<path d="M74.8769 37.4067L79.9777 34.5293L79.8469 79.2596L74.7461 82.2677L74.8769 37.4067Z" fill="#AAAAAA"/>
|
||||
<path d="M43.4872 42.245L54.6043 48.6537L54.4735 93.384L15.498 71.0188L15.6288 14.2559L43.4872 30.3431V42.245Z" fill="#F4F4F4"/>
|
||||
<path d="M15.6289 14.2562L20.7297 11.248L48.5881 27.3352L43.4872 30.3434L15.6289 14.2562Z" fill="#DCDCDC"/>
|
||||
<path d="M54.6044 48.6547L59.7052 45.7773L59.5745 90.5076L54.4736 93.5158L54.6044 48.6547Z" fill="#DCDCDC"/>
|
||||
<path d="M63.7598 19.0961L68.8606 16.0879L79.9778 34.5293L74.8769 37.4067L63.7598 19.0961Z" fill="#C9C9C9"/>
|
||||
<path d="M63.7598 19.0957L74.8769 37.4063L63.7598 30.9976V19.0957Z" fill="#DCDCDC"/>
|
||||
<path d="M43.4873 30.3441L48.5881 27.3359L59.7053 45.7774L54.6045 48.6548L43.4873 30.3441Z" fill="#F4F4F4"/>
|
||||
<path d="M43.4873 30.3438L54.6045 48.6544L43.4873 42.2457V30.3438Z" fill="#C9C9C9"/>
|
||||
<path d="M46.8751 52.4595V55.9693L23.2275 42.1367V38.627L46.8751 52.4595Z" fill="#C9C9C9"/>
|
||||
<path d="M46.8751 59.0658V62.5756L23.2275 48.6914V45.1816L46.8751 59.0658Z" fill="#C9C9C9"/>
|
||||
<path d="M46.8751 65.3621V68.8719L23.2275 54.9877V51.6328L46.8751 65.3621Z" fill="#C9C9C9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
3
images/DocumentIcon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||
<path d="M4 4c0-1.1.9-2 2-2h3.59c.4 0 .78.16 1.06.44l3.91 3.91c.28.28.44.67.44 1.06V14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V8h-3.5A1.5 1.5 0 0 1 9 6.5V3H6Zm4 .2v3.3c0 .28.22.5.5.5h3.3L10 3.2ZM17 9a1 1 0 0 0-1-1v6a3 3 0 0 1-3 3H6a1 1 0 0 0 1 1h6.06A3.94 3.94 0 0 0 17 14.06V9Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
3
images/MoonIcon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.85032 3.0153C10.4276 3.21621 12.4563 5.37119 12.4563 8C12.4563 10.7614 10.2177 13 7.45629 13C5.70158 13 4.15722 12.0961 3.26465 10.7271C4.66791 10.3479 6.58077 9.42526 7.42438 7.17555C7.97709 5.70162 8.00857 4.23763 7.85032 3.0153ZM13.4563 8C13.4563 4.68629 10.77 2 7.45629 2C7.38577 2 7.31552 2.00122 7.24555 2.00364C7.09984 2.00867 6.96358 2.07706 6.87247 2.19089C6.78136 2.30471 6.74447 2.45263 6.77147 2.59591C7.00024 3.81021 7.05064 5.32413 6.48805 6.82444C5.68804 8.95787 3.68609 9.66359 2.41062 9.89533C2.25698 9.92325 2.1252 10.0213 2.05438 10.1605C1.98356 10.2997 1.98182 10.4639 2.04969 10.6046C3.01873 12.6128 5.07502 14 7.45629 14C10.77 14 13.4563 11.3137 13.4563 8Z" fill="#0078d4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 814 B |
3
images/SunIcon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 1.33C8.276 1.33 8.5 1.554 8.5 1.83V2.83C8.5 3.106 8.276 3.33 8 3.33C7.724 3.33 7.5 3.106 7.5 2.83V1.83C7.5 1.554 7.724 1.33 8 1.33ZM8 11.33C9.841 11.33 11.33 9.841 11.33 8C11.33 6.159 9.841 4.67 8 4.67C6.159 4.67 4.67 6.159 4.67 8C4.67 9.841 6.159 11.33 8 11.33ZM8 10.33C6.711 10.33 5.67 9.289 5.67 8C5.67 6.711 6.711 5.67 8 5.67C9.289 5.67 10.33 6.711 10.33 8C10.33 9.289 9.289 10.33 8 10.33ZM14.17 8.5C14.446 8.5 14.67 8.276 14.67 8C14.67 7.724 14.446 7.5 14.17 7.5H13.17C12.894 7.5 12.67 7.724 12.67 8C12.67 8.276 12.894 8.5 13.17 8.5H14.17ZM8 12.67C8.276 12.67 8.5 12.894 8.5 13.17V14.17C8.5 14.446 8.276 14.67 8 14.67C7.724 14.67 7.5 14.446 7.5 14.17V13.17C7.5 12.894 7.724 12.67 8 12.67ZM2.83 8.5C3.106 8.5 3.33 8.276 3.33 8C3.33 7.724 3.106 7.5 2.83 7.5H1.83C1.554 7.5 1.33 7.724 1.33 8C1.33 8.276 1.554 8.5 1.83 8.5H2.83ZM2.813 2.813C3.009 2.617 3.325 2.617 3.521 2.813L4.521 3.813C4.717 4.009 4.717 4.325 4.521 4.521C4.325 4.717 4.009 4.717 3.813 4.521L2.813 3.521C2.617 3.325 2.617 3.009 2.813 2.813ZM3.521 13.187C3.325 13.383 3.009 13.383 2.813 13.187C2.617 12.991 2.617 12.675 2.813 12.479L3.813 11.479C4.009 11.283 4.325 11.283 4.521 11.479C4.717 11.675 4.717 11.991 4.521 12.187L3.521 13.187ZM13.187 2.813C12.991 2.617 12.675 2.617 12.479 2.813L11.479 3.813C11.283 4.009 11.283 4.325 11.479 4.521C11.675 4.717 11.991 4.717 12.187 4.521L13.187 3.521C13.383 3.325 13.383 3.009 13.187 2.813ZM12.479 13.187C12.675 13.383 12.991 13.383 13.187 13.187C13.383 12.991 13.383 12.675 13.187 12.479L12.187 11.479C11.991 11.283 11.675 11.283 11.479 11.479C11.283 11.675 11.283 11.991 11.479 12.187L12.479 13.187Z" fill="#0078d4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
8
images/VisualStudio.svg
Normal file
3
images/github-black-and-white.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 0C8.73438 0 9.44271 0.0960961 10.125 0.288288C10.8073 0.48048 11.4427 0.758091 12.0312 1.12112C12.6198 1.48415 13.1589 1.91124 13.6484 2.4024C14.138 2.89356 14.5573 3.44611 14.9062 4.06006C15.2552 4.67401 15.5234 5.32799 15.7109 6.02202C15.8984 6.71605 15.9948 7.44211 16 8.2002C16 9.08108 15.8698 9.92993 15.6094 10.7467C15.349 11.5636 14.9766 12.3136 14.4922 12.997C14.0078 13.6803 13.4323 14.2783 12.7656 14.7908C12.099 15.3033 11.3542 15.701 10.5312 15.984C10.5156 15.9893 10.4922 15.992 10.4609 15.992C10.4297 15.992 10.4062 15.9947 10.3906 16C10.2656 16 10.1667 15.9626 10.0938 15.8879C10.0208 15.8131 9.98438 15.7144 9.98438 15.5916V14.4705C9.98438 14.1021 9.98698 13.7257 9.99219 13.3413C9.99219 13.0691 9.95312 12.7941 9.875 12.5165C9.79688 12.2389 9.65625 12.0067 9.45312 11.8198C10.0573 11.7504 10.5859 11.625 11.0391 11.4434C11.4922 11.2619 11.8724 11.0057 12.1797 10.6747C12.487 10.3437 12.7161 9.94328 12.8672 9.47347C13.0182 9.00367 13.0964 8.43777 13.1016 7.77578C13.1016 7.35936 13.0339 6.96697 12.8984 6.5986C12.763 6.23023 12.5573 5.88856 12.2812 5.57357C12.3385 5.42409 12.3802 5.26927 12.4062 5.10911C12.4323 4.94895 12.4453 4.78879 12.4453 4.62863C12.4453 4.42042 12.4245 4.21488 12.3828 4.01201C12.3411 3.80914 12.2812 3.60627 12.2031 3.4034C12.1771 3.39273 12.1484 3.38739 12.1172 3.38739C12.0859 3.38739 12.0573 3.38739 12.0312 3.38739C11.8646 3.38739 11.6901 3.41408 11.5078 3.46747C11.3255 3.52085 11.1458 3.59026 10.9688 3.67568C10.7917 3.76109 10.6172 3.85452 10.4453 3.95596C10.2734 4.05739 10.125 4.15349 10 4.24424C9.34896 4.05739 8.68229 3.96396 8 3.96396C7.31771 3.96396 6.65104 4.05739 6 4.24424C5.86979 4.15349 5.72135 4.05739 5.55469 3.95596C5.38802 3.85452 5.21615 3.76376 5.03906 3.68368C4.86198 3.6036 4.67969 3.5342 4.49219 3.47548C4.30469 3.41675 4.13021 3.38739 3.96875 3.38739H3.88281C3.85156 3.38739 3.82292 3.39273 3.79688 3.4034C3.72396 3.60093 3.66667 3.80113 3.625 4.004C3.58333 4.20687 3.5599 4.41508 3.55469 4.62863C3.55469 4.78879 3.56771 4.94895 3.59375 5.10911C3.61979 5.26927 3.66146 5.42409 3.71875 5.57357C3.44271 5.88322 3.23698 6.22222 3.10156 6.59059C2.96615 6.95896 2.89844 7.35402 2.89844 7.77578C2.89844 8.42709 2.97396 8.99032 3.125 9.46547C3.27604 9.94061 3.50521 10.341 3.8125 10.6667C4.11979 10.9923 4.5 11.2513 4.95312 11.4434C5.40625 11.6356 5.9349 11.7638 6.53906 11.8278C6.38802 11.9666 6.27344 12.1321 6.19531 12.3243C6.11719 12.5165 6.0625 12.7167 6.03125 12.9249C5.89062 12.9943 5.74219 13.0477 5.58594 13.0851C5.42969 13.1225 5.27344 13.1411 5.11719 13.1411C4.78385 13.1411 4.50781 13.0611 4.28906 12.9009C4.07031 12.7407 3.875 12.5219 3.70312 12.2442C3.64062 12.1428 3.5651 12.0414 3.47656 11.9399C3.38802 11.8385 3.29167 11.7477 3.1875 11.6677C3.08333 11.5876 2.97135 11.5235 2.85156 11.4755C2.73177 11.4274 2.60677 11.4007 2.47656 11.3954H2.38281C2.34115 11.3954 2.30208 11.4034 2.26562 11.4194C2.22917 11.4354 2.19271 11.4515 2.15625 11.4675C2.11979 11.4835 2.10417 11.5102 2.10938 11.5475C2.10938 11.6116 2.14583 11.673 2.21875 11.7317C2.29167 11.7905 2.35156 11.8385 2.39844 11.8759L2.42188 11.8919C2.53646 11.9826 2.63542 12.0681 2.71875 12.1481C2.80208 12.2282 2.88021 12.3163 2.95312 12.4124C3.02604 12.5085 3.08594 12.6099 3.13281 12.7167C3.17969 12.8235 3.23958 12.9489 3.3125 13.0931C3.48958 13.5095 3.73698 13.8111 4.05469 13.998C4.3724 14.1849 4.75521 14.2809 5.20312 14.2863C5.33854 14.2863 5.47396 14.2783 5.60938 14.2623C5.74479 14.2462 5.88021 14.2222 6.01562 14.1902V15.5836C6.01562 15.7117 5.97917 15.8131 5.90625 15.8879C5.83333 15.9626 5.73177 16 5.60156 16H5.53906C5.51302 16 5.48958 15.9947 5.46875 15.984C4.65104 15.7117 3.90625 15.3193 3.23438 14.8068C2.5625 14.2943 1.98698 13.6937 1.50781 13.005C1.02865 12.3163 0.658854 11.5636 0.398438 10.7467C0.138021 9.92993 0.00520833 9.08108 0 8.2002C0 7.44745 0.09375 6.72139 0.28125 6.02202C0.46875 5.32266 0.739583 4.67134 1.09375 4.06807C1.44792 3.4648 1.86458 2.91225 2.34375 2.41041C2.82292 1.90858 3.36198 1.47881 3.96094 1.12112C4.5599 0.76343 5.19792 0.488488 5.875 0.296296C6.55208 0.104104 7.26042 0.00533867 8 0Z" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
3
images/moon-blue.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 7.5C4 5.567 5.567 4 7.5 4C8.5 4 9.4 4.4 10 5.1C9.5 4.8 8.9 4.6 8.3 4.6C6.8 4.6 5.6 5.8 5.6 7.3C5.6 8.8 6.8 10 8.3 10C8.9 10 9.5 9.8 10 9.5C9.4 10.2 8.5 10.6 7.5 10.6C5.567 10.6 4 9.033 4 7.1V7.5Z" fill="#0078D4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 328 B |
11
images/sun-blue.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 2C6 1.44772 6.44772 1 7 1C7.55228 1 8 1.44772 8 2C8 2.55228 7.55228 3 7 3C6.44772 3 6 2.55228 6 2Z" fill="#0078D4"/>
|
||||
<path d="M6 13C6 12.4477 6.44772 12 7 12C7.55228 12 8 12.4477 8 13C8 13.5523 7.55228 14 7 14C6.44772 14 6 13.5523 6 13Z" fill="#0078D4"/>
|
||||
<path d="M1 7C1 6.44772 1.44772 6 2 6C2.55228 6 3 6.44772 3 7C3 7.55228 2.55228 8 2 8C1.44772 8 1 7.55228 1 7Z" fill="#0078D4"/>
|
||||
<path d="M12 7C12 6.44772 12.4477 6 13 6C13.5523 6 14 6.44772 14 7C14 7.55228 13.5523 8 13 8C12.4477 8 12 7.55228 12 7Z" fill="#0078D4"/>
|
||||
<path d="M2.63604 3.63604C3.02656 3.24551 3.65973 3.24551 4.05025 3.63604C4.44078 4.02656 4.44078 4.65973 4.05025 5.05025C3.65973 5.44078 3.02656 5.44078 2.63604 5.05025C2.24551 4.65973 2.24551 4.02656 2.63604 3.63604Z" fill="#0078D4"/>
|
||||
<path d="M10.9497 9.94975C11.3403 9.55922 11.9734 9.55922 12.364 9.94975C12.7545 10.3403 12.7545 10.9734 12.364 11.364C11.9734 11.7545 11.3403 11.7545 10.9497 11.364C10.5592 10.9734 10.5592 10.3403 10.9497 9.94975Z" fill="#0078D4"/>
|
||||
<path d="M10.9497 5.05025C10.5592 4.65973 10.5592 4.02656 10.9497 3.63604C11.3403 3.24551 11.9734 3.24551 12.364 3.63604C12.7545 4.02656 12.7545 4.65973 12.364 5.05025C11.9734 5.44078 11.3403 5.44078 10.9497 5.05025Z" fill="#0078D4"/>
|
||||
<path d="M2.63604 11.364C2.24551 10.9734 2.24551 10.3403 2.63604 9.94975C3.02656 9.55922 3.65973 9.55922 4.05025 9.94975C4.44078 10.3403 4.44078 10.9734 4.05025 11.364C3.65973 11.7545 3.02656 11.7545 2.63604 11.364Z" fill="#0078D4"/>
|
||||
<circle cx="7.5" cy="7.5" r="2.5" fill="#0078D4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2869,6 +2898,7 @@ a:link {
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
overflow-x: clip;
|
||||
min-height: fit-content;
|
||||
}
|
||||
|
||||
.uniqueIndexesContainer {
|
||||
@@ -3136,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -79,13 +79,13 @@
|
||||
|
||||
.storageCapacityTitle {
|
||||
padding: @LargeSpace 0px;
|
||||
|
||||
}
|
||||
.throughputStorageValue {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.estimatedCost, .largePartitionKeyEnabled {
|
||||
.estimatedCost,
|
||||
.largePartitionKeyEnabled {
|
||||
padding: @SmallSpace 0px @LargeSpace;
|
||||
}
|
||||
|
||||
@@ -109,18 +109,25 @@
|
||||
}
|
||||
|
||||
.formTree {
|
||||
border: 1px solid #969696;
|
||||
color: #393939;
|
||||
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 {
|
||||
@@ -139,7 +146,6 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
|
||||
.spUdfTriggerHeader {
|
||||
padding: @DefaultSpace 0px @SmallSpace (2 * @MediumSpace);
|
||||
}
|
||||
@@ -151,33 +157,33 @@
|
||||
|
||||
.unselectedRadio {
|
||||
background-color: white;
|
||||
border-color: #EEE!important;
|
||||
border-color: #eee !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.disabledRadio {
|
||||
background-color: #A19F9D;
|
||||
border-color: #EEE!important;
|
||||
background-color: #a19f9d;
|
||||
border-color: #eee !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.selectedRadio {
|
||||
background-color: @AccentMediumHigh;
|
||||
border-color: #EEE!important;
|
||||
border-color: #eee !important;
|
||||
color: white !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectedRadio:hover {
|
||||
background-color: @AccentMediumHigh;
|
||||
border-color: #EEE!important;
|
||||
border-color: #eee !important;
|
||||
color: white !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectedRadio:active {
|
||||
background-color: #0072c6;
|
||||
border-color: #EEE!important;
|
||||
border-color: #eee !important;
|
||||
color: white !important;
|
||||
cursor: pointer;
|
||||
border: 1px solid #0072c6;
|
||||
@@ -204,8 +210,18 @@
|
||||
|
||||
.trigger-field {
|
||||
width: 40%;
|
||||
margin-top: 10px
|
||||
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;
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
color: var(--colorNeutralForeground1);
|
||||
padding: 10px 30px;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
270
less/tree.less
@@ -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;
|
||||
}
|
||||
320
package-lock.json
generated
@@ -10,7 +10,7 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@azure/arm-cosmosdb": "9.1.0",
|
||||
"@azure/cosmos": "4.2.0-beta.1",
|
||||
"@azure/cosmos": "4.7.0",
|
||||
"@azure/cosmos-language-service": "0.0.5",
|
||||
"@azure/identity": "4.5.0",
|
||||
"@azure/msal-browser": "2.14.2",
|
||||
@@ -116,6 +116,8 @@
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -290,57 +292,69 @@
|
||||
"version": "2.6.2",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@azure/core-rest-pipeline": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.18.0.tgz",
|
||||
"integrity": "sha512-QSoGUp4Eq/gohEFNJaUOwTN7BCc2nHTjjbm75JT0aD7W65PWM1H/tItz0GsABn22uaKyGxiMhWQLt2r+FGU89Q==",
|
||||
"node_modules/@azure/core-http-compat": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.3.0.tgz",
|
||||
"integrity": "sha512-qLQujmUypBBG0gxHd0j6/Jdmul6ttl24c8WGiLXIk7IHXdBlfoBqW27hyz3Xn6xbfdyVSarl1Ttbk0AwnZBYCw==",
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.0.0",
|
||||
"@azure/core-auth": "^1.8.0",
|
||||
"@azure/core-tracing": "^1.0.1",
|
||||
"@azure/core-util": "^1.11.0",
|
||||
"@azure/core-client": "^1.3.0",
|
||||
"@azure/core-rest-pipeline": "^1.20.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-lro": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz",
|
||||
"integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==",
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.0.0",
|
||||
"@azure/core-util": "^1.2.0",
|
||||
"@azure/logger": "^1.0.0",
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-rest-pipeline/node_modules/agent-base": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
|
||||
"integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
|
||||
"node_modules/@azure/core-lro/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/@azure/core-paging": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz",
|
||||
"integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4"
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-rest-pipeline/node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"node_modules/@azure/core-paging/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/@azure/core-rest-pipeline": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.20.0.tgz",
|
||||
"integrity": "sha512-ASoP8uqZBS3H/8N8at/XwFr6vYrRP3syTK0EUjDXQy0Y1/AUS+QeIRThKmTNJO2RggvBBxaXDPM7YoIwDGeA0g==",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
"@azure/abort-controller": "^2.0.0",
|
||||
"@azure/core-auth": "^1.8.0",
|
||||
"@azure/core-tracing": "^1.0.1",
|
||||
"@azure/core-util": "^1.11.0",
|
||||
"@azure/logger": "^1.0.0",
|
||||
"@typespec/ts-http-runtime": "^0.2.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-rest-pipeline/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz",
|
||||
"integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.0.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-rest-pipeline/node_modules/tslib": {
|
||||
@@ -379,23 +393,25 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@azure/cosmos": {
|
||||
"version": "4.2.0-beta.1",
|
||||
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.2.0-beta.1.tgz",
|
||||
"integrity": "sha512-mREONehm1DxjEKXGaNU6Wmpf9Ckb9IrhKFXhDFVs45pxmoEb3y2s/Ub0owuFmqlphpcS1zgtYQn5exn+lwnJuQ==",
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.7.0.tgz",
|
||||
"integrity": "sha512-a8OV7E41u/ZDaaaDAFdqTTiJ7c82jZc/+ot3XzNCIIilR25NBB+1ixzWQOAgP8SHRUIKfaUl6wAPdTuiG9I66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.0.0",
|
||||
"@azure/core-auth": "^1.7.1",
|
||||
"@azure/core-rest-pipeline": "^1.15.1",
|
||||
"@azure/core-tracing": "^1.1.1",
|
||||
"@azure/core-util": "^1.8.1",
|
||||
"@azure/abort-controller": "^2.1.2",
|
||||
"@azure/core-auth": "^1.9.0",
|
||||
"@azure/core-rest-pipeline": "^1.19.1",
|
||||
"@azure/core-tracing": "^1.2.0",
|
||||
"@azure/core-util": "^1.11.0",
|
||||
"@azure/keyvault-keys": "^4.9.0",
|
||||
"@azure/logger": "^1.1.4",
|
||||
"fast-json-stable-stringify": "^2.1.0",
|
||||
"jsbi": "^4.3.0",
|
||||
"priorityqueuejs": "^2.0.0",
|
||||
"semaphore": "^1.1.0",
|
||||
"tslib": "^2.6.2"
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/cosmos-language-service": {
|
||||
@@ -425,8 +441,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/cosmos/node_modules/tslib": {
|
||||
"version": "2.6.2",
|
||||
"license": "0BSD"
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/@azure/identity": {
|
||||
"version": "4.5.0",
|
||||
@@ -492,14 +509,66 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/@azure/logger": {
|
||||
"version": "1.0.4",
|
||||
"license": "MIT",
|
||||
"node_modules/@azure/keyvault-common": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.0.0.tgz",
|
||||
"integrity": "sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==",
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.0.0",
|
||||
"@azure/core-auth": "^1.3.0",
|
||||
"@azure/core-client": "^1.5.0",
|
||||
"@azure/core-rest-pipeline": "^1.8.0",
|
||||
"@azure/core-tracing": "^1.0.0",
|
||||
"@azure/core-util": "^1.10.0",
|
||||
"@azure/logger": "^1.1.4",
|
||||
"tslib": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/keyvault-common/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/@azure/keyvault-keys": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.9.0.tgz",
|
||||
"integrity": "sha512-ZBP07+K4Pj3kS4TF4XdkqFcspWwBHry3vJSOFM5k5ZABvf7JfiMonvaFk2nBF6xjlEbMpz5PE1g45iTMme0raQ==",
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.0.0",
|
||||
"@azure/core-auth": "^1.3.0",
|
||||
"@azure/core-client": "^1.5.0",
|
||||
"@azure/core-http-compat": "^2.0.1",
|
||||
"@azure/core-lro": "^2.2.0",
|
||||
"@azure/core-paging": "^1.1.1",
|
||||
"@azure/core-rest-pipeline": "^1.8.1",
|
||||
"@azure/core-tracing": "^1.0.0",
|
||||
"@azure/core-util": "^1.0.0",
|
||||
"@azure/keyvault-common": "^2.0.0",
|
||||
"@azure/logger": "^1.0.0",
|
||||
"tslib": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/keyvault-keys/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/@azure/logger": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.2.0.tgz",
|
||||
"integrity": "sha512-0hKEzLhpw+ZTAfNJyRrn6s+V0nDWzXk9OjBr2TiGIu0OfMr5s2V4FpKLTAK3Ca5r5OKLbf4hkOGDPyiRjie/jA==",
|
||||
"dependencies": {
|
||||
"@typespec/ts-http-runtime": "^0.2.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/logger/node_modules/tslib": {
|
||||
@@ -559,6 +628,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/ms-rest-js/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/ms-rest-js/node_modules/xml2js": {
|
||||
"version": "0.5.0",
|
||||
"license": "MIT",
|
||||
@@ -618,6 +695,14 @@
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-node/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
|
||||
@@ -7528,6 +7613,14 @@
|
||||
"uuid": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/commutable/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/connected-components": {
|
||||
"version": "6.8.2",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -9058,6 +9151,14 @@
|
||||
"uuid": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/fixtures/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/iron-icons": {
|
||||
"version": "1.0.0",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -9215,6 +9316,14 @@
|
||||
"uuid": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/messaging/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/monaco-editor": {
|
||||
"version": "3.2.2",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -9330,6 +9439,14 @@
|
||||
"version": "0.18.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nteract/monaco-editor/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/mythic-configuration": {
|
||||
"version": "1.0.12",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -9598,6 +9715,14 @@
|
||||
"uuid": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/reducers/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/selectors": {
|
||||
"version": "3.2.0",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -9821,6 +9946,14 @@
|
||||
"uuid": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/types/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/auth-token": {
|
||||
"version": "4.0.0",
|
||||
"license": "MIT",
|
||||
@@ -13074,6 +13207,56 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typespec/ts-http-runtime": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.2.2.tgz",
|
||||
"integrity": "sha512-Gz/Sm64+Sq/vklJu1tt9t+4R2lvnud8NbTD/ZfpZtMiUX7YeVpCA8j6NSW8ptwcoLL+NmYANwqP8DV0q/bwl2w==",
|
||||
"dependencies": {
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typespec/ts-http-runtime/node_modules/agent-base": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
|
||||
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@typespec/ts-http-runtime/node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@typespec/ts-http-runtime/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@typespec/ts-http-runtime/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/@ungap/url-search-params": {
|
||||
"version": "0.2.2",
|
||||
"license": "ISC"
|
||||
@@ -26302,6 +26485,15 @@
|
||||
"xmlbuilder": "^15.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-trx-results-processor/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-util": {
|
||||
"version": "24.9.0",
|
||||
"license": "MIT",
|
||||
@@ -27063,11 +27255,6 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsbi": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz",
|
||||
"integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g=="
|
||||
},
|
||||
"node_modules/jsbn": {
|
||||
"version": "0.1.1",
|
||||
"license": "MIT"
|
||||
@@ -33641,6 +33828,15 @@
|
||||
"websocket-driver": "^0.7.4"
|
||||
}
|
||||
},
|
||||
"node_modules/sockjs/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -35507,8 +35703,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"license": "MIT",
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
|
||||
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
@@ -35734,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"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@azure/arm-cosmosdb": "9.1.0",
|
||||
"@azure/cosmos": "4.2.0-beta.1",
|
||||
"@azure/cosmos": "4.7.0",
|
||||
"@azure/cosmos-language-service": "0.0.5",
|
||||
"@azure/identity": "4.5.0",
|
||||
"@azure/msal-browser": "2.14.2",
|
||||
@@ -46,8 +46,8 @@
|
||||
"@types/mkdirp": "1.0.1",
|
||||
"@types/node-fetch": "2.5.7",
|
||||
"@xmldom/xmldom": "0.7.13",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"allotment": "1.20.2",
|
||||
"applicationinsights": "1.8.0",
|
||||
"bootstrap": "3.4.1",
|
||||
@@ -111,6 +111,8 @@
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[defaults]
|
||||
group = dataexplorer-preview
|
||||
sku = P1V2
|
||||
sku = P1v2
|
||||
appserviceplan = dataexplorer-preview
|
||||
location = westus2
|
||||
web = dataexplorer-preview
|
||||
|
||||
@@ -7,7 +7,6 @@ const backendEndpoint = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
|
||||
const previewSiteEndpoint = "https://dataexplorer-preview.azurewebsites.net";
|
||||
const previewStorageWebsiteEndpoint = "https://dataexplorerpreview.z5.web.core.windows.net/";
|
||||
const githubApiUrl = "https://api.github.com/repos/Azure/cosmos-explorer";
|
||||
const githubPullRequestUrl = "https://github.com/Azure/cosmos-explorer/pull";
|
||||
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
|
||||
|
||||
const api = createProxyMiddleware({
|
||||
@@ -57,11 +56,7 @@ app.get("/pull/:pr(\\d+)", (req, res) => {
|
||||
|
||||
fetch(`${githubApiUrl}/pulls/${pr}`)
|
||||
.then((response) => response.json())
|
||||
.then(({ head: { ref, sha } }) => {
|
||||
const prUrl = new URL(`${githubPullRequestUrl}/${pr}`);
|
||||
prUrl.hash = ref;
|
||||
search.set("feature.pr", prUrl.href);
|
||||
|
||||
.then(({ head: { sha } }) => {
|
||||
const explorer = new URL(`${previewSiteEndpoint}/commit/${sha}/explorer.html`);
|
||||
explorer.search = search.toString();
|
||||
|
||||
|
||||
36199
preview/package-lock.json
generated
@@ -4,16 +4,18 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"deploy": "az webapp up --name \"dataexplorer-preview\" --subscription \"cosmosdb-portalteam-runners\" --resource-group \"dataexplorer-preview\" --runtime \"NODE:18-lts\" --sku P1V2",
|
||||
"deploy": "az webapp up --name \"dataexplorer-preview\" --subscription \"cosmosdb-portalteam-runners\" --resource-group \"dataexplorer-preview\" --runtime \"NODE:20-lts\" --sku P1V2",
|
||||
"start": "node index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Microsoft Corporation",
|
||||
"dependencies": {
|
||||
"express": "^4.17.1",
|
||||
"body-parser": "^1.20.3",
|
||||
"express": "^4.21.2",
|
||||
"http-proxy-middleware": "^3.0.3",
|
||||
"node": "^18.20.6",
|
||||
"node-fetch": "^2.6.1"
|
||||
"node": "^20.19.5",
|
||||
"node-fetch": "^2.6.1",
|
||||
"path-to-regexp": "^0.1.12"
|
||||
}
|
||||
}
|
||||
|
||||
11286
sampleData/fabricSampleData.json
Normal file
288126
sampleData/fabricSampleDataVectors.json
Normal file
@@ -90,6 +90,10 @@ export class CapabilityNames {
|
||||
public static readonly EnableServerless: string = "EnableServerless";
|
||||
public static readonly EnableNoSQLVectorSearch: string = "EnableNoSQLVectorSearch";
|
||||
public static readonly EnableNoSQLFullTextSearch: string = "EnableNoSQLFullTextSearch";
|
||||
public static readonly EnableDataMasking: string = "EnableDataMasking";
|
||||
public static readonly EnableDynamicDataMasking: string = "EnableDynamicDataMasking";
|
||||
public static readonly EnableNoSQLFullTextSearchPreviewFeatures: string = "EnableNoSQLFullTextSearchPreviewFeatures";
|
||||
public static readonly EnableOnlineCopyFeature: string = "EnableOnlineContainerCopy";
|
||||
}
|
||||
|
||||
export enum CapacityMode {
|
||||
@@ -138,13 +142,12 @@ export enum MongoBackendEndpointType {
|
||||
remote,
|
||||
}
|
||||
|
||||
export class BackendApi {
|
||||
public static readonly GenerateToken: string = "GenerateToken";
|
||||
public static readonly PortalSettings: string = "PortalSettings";
|
||||
public static readonly AccountRestrictions: string = "AccountRestrictions";
|
||||
public static readonly RuntimeProxy: string = "RuntimeProxy";
|
||||
public static readonly DisallowedLocations: string = "DisallowedLocations";
|
||||
public static readonly SampleData: string = "SampleData";
|
||||
export class AadScopeEndpoints {
|
||||
public static readonly Development: string = "https://cosmos.azure.com";
|
||||
public static readonly MPAC: string = "https://cosmos.azure.com";
|
||||
public static readonly Prod: string = "https://cosmos.azure.com";
|
||||
public static readonly Fairfax: string = "https://cosmos.azure.us";
|
||||
public static readonly Mooncake: string = "https://cosmos.azure.cn";
|
||||
}
|
||||
|
||||
export class PortalBackendEndpoints {
|
||||
@@ -264,6 +267,7 @@ export class HttpHeaders {
|
||||
public static activityId: string = "x-ms-activity-id";
|
||||
public static apiType: string = "x-ms-cosmos-apitype";
|
||||
public static authorization: string = "authorization";
|
||||
public static entraIdToken: string = "x-ms-entraid-token";
|
||||
public static collectionIndexTransformationProgress: string =
|
||||
"x-ms-documentdb-collection-index-transformation-progress";
|
||||
public static continuation: string = "x-ms-continuation";
|
||||
@@ -293,6 +297,7 @@ export class HttpHeaders {
|
||||
public static migrateOfferToManualThroughput: string = "x-ms-cosmos-migrate-offer-to-manual-throughput";
|
||||
public static migrateOfferToAutopilot: string = "x-ms-cosmos-migrate-offer-to-autopilot";
|
||||
public static xAPIKey: string = "X-API-Key";
|
||||
public static sessionId: string = "x-ms-client-session-id";
|
||||
}
|
||||
|
||||
export class ContentType {
|
||||
@@ -774,3 +779,10 @@ export const ShortenedQueryCopilotSampleContainerSchema = {
|
||||
|
||||
userPrompt: "find all products",
|
||||
};
|
||||
|
||||
export enum MongoGuidRepresentation {
|
||||
Standard = "Standard",
|
||||
CSharpLegacy = "CSharpLegacy",
|
||||
JavaLegacy = "JavaLegacy",
|
||||
PythonLegacy = "PythonLegacy",
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
|
||||
import { AuthorizationToken } from "Contracts/FabricMessageTypes";
|
||||
import { checkDatabaseResourceTokensValidity, isFabricMirroredKey } from "Platform/Fabric/FabricUtil";
|
||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
import { useDataplaneRbacAuthorization } from "Utils/AuthorizationUtils";
|
||||
import { AuthType } from "../AuthType";
|
||||
import { PriorityLevel } from "../Common/Constants";
|
||||
import * as Logger from "../Common/Logger";
|
||||
import { Platform, configContext } from "../ConfigContext";
|
||||
import { FabricArtifactInfo, updateUserContext, userContext } from "../UserContext";
|
||||
import { isDataplaneRbacSupported } from "../Utils/APITypeUtils";
|
||||
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
|
||||
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
|
||||
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
|
||||
@@ -20,8 +20,7 @@ const _global = typeof self === "undefined" ? window : self;
|
||||
export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
|
||||
const { verb, resourceId, resourceType, headers } = requestInfo;
|
||||
|
||||
const dataPlaneRBACOptionEnabled = userContext.dataPlaneRbacEnabled && isDataplaneRbacSupported(userContext.apiType);
|
||||
if (userContext.features.enableAadDataPlane || dataPlaneRBACOptionEnabled) {
|
||||
if (useDataplaneRbacAuthorization(userContext)) {
|
||||
Logger.logInfo(
|
||||
`AAD Data Plane Feature flag set to ${userContext.features.enableAadDataPlane} for account with disable local auth ${userContext.databaseAccount.properties.disableLocalAuth} `,
|
||||
"Explorer/tokenProvider",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TagNames, WorkloadType } from "Common/Constants";
|
||||
import { Tags } from "Contracts/DataModels";
|
||||
import { isFabric } from "Platform/Fabric/FabricUtil";
|
||||
import { userContext } from "../UserContext";
|
||||
import { ApiType, userContext } from "../UserContext";
|
||||
|
||||
function isVirtualNetworkFilterEnabled() {
|
||||
return userContext.databaseAccount?.properties?.isVirtualNetworkFilterEnabled;
|
||||
@@ -33,3 +33,33 @@ export function isGlobalSecondaryIndexEnabled(): boolean {
|
||||
!isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews
|
||||
);
|
||||
}
|
||||
|
||||
export const getDatabaseEndpoint = (apiType: ApiType): string => {
|
||||
switch (apiType) {
|
||||
case "Mongo":
|
||||
return "mongodbDatabases";
|
||||
case "Cassandra":
|
||||
return "cassandraKeyspaces";
|
||||
case "Gremlin":
|
||||
return "gremlinDatabases";
|
||||
case "Tables":
|
||||
return "tables";
|
||||
case "SQL":
|
||||
default:
|
||||
return "sqlDatabases";
|
||||
}
|
||||
};
|
||||
|
||||
export const getCollectionEndpoint = (apiType: ApiType): string => {
|
||||
switch (apiType) {
|
||||
case "Mongo":
|
||||
return "collections";
|
||||
case "Cassandra":
|
||||
return "tables";
|
||||
case "Gremlin":
|
||||
return "graphs";
|
||||
case "SQL":
|
||||
default:
|
||||
return "containers";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,3 +28,39 @@ describe("Environment Utility Test", () => {
|
||||
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Development);
|
||||
});
|
||||
});
|
||||
describe("normalizeArmEndpoint", () => {
|
||||
it("should append '/' if not present", () => {
|
||||
expect(EnvironmentUtility.normalizeArmEndpoint("https://example.com")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("should return the same uri if '/' is present at the end", () => {
|
||||
expect(EnvironmentUtility.normalizeArmEndpoint("https://example.com/")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(EnvironmentUtility.normalizeArmEndpoint("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnvironment", () => {
|
||||
it("should return Prod environment", () => {
|
||||
updateConfigContext({
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
||||
});
|
||||
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Prod);
|
||||
});
|
||||
|
||||
it("should return Fairfax environment", () => {
|
||||
updateConfigContext({
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Fairfax,
|
||||
});
|
||||
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Fairfax);
|
||||
});
|
||||
|
||||
it("should return Mooncake environment", () => {
|
||||
updateConfigContext({
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mooncake,
|
||||
});
|
||||
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Mooncake);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PortalBackendEndpoints } from "Common/Constants";
|
||||
import { AadScopeEndpoints, PortalBackendEndpoints } from "Common/Constants";
|
||||
import * as Logger from "Common/Logger";
|
||||
import { configContext } from "ConfigContext";
|
||||
|
||||
export function normalizeArmEndpoint(uri: string): string {
|
||||
@@ -27,3 +28,17 @@ export const getEnvironment = (): Environment => {
|
||||
|
||||
return environmentMap[configContext.PORTAL_BACKEND_ENDPOINT];
|
||||
};
|
||||
|
||||
export const getEnvironmentScopeEndpoint = (): string => {
|
||||
const environment = getEnvironment();
|
||||
const endpoint = AadScopeEndpoints[environment];
|
||||
if (!endpoint) {
|
||||
throw new Error("Cannot determine AAD scope endpoint");
|
||||
}
|
||||
const hrefEndpoint = new URL(endpoint).href.replace(/\/+$/, "/.default");
|
||||
Logger.logInfo(
|
||||
`Using AAD scope endpoint: ${hrefEndpoint}, Environment: ${environment}`,
|
||||
"EnvironmentUtility/getEnvironmentScopeEndpoint",
|
||||
);
|
||||
return hrefEndpoint;
|
||||
};
|
||||
|
||||
@@ -23,7 +23,10 @@ export const handleError = (error: string | ARMError | Error, area: string, cons
|
||||
};
|
||||
|
||||
export const getErrorMessage = (error: string | Error = ""): string => {
|
||||
const errorMessage = typeof error === "string" ? error : error.message;
|
||||
let errorMessage = typeof error === "string" ? error : error.message;
|
||||
if (!errorMessage) {
|
||||
errorMessage = JSON.stringify(error);
|
||||
}
|
||||
return replaceKnownError(errorMessage);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { QueryOperationOptions } from "@azure/cosmos";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import * as Constants from "../Common/Constants";
|
||||
import { QueryResults } from "../Contracts/ViewModels";
|
||||
@@ -14,18 +13,14 @@ interface QueryResponse {
|
||||
}
|
||||
|
||||
export interface MinimalQueryIterator {
|
||||
fetchNext: (queryOperationOptions?: QueryOperationOptions) => Promise<QueryResponse>;
|
||||
fetchNext: () => Promise<QueryResponse>;
|
||||
}
|
||||
|
||||
// Pick<QueryIterator<any>, "fetchNext">;
|
||||
|
||||
export function nextPage(
|
||||
documentsIterator: MinimalQueryIterator,
|
||||
firstItemIndex: number,
|
||||
queryOperationOptions?: QueryOperationOptions,
|
||||
): Promise<QueryResults> {
|
||||
export function nextPage(documentsIterator: MinimalQueryIterator, firstItemIndex: number): Promise<QueryResults> {
|
||||
TelemetryProcessor.traceStart(Action.ExecuteQuery);
|
||||
return documentsIterator.fetchNext(queryOperationOptions).then((response) => {
|
||||
return documentsIterator.fetchNext().then((response) => {
|
||||
TelemetryProcessor.traceSuccess(Action.ExecuteQuery, { dataExplorerArea: Constants.Areas.Tab });
|
||||
const documents = response.resources;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
52
src/Common/LoadingOverlay.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import LoadingOverlay from "./LoadingOverlay";
|
||||
|
||||
describe("LoadingOverlay", () => {
|
||||
const defaultProps = {
|
||||
isLoading: true,
|
||||
label: "Loading...",
|
||||
};
|
||||
|
||||
it("should render loading overlay when isLoading is true", () => {
|
||||
const { container } = render(<LoadingOverlay {...defaultProps} />);
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render loading overlay with custom label", () => {
|
||||
const customProps = {
|
||||
isLoading: true,
|
||||
label: "Processing your request...",
|
||||
};
|
||||
const { container } = render(<LoadingOverlay {...customProps} />);
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render loading overlay with empty label", () => {
|
||||
const emptyLabelProps = {
|
||||
isLoading: true,
|
||||
label: "",
|
||||
};
|
||||
const { container } = render(<LoadingOverlay {...emptyLabelProps} />);
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should return null when isLoading is false", () => {
|
||||
const notLoadingProps = {
|
||||
isLoading: false,
|
||||
label: "Loading...",
|
||||
};
|
||||
const { container } = render(<LoadingOverlay {...notLoadingProps} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle long labels properly", () => {
|
||||
const longLabelProps = {
|
||||
isLoading: true,
|
||||
label:
|
||||
"This is a very long loading message that might span multiple lines and should still render correctly in the loading overlay component",
|
||||
};
|
||||
const { container } = render(<LoadingOverlay {...longLabelProps} />);
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
31
src/Common/LoadingOverlay.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Overlay, Spinner, SpinnerSize } from "@fluentui/react";
|
||||
import React from "react";
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
isLoading: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) => {
|
||||
if (!isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Overlay
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: "rgba(255,255,255,0.9)",
|
||||
zIndex: 9999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Spinner size={SpinnerSize.large} label={label} styles={{ label: { fontWeight: 600 } }} />
|
||||
</Overlay>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingOverlay;
|
||||
@@ -65,7 +65,6 @@ describe("MongoProxyClient", () => {
|
||||
});
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||
});
|
||||
@@ -84,7 +83,6 @@ describe("MongoProxyClient", () => {
|
||||
it("builds the correct proxy URL in development", () => {
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
queryDocuments(databaseId, collection, true, "{}");
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
@@ -101,7 +99,6 @@ describe("MongoProxyClient", () => {
|
||||
});
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||
});
|
||||
@@ -120,7 +117,6 @@ describe("MongoProxyClient", () => {
|
||||
it("builds the correct proxy URL in development", () => {
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
readDocument(databaseId, collection, documentId);
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
@@ -137,7 +133,6 @@ describe("MongoProxyClient", () => {
|
||||
});
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||
});
|
||||
@@ -156,7 +151,6 @@ describe("MongoProxyClient", () => {
|
||||
it("builds the correct proxy URL in development", () => {
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
readDocument(databaseId, collection, documentId);
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
@@ -173,7 +167,6 @@ describe("MongoProxyClient", () => {
|
||||
});
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||
});
|
||||
@@ -197,7 +190,6 @@ describe("MongoProxyClient", () => {
|
||||
});
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||
});
|
||||
@@ -216,7 +208,6 @@ describe("MongoProxyClient", () => {
|
||||
it("builds the correct proxy URL in development", () => {
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
deleteDocuments(databaseId, collection, [documentId]);
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
@@ -233,7 +224,6 @@ describe("MongoProxyClient", () => {
|
||||
});
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Constants as CosmosSDKConstants } from "@azure/cosmos";
|
||||
import { getMongoGuidRepresentation } from "Shared/StorageUtility";
|
||||
import { AuthType } from "../AuthType";
|
||||
import { configContext } from "../ConfigContext";
|
||||
import * as DataModels from "../Contracts/DataModels";
|
||||
@@ -6,6 +7,7 @@ import { MessageTypes } from "../Contracts/ExplorerContracts";
|
||||
import { Collection } from "../Contracts/ViewModels";
|
||||
import DocumentId from "../Explorer/Tree/DocumentId";
|
||||
import { userContext } from "../UserContext";
|
||||
import { isDataplaneRbacEnabledForProxyApi } from "../Utils/AuthorizationUtils";
|
||||
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
|
||||
import { ApiType, ContentType, HttpHeaders, HttpStatusCodes } from "./Constants";
|
||||
import { MinimalQueryIterator } from "./IteratorUtilities";
|
||||
@@ -15,13 +17,20 @@ const defaultHeaders = {
|
||||
[HttpHeaders.apiType]: ApiType.MongoDB.toString(),
|
||||
[CosmosSDKConstants.HttpHeaders.MaxEntityCount]: "100",
|
||||
[CosmosSDKConstants.HttpHeaders.Version]: "2017-11-15",
|
||||
[HttpHeaders.sessionId]: userContext.sessionId,
|
||||
};
|
||||
|
||||
function authHeaders() {
|
||||
if (userContext.authType === AuthType.EncryptedToken) {
|
||||
return { [HttpHeaders.guestAccessToken]: userContext.accessToken };
|
||||
} else {
|
||||
return { [HttpHeaders.authorization]: userContext.authorizationToken };
|
||||
const headers: { [key: string]: string } = {
|
||||
[HttpHeaders.authorization]: userContext.authorizationToken,
|
||||
};
|
||||
if (isDataplaneRbacEnabledForProxyApi(userContext)) {
|
||||
headers[HttpHeaders.entraIdToken] = userContext.aadToken;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +148,9 @@ export function readDocument(
|
||||
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey
|
||||
? documentId.partitionKeyProperties?.[0]
|
||||
: "",
|
||||
clientSettings: {
|
||||
guidRepresentation: getMongoGuidRepresentation(),
|
||||
},
|
||||
};
|
||||
|
||||
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
|
||||
@@ -181,6 +193,9 @@ export function createDocument(
|
||||
partitionKey:
|
||||
collection && collection.partitionKey && !collection.partitionKey.systemKey ? partitionKeyProperty : "",
|
||||
documentContent: JSON.stringify(documentContent),
|
||||
clientSettings: {
|
||||
guidRepresentation: getMongoGuidRepresentation(),
|
||||
},
|
||||
};
|
||||
|
||||
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
|
||||
@@ -228,6 +243,9 @@ export function updateDocument(
|
||||
? documentId.partitionKeyProperties?.[0]
|
||||
: "",
|
||||
documentContent,
|
||||
clientSettings: {
|
||||
guidRepresentation: getMongoGuidRepresentation(),
|
||||
},
|
||||
};
|
||||
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
|
||||
|
||||
@@ -274,6 +292,9 @@ export function deleteDocuments(
|
||||
subscriptionID: userContext.subscriptionId,
|
||||
resourceGroup: userContext.resourceGroup,
|
||||
databaseAccountName: databaseAccount.name,
|
||||
clientSettings: {
|
||||
guidRepresentation: getMongoGuidRepresentation(),
|
||||
},
|
||||
};
|
||||
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
|
||||
|
||||
|
||||
13
src/Common/Pager/Pager.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.pager-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pager-container > div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
111
src/Common/Pager/index.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { IconButton, Text } from "@fluentui/react";
|
||||
import * as React from "react";
|
||||
import "./Pager.css";
|
||||
|
||||
export interface PagerProps {
|
||||
startIndex: number;
|
||||
totalCount: number;
|
||||
pageSize: number;
|
||||
onLoadPage: (startIndex: number, pageSize: number) => void;
|
||||
disabled?: boolean;
|
||||
showFirstLast?: boolean;
|
||||
showItemCount?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const iconButtonStyles = {
|
||||
root: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
rootHovered: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
rootPressed: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
rootDisabled: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
rootFocused: {
|
||||
backgroundColor: "transparent",
|
||||
outline: "none",
|
||||
},
|
||||
};
|
||||
|
||||
const Pager: React.FC<PagerProps> = ({
|
||||
startIndex,
|
||||
totalCount,
|
||||
pageSize,
|
||||
onLoadPage,
|
||||
disabled = false,
|
||||
showFirstLast = true,
|
||||
showItemCount = true,
|
||||
className,
|
||||
}) => {
|
||||
// Calculate current page and total pages from startIndex
|
||||
const currentPage = Math.floor(startIndex / pageSize) + 1;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const endIndex = Math.min(startIndex + pageSize, totalCount);
|
||||
|
||||
const handleFirstPage = () => onLoadPage(0, pageSize);
|
||||
const handlePreviousPage = () => onLoadPage(startIndex - pageSize, pageSize);
|
||||
const handleNextPage = () => onLoadPage(startIndex + pageSize, pageSize);
|
||||
const handleLastPage = () => onLoadPage((totalPages - 1) * pageSize, pageSize);
|
||||
|
||||
if (totalCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className || "pager-container"}>
|
||||
{showItemCount && (
|
||||
<Text>
|
||||
Showing {startIndex + 1} - {endIndex} of {totalCount} items
|
||||
</Text>
|
||||
)}
|
||||
<div>
|
||||
{showFirstLast && (
|
||||
<IconButton
|
||||
iconProps={{ iconName: "DoubleChevronLeft" }}
|
||||
title="First page"
|
||||
ariaLabel="Go to first page"
|
||||
onClick={handleFirstPage}
|
||||
disabled={disabled || currentPage === 1}
|
||||
styles={iconButtonStyles}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
iconProps={{ iconName: "ChevronLeft" }}
|
||||
title="Previous page"
|
||||
ariaLabel="Go to previous page"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={disabled || currentPage === 1}
|
||||
styles={iconButtonStyles}
|
||||
/>
|
||||
<Text>
|
||||
Page {currentPage} of {totalPages}
|
||||
</Text>
|
||||
<IconButton
|
||||
iconProps={{ iconName: "ChevronRight" }}
|
||||
title="Next page"
|
||||
ariaLabel="Go to next page"
|
||||
onClick={handleNextPage}
|
||||
disabled={disabled || currentPage === totalPages}
|
||||
styles={iconButtonStyles}
|
||||
/>
|
||||
{showFirstLast && (
|
||||
<IconButton
|
||||
iconProps={{ iconName: "DoubleChevronRight" }}
|
||||
title="Last page"
|
||||
ariaLabel="Go to last page"
|
||||
onClick={handleLastPage}
|
||||
disabled={disabled || currentPage === totalPages}
|
||||
styles={iconButtonStyles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pager;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { monaco } from "Explorer/LazyMonaco";
|
||||
import { getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
|
||||
|
||||
export enum QueryErrorSeverity {
|
||||
Error = "Error",
|
||||
@@ -103,20 +102,9 @@ export interface ErrorEnrichment {
|
||||
learnMoreUrl?: string;
|
||||
}
|
||||
|
||||
const REPLACEMENT_MESSAGES: Record<string, (original: string) => string> = {
|
||||
OPERATION_RU_LIMIT_EXCEEDED: (original) => {
|
||||
if (ruThresholdEnabled()) {
|
||||
const threshold = getRUThreshold();
|
||||
return `Query exceeded the Request Unit (RU) limit of ${threshold} RUs. You can change this limit in Data Explorer settings.`;
|
||||
}
|
||||
return original;
|
||||
},
|
||||
};
|
||||
const REPLACEMENT_MESSAGES: Record<string, (original: string) => string> = {};
|
||||
|
||||
const HELP_LINKS: Record<string, string> = {
|
||||
OPERATION_RU_LIMIT_EXCEEDED:
|
||||
"https://learn.microsoft.com/en-us/azure/cosmos-db/data-explorer#configure-request-unit-threshold",
|
||||
};
|
||||
const HELP_LINKS: Record<string, string> = {};
|
||||
|
||||
export default class QueryError {
|
||||
message: string;
|
||||
|
||||
32
src/Common/ShimmerTree/ShimmerTree.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Shimmer, ShimmerElementType, Stack } from "@fluentui/react";
|
||||
import * as React from "react";
|
||||
|
||||
export interface IndentLevel {
|
||||
level: number;
|
||||
width?: string;
|
||||
}
|
||||
interface ShimmerTreeProps {
|
||||
indentLevels: IndentLevel[];
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const ShimmerTree = ({ indentLevels, style = {} }: ShimmerTreeProps) => {
|
||||
const renderShimmers = (indent: IndentLevel) => (
|
||||
<Shimmer
|
||||
key={Math.random()}
|
||||
shimmerElements={[
|
||||
{ type: ShimmerElementType.gap, width: `${indent.level * 20}px` },
|
||||
{ type: ShimmerElementType.line, height: 16, width: indent.width || "100%" },
|
||||
]}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack tokens={{ childrenGap: 8 }} style={{ width: "50%", ...style }} data-testid="shimmer-stack">
|
||||
{indentLevels.map((indentLevel: IndentLevel) => renderShimmers(indentLevel))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShimmerTree;
|
||||
@@ -4,13 +4,18 @@ import * as React from "react";
|
||||
export interface TooltipProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
ariaLabelForTooltip?: string;
|
||||
}
|
||||
|
||||
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children, className }: TooltipProps) => {
|
||||
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({
|
||||
children,
|
||||
className,
|
||||
ariaLabelForTooltip = children,
|
||||
}: TooltipProps) => {
|
||||
return (
|
||||
<span className={className}>
|
||||
<TooltipHost content={children}>
|
||||
<Icon iconName="Info" ariaLabel={children} className="panelInfoIcon" tabIndex={0} />
|
||||
<Icon iconName="Info" aria-label={ariaLabelForTooltip} className="panelInfoIcon" tabIndex={0} />
|
||||
</TooltipHost>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -50,10 +50,33 @@ export const Upload: FunctionComponent<UploadProps> = ({
|
||||
const title = label + " to upload";
|
||||
return (
|
||||
<div>
|
||||
<span className="renewUploadItemsHeader">{label}</span>
|
||||
<span className="renewUploadItemsHeader" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
{label}
|
||||
</span>
|
||||
{tooltip && <InfoTooltip>{tooltip}</InfoTooltip>}
|
||||
<Stack horizontal>
|
||||
<TextField styles={{ fieldGroup: { width: 300 } }} readOnly value={selectedFilesTitle.toString()} />
|
||||
<TextField
|
||||
styles={{
|
||||
fieldGroup: {
|
||||
width: 300,
|
||||
backgroundColor: "var(--colorNeutralBackground3)",
|
||||
borderColor: "var(--colorNeutralStroke1)",
|
||||
},
|
||||
field: {
|
||||
backgroundColor: "var(--colorNeutralBackground3)",
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
subComponentStyles: {
|
||||
label: {
|
||||
root: {
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
readOnly
|
||||
value={selectedFilesTitle.toString()}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
id="importFileInput"
|
||||
|
||||
73
src/Common/__snapshots__/LoadingOverlay.test.tsx.snap
Normal file
@@ -0,0 +1,73 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`LoadingOverlay should handle long labels properly 1`] = `
|
||||
<div
|
||||
class="ms-Overlay root-109"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner root-111"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner-circle ms-Spinner--large circle-112"
|
||||
/>
|
||||
<div
|
||||
class="ms-Spinner-label label-113"
|
||||
>
|
||||
This is a very long loading message that might span multiple lines and should still render correctly in the loading overlay component
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`LoadingOverlay should render loading overlay when isLoading is true 1`] = `
|
||||
<div
|
||||
class="ms-Overlay root-109"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner root-111"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner-circle ms-Spinner--large circle-112"
|
||||
/>
|
||||
<div
|
||||
class="ms-Spinner-label label-113"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`LoadingOverlay should render loading overlay with custom label 1`] = `
|
||||
<div
|
||||
class="ms-Overlay root-109"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner root-111"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner-circle ms-Spinner--large circle-112"
|
||||
/>
|
||||
<div
|
||||
class="ms-Spinner-label label-113"
|
||||
>
|
||||
Processing your request...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`LoadingOverlay should render loading overlay with empty label 1`] = `
|
||||
<div
|
||||
class="ms-Overlay root-109"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner root-111"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner-circle ms-Spinner--large circle-112"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`getCommonQueryOptions builds the correct default options objects 1`] = `
|
||||
{
|
||||
"disableNonStreamingOrderByQuery": true,
|
||||
"enableQueryControl": false,
|
||||
"enableScanInQuery": true,
|
||||
"forceQueryPlan": true,
|
||||
"maxDegreeOfParallelism": 0,
|
||||
@@ -13,7 +13,7 @@ exports[`getCommonQueryOptions builds the correct default options objects 1`] =
|
||||
|
||||
exports[`getCommonQueryOptions reads from localStorage 1`] = `
|
||||
{
|
||||
"disableNonStreamingOrderByQuery": true,
|
||||
"enableQueryControl": false,
|
||||
"enableScanInQuery": true,
|
||||
"forceQueryPlan": true,
|
||||
"maxDegreeOfParallelism": 17,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ContainerRequest, ContainerResponse, DatabaseRequest, DatabaseResponse, RequestOptions } from "@azure/cosmos";
|
||||
import { sendMessage } from "Common/MessageHandler";
|
||||
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
@@ -43,6 +45,14 @@ export const createCollection = async (params: DataModels.CreateCollectionParams
|
||||
}
|
||||
|
||||
logConsoleInfo(`Successfully created container ${params.collectionId}`);
|
||||
|
||||
if (isFabricNative()) {
|
||||
sendMessage({
|
||||
type: FabricMessageTypes.ContainerUpdated,
|
||||
params: { updateType: "created" },
|
||||
});
|
||||
}
|
||||
|
||||
return collection;
|
||||
} catch (error) {
|
||||
handleError(error, "CreateCollection", `Error while creating container ${params.collectionId}`);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { sendMessage } from "Common/MessageHandler";
|
||||
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
||||
import { isFabric } from "Platform/Fabric/FabricUtil";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import { userContext } from "../../UserContext";
|
||||
@@ -19,6 +21,11 @@ export async function deleteCollection(databaseId: string, collectionId: string)
|
||||
await client().database(databaseId).container(collectionId).delete();
|
||||
}
|
||||
logConsoleInfo(`Successfully deleted container ${collectionId}`);
|
||||
|
||||
sendMessage({
|
||||
type: FabricMessageTypes.ContainerUpdated,
|
||||
params: { updateType: "deleted" },
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, "DeleteCollection", `Error while deleting container ${collectionId}`);
|
||||
throw error;
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface IBulkDeleteResult {
|
||||
export const deleteDocuments = async (
|
||||
collection: CollectionBase,
|
||||
documentIds: DocumentId[],
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<IBulkDeleteResult[]> => {
|
||||
const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`);
|
||||
try {
|
||||
@@ -65,7 +66,11 @@ export const deleteDocuments = async (
|
||||
operationType: BulkOperationType.Delete,
|
||||
}));
|
||||
|
||||
const promise = v2Container.items.bulk(operations).then((bulkResults) => {
|
||||
const promise = v2Container.items
|
||||
.bulk(operations, undefined, {
|
||||
abortSignal,
|
||||
})
|
||||
.then((bulkResults) => {
|
||||
return bulkResults.map((bulkResult, index) => {
|
||||
const documentId = documentIdsChunk[index];
|
||||
return { ...bulkResult, documentId };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import { configContext } from "../../ConfigContext";
|
||||
import { userContext } from "../../UserContext";
|
||||
@@ -41,7 +42,7 @@ interface MetricsResponse {
|
||||
}
|
||||
|
||||
export const getCollectionUsageSizeInKB = async (databaseName: string, containerName: string): Promise<number> => {
|
||||
if (userContext.authType !== AuthType.AAD) {
|
||||
if (userContext.authType !== AuthType.AAD || isFabricNative()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FeedOptions, ItemDefinition, QueryIterator, Resource } from "@azure/cosmos";
|
||||
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
|
||||
import { Queries } from "../Constants";
|
||||
import { client } from "../CosmosClient";
|
||||
@@ -26,7 +25,7 @@ export const getCommonQueryOptions = (options: FeedOptions): FeedOptions => {
|
||||
options.maxItemCount ||
|
||||
(storedItemPerPageSetting !== undefined && storedItemPerPageSetting) ||
|
||||
Queries.itemsPerPage;
|
||||
options.enableQueryControl = LocalStorageUtility.getEntryBoolean(StorageKey.QueryControlEnabled);
|
||||
options.maxDegreeOfParallelism = LocalStorageUtility.getEntryNumber(StorageKey.MaxDegreeOfParellism);
|
||||
options.disableNonStreamingOrderByQuery = !isVectorSearchEnabled();
|
||||
return options;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { QueryOperationOptions } from "@azure/cosmos";
|
||||
import { QueryResults } from "../../Contracts/ViewModels";
|
||||
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { getEntityName } from "../DocumentUtility";
|
||||
@@ -9,13 +8,12 @@ export const queryDocumentsPage = async (
|
||||
resourceName: string,
|
||||
documentsIterator: MinimalQueryIterator,
|
||||
firstItemIndex: number,
|
||||
queryOperationOptions?: QueryOperationOptions,
|
||||
): Promise<QueryResults> => {
|
||||
const entityName = getEntityName();
|
||||
const clearMessage = logConsoleProgress(`Querying ${entityName} for container ${resourceName}`);
|
||||
|
||||
try {
|
||||
const result: QueryResults = await nextPage(documentsIterator, firstItemIndex, queryOperationOptions);
|
||||
const result: QueryResults = await nextPage(documentsIterator, firstItemIndex);
|
||||
const itemCount = (result.documents && result.documents.length) || 0;
|
||||
logConsoleInfo(`Successfully fetched ${itemCount} ${entityName} for container ${resourceName}`);
|
||||
return result;
|
||||
|
||||
@@ -12,13 +12,13 @@ import { handleError } from "../ErrorHandlingUtils";
|
||||
import { readOfferWithSDK } from "./readOfferWithSDK";
|
||||
|
||||
export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise<Offer> => {
|
||||
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
|
||||
|
||||
if (isFabric()) {
|
||||
// Not exposing offers in Fabric
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`);
|
||||
|
||||
try {
|
||||
if (
|
||||
userContext.authType === AuthType.AAD &&
|
||||
|
||||
@@ -126,12 +126,5 @@ async function readCollectionsWithARM(databaseId: string): Promise<DataModels.Co
|
||||
throw new Error(`Unsupported default experience type: ${apiType}`);
|
||||
}
|
||||
|
||||
// TO DO: Remove when we get RP API Spec with materializedViews
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
return rpResponse?.value?.map((collection: any) => {
|
||||
const collectionDataModel: DataModels.Collection = collection.properties?.resource as DataModels.Collection;
|
||||
collectionDataModel.materializedViews = collection.properties?.resource?.materializedViews;
|
||||
collectionDataModel.materializedViewDefinition = collection.properties?.resource?.materializedViewDefinition;
|
||||
return collectionDataModel;
|
||||
});
|
||||
return rpResponse?.value?.map((collection) => collection.properties?.resource as DataModels.Collection);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Item, RequestOptions } from "@azure/cosmos";
|
||||
import { HttpHeaders } from "Common/Constants";
|
||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
import { CollectionBase } from "../../Contracts/ViewModels";
|
||||
import DocumentId from "../../Explorer/Tree/DocumentId";
|
||||
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
@@ -23,10 +24,17 @@ export const updateDocument = async (
|
||||
[HttpHeaders.partitionKey]: documentId.partitionKeyValue,
|
||||
}
|
||||
: {};
|
||||
|
||||
// If user has chosen to ignore partition key on update, pass null instead of actual partition key value
|
||||
const ignorePartitionKeyOnDocumentUpdateFlag = LocalStorageUtility.getEntryBoolean(
|
||||
StorageKey.IgnorePartitionKeyOnDocumentUpdate,
|
||||
);
|
||||
const partitionKey = ignorePartitionKeyOnDocumentUpdateFlag ? undefined : getPartitionKeyValue(documentId);
|
||||
|
||||
const response = await client()
|
||||
.database(collection.databaseId)
|
||||
.container(collection.id())
|
||||
.item(documentId.id(), getPartitionKeyValue(documentId))
|
||||
.item(documentId.id(), partitionKey)
|
||||
.replace(newDocument, options);
|
||||
|
||||
logConsoleInfo(`Successfully updated ${entityName} ${documentId.id()}`);
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { CassandraProxyEndpoints, JunoEndpoints, MongoProxyEndpoints, PortalBackendEndpoints } from "Common/Constants";
|
||||
import {
|
||||
BackendApi,
|
||||
CassandraProxyEndpoints,
|
||||
JunoEndpoints,
|
||||
MongoProxyEndpoints,
|
||||
PortalBackendEndpoints,
|
||||
} from "Common/Constants";
|
||||
import {
|
||||
allowedAadEndpoints,
|
||||
allowedArcadiaEndpoints,
|
||||
allowedEmulatorEndpoints,
|
||||
allowedGraphEndpoints,
|
||||
allowedHostedExplorerEndpoints,
|
||||
allowedJunoOrigins,
|
||||
allowedMsalRedirectEndpoints,
|
||||
defaultAllowedAadEndpoints,
|
||||
defaultAllowedArmEndpoints,
|
||||
defaultAllowedBackendEndpoints,
|
||||
defaultAllowedCassandraProxyEndpoints,
|
||||
defaultAllowedGraphEndpoints,
|
||||
defaultAllowedMongoProxyEndpoints,
|
||||
validateEndpoint,
|
||||
} from "Utils/EndpointUtils";
|
||||
@@ -29,6 +23,8 @@ export enum Platform {
|
||||
|
||||
export interface ConfigContext {
|
||||
platform: Platform;
|
||||
allowedAadEndpoints: ReadonlyArray<string>;
|
||||
allowedGraphEndpoints: ReadonlyArray<string>;
|
||||
allowedArmEndpoints: ReadonlyArray<string>;
|
||||
allowedBackendEndpoints: ReadonlyArray<string>;
|
||||
allowedCassandraProxyEndpoints: ReadonlyArray<string>;
|
||||
@@ -37,10 +33,8 @@ export interface ConfigContext {
|
||||
gitSha?: string;
|
||||
proxyPath?: string;
|
||||
AAD_ENDPOINT: string;
|
||||
ARM_AUTH_AREA: string;
|
||||
ARM_ENDPOINT: string;
|
||||
EMULATOR_ENDPOINT?: string;
|
||||
ARM_API_VERSION: string;
|
||||
GRAPH_ENDPOINT: string;
|
||||
GRAPH_API_VERSION: string;
|
||||
// This is the endpoint to get offering Ids to be used to fetch prices. Refer to this doc: https://learn.microsoft.com/en-us/rest/api/marketplacecatalog/dataplane/skus/list?view=rest-marketplacecatalog-dataplane-2023-05-01-preview&tabs=HTTP
|
||||
@@ -50,27 +44,24 @@ export interface ConfigContext {
|
||||
ARCADIA_ENDPOINT: string;
|
||||
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
|
||||
PORTAL_BACKEND_ENDPOINT: string;
|
||||
NEW_BACKEND_APIS?: BackendApi[];
|
||||
MONGO_PROXY_ENDPOINT: string;
|
||||
CASSANDRA_PROXY_ENDPOINT: string;
|
||||
NEW_CASSANDRA_APIS?: string[];
|
||||
PROXY_PATH?: string;
|
||||
JUNO_ENDPOINT: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
GITHUB_TEST_ENV_CLIENT_ID: string;
|
||||
GITHUB_CLIENT_SECRET?: string; // No need to inject secret for prod. Juno already knows it.
|
||||
isTerminalEnabled: boolean;
|
||||
isPhoenixEnabled: boolean;
|
||||
hostedExplorerURL: string;
|
||||
armAPIVersion?: string;
|
||||
msalRedirectURI?: string;
|
||||
globallyEnabledCassandraAPIs?: string[];
|
||||
globallyEnabledMongoAPIs?: string[];
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
let configContext: Readonly<ConfigContext> = {
|
||||
platform: Platform.Portal,
|
||||
allowedAadEndpoints: defaultAllowedAadEndpoints,
|
||||
allowedGraphEndpoints: defaultAllowedGraphEndpoints,
|
||||
allowedArmEndpoints: defaultAllowedArmEndpoints,
|
||||
allowedBackendEndpoints: defaultAllowedBackendEndpoints,
|
||||
allowedCassandraProxyEndpoints: defaultAllowedCassandraProxyEndpoints,
|
||||
@@ -85,17 +76,12 @@ let configContext: Readonly<ConfigContext> = {
|
||||
`^https:\\/\\/cosmos-db-dataexplorer-germanycentral\\.azurewebsites\\.de$`,
|
||||
`^https:\\/\\/.*\\.fabric\\.microsoft\\.com$`,
|
||||
`^https:\\/\\/.*\\.powerbi\\.com$`,
|
||||
`^https:\\/\\/.*\\.analysis-df\\.net$`,
|
||||
`^https:\\/\\/.*\\.analysis-df\\.windows\\.net$`,
|
||||
`^https:\\/\\/.*\\.azure-test\\.net$`,
|
||||
`^https:\\/\\/dataexplorer-preview\\.azurewebsites\\.net$`,
|
||||
], // Webpack injects this at build time
|
||||
gitSha: process.env.GIT_SHA,
|
||||
hostedExplorerURL: "https://cosmos.azure.com/",
|
||||
AAD_ENDPOINT: "https://login.microsoftonline.com/",
|
||||
ARM_AUTH_AREA: "https://management.azure.com/",
|
||||
ARM_ENDPOINT: "https://management.azure.com/",
|
||||
ARM_API_VERSION: "2016-06-01",
|
||||
GRAPH_ENDPOINT: "https://graph.microsoft.com",
|
||||
GRAPH_API_VERSION: "1.6",
|
||||
CATALOG_ENDPOINT: "https://catalogapi.azure.com/",
|
||||
@@ -109,11 +95,7 @@ let configContext: Readonly<ConfigContext> = {
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
|
||||
NEW_CASSANDRA_APIS: ["postQuery", "createOrDelete", "getKeys", "getSchema"],
|
||||
isTerminalEnabled: false,
|
||||
isPhoenixEnabled: false,
|
||||
globallyEnabledCassandraAPIs: [],
|
||||
globallyEnabledMongoAPIs: [],
|
||||
};
|
||||
|
||||
export function resetConfigContext(): void {
|
||||
@@ -128,19 +110,38 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateEndpoint(newContext.ARM_ENDPOINT, configContext.allowedArmEndpoints || defaultAllowedArmEndpoints)) {
|
||||
delete newContext.ARM_ENDPOINT;
|
||||
if (newContext.allowedAadEndpoints) {
|
||||
Object.assign(configContext, { allowedAadEndpoints: newContext.allowedAadEndpoints });
|
||||
}
|
||||
if (newContext.allowedArmEndpoints) {
|
||||
Object.assign(configContext, { allowedArmEndpoints: newContext.allowedArmEndpoints });
|
||||
}
|
||||
if (newContext.allowedGraphEndpoints) {
|
||||
Object.assign(configContext, { allowedGraphEndpoints: newContext.allowedGraphEndpoints });
|
||||
}
|
||||
if (newContext.allowedBackendEndpoints) {
|
||||
Object.assign(configContext, { allowedBackendEndpoints: newContext.allowedBackendEndpoints });
|
||||
}
|
||||
if (newContext.allowedMongoProxyEndpoints) {
|
||||
Object.assign(configContext, { allowedMongoProxyEndpoints: newContext.allowedMongoProxyEndpoints });
|
||||
}
|
||||
if (newContext.allowedCassandraProxyEndpoints) {
|
||||
Object.assign(configContext, { allowedCassandraProxyEndpoints: newContext.allowedCassandraProxyEndpoints });
|
||||
}
|
||||
|
||||
if (!validateEndpoint(newContext.AAD_ENDPOINT, allowedAadEndpoints)) {
|
||||
if (!validateEndpoint(newContext.AAD_ENDPOINT, configContext.allowedAadEndpoints)) {
|
||||
delete newContext.AAD_ENDPOINT;
|
||||
}
|
||||
|
||||
if (!validateEndpoint(newContext.ARM_ENDPOINT, configContext.allowedArmEndpoints)) {
|
||||
delete newContext.ARM_ENDPOINT;
|
||||
}
|
||||
|
||||
if (!validateEndpoint(newContext.EMULATOR_ENDPOINT, allowedEmulatorEndpoints)) {
|
||||
delete newContext.EMULATOR_ENDPOINT;
|
||||
}
|
||||
|
||||
if (!validateEndpoint(newContext.GRAPH_ENDPOINT, allowedGraphEndpoints)) {
|
||||
if (!validateEndpoint(newContext.GRAPH_ENDPOINT, configContext.allowedGraphEndpoints)) {
|
||||
delete newContext.GRAPH_ENDPOINT;
|
||||
}
|
||||
|
||||
@@ -148,21 +149,15 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
|
||||
delete newContext.ARCADIA_ENDPOINT;
|
||||
}
|
||||
|
||||
if (
|
||||
!validateEndpoint(
|
||||
newContext.MONGO_PROXY_ENDPOINT,
|
||||
configContext.allowedMongoProxyEndpoints || defaultAllowedMongoProxyEndpoints,
|
||||
)
|
||||
) {
|
||||
if (!validateEndpoint(newContext.PORTAL_BACKEND_ENDPOINT, configContext.allowedBackendEndpoints)) {
|
||||
delete newContext.PORTAL_BACKEND_ENDPOINT;
|
||||
}
|
||||
|
||||
if (!validateEndpoint(newContext.MONGO_PROXY_ENDPOINT, configContext.allowedMongoProxyEndpoints)) {
|
||||
delete newContext.MONGO_PROXY_ENDPOINT;
|
||||
}
|
||||
|
||||
if (
|
||||
!validateEndpoint(
|
||||
newContext.CASSANDRA_PROXY_ENDPOINT,
|
||||
configContext.allowedCassandraProxyEndpoints || defaultAllowedCassandraProxyEndpoints,
|
||||
)
|
||||
) {
|
||||
if (!validateEndpoint(newContext.CASSANDRA_PROXY_ENDPOINT, configContext.allowedCassandraProxyEndpoints)) {
|
||||
delete newContext.CASSANDRA_PROXY_ENDPOINT;
|
||||
}
|
||||
|
||||
@@ -187,8 +182,7 @@ if (process.env.NODE_ENV === "development") {
|
||||
PROXY_PATH: "/proxy",
|
||||
EMULATOR_ENDPOINT: "https://localhost:8081",
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
|
||||
// MONGO_PROXY_ENDPOINT: "https://cosmos-db-portal-mongoproxy1-mpac-westus.azurewebsites.net",
|
||||
MONGO_PROXY_ENDPOINT: "https://localhost:7238",
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac,
|
||||
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Mpac,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export enum PaneKind {
|
||||
GlobalSettings,
|
||||
AdHocAccess,
|
||||
SwitchDirectory,
|
||||
QuickStart,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FabricMessageTypes } from "./FabricMessageTypes";
|
||||
import { MessageTypes } from "./MessageTypes";
|
||||
|
||||
// This is the current version of these messages
|
||||
export const DATA_EXPLORER_RPC_VERSION = "3";
|
||||
@@ -19,9 +20,32 @@ export type DataExploreMessageV3 =
|
||||
type: FabricMessageTypes.GetAllResourceTokens;
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: FabricMessageTypes.GetAccessToken;
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: MessageTypes.TelemetryInfo;
|
||||
data: {
|
||||
action: string;
|
||||
actionModifier: string;
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: FabricMessageTypes.OpenSettings;
|
||||
settingsId: string;
|
||||
params: [{ settingsId?: "About" | "Connection" }];
|
||||
}
|
||||
| {
|
||||
type: FabricMessageTypes.RestoreContainer;
|
||||
params: [];
|
||||
}
|
||||
| {
|
||||
type: FabricMessageTypes.ContainerUpdated;
|
||||
params: {
|
||||
updateType: "created" | "deleted" | "settings";
|
||||
};
|
||||
};
|
||||
export interface GetCosmosTokenMessageOptions {
|
||||
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
|
||||
|
||||
@@ -7,17 +7,37 @@ export interface ArmEntity {
|
||||
type: string;
|
||||
kind: string;
|
||||
tags?: Tags;
|
||||
resourceGroup?: string;
|
||||
}
|
||||
|
||||
export interface DatabaseAccountUserAssignedIdentity {
|
||||
[key: string]: {
|
||||
principalId: string;
|
||||
clientId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DatabaseAccountIdentity {
|
||||
type: string;
|
||||
principalId?: string;
|
||||
tenantId?: string;
|
||||
userAssignedIdentities?: DatabaseAccountUserAssignedIdentity;
|
||||
}
|
||||
|
||||
export interface DatabaseAccount extends ArmEntity {
|
||||
properties: DatabaseAccountExtendedProperties;
|
||||
systemData?: DatabaseAccountSystemData;
|
||||
identity?: DatabaseAccountIdentity | null;
|
||||
}
|
||||
|
||||
export interface DatabaseAccountSystemData {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DatabaseAccountBackupPolicy {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface DatabaseAccountExtendedProperties {
|
||||
documentEndpoint?: string;
|
||||
disableLocalAuth?: boolean;
|
||||
@@ -28,6 +48,8 @@ export interface DatabaseAccountExtendedProperties {
|
||||
capabilities?: Capability[];
|
||||
enableMultipleWriteLocations?: boolean;
|
||||
mongoEndpoint?: string;
|
||||
backupPolicy?: DatabaseAccountBackupPolicy;
|
||||
defaultIdentity?: string;
|
||||
readLocations?: DatabaseAccountResponseLocation[];
|
||||
writeLocations?: DatabaseAccountResponseLocation[];
|
||||
enableFreeTier?: boolean;
|
||||
@@ -43,6 +65,7 @@ export interface DatabaseAccountExtendedProperties {
|
||||
publicNetworkAccess?: string;
|
||||
enablePriorityBasedExecution?: boolean;
|
||||
vcoreMongoEndpoint?: string;
|
||||
enableAllVersionsAndDeletesChangeFeed?: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseAccountResponseLocation {
|
||||
@@ -100,6 +123,24 @@ export interface Subscription {
|
||||
authorizationSource?: string;
|
||||
}
|
||||
|
||||
export interface DatabaseModel extends ArmEntity {
|
||||
properties: DatabaseGetProperties;
|
||||
}
|
||||
|
||||
export interface DatabaseGetProperties {
|
||||
resource: DatabaseResource & ExtendedResourceProperties;
|
||||
}
|
||||
export interface DatabaseResource {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ExtendedResourceProperties {
|
||||
readonly _rid?: string;
|
||||
readonly _self?: string;
|
||||
readonly _ts?: number;
|
||||
readonly _etag?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionPolicies {
|
||||
locationPlacementId: string;
|
||||
quotaId: string;
|
||||
@@ -162,6 +203,7 @@ export interface Collection extends Resource {
|
||||
geospatialConfig?: GeospatialConfig;
|
||||
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
|
||||
fullTextPolicy?: FullTextPolicy;
|
||||
dataMaskingPolicy?: DataMaskingPolicy;
|
||||
schema?: ISchema;
|
||||
requestSchema?: () => void;
|
||||
computedProperties?: ComputedProperties;
|
||||
@@ -226,6 +268,17 @@ export interface ComputedProperty {
|
||||
|
||||
export type ComputedProperties = ComputedProperty[];
|
||||
|
||||
export interface DataMaskingPolicy {
|
||||
includedPaths: Array<{
|
||||
path: string;
|
||||
strategy: string;
|
||||
startPosition: number;
|
||||
length: number;
|
||||
}>;
|
||||
excludedPaths: string[];
|
||||
isPolicyEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface MaterializedView {
|
||||
id: string;
|
||||
_rid: string;
|
||||
@@ -388,7 +441,7 @@ export interface VectorEmbeddingPolicy {
|
||||
}
|
||||
|
||||
export interface VectorEmbedding {
|
||||
dataType: "float16" | "float32" | "uint8" | "int8";
|
||||
dataType: "float32" | "uint8" | "int8";
|
||||
dimensions: number;
|
||||
distanceFunction: "euclidean" | "cosine" | "dotproduct";
|
||||
path: string;
|
||||
|
||||
@@ -7,6 +7,8 @@ export enum FabricMessageTypes {
|
||||
GetAccessToken = "GetAccessToken",
|
||||
Ready = "Ready",
|
||||
OpenSettings = "OpenSettings",
|
||||
RestoreContainer = "RestoreContainer",
|
||||
ContainerUpdated = "ContainerUpdated",
|
||||
}
|
||||
|
||||
export interface AuthorizationToken {
|
||||
|
||||
@@ -49,4 +49,6 @@ export enum MessageTypes {
|
||||
Ready, // unused. Can be removed if the portal uses the same list of enums.
|
||||
OpenCESCVAFeedbackBlade,
|
||||
ActivateTab,
|
||||
OpenContainerCopyFeedbackBlade,
|
||||
UpdateTheme,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ItemDefinition,
|
||||
JSONObject,
|
||||
QueryMetrics,
|
||||
Resource,
|
||||
@@ -6,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";
|
||||
@@ -30,8 +31,11 @@ export interface UploadDetailsRecord {
|
||||
numFailed: number;
|
||||
numThrottled: number;
|
||||
errors: string[];
|
||||
resources?: ItemDefinition[];
|
||||
}
|
||||
|
||||
export type BulkInsertResult = Omit<UploadDetailsRecord, "fileName">;
|
||||
|
||||
export interface QueryResultsMetadata {
|
||||
hasMoreResults: boolean;
|
||||
firstItemIndex: number;
|
||||
@@ -46,6 +50,7 @@ export interface QueryResults extends QueryResultsMetadata {
|
||||
roundTrips?: number;
|
||||
headers?: any;
|
||||
queryMetrics?: QueryMetrics;
|
||||
ruThresholdExceeded?: boolean;
|
||||
}
|
||||
|
||||
export interface Button {
|
||||
@@ -135,6 +140,7 @@ export interface Collection extends CollectionBase {
|
||||
requestSchema?: () => void;
|
||||
vectorEmbeddingPolicy: ko.Observable<DataModels.VectorEmbeddingPolicy>;
|
||||
fullTextPolicy: ko.Observable<DataModels.FullTextPolicy>;
|
||||
dataMaskingPolicy: ko.Observable<DataModels.DataMaskingPolicy>;
|
||||
indexingPolicy: ko.Observable<DataModels.IndexingPolicy>;
|
||||
uniqueKeyPolicy: DataModels.UniqueKeyPolicy;
|
||||
usageSizeInKB: ko.Observable<number>;
|
||||
@@ -438,6 +444,12 @@ export interface DataExplorerInputsFrame {
|
||||
[key: string]: string;
|
||||
};
|
||||
feedbackPolicies?: any;
|
||||
aadToken?: string;
|
||||
containerCopyEnabled?: boolean;
|
||||
sessionId?: string;
|
||||
theme?: {
|
||||
mode: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelfServeFrameInputs {
|
||||
@@ -471,3 +483,6 @@ export interface DropdownOption<T> {
|
||||
value: T;
|
||||
disable?: boolean;
|
||||
}
|
||||
|
||||
// Remove the duplicate Explorer interface and export the type
|
||||
export type { Explorer };
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<!doctype html>
|
||||
<html class="no-js" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="data:," />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="heatmap"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,55 +0,0 @@
|
||||
@import "../../../less/Common/Constants";
|
||||
html {
|
||||
font-family: @DataExplorerFont;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
border: 0px;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: @DataExplorerFont;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
border: 0px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#heatmap {
|
||||
.dark-theme {
|
||||
color: @BaseLight;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 3px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.noDataMessage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0.97;
|
||||
div {
|
||||
border-color: rgba(204, 204, 204, 0.8);
|
||||
box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.12);
|
||||
padding: 15px 10px;
|
||||
width: calc(55% - 40px);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import { handleMessage, Heatmap, isDarkTheme } from "./Heatmap";
|
||||
import { PortalTheme } from "./HeatmapDatatypes";
|
||||
|
||||
describe("The Heatmap Control", () => {
|
||||
const dataPoints = {
|
||||
"1": {
|
||||
"2019-06-19T00:59:10Z": {
|
||||
"Normalized Throughput": 0.35,
|
||||
},
|
||||
"2019-06-19T00:48:10Z": {
|
||||
"Normalized Throughput": 0.25,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const chartCaptions = {
|
||||
chartTitle: "chart title",
|
||||
yAxisTitle: "YAxisTitle",
|
||||
tooltipText: "Tooltip text",
|
||||
timeWindow: 123456789,
|
||||
};
|
||||
|
||||
let heatmap: Heatmap;
|
||||
const theme: PortalTheme = 1;
|
||||
const divElement = `<div id="${Heatmap.elementId}"></div>`;
|
||||
|
||||
describe("drawHeatmap rendering", () => {
|
||||
beforeEach(() => {
|
||||
heatmap = new Heatmap(dataPoints, chartCaptions, theme);
|
||||
document.body.innerHTML = divElement;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ``;
|
||||
});
|
||||
|
||||
it("should call _getChartSettings when drawHeatmap is invoked", () => {
|
||||
const _getChartSettings = jest.spyOn(heatmap, "_getChartSettings");
|
||||
heatmap.drawHeatmap();
|
||||
expect(_getChartSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call _getLayoutSettings when drawHeatmap is invoked", () => {
|
||||
const _getLayoutSettings = jest.spyOn(heatmap, "_getLayoutSettings");
|
||||
heatmap.drawHeatmap();
|
||||
expect(_getLayoutSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call _getChartDisplaySettings when drawHeatmap is invoked", () => {
|
||||
const _getChartDisplaySettings = jest.spyOn(heatmap, "_getChartDisplaySettings");
|
||||
heatmap.drawHeatmap();
|
||||
expect(_getChartDisplaySettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drawHeatmap should render a Heatmap inside the div element", () => {
|
||||
heatmap.drawHeatmap();
|
||||
expect(document.body.innerHTML).not.toEqual(divElement);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateMatrixFromMap", () => {
|
||||
it("should massage input data to match output expected", () => {
|
||||
expect(heatmap.generateMatrixFromMap(dataPoints).yAxisPoints).toEqual(["1"]);
|
||||
expect(heatmap.generateMatrixFromMap(dataPoints).dataPoints).toEqual([[0.25, 0.35]]);
|
||||
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints.length).toEqual(2);
|
||||
});
|
||||
|
||||
it("should output the date format to ISO8601 string format", () => {
|
||||
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints[0].slice(10, 11)).toEqual("T");
|
||||
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints[0].slice(-1)).toEqual("Z");
|
||||
});
|
||||
|
||||
it("should convert the time to the user's local time", () => {
|
||||
if (dayjs().utcOffset()) {
|
||||
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints).not.toEqual([
|
||||
"2019-06-19T00:48:10Z",
|
||||
"2019-06-19T00:59:10Z",
|
||||
]);
|
||||
} else {
|
||||
expect(heatmap.generateMatrixFromMap(dataPoints).xAxisPoints).toEqual([
|
||||
"2019-06-19T00:48:10Z",
|
||||
"2019-06-19T00:59:10Z",
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDarkTheme", () => {
|
||||
it("isDarkTheme should return the correct result", () => {
|
||||
expect(isDarkTheme(PortalTheme.dark)).toEqual(true);
|
||||
expect(isDarkTheme(PortalTheme.azure)).not.toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("iframe rendering when there is no data", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ``;
|
||||
});
|
||||
|
||||
it("should show a no data message with a dark theme", () => {
|
||||
const data = {
|
||||
data: {
|
||||
signature: "pcIframe",
|
||||
data: {
|
||||
chartData: {},
|
||||
chartSettings: {},
|
||||
theme: 4,
|
||||
},
|
||||
},
|
||||
origin: "http://localhost",
|
||||
};
|
||||
|
||||
const divElement = `<div id="${Heatmap.elementId}"></div>`;
|
||||
document.body.innerHTML = divElement;
|
||||
|
||||
handleMessage(data as MessageEvent);
|
||||
expect(document.body.innerHTML).toContain("dark-theme");
|
||||
expect(document.body.innerHTML).toContain("noDataMessage");
|
||||
});
|
||||
|
||||
it("should show a no data message with a white theme", () => {
|
||||
const data = {
|
||||
data: {
|
||||
signature: "pcIframe",
|
||||
data: {
|
||||
chartData: {},
|
||||
chartSettings: {},
|
||||
theme: 2,
|
||||
},
|
||||
},
|
||||
origin: "http://localhost",
|
||||
};
|
||||
|
||||
const divElement = `<div id="${Heatmap.elementId}"></div>`;
|
||||
document.body.innerHTML = divElement;
|
||||
|
||||
handleMessage(data as MessageEvent);
|
||||
expect(document.body.innerHTML).not.toContain("dark-theme");
|
||||
expect(document.body.innerHTML).toContain("noDataMessage");
|
||||
});
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import * as Plotly from "plotly.js-cartesian-dist-min";
|
||||
import { sendCachedDataMessage, sendReadyMessage } from "../../Common/MessageHandler";
|
||||
import { StyleConstants } from "../../Common/StyleConstants";
|
||||
import { MessageTypes } from "../../Contracts/ExplorerContracts";
|
||||
import { isInvalidParentFrameOrigin } from "../../Utils/MessageValidation";
|
||||
import "./Heatmap.less";
|
||||
import {
|
||||
ChartSettings,
|
||||
DataPayload,
|
||||
DisplaySettings,
|
||||
FontSettings,
|
||||
HeatmapCaptions,
|
||||
HeatmapData,
|
||||
LayoutSettings,
|
||||
PartitionTimeStampToData,
|
||||
PortalTheme,
|
||||
} from "./HeatmapDatatypes";
|
||||
|
||||
export class Heatmap {
|
||||
public static readonly elementId: string = "heatmap";
|
||||
|
||||
private _chartData: HeatmapData;
|
||||
private _heatmapCaptions: HeatmapCaptions;
|
||||
private _theme: PortalTheme;
|
||||
private _defaultFontColor: string;
|
||||
|
||||
constructor(data: DataPayload, heatmapCaptions: HeatmapCaptions, theme: PortalTheme) {
|
||||
this._theme = theme;
|
||||
this._defaultFontColor = StyleConstants.BaseDark;
|
||||
this._setThemeColorForChart();
|
||||
this._chartData = this.generateMatrixFromMap(data);
|
||||
this._heatmapCaptions = heatmapCaptions;
|
||||
}
|
||||
|
||||
private _setThemeColorForChart() {
|
||||
if (isDarkTheme(this._theme)) {
|
||||
this._defaultFontColor = StyleConstants.BaseLight;
|
||||
}
|
||||
}
|
||||
|
||||
private _getFontStyles(size: number = StyleConstants.MediumFontSize, color = "#838383"): FontSettings {
|
||||
return {
|
||||
family: StyleConstants.DataExplorerFont,
|
||||
size,
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
public generateMatrixFromMap(data: DataPayload): HeatmapData {
|
||||
// all keys in data payload, sorted...
|
||||
const rows: string[] = Object.keys(data).sort((a: string, b: string) => {
|
||||
if (parseInt(a) < parseInt(b)) {
|
||||
return -1;
|
||||
} else {
|
||||
if (parseInt(a) > parseInt(b)) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
const output: HeatmapData = {
|
||||
yAxisPoints: [],
|
||||
dataPoints: [],
|
||||
xAxisPoints: Object.keys(data[rows[0]]).sort((a: string, b: string) => {
|
||||
if (a < b) {
|
||||
return -1;
|
||||
} else {
|
||||
if (a > b) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}),
|
||||
};
|
||||
// go thru all rows and create 2d matrix for heatmap...
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
output.yAxisPoints.push(rows[i]);
|
||||
const dataPoints: number[] = [];
|
||||
for (let a = 0; a < output.xAxisPoints.length; a++) {
|
||||
const row: PartitionTimeStampToData = data[rows[i]];
|
||||
dataPoints.push(row[output.xAxisPoints[a]]["Normalized Throughput"]);
|
||||
}
|
||||
output.dataPoints.push(dataPoints);
|
||||
}
|
||||
for (let a = 0; a < output.xAxisPoints.length; a++) {
|
||||
const dateTime = output.xAxisPoints[a];
|
||||
// convert to local users timezone...
|
||||
const day = dayjs(new Date(dateTime)).format("YYYY-MM-DD");
|
||||
const hour = dayjs(new Date(dateTime)).format("HH:mm:ss");
|
||||
// coerce to ISOString format since that is what plotly wants...
|
||||
output.xAxisPoints[a] = `${day}T${hour}Z`;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// public for testing purposes
|
||||
public _getChartSettings(): ChartSettings[] {
|
||||
return [
|
||||
{
|
||||
z: this._chartData.dataPoints,
|
||||
type: "heatmap",
|
||||
zmin: 0,
|
||||
zmid: 50,
|
||||
zmax: 100,
|
||||
colorscale: [
|
||||
[0.0, "#1FD338"],
|
||||
[0.1, "#1CAD2F"],
|
||||
[0.2, "#50A527"],
|
||||
[0.3, "#719F21"],
|
||||
[0.4, "#95991B"],
|
||||
[0.5, "#CE8F11"],
|
||||
[0.6, "#E27F0F"],
|
||||
[0.7, "#E46612"],
|
||||
[0.8, "#E64914"],
|
||||
[0.9, "#B80016"],
|
||||
[1.0, "#B80016"],
|
||||
],
|
||||
name: "",
|
||||
hovertemplate: this._heatmapCaptions.tooltipText,
|
||||
colorbar: {
|
||||
thickness: 15,
|
||||
outlinewidth: 0,
|
||||
tickcolor: StyleConstants.BaseDark,
|
||||
tickfont: this._getFontStyles(10, this._defaultFontColor),
|
||||
},
|
||||
y: this._chartData.yAxisPoints,
|
||||
x: this._chartData.xAxisPoints,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// public for testing purposes
|
||||
public _getLayoutSettings(): LayoutSettings {
|
||||
return {
|
||||
margin: {
|
||||
l: 40,
|
||||
r: 10,
|
||||
b: 35,
|
||||
t: 30,
|
||||
pad: 0,
|
||||
},
|
||||
paper_bgcolor: "transparent",
|
||||
plot_bgcolor: "transparent",
|
||||
width: 462,
|
||||
height: 240,
|
||||
yaxis: {
|
||||
title: this._heatmapCaptions.yAxisTitle,
|
||||
titlefont: this._getFontStyles(11),
|
||||
autorange: true,
|
||||
showgrid: false,
|
||||
zeroline: false,
|
||||
showline: false,
|
||||
autotick: true,
|
||||
fixedrange: true,
|
||||
ticks: "",
|
||||
showticklabels: false,
|
||||
},
|
||||
xaxis: {
|
||||
fixedrange: true,
|
||||
title: "*White area in heatmap indicates there is no available data",
|
||||
titlefont: this._getFontStyles(11),
|
||||
autorange: true,
|
||||
showgrid: false,
|
||||
zeroline: false,
|
||||
showline: false,
|
||||
autotick: true,
|
||||
tickformat: this._heatmapCaptions.timeWindow > 7 ? "%I:%M %p" : "%b %e",
|
||||
showticklabels: true,
|
||||
tickfont: this._getFontStyles(10),
|
||||
},
|
||||
title: {
|
||||
text: this._heatmapCaptions.chartTitle,
|
||||
x: 0.01,
|
||||
font: this._getFontStyles(13, this._defaultFontColor),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// public for testing purposes
|
||||
public _getChartDisplaySettings(): DisplaySettings {
|
||||
return {
|
||||
/* heatmap can be fully responsive however the min-height needed in that case is greater than the iframe portal height, hence explicit width + height have been set in _getLayoutSettings
|
||||
responsive: true,*/
|
||||
displayModeBar: false,
|
||||
};
|
||||
}
|
||||
|
||||
public drawHeatmap(): void {
|
||||
// todo - create random elementId generator so multiple heatmaps can be created - ticket # 431469
|
||||
Plotly.plot(
|
||||
Heatmap.elementId,
|
||||
this._getChartSettings(),
|
||||
this._getLayoutSettings(),
|
||||
this._getChartDisplaySettings(),
|
||||
);
|
||||
const plotDiv: any = document.getElementById(Heatmap.elementId);
|
||||
plotDiv.on("plotly_click", (data: any) => {
|
||||
let timeSelected: string = data.points[0].x;
|
||||
timeSelected = timeSelected.replace(" ", "T");
|
||||
timeSelected = `${timeSelected}Z`;
|
||||
let xAxisIndex = 0;
|
||||
for (let i = 0; i < this._chartData.xAxisPoints.length; i++) {
|
||||
if (this._chartData.xAxisPoints[i] === timeSelected) {
|
||||
xAxisIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const output = [];
|
||||
for (let i = 0; i < this._chartData.dataPoints.length; i++) {
|
||||
output.push(this._chartData.dataPoints[i][xAxisIndex]);
|
||||
}
|
||||
sendCachedDataMessage(MessageTypes.LogInfo, output);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function isDarkTheme(theme: PortalTheme) {
|
||||
return theme === PortalTheme.dark;
|
||||
}
|
||||
|
||||
export function handleMessage(event: MessageEvent) {
|
||||
if (isInvalidParentFrameOrigin(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof event.data !== "object" || event.data["signature"] !== "pcIframe") {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof event.data.data !== "object" ||
|
||||
!("chartData" in event.data.data) ||
|
||||
!("chartSettings" in event.data.data)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
Plotly.purge(Heatmap.elementId);
|
||||
|
||||
document.getElementById(Heatmap.elementId)!.innerHTML = "";
|
||||
const data = event.data.data;
|
||||
const chartData: DataPayload = data.chartData;
|
||||
const chartSettings: HeatmapCaptions = data.chartSettings;
|
||||
const chartTheme: PortalTheme = data.theme;
|
||||
if (Object.keys(chartData).length) {
|
||||
new Heatmap(chartData, chartSettings, chartTheme).drawHeatmap();
|
||||
} else {
|
||||
const chartTitleElement = document.createElement("div");
|
||||
chartTitleElement.innerHTML = data.chartSettings.chartTitle;
|
||||
chartTitleElement.classList.add("chartTitle");
|
||||
|
||||
const noDataMessageElement = document.createElement("div");
|
||||
noDataMessageElement.classList.add("noDataMessage");
|
||||
const noDataMessageContent = document.createElement("div");
|
||||
noDataMessageContent.innerHTML = data.errorMessage;
|
||||
|
||||
noDataMessageElement.appendChild(noDataMessageContent);
|
||||
|
||||
if (isDarkTheme(chartTheme)) {
|
||||
chartTitleElement.classList.add("dark-theme");
|
||||
noDataMessageElement.classList.add("dark-theme");
|
||||
noDataMessageContent.classList.add("dark-theme");
|
||||
}
|
||||
|
||||
document.getElementById(Heatmap.elementId)!.appendChild(chartTitleElement);
|
||||
document.getElementById(Heatmap.elementId)!.appendChild(noDataMessageElement);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage, false);
|
||||
sendReadyMessage();
|
||||
@@ -1,106 +0,0 @@
|
||||
type dataPoint = string | number;
|
||||
|
||||
export interface DataPayload {
|
||||
[id: string]: PartitionTimeStampToData;
|
||||
}
|
||||
|
||||
export enum PortalTheme {
|
||||
blue = 1,
|
||||
azure,
|
||||
light,
|
||||
dark,
|
||||
}
|
||||
|
||||
export interface HeatmapData {
|
||||
yAxisPoints: string[];
|
||||
xAxisPoints: string[];
|
||||
dataPoints: dataPoint[][];
|
||||
}
|
||||
|
||||
export interface HeatmapCaptions {
|
||||
chartTitle: string;
|
||||
yAxisTitle: string;
|
||||
tooltipText: string;
|
||||
timeWindow: number;
|
||||
}
|
||||
|
||||
export interface FontSettings {
|
||||
family: string;
|
||||
size: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface LayoutSettings {
|
||||
paper_bgcolor?: string;
|
||||
plot_bgcolor?: string;
|
||||
margin?: {
|
||||
l: number;
|
||||
r: number;
|
||||
b: number;
|
||||
t: number;
|
||||
pad: number;
|
||||
};
|
||||
width?: number;
|
||||
height?: number;
|
||||
yaxis?: {
|
||||
fixedrange: boolean;
|
||||
title: HeatmapCaptions["yAxisTitle"];
|
||||
titlefont: FontSettings;
|
||||
autorange: boolean;
|
||||
showgrid: boolean;
|
||||
zeroline: boolean;
|
||||
showline: boolean;
|
||||
autotick: boolean;
|
||||
ticks: "";
|
||||
showticklabels: boolean;
|
||||
};
|
||||
xaxis?: {
|
||||
fixedrange: boolean;
|
||||
title: string;
|
||||
titlefont: FontSettings;
|
||||
autorange: boolean;
|
||||
showgrid: boolean;
|
||||
zeroline: boolean;
|
||||
showline: boolean;
|
||||
autotick: boolean;
|
||||
showticklabels: boolean;
|
||||
tickformat: string;
|
||||
tickfont: FontSettings;
|
||||
};
|
||||
title?: {
|
||||
text: HeatmapCaptions["chartTitle"];
|
||||
x: number;
|
||||
font?: FontSettings;
|
||||
};
|
||||
font?: FontSettings;
|
||||
}
|
||||
|
||||
export interface ChartSettings {
|
||||
z: HeatmapData["dataPoints"];
|
||||
type: "heatmap";
|
||||
zmin: number;
|
||||
zmid: number;
|
||||
zmax: number;
|
||||
colorscale: [number, string][];
|
||||
name: string;
|
||||
hovertemplate: HeatmapCaptions["tooltipText"];
|
||||
colorbar: {
|
||||
thickness: number;
|
||||
outlinewidth: number;
|
||||
tickcolor: string;
|
||||
tickfont: FontSettings;
|
||||
};
|
||||
y: HeatmapData["yAxisPoints"];
|
||||
x: HeatmapData["xAxisPoints"];
|
||||
}
|
||||
|
||||
export interface DisplaySettings {
|
||||
displayModeBar: boolean;
|
||||
responsive?: boolean;
|
||||
}
|
||||
|
||||
export interface PartitionTimeStampToData {
|
||||
[timeSeriesDates: string]: {
|
||||
[NormalizedThroughput: string]: number;
|
||||
};
|
||||
}
|
||||
729
src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx
Normal file
@@ -0,0 +1,729 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import * as Logger from "../../../Common/Logger";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
import * as dataTransferService from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
|
||||
import * as CopyJobUtils from "../CopyJobUtils";
|
||||
import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider";
|
||||
import { CopyJobActions, CopyJobStatusType } from "../Enums/CopyJobEnums";
|
||||
import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails";
|
||||
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
|
||||
import { CopyJobContextState, CopyJobType } from "../Types/CopyJobTypes";
|
||||
import {
|
||||
getCopyJobs,
|
||||
openCopyJobDetailsPanel,
|
||||
openCreateCopyJobPanel,
|
||||
submitCreateCopyJob,
|
||||
updateCopyJobStatus,
|
||||
} from "./CopyJobActions";
|
||||
|
||||
jest.mock("UserContext", () => ({
|
||||
userContext: {
|
||||
databaseAccount: {
|
||||
id: "/subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../hooks/useSidePanel");
|
||||
jest.mock("../../../Common/Logger");
|
||||
jest.mock("../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs");
|
||||
jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState");
|
||||
jest.mock("../CopyJobUtils");
|
||||
|
||||
describe("CopyJobActions", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("openCreateCopyJobPanel", () => {
|
||||
it("should open side panel with correct parameters", () => {
|
||||
const mockExplorer = {} as Explorer;
|
||||
const mockSetPanelHasConsole = jest.fn();
|
||||
const mockOpenSidePanel = jest.fn();
|
||||
|
||||
(useSidePanel.getState as jest.Mock).mockReturnValue({
|
||||
setPanelHasConsole: mockSetPanelHasConsole,
|
||||
openSidePanel: mockOpenSidePanel,
|
||||
});
|
||||
|
||||
openCreateCopyJobPanel(mockExplorer);
|
||||
|
||||
expect(mockSetPanelHasConsole).toHaveBeenCalledWith(false);
|
||||
expect(mockOpenSidePanel).toHaveBeenCalledWith(expect.any(String), expect.any(Object), "650px");
|
||||
});
|
||||
|
||||
it("should render CreateCopyJobScreensProvider in side panel", () => {
|
||||
const mockExplorer = {} as Explorer;
|
||||
const mockOpenSidePanel = jest.fn();
|
||||
|
||||
(useSidePanel.getState as jest.Mock).mockReturnValue({
|
||||
setPanelHasConsole: jest.fn(),
|
||||
openSidePanel: mockOpenSidePanel,
|
||||
});
|
||||
|
||||
openCreateCopyJobPanel(mockExplorer);
|
||||
|
||||
const sidePanelContent = mockOpenSidePanel.mock.calls[0][1];
|
||||
expect(sidePanelContent.type).toBe(CreateCopyJobScreensProvider);
|
||||
expect(sidePanelContent.props.explorer).toBe(mockExplorer);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openCopyJobDetailsPanel", () => {
|
||||
it("should open side panel with job details", () => {
|
||||
const mockJob: CopyJobType = {
|
||||
ID: "1",
|
||||
Mode: "online",
|
||||
Name: "test-job",
|
||||
Status: CopyJobStatusType.InProgress,
|
||||
CompletionPercentage: 50,
|
||||
Duration: "01 hours, 30 minutes, 45 seconds",
|
||||
LastUpdatedTime: "1/1/2025, 10:00:00 AM",
|
||||
timestamp: 1704106800000,
|
||||
Source: {
|
||||
component: "CosmosDBSql",
|
||||
databaseName: "source-db",
|
||||
containerName: "source-container",
|
||||
},
|
||||
Destination: {
|
||||
component: "CosmosDBSql",
|
||||
databaseName: "target-db",
|
||||
containerName: "target-container",
|
||||
},
|
||||
};
|
||||
|
||||
const mockSetPanelHasConsole = jest.fn();
|
||||
const mockOpenSidePanel = jest.fn();
|
||||
|
||||
(useSidePanel.getState as jest.Mock).mockReturnValue({
|
||||
setPanelHasConsole: mockSetPanelHasConsole,
|
||||
openSidePanel: mockOpenSidePanel,
|
||||
});
|
||||
|
||||
openCopyJobDetailsPanel(mockJob);
|
||||
|
||||
expect(mockSetPanelHasConsole).toHaveBeenCalledWith(false);
|
||||
expect(mockOpenSidePanel).toHaveBeenCalledWith(expect.stringContaining("test-job"), expect.any(Object), "650px");
|
||||
});
|
||||
|
||||
it("should render CopyJobDetails component with correct job", () => {
|
||||
const mockJob: CopyJobType = {
|
||||
ID: "1",
|
||||
Mode: "offline",
|
||||
Name: "test-job-2",
|
||||
Status: CopyJobStatusType.Completed,
|
||||
CompletionPercentage: 100,
|
||||
Duration: "02 hours, 15 minutes, 30 seconds",
|
||||
LastUpdatedTime: "1/2/2025, 11:00:00 AM",
|
||||
timestamp: 1704193200000,
|
||||
Source: {
|
||||
component: "CosmosDBSql",
|
||||
databaseName: "source-db",
|
||||
containerName: "source-container",
|
||||
},
|
||||
Destination: {
|
||||
component: "CosmosDBSql",
|
||||
databaseName: "target-db",
|
||||
containerName: "target-container",
|
||||
},
|
||||
};
|
||||
|
||||
const mockOpenSidePanel = jest.fn();
|
||||
|
||||
(useSidePanel.getState as jest.Mock).mockReturnValue({
|
||||
setPanelHasConsole: jest.fn(),
|
||||
openSidePanel: mockOpenSidePanel,
|
||||
});
|
||||
|
||||
openCopyJobDetailsPanel(mockJob);
|
||||
|
||||
const sidePanelContent = mockOpenSidePanel.mock.calls[0][1];
|
||||
expect(sidePanelContent.type).toBe(CopyJobDetails);
|
||||
expect(sidePanelContent.props.job).toBe(mockJob);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCopyJobs", () => {
|
||||
beforeEach(() => {
|
||||
(CopyJobUtils.getAccountDetailsFromResourceId as jest.Mock).mockReturnValue({
|
||||
subscriptionId: "sub-123",
|
||||
resourceGroup: "rg-test",
|
||||
accountName: "test-account",
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch and format copy jobs successfully", async () => {
|
||||
const mockResponse = {
|
||||
value: [
|
||||
{
|
||||
properties: {
|
||||
jobName: "job-1",
|
||||
status: "InProgress",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 50,
|
||||
totalCount: 100,
|
||||
mode: "online",
|
||||
duration: "01:30:45",
|
||||
source: {
|
||||
component: "CosmosDBSql",
|
||||
databaseName: "source-db",
|
||||
containerName: "source-container",
|
||||
},
|
||||
destination: {
|
||||
component: "CosmosDBSql",
|
||||
databaseName: "target-db",
|
||||
containerName: "target-container",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
|
||||
formattedDateTime: "1/1/2025, 10:00:00 AM",
|
||||
timestamp: 1704106800000,
|
||||
});
|
||||
(CopyJobUtils.convertTime as jest.Mock).mockReturnValue("01 hours, 30 minutes, 45 seconds");
|
||||
(CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("InProgress");
|
||||
|
||||
const result = await getCopyJobs();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
ID: "1",
|
||||
Name: "job-1",
|
||||
Status: "InProgress",
|
||||
CompletionPercentage: 50,
|
||||
Mode: "online",
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter jobs by CosmosDBSql component", async () => {
|
||||
const mockResponse = {
|
||||
value: [
|
||||
{
|
||||
properties: {
|
||||
jobName: "sql-job",
|
||||
status: "Completed",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 100,
|
||||
totalCount: 100,
|
||||
mode: "offline",
|
||||
duration: "02:00:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
jobName: "other-job",
|
||||
status: "Completed",
|
||||
lastUpdatedUtcTime: "2025-01-01T11:00:00Z",
|
||||
processedCount: 100,
|
||||
totalCount: 100,
|
||||
mode: "offline",
|
||||
duration: "01:00:00",
|
||||
source: { component: "OtherComponent", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
|
||||
formattedDateTime: "1/1/2025, 10:00:00 AM",
|
||||
timestamp: 1704106800000,
|
||||
});
|
||||
(CopyJobUtils.convertTime as jest.Mock).mockReturnValue("02 hours");
|
||||
(CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("Completed");
|
||||
|
||||
const result = await getCopyJobs();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].Name).toBe("sql-job");
|
||||
});
|
||||
|
||||
it("should sort jobs by last updated time (newest first)", async () => {
|
||||
const mockResponse = {
|
||||
value: [
|
||||
{
|
||||
properties: {
|
||||
jobName: "older-job",
|
||||
status: "Completed",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 100,
|
||||
totalCount: 100,
|
||||
mode: "offline",
|
||||
duration: "01:00:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
jobName: "newer-job",
|
||||
status: "InProgress",
|
||||
lastUpdatedUtcTime: "2025-01-02T10:00:00Z",
|
||||
processedCount: 50,
|
||||
totalCount: 100,
|
||||
mode: "online",
|
||||
duration: "00:30:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db3", containerName: "c3" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db4", containerName: "c4" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
|
||||
formattedDateTime: "1/1/2025, 10:00:00 AM",
|
||||
timestamp: 1704106800000,
|
||||
});
|
||||
(CopyJobUtils.convertTime as jest.Mock).mockReturnValue("01 hours");
|
||||
(CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("Completed");
|
||||
|
||||
const result = await getCopyJobs();
|
||||
|
||||
expect(result[0].Name).toBe("newer-job");
|
||||
expect(result[1].Name).toBe("older-job");
|
||||
});
|
||||
|
||||
it("should calculate completion percentage correctly", async () => {
|
||||
const mockResponse = {
|
||||
value: [
|
||||
{
|
||||
properties: {
|
||||
jobName: "job-1",
|
||||
status: "InProgress",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 75,
|
||||
totalCount: 100,
|
||||
mode: "online",
|
||||
duration: "01:00:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
|
||||
formattedDateTime: "1/1/2025, 10:00:00 AM",
|
||||
timestamp: 1704106800000,
|
||||
});
|
||||
(CopyJobUtils.convertTime as jest.Mock).mockReturnValue("01 hours");
|
||||
(CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("InProgress");
|
||||
|
||||
const result = await getCopyJobs();
|
||||
|
||||
expect(result[0].CompletionPercentage).toBe(75);
|
||||
});
|
||||
|
||||
it("should handle zero total count gracefully", async () => {
|
||||
const mockResponse = {
|
||||
value: [
|
||||
{
|
||||
properties: {
|
||||
jobName: "job-1",
|
||||
status: "Pending",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 0,
|
||||
totalCount: 0,
|
||||
mode: "online",
|
||||
duration: "00:00:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
|
||||
formattedDateTime: "1/1/2025, 10:00:00 AM",
|
||||
timestamp: 1704106800000,
|
||||
});
|
||||
(CopyJobUtils.convertTime as jest.Mock).mockReturnValue("0 seconds");
|
||||
(CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("Pending");
|
||||
|
||||
const result = await getCopyJobs();
|
||||
|
||||
expect(result[0].CompletionPercentage).toBe(0);
|
||||
});
|
||||
|
||||
it("should extract error messages if present", async () => {
|
||||
const mockError = {
|
||||
message: "Error message line 1\r\n\r\nError message line 2",
|
||||
code: "ErrorCode123",
|
||||
};
|
||||
const mockResponse = {
|
||||
value: [
|
||||
{
|
||||
properties: {
|
||||
jobName: "failed-job",
|
||||
status: "Failed",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 50,
|
||||
totalCount: 100,
|
||||
mode: "offline",
|
||||
duration: "00:30:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
error: mockError,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
|
||||
formattedDateTime: "1/1/2025, 10:00:00 AM",
|
||||
timestamp: 1704106800000,
|
||||
});
|
||||
(CopyJobUtils.convertTime as jest.Mock).mockReturnValue("30 minutes");
|
||||
(CopyJobUtils.convertToCamelCase as jest.Mock).mockReturnValue("Failed");
|
||||
(CopyJobUtils.extractErrorMessage as jest.Mock).mockReturnValue({
|
||||
message: "Error message line 1",
|
||||
code: "ErrorCode123",
|
||||
});
|
||||
|
||||
const result = await getCopyJobs();
|
||||
|
||||
expect(result[0].Error).toEqual({
|
||||
message: "Error message line 1",
|
||||
code: "ErrorCode123",
|
||||
});
|
||||
expect(CopyJobUtils.extractErrorMessage).toHaveBeenCalledWith(mockError);
|
||||
});
|
||||
|
||||
it("should abort previous request when new request is made", async () => {
|
||||
const mockAbortController = {
|
||||
abort: jest.fn(),
|
||||
signal: {} as AbortSignal,
|
||||
};
|
||||
(global as any).AbortController = jest.fn(() => mockAbortController);
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({ value: [] });
|
||||
|
||||
getCopyJobs();
|
||||
expect(mockAbortController.abort).not.toHaveBeenCalled();
|
||||
|
||||
getCopyJobs();
|
||||
expect(mockAbortController.abort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should throw error for invalid response format", async () => {
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({
|
||||
value: "not-an-array",
|
||||
});
|
||||
|
||||
await expect(getCopyJobs()).rejects.toThrow("Invalid migration job status response: Expected an array of jobs.");
|
||||
});
|
||||
|
||||
it("should handle abort signal error", async () => {
|
||||
const abortError = {
|
||||
message: "Aborted",
|
||||
content: JSON.stringify({ message: "signal is aborted without reason" }),
|
||||
};
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(abortError);
|
||||
|
||||
await expect(getCopyJobs()).rejects.toMatchObject({
|
||||
message: expect.stringContaining("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();
|
||||
});
|
||||
});
|
||||
});
|
||||
207
src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import { logError } from "../../../Common/Logger";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
import {
|
||||
cancel,
|
||||
complete,
|
||||
create,
|
||||
listByDatabaseAccount,
|
||||
pause,
|
||||
resume,
|
||||
} from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
|
||||
import {
|
||||
CreateJobRequest,
|
||||
DataTransferJobGetResults,
|
||||
} from "../../../Utils/arm/generatedClients/dataTransferService/types";
|
||||
import ContainerCopyMessages from "../ContainerCopyMessages";
|
||||
import {
|
||||
convertTime,
|
||||
convertToCamelCase,
|
||||
COSMOS_SQL_COMPONENT,
|
||||
extractErrorMessage,
|
||||
formatUTCDateTime,
|
||||
getAccountDetailsFromResourceId,
|
||||
isIntraAccountCopy,
|
||||
} from "../CopyJobUtils";
|
||||
import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider";
|
||||
import { CopyJobActions, CopyJobStatusType } from "../Enums/CopyJobEnums";
|
||||
import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails";
|
||||
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
|
||||
import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types/CopyJobTypes";
|
||||
|
||||
export const openCreateCopyJobPanel = (explorer: Explorer) => {
|
||||
const sidePanelState = useSidePanel.getState();
|
||||
sidePanelState.setPanelHasConsole(false);
|
||||
sidePanelState.openSidePanel(
|
||||
ContainerCopyMessages.createCopyJobPanelTitle,
|
||||
<CreateCopyJobScreensProvider explorer={explorer} />,
|
||||
"650px",
|
||||
);
|
||||
};
|
||||
|
||||
export const openCopyJobDetailsPanel = (job: CopyJobType) => {
|
||||
const sidePanelState = useSidePanel.getState();
|
||||
sidePanelState.setPanelHasConsole(false);
|
||||
sidePanelState.openSidePanel(
|
||||
ContainerCopyMessages.copyJobDetailsPanelTitle(job.Name),
|
||||
<CopyJobDetails job={job} />,
|
||||
"650px",
|
||||
);
|
||||
};
|
||||
|
||||
let copyJobsAbortController: AbortController | null = null;
|
||||
|
||||
export const getCopyJobs = async (): Promise<CopyJobType[]> => {
|
||||
try {
|
||||
if (copyJobsAbortController) {
|
||||
copyJobsAbortController.abort();
|
||||
}
|
||||
copyJobsAbortController = new AbortController();
|
||||
|
||||
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
|
||||
userContext.databaseAccount?.id || "",
|
||||
);
|
||||
const response = await listByDatabaseAccount(
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
accountName,
|
||||
copyJobsAbortController.signal,
|
||||
);
|
||||
|
||||
const jobs = response.value || [];
|
||||
if (!Array.isArray(jobs)) {
|
||||
throw new Error("Invalid migration job status response: Expected an array of jobs.");
|
||||
}
|
||||
copyJobsAbortController = null;
|
||||
|
||||
const calculateCompletionPercentage = (processed: number, total: number): number => {
|
||||
if (
|
||||
typeof processed !== "number" ||
|
||||
typeof total !== "number" ||
|
||||
!isFinite(processed) ||
|
||||
!isFinite(total) ||
|
||||
total <= 0
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const percentage = Math.round((processed / total) * 100);
|
||||
return Math.max(0, Math.min(100, percentage));
|
||||
};
|
||||
|
||||
const formattedJobs: CopyJobType[] = jobs
|
||||
.filter(
|
||||
(job: DataTransferJobGetResults) =>
|
||||
job.properties?.source?.component === COSMOS_SQL_COMPONENT &&
|
||||
job.properties?.destination?.component === COSMOS_SQL_COMPONENT,
|
||||
)
|
||||
.sort(
|
||||
(current: DataTransferJobGetResults, next: DataTransferJobGetResults) =>
|
||||
new Date(next.properties.lastUpdatedUtcTime).getTime() -
|
||||
new Date(current.properties.lastUpdatedUtcTime).getTime(),
|
||||
)
|
||||
.map((job: DataTransferJobGetResults, index: number) => {
|
||||
const dateTimeObj = formatUTCDateTime(job.properties.lastUpdatedUtcTime);
|
||||
|
||||
return {
|
||||
ID: (index + 1).toString(),
|
||||
Mode: job.properties.mode,
|
||||
Name: job.properties.jobName,
|
||||
Source: job.properties.source,
|
||||
Destination: job.properties.destination,
|
||||
Status: convertToCamelCase(job.properties.status) as CopyJobType["Status"],
|
||||
CompletionPercentage: calculateCompletionPercentage(job.properties.processedCount, job.properties.totalCount),
|
||||
Duration: convertTime(job.properties.duration),
|
||||
LastUpdatedTime: dateTimeObj.formattedDateTime,
|
||||
timestamp: dateTimeObj.timestamp,
|
||||
Error: job.properties.error ? extractErrorMessage(job.properties.error as unknown as CopyJobErrorType) : null,
|
||||
} as CopyJobType;
|
||||
});
|
||||
return formattedJobs;
|
||||
} catch (error) {
|
||||
const errorContent = JSON.stringify(error.content || error.message || error);
|
||||
if (errorContent.includes("signal is aborted without reason")) {
|
||||
throw {
|
||||
message: "Previous copy job request was cancelled.",
|
||||
};
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess: () => void) => {
|
||||
try {
|
||||
const { source, target, migrationType, jobName } = state;
|
||||
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
|
||||
userContext.databaseAccount?.id || "",
|
||||
);
|
||||
const isSameAccount = isIntraAccountCopy(source?.account?.id, target?.account?.id);
|
||||
const body = {
|
||||
properties: {
|
||||
source: {
|
||||
component: "CosmosDBSql",
|
||||
...(isSameAccount ? {} : { remoteAccountName: source?.account?.name }),
|
||||
databaseName: source?.databaseId,
|
||||
containerName: source?.containerId,
|
||||
},
|
||||
destination: {
|
||||
component: "CosmosDBSql",
|
||||
databaseName: target?.databaseId,
|
||||
containerName: target?.containerId,
|
||||
},
|
||||
mode: migrationType,
|
||||
},
|
||||
} as unknown as CreateJobRequest;
|
||||
|
||||
const response = await create(subscriptionId, resourceGroup, accountName, jobName, body);
|
||||
MonitorCopyJobsRefState.getState().ref?.refreshJobList();
|
||||
onSuccess();
|
||||
return response;
|
||||
} catch (error) {
|
||||
const errorMessage = error.message || "Error submitting create copy job. Please try again later.";
|
||||
logError(errorMessage, "CopyJob/CopyJobActions.submitCreateCopyJob");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateCopyJobStatus = async (job: CopyJobType, action: string): Promise<DataTransferJobGetResults> => {
|
||||
try {
|
||||
let updateFn = null;
|
||||
switch (action.toLowerCase()) {
|
||||
case CopyJobActions.pause:
|
||||
updateFn = pause;
|
||||
break;
|
||||
case CopyJobActions.resume:
|
||||
updateFn = resume;
|
||||
break;
|
||||
case CopyJobActions.cancel:
|
||||
updateFn = cancel;
|
||||
break;
|
||||
case CopyJobActions.complete:
|
||||
updateFn = complete;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported action: ${action}`);
|
||||
}
|
||||
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
|
||||
userContext.databaseAccount?.id || "",
|
||||
);
|
||||
const response = await updateFn?.(subscriptionId, resourceGroup, accountName, job.Name);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const errorMessage = JSON.stringify((error as CopyJobError).message || error.content || error);
|
||||
|
||||
const statusList = [CopyJobStatusType.Running, CopyJobStatusType.InProgress, CopyJobStatusType.Partitioning];
|
||||
const pattern = new RegExp(`'(${statusList.join("|")})'`, "g");
|
||||
const normalizedErrorMessage = errorMessage.replace(
|
||||
pattern,
|
||||
`'${ContainerCopyMessages.MonitorJobs.Status.InProgress}'`,
|
||||
);
|
||||
logError(`Error updating copy job status: ${normalizedErrorMessage}`, "CopyJob/CopyJobActions.updateCopyJobStatus");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
185
src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.test.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||
import Explorer from "../../Explorer";
|
||||
import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil";
|
||||
import CopyJobCommandBar from "./CopyJobCommandBar";
|
||||
import * as Utils from "./Utils";
|
||||
|
||||
jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState");
|
||||
jest.mock("../../Menus/CommandBar/CommandBarUtil");
|
||||
jest.mock("./Utils");
|
||||
|
||||
describe("CopyJobCommandBar", () => {
|
||||
let mockExplorer: Explorer;
|
||||
let mockConvertButton: jest.MockedFunction<typeof CommandBarUtil.convertButton>;
|
||||
let mockGetCommandBarButtons: jest.MockedFunction<typeof Utils.getCommandBarButtons>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExplorer = {} as Explorer;
|
||||
|
||||
mockConvertButton = CommandBarUtil.convertButton as jest.MockedFunction<typeof CommandBarUtil.convertButton>;
|
||||
mockGetCommandBarButtons = Utils.getCommandBarButtons as jest.MockedFunction<typeof Utils.getCommandBarButtons>;
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render without crashing", () => {
|
||||
mockGetCommandBarButtons.mockReturnValue([]);
|
||||
mockConvertButton.mockReturnValue([]);
|
||||
|
||||
const { container } = render(<CopyJobCommandBar explorer={mockExplorer} />);
|
||||
expect(container.querySelector(".commandBarContainer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call getCommandBarButtons with explorer", () => {
|
||||
mockGetCommandBarButtons.mockReturnValue([]);
|
||||
mockConvertButton.mockReturnValue([]);
|
||||
|
||||
render(<CopyJobCommandBar explorer={mockExplorer} />);
|
||||
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer);
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call convertButton with command bar items and background color", () => {
|
||||
const mockCommandButtonProps: CommandButtonComponentProps[] = [
|
||||
{
|
||||
iconSrc: "icon.svg",
|
||||
iconAlt: "Test Icon",
|
||||
onCommandClick: jest.fn(),
|
||||
commandButtonLabel: "Test Button",
|
||||
ariaLabel: "Test Button Aria Label",
|
||||
tooltipText: "Test Tooltip",
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
mockGetCommandBarButtons.mockReturnValue(mockCommandButtonProps);
|
||||
mockConvertButton.mockReturnValue([]);
|
||||
|
||||
render(<CopyJobCommandBar explorer={mockExplorer} />);
|
||||
|
||||
expect(mockConvertButton).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should render FluentCommandBar with correct aria label", () => {
|
||||
mockGetCommandBarButtons.mockReturnValue([]);
|
||||
mockConvertButton.mockReturnValue([]);
|
||||
|
||||
const { getByRole } = render(<CopyJobCommandBar explorer={mockExplorer} />);
|
||||
|
||||
const commandBar = getByRole("menubar", { hidden: true });
|
||||
expect(commandBar).toHaveAttribute("aria-label", "Use left and right arrow keys to navigate between commands");
|
||||
});
|
||||
|
||||
it("should render FluentCommandBar with converted items", () => {
|
||||
const mockCommandButtonProps: CommandButtonComponentProps[] = [
|
||||
{
|
||||
iconSrc: "icon1.svg",
|
||||
iconAlt: "Test Icon 1",
|
||||
onCommandClick: jest.fn(),
|
||||
commandButtonLabel: "Test Button 1",
|
||||
ariaLabel: "Test Button 1 Aria Label",
|
||||
tooltipText: "Test Tooltip 1",
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
iconSrc: "icon2.svg",
|
||||
iconAlt: "Test Icon 2",
|
||||
onCommandClick: jest.fn(),
|
||||
commandButtonLabel: "Test Button 2",
|
||||
ariaLabel: "Test Button 2 Aria Label",
|
||||
tooltipText: "Test Tooltip 2",
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockFluentItems = [
|
||||
{
|
||||
key: "button1",
|
||||
text: "Test Button 1",
|
||||
iconProps: { iconName: "Add" },
|
||||
},
|
||||
{
|
||||
key: "button2",
|
||||
text: "Test Button 2",
|
||||
iconProps: { iconName: "Feedback" },
|
||||
},
|
||||
];
|
||||
|
||||
mockGetCommandBarButtons.mockReturnValue(mockCommandButtonProps);
|
||||
mockConvertButton.mockReturnValue(mockFluentItems);
|
||||
|
||||
const { container } = render(<CopyJobCommandBar explorer={mockExplorer} />);
|
||||
|
||||
expect(mockConvertButton).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector(".commandBarContainer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle multiple command bar buttons", () => {
|
||||
const mockCommandButtonProps: CommandButtonComponentProps[] = [
|
||||
{
|
||||
iconSrc: "create.svg",
|
||||
iconAlt: "Create",
|
||||
onCommandClick: jest.fn(),
|
||||
commandButtonLabel: "Create Copy Job",
|
||||
ariaLabel: "Create Copy Job",
|
||||
tooltipText: "Create Copy Job",
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
iconSrc: "refresh.svg",
|
||||
iconAlt: "Refresh",
|
||||
onCommandClick: jest.fn(),
|
||||
commandButtonLabel: "Refresh",
|
||||
ariaLabel: "Refresh",
|
||||
tooltipText: "Refresh",
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
iconSrc: "feedback.svg",
|
||||
iconAlt: "Feedback",
|
||||
onCommandClick: jest.fn(),
|
||||
commandButtonLabel: "Feedback",
|
||||
ariaLabel: "Feedback",
|
||||
tooltipText: "Feedback",
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
mockGetCommandBarButtons.mockReturnValue(mockCommandButtonProps);
|
||||
mockConvertButton.mockReturnValue([
|
||||
{ key: "create", text: "Create Copy Job" },
|
||||
{ key: "refresh", text: "Refresh" },
|
||||
{ key: "feedback", text: "Feedback" },
|
||||
]);
|
||||
|
||||
render(<CopyJobCommandBar explorer={mockExplorer} />);
|
||||
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer);
|
||||
expect(mockConvertButton.mock.calls[0][0]).toEqual(mockCommandButtonProps);
|
||||
});
|
||||
|
||||
it("should re-render when explorer prop changes", () => {
|
||||
const mockExplorer1 = { id: "explorer1" } as unknown as Explorer;
|
||||
const mockExplorer2 = { id: "explorer2" } as unknown as Explorer;
|
||||
|
||||
mockGetCommandBarButtons.mockReturnValue([]);
|
||||
mockConvertButton.mockReturnValue([]);
|
||||
|
||||
const { rerender } = render(<CopyJobCommandBar explorer={mockExplorer1} />);
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1);
|
||||
|
||||
rerender(<CopyJobCommandBar explorer={mockExplorer2} />);
|
||||
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2);
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
33
src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { StyleConstants } from "../../../Common/StyleConstants";
|
||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||
import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil";
|
||||
import { ContainerCopyProps } from "../Types/CopyJobTypes";
|
||||
import { getCommandBarButtons } from "./Utils";
|
||||
|
||||
const backgroundColor = StyleConstants.BaseLight;
|
||||
const rootStyle = {
|
||||
root: {
|
||||
backgroundColor: backgroundColor,
|
||||
},
|
||||
};
|
||||
|
||||
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ explorer }) => {
|
||||
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer);
|
||||
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
|
||||
|
||||
return (
|
||||
<div className="commandBarContainer">
|
||||
<FluentCommandBar
|
||||
ariaLabel="Use left and right arrow keys to navigate between commands"
|
||||
styles={rootStyle}
|
||||
items={controlButtons}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CopyJobCommandBar.displayName = "CopyJobCommandBar";
|
||||
|
||||
export default CopyJobCommandBar;
|
||||
268
src/Explorer/ContainerCopy/CommandBar/Utils.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||
import Explorer from "../../Explorer";
|
||||
import * as Actions from "../Actions/CopyJobActions";
|
||||
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
|
||||
import { getCommandBarButtons } from "./Utils";
|
||||
|
||||
jest.mock("../../../ConfigContext", () => ({
|
||||
configContext: {
|
||||
platform: "Portal",
|
||||
},
|
||||
Platform: {
|
||||
Portal: "Portal",
|
||||
Emulator: "Emulator",
|
||||
Hosted: "Hosted",
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../Actions/CopyJobActions", () => ({
|
||||
openCreateCopyJobPanel: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState", () => ({
|
||||
MonitorCopyJobsRefState: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("CommandBar Utils", () => {
|
||||
let mockExplorer: Explorer;
|
||||
let mockOpenContainerCopyFeedbackBlade: jest.Mock;
|
||||
let mockRefreshJobList: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockOpenContainerCopyFeedbackBlade = jest.fn();
|
||||
mockRefreshJobList = jest.fn();
|
||||
|
||||
mockExplorer = {
|
||||
openContainerCopyFeedbackBlade: mockOpenContainerCopyFeedbackBlade,
|
||||
} as unknown as Explorer;
|
||||
|
||||
(MonitorCopyJobsRefState as unknown as jest.Mock).mockImplementation((selector) => {
|
||||
const state = {
|
||||
ref: {
|
||||
refreshJobList: mockRefreshJobList,
|
||||
},
|
||||
};
|
||||
return selector(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCommandBarButtons", () => {
|
||||
it("should return an array of command button props", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
|
||||
expect(buttons).toBeDefined();
|
||||
expect(Array.isArray(buttons)).toBe(true);
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should include create copy job button", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const createButton = buttons[0];
|
||||
|
||||
expect(createButton).toBeDefined();
|
||||
expect(createButton.commandButtonLabel).toBeUndefined();
|
||||
expect(createButton.ariaLabel).toBe("Create a new container copy job");
|
||||
expect(createButton.tooltipText).toBe("Create Copy Job");
|
||||
expect(createButton.hasPopup).toBe(false);
|
||||
expect(createButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should include refresh button", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const refreshButton = buttons[1];
|
||||
|
||||
expect(refreshButton).toBeDefined();
|
||||
expect(refreshButton.ariaLabel).toBe("Refresh copy jobs");
|
||||
expect(refreshButton.tooltipText).toBe("Refresh");
|
||||
expect(refreshButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should include feedback button when platform is Portal", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
|
||||
expect(buttons.length).toBe(3);
|
||||
|
||||
const feedbackButton = buttons[2];
|
||||
expect(feedbackButton).toBeDefined();
|
||||
expect(feedbackButton.ariaLabel).toBe("Provide feedback on copy jobs");
|
||||
expect(feedbackButton.tooltipText).toBe("Feedback");
|
||||
expect(feedbackButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should not include feedback button when platform is not Portal", async () => {
|
||||
jest.resetModules();
|
||||
jest.doMock("../../../ConfigContext", () => ({
|
||||
configContext: {
|
||||
platform: "Emulator",
|
||||
},
|
||||
Platform: {
|
||||
Portal: "Portal",
|
||||
Emulator: "Emulator",
|
||||
Hosted: "Hosted",
|
||||
},
|
||||
}));
|
||||
|
||||
const { getCommandBarButtons: getCommandBarButtonsEmulator } = await import("./Utils");
|
||||
const buttons = getCommandBarButtonsEmulator(mockExplorer);
|
||||
|
||||
expect(buttons.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should call openCreateCopyJobPanel when create button is clicked", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const createButton = buttons[0];
|
||||
|
||||
createButton.onCommandClick({} as React.SyntheticEvent);
|
||||
|
||||
expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledWith(mockExplorer);
|
||||
expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call refreshJobList when refresh button is clicked", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const refreshButton = buttons[1];
|
||||
|
||||
refreshButton.onCommandClick({} as React.SyntheticEvent);
|
||||
|
||||
expect(mockRefreshJobList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call openContainerCopyFeedbackBlade when feedback button is clicked", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const feedbackButton = buttons[2];
|
||||
|
||||
feedbackButton.onCommandClick({} as React.SyntheticEvent);
|
||||
|
||||
expect(mockOpenContainerCopyFeedbackBlade).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should return buttons with correct icon sources", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
|
||||
expect(buttons[0].iconSrc).toBeDefined();
|
||||
expect(buttons[0].iconAlt).toBe("Create Copy Job");
|
||||
|
||||
expect(buttons[1].iconSrc).toBeDefined();
|
||||
expect(buttons[1].iconAlt).toBe("Refresh");
|
||||
|
||||
expect(buttons[2].iconSrc).toBeDefined();
|
||||
expect(buttons[2].iconAlt).toBe("Feedback");
|
||||
});
|
||||
|
||||
it("should handle null MonitorCopyJobsRefState ref gracefully", () => {
|
||||
(MonitorCopyJobsRefState as unknown as jest.Mock).mockImplementationOnce((selector) => {
|
||||
const state: { ref: null } = { ref: null };
|
||||
return selector(state);
|
||||
});
|
||||
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const refreshButton = buttons[1];
|
||||
|
||||
expect(() => refreshButton.onCommandClick({} as React.SyntheticEvent)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should set hasPopup to false for all buttons", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(button.hasPopup).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should set commandButtonLabel to undefined for all buttons", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(button.commandButtonLabel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should respect disabled state when provided", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(button.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return CommandButtonComponentProps with all required properties", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
|
||||
buttons.forEach((button: CommandButtonComponentProps) => {
|
||||
expect(button).toHaveProperty("iconSrc");
|
||||
expect(button).toHaveProperty("iconAlt");
|
||||
expect(button).toHaveProperty("onCommandClick");
|
||||
expect(button).toHaveProperty("commandButtonLabel");
|
||||
expect(button).toHaveProperty("ariaLabel");
|
||||
expect(button).toHaveProperty("tooltipText");
|
||||
expect(button).toHaveProperty("hasPopup");
|
||||
expect(button).toHaveProperty("disabled");
|
||||
});
|
||||
});
|
||||
|
||||
it("should maintain button order: create, refresh, feedback", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
|
||||
expect(buttons[0].tooltipText).toBe("Create Copy Job");
|
||||
expect(buttons[1].tooltipText).toBe("Refresh");
|
||||
expect(buttons[2].tooltipText).toBe("Feedback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Button click handlers", () => {
|
||||
it("should execute click handlers without errors", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(() => button.onCommandClick({} as React.SyntheticEvent)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call correct action for each button", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
|
||||
buttons[0].onCommandClick({} as React.SyntheticEvent);
|
||||
expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledWith(mockExplorer);
|
||||
|
||||
buttons[1].onCommandClick({} as React.SyntheticEvent);
|
||||
expect(mockRefreshJobList).toHaveBeenCalled();
|
||||
|
||||
buttons[2].onCommandClick({} as React.SyntheticEvent);
|
||||
expect(mockOpenContainerCopyFeedbackBlade).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("should have aria labels for all buttons", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(button.ariaLabel).toBeDefined();
|
||||
expect(typeof button.ariaLabel).toBe("string");
|
||||
expect(button.ariaLabel.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should have tooltip text for all buttons", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(button.tooltipText).toBeDefined();
|
||||
expect(typeof button.tooltipText).toBe("string");
|
||||
expect(button.tooltipText.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should have icon alt text for all buttons", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(button.iconAlt).toBeDefined();
|
||||
expect(typeof button.iconAlt).toBe("string");
|
||||
expect(button.iconAlt.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
59
src/Explorer/ContainerCopy/CommandBar/Utils.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import AddIcon from "../../../../images/Add.svg";
|
||||
import FeedbackIcon from "../../../../images/Feedback-Command.svg";
|
||||
import RefreshIcon from "../../../../images/refresh-cosmos.svg";
|
||||
import { configContext, Platform } from "../../../ConfigContext";
|
||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||
import Explorer from "../../Explorer";
|
||||
import * as Actions from "../Actions/CopyJobActions";
|
||||
import ContainerCopyMessages from "../ContainerCopyMessages";
|
||||
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
|
||||
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
|
||||
|
||||
function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
|
||||
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
|
||||
const buttons: CopyJobCommandBarBtnType[] = [
|
||||
{
|
||||
key: "createCopyJob",
|
||||
iconSrc: AddIcon,
|
||||
label: ContainerCopyMessages.createCopyJobButtonLabel,
|
||||
ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel,
|
||||
onClick: () => Actions.openCreateCopyJobPanel(explorer),
|
||||
},
|
||||
{
|
||||
key: "refresh",
|
||||
iconSrc: RefreshIcon,
|
||||
label: ContainerCopyMessages.refreshButtonLabel,
|
||||
ariaLabel: ContainerCopyMessages.refreshButtonAriaLabel,
|
||||
onClick: () => monitorCopyJobsRef?.refreshJobList(),
|
||||
},
|
||||
];
|
||||
if (configContext.platform === Platform.Portal) {
|
||||
buttons.push({
|
||||
key: "feedback",
|
||||
iconSrc: FeedbackIcon,
|
||||
label: ContainerCopyMessages.feedbackButtonLabel,
|
||||
ariaLabel: ContainerCopyMessages.feedbackButtonAriaLabel,
|
||||
onClick: () => {
|
||||
explorer.openContainerCopyFeedbackBlade();
|
||||
},
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProps {
|
||||
return {
|
||||
iconSrc: config.iconSrc,
|
||||
iconAlt: config.label,
|
||||
onCommandClick: config.onClick,
|
||||
commandButtonLabel: undefined as string | undefined,
|
||||
ariaLabel: config.ariaLabel,
|
||||
tooltipText: config.label,
|
||||
hasPopup: false,
|
||||
disabled: config.disabled ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] {
|
||||
return getCopyJobBtns(explorer).map(btnMapper);
|
||||
}
|
||||
177
src/Explorer/ContainerCopy/ContainerCopyMessages.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
export default {
|
||||
// Copy Job Command Bar
|
||||
feedbackButtonLabel: "Feedback",
|
||||
feedbackButtonAriaLabel: "Provide feedback on copy jobs",
|
||||
refreshButtonLabel: "Refresh",
|
||||
refreshButtonAriaLabel: "Refresh copy jobs",
|
||||
createCopyJobButtonLabel: "Create Copy Job",
|
||||
createCopyJobButtonAriaLabel: "Create a new container copy job",
|
||||
|
||||
// No Copy Jobs Found
|
||||
noCopyJobsTitle: "No copy jobs to show",
|
||||
createCopyJobButtonText: "Create a container copy job",
|
||||
|
||||
// Copy Job Details
|
||||
copyJobDetailsPanelTitle: (jobName: string) => jobName || "Job Details",
|
||||
errorTitle: "Error Details",
|
||||
selectedContainers: "Selected Containers",
|
||||
|
||||
// Create Copy Job Panel
|
||||
createCopyJobPanelTitle: "Create copy job",
|
||||
|
||||
// Select Account Screen
|
||||
selectAccountDescription: "Please select a source account from which to copy.",
|
||||
subscriptionDropdownLabel: "Subscription",
|
||||
subscriptionDropdownPlaceholder: "Select a subscription",
|
||||
sourceAccountDropdownLabel: "Account",
|
||||
sourceAccountDropdownPlaceholder: "Select an account",
|
||||
migrationTypeCheckboxLabel: "Copy container in offline mode",
|
||||
|
||||
// Select Source and Target Containers Screen
|
||||
selectSourceAndTargetContainersDescription:
|
||||
"Please select a source container and a destination container to copy to.",
|
||||
sourceContainerSubHeading: "Source container",
|
||||
targetContainerSubHeading: "Destination container",
|
||||
databaseDropdownLabel: "Database",
|
||||
databaseDropdownPlaceholder: "Select a database",
|
||||
containerDropdownLabel: "Container",
|
||||
containerDropdownPlaceholder: "Select a container",
|
||||
createNewContainerSubHeading: "Select the properties for your container.",
|
||||
createContainerButtonLabel: "Create a new container",
|
||||
createContainerHeading: "Create new container",
|
||||
|
||||
// Preview and Create Screen
|
||||
jobNameLabel: "Job name",
|
||||
sourceSubscriptionLabel: "Source subscription",
|
||||
sourceAccountLabel: "Source account",
|
||||
sourceDatabaseLabel: "Source database",
|
||||
sourceContainerLabel: "Source container",
|
||||
targetDatabaseLabel: "Destination database",
|
||||
targetContainerLabel: "Destination container",
|
||||
|
||||
// Assign Permissions Screen
|
||||
assignPermissions: {
|
||||
crossAccountDescription:
|
||||
"To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.",
|
||||
intraAccountOnlineDescription: (accountName: string) =>
|
||||
`Follow the steps below to enable online copy on your "${accountName}" account.`,
|
||||
crossAccountConfiguration: {
|
||||
title: "Cross-account container copy",
|
||||
description: (sourceAccount: string, destinationAccount: string) =>
|
||||
`Please follow the instruction below to grant requisite permissions to copy data from "${sourceAccount}" to "${destinationAccount}".`,
|
||||
},
|
||||
onlineConfiguration: {
|
||||
title: "Online container copy",
|
||||
description: (accountName: string) =>
|
||||
`Please follow the instructions below to enable online copy on your "${accountName}" account.`,
|
||||
},
|
||||
},
|
||||
toggleBtn: {
|
||||
onText: "On",
|
||||
offText: "Off",
|
||||
},
|
||||
popoverOverlaySpinnerLabel: "Please wait while we process your request...",
|
||||
addManagedIdentity: {
|
||||
title: "System-assigned managed identity enabled.",
|
||||
description:
|
||||
"A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.",
|
||||
descriptionHrefText: "Learn more about Managed identities.",
|
||||
descriptionHref: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
|
||||
toggleLabel: "System assigned managed identity",
|
||||
tooltip: {
|
||||
content: "Learn more about",
|
||||
hrefText: "Managed Identities.",
|
||||
href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
|
||||
},
|
||||
userAssignedIdentityTooltip: "You can select an existing user assigned identity or create a new one.",
|
||||
userAssignedIdentityLabel: "You may also select a user assigned managed identity.",
|
||||
createUserAssignedIdentityLink: "Create User Assigned Managed Identity",
|
||||
enablementTitle: "Enable system assigned managed identity",
|
||||
enablementDescription: (accountName: string) =>
|
||||
accountName
|
||||
? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button.`
|
||||
: "",
|
||||
},
|
||||
defaultManagedIdentity: {
|
||||
title: "System-assigned managed identity set as default.",
|
||||
description: (accountName: string) =>
|
||||
`Set the system-assigned managed identity as default for "${accountName}" by switching it on.`,
|
||||
tooltip: {
|
||||
content: "Learn more about",
|
||||
hrefText: "Default Managed Identities.",
|
||||
href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
|
||||
},
|
||||
popoverTitle: "System assigned managed identity set as default",
|
||||
popoverDescription: (accountName: string) =>
|
||||
`Assign the system-assigned managed identity as the default for "${accountName}". To confirm, click the "Yes" button. `,
|
||||
},
|
||||
readPermissionAssigned: {
|
||||
title: "Read permissions assigned to the default identity.",
|
||||
description:
|
||||
"To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.",
|
||||
tooltip: {
|
||||
content: "Learn more about",
|
||||
hrefText: "Read permissions.",
|
||||
href: "https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control",
|
||||
},
|
||||
popoverTitle: "Read permissions assigned to default identity.",
|
||||
popoverDescription:
|
||||
"Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button.",
|
||||
},
|
||||
pointInTimeRestore: {
|
||||
title: "Point In Time Restore enabled",
|
||||
description: (accessName: string) =>
|
||||
`To facilitate online container copy jobs, please update your "${accessName}" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.`,
|
||||
tooltip: {
|
||||
content: "Learn more about",
|
||||
hrefText: "Continuous Backup",
|
||||
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction",
|
||||
},
|
||||
buttonText: "Enable Point In Time Restore",
|
||||
},
|
||||
onlineCopyEnabled: {
|
||||
title: "Online copy enabled",
|
||||
description: (accountName: string) =>
|
||||
`Enable online container copy by clicking the button below on your "${accountName}" account.`,
|
||||
hrefText: "Learn more about online copy jobs",
|
||||
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy",
|
||||
buttonText: "Enable Online Copy",
|
||||
validateAllVersionsAndDeletesChangeFeedSpinnerLabel:
|
||||
"Validating All versions and deletes change feed mode (preview)...",
|
||||
enablingAllVersionsAndDeletesChangeFeedSpinnerLabel:
|
||||
"Enabling All versions and deletes change feed mode (preview)...",
|
||||
enablingOnlineCopySpinnerLabel: (accountName: string) =>
|
||||
`Enabling online copy on your "${accountName}" account ...`,
|
||||
},
|
||||
MonitorJobs: {
|
||||
Columns: {
|
||||
lastUpdatedTime: "Date & time",
|
||||
name: "Job name",
|
||||
status: "Status",
|
||||
completionPercentage: "Completion %",
|
||||
duration: "Duration",
|
||||
error: "Error message",
|
||||
mode: "Mode",
|
||||
actions: "Actions",
|
||||
},
|
||||
Actions: {
|
||||
pause: "Pause",
|
||||
resume: "Resume",
|
||||
cancel: "Cancel",
|
||||
complete: "Complete",
|
||||
viewDetails: "View Details",
|
||||
},
|
||||
Status: {
|
||||
Pending: "Queued",
|
||||
InProgress: "Running",
|
||||
Running: "Running",
|
||||
Partitioning: "Running",
|
||||
Paused: "Paused",
|
||||
Completed: "Completed",
|
||||
Failed: "Failed",
|
||||
Faulted: "Failed",
|
||||
Skipped: "Cancelled",
|
||||
Cancelled: "Cancelled",
|
||||
},
|
||||
},
|
||||
};
|
||||
131
src/Explorer/ContainerCopy/ContainerCopyPanel.test.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import Explorer from "../Explorer";
|
||||
import ContainerCopyPanel from "./ContainerCopyPanel";
|
||||
import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefState";
|
||||
|
||||
jest.mock("./CommandBar/CopyJobCommandBar", () => {
|
||||
const MockCopyJobCommandBar = () => {
|
||||
return <div data-testid="copy-job-command-bar">CopyJobCommandBar</div>;
|
||||
};
|
||||
MockCopyJobCommandBar.displayName = "CopyJobCommandBar";
|
||||
return MockCopyJobCommandBar;
|
||||
});
|
||||
|
||||
jest.mock("./MonitorCopyJobs/MonitorCopyJobs", () => {
|
||||
const React = jest.requireActual("react");
|
||||
const MockMonitorCopyJobs = React.forwardRef((_props: any, ref: any) => {
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
refreshJobList: jest.fn(),
|
||||
}));
|
||||
return <div data-testid="monitor-copy-jobs">MonitorCopyJobs</div>;
|
||||
});
|
||||
MockMonitorCopyJobs.displayName = "MonitorCopyJobs";
|
||||
return MockMonitorCopyJobs;
|
||||
});
|
||||
|
||||
jest.mock("./MonitorCopyJobs/MonitorCopyJobRefState", () => ({
|
||||
MonitorCopyJobsRefState: {
|
||||
getState: jest.fn(() => ({
|
||||
setRef: jest.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ContainerCopyPanel", () => {
|
||||
let mockExplorer: Explorer;
|
||||
let mockSetRef: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExplorer = {} as Explorer;
|
||||
|
||||
mockSetRef = jest.fn();
|
||||
(MonitorCopyJobsRefState.getState as jest.Mock).mockReturnValue({
|
||||
setRef: mockSetRef,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the component with correct structure", () => {
|
||||
render(<ContainerCopyPanel explorer={mockExplorer} />);
|
||||
|
||||
const wrapper = document.querySelector("#containerCopyWrapper");
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
expect(wrapper).toHaveClass("flexContainer", "hideOverflows");
|
||||
});
|
||||
|
||||
it("renders CopyJobCommandBar component", () => {
|
||||
render(<ContainerCopyPanel explorer={mockExplorer} />);
|
||||
|
||||
const commandBar = screen.getByTestId("copy-job-command-bar");
|
||||
expect(commandBar).toBeInTheDocument();
|
||||
expect(commandBar).toHaveTextContent("CopyJobCommandBar");
|
||||
});
|
||||
|
||||
it("renders MonitorCopyJobs component", () => {
|
||||
render(<ContainerCopyPanel explorer={mockExplorer} />);
|
||||
|
||||
const monitorCopyJobs = screen.getByTestId("monitor-copy-jobs");
|
||||
expect(monitorCopyJobs).toBeInTheDocument();
|
||||
expect(monitorCopyJobs).toHaveTextContent("MonitorCopyJobs");
|
||||
});
|
||||
|
||||
it("passes explorer prop to child components", () => {
|
||||
render(<ContainerCopyPanel explorer={mockExplorer} />);
|
||||
|
||||
expect(screen.getByTestId("copy-job-command-bar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("monitor-copy-jobs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets the MonitorCopyJobs ref in the state on mount", async () => {
|
||||
render(<ContainerCopyPanel explorer={mockExplorer} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetRef).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const refArgument = mockSetRef.mock.calls[0][0];
|
||||
expect(refArgument).toBeDefined();
|
||||
expect(refArgument).toHaveProperty("refreshJobList");
|
||||
expect(typeof refArgument.refreshJobList).toBe("function");
|
||||
});
|
||||
|
||||
it("updates the ref state when monitorCopyJobsRef changes", async () => {
|
||||
const { rerender } = render(<ContainerCopyPanel explorer={mockExplorer} />);
|
||||
await waitFor(() => {
|
||||
expect(mockSetRef).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
mockSetRef.mockClear();
|
||||
rerender(<ContainerCopyPanel explorer={mockExplorer} />);
|
||||
});
|
||||
|
||||
it("handles missing explorer prop gracefully", () => {
|
||||
const { container } = render(<ContainerCopyPanel explorer={undefined as any} />);
|
||||
expect(container.querySelector("#containerCopyWrapper")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies correct CSS classes to wrapper", () => {
|
||||
render(<ContainerCopyPanel explorer={mockExplorer} />);
|
||||
|
||||
const wrapper = document.querySelector("#containerCopyWrapper");
|
||||
expect(wrapper).toHaveClass("flexContainer");
|
||||
expect(wrapper).toHaveClass("hideOverflows");
|
||||
});
|
||||
|
||||
it("maintains ref across re-renders", async () => {
|
||||
const { rerender } = render(<ContainerCopyPanel explorer={mockExplorer} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetRef).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const firstCallRef = mockSetRef.mock.calls[0][0];
|
||||
const newExplorer = {} as Explorer;
|
||||
rerender(<ContainerCopyPanel explorer={newExplorer} />);
|
||||
expect(mockSetRef.mock.calls[0][0]).toBe(firstCallRef);
|
||||
});
|
||||
});
|
||||
25
src/Explorer/ContainerCopy/ContainerCopyPanel.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { useEffect } from "react";
|
||||
import CopyJobCommandBar from "./CommandBar/CopyJobCommandBar";
|
||||
import "./containerCopyStyles.less";
|
||||
import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefState";
|
||||
import MonitorCopyJobs, { MonitorCopyJobsRef } from "./MonitorCopyJobs/MonitorCopyJobs";
|
||||
import { ContainerCopyProps } from "./Types/CopyJobTypes";
|
||||
|
||||
const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ explorer }) => {
|
||||
const monitorCopyJobsRef = React.useRef<MonitorCopyJobsRef>();
|
||||
useEffect(() => {
|
||||
if (monitorCopyJobsRef.current) {
|
||||
MonitorCopyJobsRefState.getState().setRef(monitorCopyJobsRef.current);
|
||||
}
|
||||
}, [monitorCopyJobsRef.current]);
|
||||
return (
|
||||
<div id="containerCopyWrapper" className="flexContainer hideOverflows">
|
||||
<CopyJobCommandBar explorer={explorer} />
|
||||
<MonitorCopyJobs ref={monitorCopyJobsRef} explorer={explorer} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ContainerCopyPanel.displayName = "ContainerCopyPanel";
|
||||
|
||||
export default ContainerCopyPanel;
|
||||
660
src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx
Normal file
@@ -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(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<div data-testid="test-child">Test Child</div>
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-child")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("test-child")).toHaveTextContent("Test Child");
|
||||
});
|
||||
|
||||
it("should initialize with default state", () => {
|
||||
let contextValue: any;
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<CopyJobContext.Consumer>
|
||||
{(value) => {
|
||||
contextValue = value;
|
||||
return null;
|
||||
}}
|
||||
</CopyJobContext.Consumer>
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue).toBeDefined();
|
||||
expect(contextValue.copyJobState).toEqual({
|
||||
jobName: "",
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
source: {
|
||||
subscription: 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(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<CopyJobContext.Consumer>
|
||||
{(value) => {
|
||||
contextValue = value;
|
||||
return null;
|
||||
}}
|
||||
</CopyJobContext.Consumer>
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue.setCopyJobState).toBeDefined();
|
||||
expect(typeof contextValue.setCopyJobState).toBe("function");
|
||||
});
|
||||
|
||||
it("should provide setFlow function", () => {
|
||||
let contextValue: any;
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<CopyJobContext.Consumer>
|
||||
{(value) => {
|
||||
contextValue = value;
|
||||
return null;
|
||||
}}
|
||||
</CopyJobContext.Consumer>
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue.setFlow).toBeDefined();
|
||||
expect(typeof contextValue.setFlow).toBe("function");
|
||||
});
|
||||
|
||||
it("should provide setContextError function", () => {
|
||||
let contextValue: any;
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<CopyJobContext.Consumer>
|
||||
{(value) => {
|
||||
contextValue = value;
|
||||
return null;
|
||||
}}
|
||||
</CopyJobContext.Consumer>
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue.setContextError).toBeDefined();
|
||||
expect(typeof contextValue.setContextError).toBe("function");
|
||||
});
|
||||
|
||||
it("should provide resetCopyJobState function", () => {
|
||||
let contextValue: any;
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<CopyJobContext.Consumer>
|
||||
{(value) => {
|
||||
contextValue = value;
|
||||
return null;
|
||||
}}
|
||||
</CopyJobContext.Consumer>
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue.resetCopyJobState).toBeDefined();
|
||||
expect(typeof contextValue.resetCopyJobState).toBe("function");
|
||||
});
|
||||
|
||||
it("should update copyJobState when setCopyJobState is called", () => {
|
||||
let contextValue: any;
|
||||
|
||||
const TestComponent = (): JSX.Element => {
|
||||
const context = useCopyJobContext();
|
||||
contextValue = context;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
context.setCopyJobState({
|
||||
...context.copyJobState,
|
||||
jobName: "test-job",
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
})
|
||||
}
|
||||
>
|
||||
Update Job
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<TestComponent />
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
const button = screen.getByText("Update Job");
|
||||
act(() => {
|
||||
button.click();
|
||||
});
|
||||
|
||||
expect(contextValue.copyJobState.jobName).toBe("test-job");
|
||||
expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Online);
|
||||
});
|
||||
|
||||
it("should update flow when setFlow is called", () => {
|
||||
let contextValue: any;
|
||||
|
||||
const TestComponent = (): JSX.Element => {
|
||||
const context = useCopyJobContext();
|
||||
contextValue = context;
|
||||
|
||||
const handleSetFlow = (): void => {
|
||||
context.setFlow({ currentScreen: "source-selection" });
|
||||
};
|
||||
|
||||
return <button onClick={handleSetFlow}>Set Flow</button>;
|
||||
};
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<TestComponent />
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue.flow).toBeNull();
|
||||
|
||||
const button = screen.getByText("Set Flow");
|
||||
act(() => {
|
||||
button.click();
|
||||
});
|
||||
|
||||
expect(contextValue.flow).toEqual({ currentScreen: "source-selection" });
|
||||
});
|
||||
|
||||
it("should update contextError when setContextError is called", () => {
|
||||
let contextValue: any;
|
||||
|
||||
const TestComponent = (): JSX.Element => {
|
||||
const context = useCopyJobContext();
|
||||
contextValue = context;
|
||||
|
||||
return <button onClick={() => context.setContextError("Test error message")}>Set Error</button>;
|
||||
};
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<TestComponent />
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue.contextError).toBeNull();
|
||||
|
||||
const button = screen.getByText("Set Error");
|
||||
act(() => {
|
||||
button.click();
|
||||
});
|
||||
|
||||
expect(contextValue.contextError).toBe("Test error message");
|
||||
});
|
||||
|
||||
it("should reset copyJobState when resetCopyJobState is called", () => {
|
||||
let contextValue: any;
|
||||
|
||||
const TestComponent = (): JSX.Element => {
|
||||
const context = useCopyJobContext();
|
||||
contextValue = context;
|
||||
|
||||
const handleUpdate = (): void => {
|
||||
context.setCopyJobState({
|
||||
...context.copyJobState,
|
||||
jobName: "modified-job",
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
source: {
|
||||
...context.copyJobState.source,
|
||||
databaseId: "test-db",
|
||||
containerId: "test-container",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={handleUpdate}>Update</button>
|
||||
<button onClick={context.resetCopyJobState}>Reset</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<TestComponent />
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
const updateButton = screen.getByText("Update");
|
||||
act(() => {
|
||||
updateButton.click();
|
||||
});
|
||||
|
||||
expect(contextValue.copyJobState.jobName).toBe("modified-job");
|
||||
expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Online);
|
||||
expect(contextValue.copyJobState.source.databaseId).toBe("test-db");
|
||||
|
||||
const resetButton = screen.getByText("Reset");
|
||||
act(() => {
|
||||
resetButton.click();
|
||||
});
|
||||
|
||||
expect(contextValue.copyJobState.jobName).toBe("");
|
||||
expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Offline);
|
||||
expect(contextValue.copyJobState.source.databaseId).toBe("");
|
||||
expect(contextValue.copyJobState.source.containerId).toBe("");
|
||||
});
|
||||
|
||||
it("should maintain explorer reference", () => {
|
||||
let contextValue: any;
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<CopyJobContext.Consumer>
|
||||
{(value) => {
|
||||
contextValue = value;
|
||||
return null;
|
||||
}}
|
||||
</CopyJobContext.Consumer>
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue.explorer).toBe(mockExplorer);
|
||||
});
|
||||
|
||||
it("should handle multiple state updates correctly", () => {
|
||||
let contextValue: any;
|
||||
|
||||
const TestComponent = (): JSX.Element => {
|
||||
const context = useCopyJobContext();
|
||||
contextValue = context;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => context.setCopyJobState({ ...context.copyJobState, jobName: "job-1" })}>
|
||||
Update 1
|
||||
</button>
|
||||
<button onClick={() => context.setFlow({ currentScreen: "screen-1" })}>Flow 1</button>
|
||||
<button onClick={() => context.setContextError("error-1")}>Error 1</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<TestComponent />
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Update 1").click();
|
||||
});
|
||||
expect(contextValue.copyJobState.jobName).toBe("job-1");
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Flow 1").click();
|
||||
});
|
||||
expect(contextValue.flow).toEqual({ currentScreen: "screen-1" });
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Error 1").click();
|
||||
});
|
||||
expect(contextValue.contextError).toBe("error-1");
|
||||
});
|
||||
|
||||
it("should handle partial state updates", () => {
|
||||
let contextValue: any;
|
||||
|
||||
const TestComponent = (): JSX.Element => {
|
||||
const context = useCopyJobContext();
|
||||
contextValue = context;
|
||||
|
||||
const handlePartialUpdate = (): void => {
|
||||
context.setCopyJobState((prev) => ({
|
||||
...prev,
|
||||
jobName: "partial-update",
|
||||
}));
|
||||
};
|
||||
|
||||
return <button onClick={handlePartialUpdate}>Partial Update</button>;
|
||||
};
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<TestComponent />
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
const initialState = { ...contextValue.copyJobState };
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Partial Update").click();
|
||||
});
|
||||
|
||||
expect(contextValue.copyJobState.jobName).toBe("partial-update");
|
||||
expect(contextValue.copyJobState.migrationType).toBe(initialState.migrationType);
|
||||
expect(contextValue.copyJobState.source).toEqual(initialState.source);
|
||||
expect(contextValue.copyJobState.target).toEqual(initialState.target);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCopyJobContext", () => {
|
||||
it("should return context value when used within provider", () => {
|
||||
let contextValue: any;
|
||||
|
||||
const TestComponent = (): null => {
|
||||
const context = useCopyJobContext();
|
||||
contextValue = context;
|
||||
return null;
|
||||
};
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<TestComponent />
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue).toBeDefined();
|
||||
expect(contextValue.copyJobState).toBeDefined();
|
||||
expect(contextValue.setCopyJobState).toBeDefined();
|
||||
expect(contextValue.flow).toBeNull();
|
||||
expect(contextValue.setFlow).toBeDefined();
|
||||
expect(contextValue.contextError).toBeNull();
|
||||
expect(contextValue.setContextError).toBeDefined();
|
||||
expect(contextValue.resetCopyJobState).toBeDefined();
|
||||
expect(contextValue.explorer).toBe(mockExplorer);
|
||||
});
|
||||
|
||||
it("should throw error when used outside provider", () => {
|
||||
const originalError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
const TestComponent = (): null => {
|
||||
useCopyJobContext();
|
||||
return null;
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
render(<TestComponent />);
|
||||
}).toThrow("useCopyJobContext must be used within a CopyJobContextProvider");
|
||||
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
it("should allow updating state through hook", () => {
|
||||
let contextValue: any;
|
||||
|
||||
const TestComponent = (): JSX.Element => {
|
||||
const context = useCopyJobContext();
|
||||
contextValue = context;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
context.setCopyJobState({
|
||||
...context.copyJobState,
|
||||
jobName: "hook-test-job",
|
||||
})
|
||||
}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<TestComponent />
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Update").click();
|
||||
});
|
||||
|
||||
expect(contextValue.copyJobState.jobName).toBe("hook-test-job");
|
||||
});
|
||||
|
||||
it("should allow resetting state through hook", () => {
|
||||
let contextValue: any;
|
||||
|
||||
const TestComponent = (): JSX.Element => {
|
||||
const context = useCopyJobContext();
|
||||
contextValue = context;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() =>
|
||||
context.setCopyJobState({
|
||||
...context.copyJobState,
|
||||
jobName: "modified",
|
||||
source: {
|
||||
...context.copyJobState.source,
|
||||
databaseId: "modified-db",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Modify
|
||||
</button>
|
||||
<button onClick={() => context.resetCopyJobState()}>Reset</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<TestComponent />
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Modify").click();
|
||||
});
|
||||
|
||||
expect(contextValue.copyJobState.jobName).toBe("modified");
|
||||
expect(contextValue.copyJobState.source.databaseId).toBe("modified-db");
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Reset").click();
|
||||
});
|
||||
|
||||
expect(contextValue.copyJobState.jobName).toBe("");
|
||||
expect(contextValue.copyJobState.source.databaseId).toBe("");
|
||||
});
|
||||
|
||||
it("should maintain state consistency across multiple components", () => {
|
||||
let contextValue1: any;
|
||||
let contextValue2: any;
|
||||
|
||||
const TestComponent1 = (): JSX.Element => {
|
||||
const context = useCopyJobContext();
|
||||
contextValue1 = context;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
context.setCopyJobState({
|
||||
...context.copyJobState,
|
||||
jobName: "shared-job",
|
||||
})
|
||||
}
|
||||
>
|
||||
Update From Component 1
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const TestComponent2 = (): JSX.Element => {
|
||||
const context = useCopyJobContext();
|
||||
contextValue2 = context;
|
||||
return <div data-testid="component-2">Component 2</div>;
|
||||
};
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<TestComponent1 />
|
||||
<TestComponent2 />
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue1.copyJobState).toEqual(contextValue2.copyJobState);
|
||||
|
||||
act(() => {
|
||||
screen.getByText("Update From Component 1").click();
|
||||
});
|
||||
|
||||
expect(contextValue1.copyJobState.jobName).toBe("shared-job");
|
||||
expect(contextValue2.copyJobState.jobName).toBe("shared-job");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Initial State", () => {
|
||||
it("should initialize with offline migration type", () => {
|
||||
let contextValue: any;
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<CopyJobContext.Consumer>
|
||||
{(value) => {
|
||||
contextValue = value;
|
||||
return null;
|
||||
}}
|
||||
</CopyJobContext.Consumer>
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Offline);
|
||||
});
|
||||
|
||||
it("should initialize source with userContext values", () => {
|
||||
let contextValue: any;
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<CopyJobContext.Consumer>
|
||||
{(value) => {
|
||||
contextValue = value;
|
||||
return null;
|
||||
}}
|
||||
</CopyJobContext.Consumer>
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue.copyJobState.source?.subscription?.subscriptionId).toBeUndefined();
|
||||
expect(contextValue.copyJobState.source?.account?.name).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should initialize target with userContext values", () => {
|
||||
let contextValue: any;
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<CopyJobContext.Consumer>
|
||||
{(value) => {
|
||||
contextValue = value;
|
||||
return null;
|
||||
}}
|
||||
</CopyJobContext.Consumer>
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue.copyJobState.target.subscriptionId).toBe("test-subscription-id");
|
||||
expect(contextValue.copyJobState.target.account.name).toBe("test-account");
|
||||
});
|
||||
|
||||
it("should initialize sourceReadAccessFromTarget as false", () => {
|
||||
let contextValue: any;
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<CopyJobContext.Consumer>
|
||||
{(value) => {
|
||||
contextValue = value;
|
||||
return null;
|
||||
}}
|
||||
</CopyJobContext.Consumer>
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue.copyJobState.sourceReadAccessFromTarget).toBe(false);
|
||||
});
|
||||
|
||||
it("should initialize with empty database and container ids", () => {
|
||||
let contextValue: any;
|
||||
|
||||
render(
|
||||
<CopyJobContextProvider explorer={mockExplorer}>
|
||||
<CopyJobContext.Consumer>
|
||||
{(value) => {
|
||||
contextValue = value;
|
||||
return null;
|
||||
}}
|
||||
</CopyJobContext.Consumer>
|
||||
</CopyJobContextProvider>,
|
||||
);
|
||||
|
||||
expect(contextValue.copyJobState.source.databaseId).toBe("");
|
||||
expect(contextValue.copyJobState.source.containerId).toBe("");
|
||||
expect(contextValue.copyJobState.target.databaseId).toBe("");
|
||||
expect(contextValue.copyJobState.target.containerId).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
64
src/Explorer/ContainerCopy/Context/CopyJobContext.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import { CopyJobMigrationType } from "../Enums/CopyJobEnums";
|
||||
import { CopyJobContextProviderType, CopyJobContextState, CopyJobFlowType } from "../Types/CopyJobTypes";
|
||||
|
||||
export const CopyJobContext = React.createContext<CopyJobContextProviderType>(null);
|
||||
export const useCopyJobContext = (): CopyJobContextProviderType => {
|
||||
const context = React.useContext(CopyJobContext);
|
||||
if (!context) {
|
||||
throw new Error("useCopyJobContext must be used within a CopyJobContextProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface CopyJobContextProviderProps {
|
||||
children: React.ReactNode;
|
||||
explorer: Explorer;
|
||||
}
|
||||
|
||||
const getInitialCopyJobState = (): CopyJobContextState => {
|
||||
return {
|
||||
jobName: "",
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
source: {
|
||||
subscription: null,
|
||||
account: null,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
target: {
|
||||
subscriptionId: userContext.subscriptionId || "",
|
||||
account: userContext.databaseAccount || null,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
sourceReadAccessFromTarget: false,
|
||||
};
|
||||
};
|
||||
|
||||
const CopyJobContextProvider: React.FC<CopyJobContextProviderProps> = (props) => {
|
||||
const [copyJobState, setCopyJobState] = React.useState<CopyJobContextState>(getInitialCopyJobState());
|
||||
const [flow, setFlow] = React.useState<CopyJobFlowType | null>(null);
|
||||
const [contextError, setContextError] = React.useState<string | null>(null);
|
||||
|
||||
const resetCopyJobState = () => {
|
||||
setCopyJobState(getInitialCopyJobState());
|
||||
};
|
||||
|
||||
const contextValue: CopyJobContextProviderType = {
|
||||
contextError,
|
||||
setContextError,
|
||||
copyJobState,
|
||||
setCopyJobState,
|
||||
flow,
|
||||
setFlow,
|
||||
resetCopyJobState,
|
||||
explorer: props.explorer,
|
||||
};
|
||||
|
||||
return <CopyJobContext.Provider value={contextValue}>{props.children}</CopyJobContext.Provider>;
|
||||
};
|
||||
|
||||
export default CopyJobContextProvider;
|
||||
490
src/Explorer/ContainerCopy/CopyJobUtils.test.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import * as CopyJobUtils from "./CopyJobUtils";
|
||||
import { CopyJobContextState, CopyJobErrorType, CopyJobType } from "./Types/CopyJobTypes";
|
||||
|
||||
describe("CopyJobUtils", () => {
|
||||
describe("buildResourceLink", () => {
|
||||
const mockResource: DatabaseAccount = {
|
||||
id: "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1",
|
||||
name: "account1",
|
||||
location: "eastus",
|
||||
type: "Microsoft.DocumentDB/databaseAccounts",
|
||||
kind: "GlobalDocumentDB",
|
||||
properties: {},
|
||||
};
|
||||
|
||||
let originalLocation: Location;
|
||||
|
||||
beforeEach(() => {
|
||||
originalLocation = window.location;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(window as any).location = originalLocation;
|
||||
});
|
||||
|
||||
it("should build resource link with Azure portal endpoint", () => {
|
||||
delete (window as any).location;
|
||||
(window as any).location = {
|
||||
...originalLocation,
|
||||
origin: "https://portal.azure.com",
|
||||
ancestorOrigins: ["https://portal.azure.com"] as any,
|
||||
} as Location;
|
||||
|
||||
const link = CopyJobUtils.buildResourceLink(mockResource);
|
||||
expect(link).toBe(
|
||||
"https://portal.azure.com/#resource/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should replace cosmos.azure with portal.azure", () => {
|
||||
delete (window as any).location;
|
||||
(window as any).location = {
|
||||
...originalLocation,
|
||||
origin: "https://cosmos.azure.com",
|
||||
ancestorOrigins: ["https://cosmos.azure.com"] as any,
|
||||
} as Location;
|
||||
|
||||
const link = CopyJobUtils.buildResourceLink(mockResource);
|
||||
expect(link).toContain("https://portal.azure.com");
|
||||
});
|
||||
|
||||
it("should use Azure portal endpoint for localhost", () => {
|
||||
delete (window as any).location;
|
||||
(window as any).location = {
|
||||
...originalLocation,
|
||||
origin: "http://localhost:1234",
|
||||
ancestorOrigins: ["http://localhost:1234"] as any,
|
||||
} as Location;
|
||||
|
||||
const link = CopyJobUtils.buildResourceLink(mockResource);
|
||||
expect(link).toContain("https://ms.portal.azure.com");
|
||||
});
|
||||
|
||||
it("should remove trailing slash from origin", () => {
|
||||
delete (window as any).location;
|
||||
(window as any).location = {
|
||||
...originalLocation,
|
||||
origin: "https://portal.azure.com/",
|
||||
ancestorOrigins: ["https://portal.azure.com/"] as any,
|
||||
} as Location;
|
||||
|
||||
const link = CopyJobUtils.buildResourceLink(mockResource);
|
||||
expect(link).toBe(
|
||||
"https://portal.azure.com/#resource/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDataTransferJobPath", () => {
|
||||
it("should build basic path without jobName or action", () => {
|
||||
const path = CopyJobUtils.buildDataTransferJobPath({
|
||||
subscriptionId: "sub123",
|
||||
resourceGroup: "rg1",
|
||||
accountName: "account1",
|
||||
});
|
||||
|
||||
expect(path).toBe(
|
||||
"/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1/dataTransferJobs",
|
||||
);
|
||||
});
|
||||
|
||||
it("should build path with jobName", () => {
|
||||
const path = CopyJobUtils.buildDataTransferJobPath({
|
||||
subscriptionId: "sub123",
|
||||
resourceGroup: "rg1",
|
||||
accountName: "account1",
|
||||
jobName: "job1",
|
||||
});
|
||||
|
||||
expect(path).toBe(
|
||||
"/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1/dataTransferJobs/job1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should build path with jobName and action", () => {
|
||||
const path = CopyJobUtils.buildDataTransferJobPath({
|
||||
subscriptionId: "sub123",
|
||||
resourceGroup: "rg1",
|
||||
accountName: "account1",
|
||||
jobName: "job1",
|
||||
action: "cancel",
|
||||
});
|
||||
|
||||
expect(path).toBe(
|
||||
"/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1/dataTransferJobs/job1/cancel",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertTime", () => {
|
||||
it("should convert time string with hours, minutes, and seconds", () => {
|
||||
const result = CopyJobUtils.convertTime("02:30:45");
|
||||
expect(result).toBe("02 hours, 30 minutes, 45 seconds");
|
||||
});
|
||||
|
||||
it("should convert time string with only seconds", () => {
|
||||
const result = CopyJobUtils.convertTime("00:00:30");
|
||||
expect(result).toBe("30 seconds");
|
||||
});
|
||||
|
||||
it("should convert time string with only minutes and seconds", () => {
|
||||
const result = CopyJobUtils.convertTime("00:05:15");
|
||||
expect(result).toBe("05 minutes, 15 seconds");
|
||||
});
|
||||
|
||||
it("should round seconds", () => {
|
||||
const result = CopyJobUtils.convertTime("00:00:45.678");
|
||||
expect(result).toBe("46 seconds");
|
||||
});
|
||||
|
||||
it("should return '0 seconds' for zero time", () => {
|
||||
const result = CopyJobUtils.convertTime("00:00:00");
|
||||
expect(result).toBe("0 seconds");
|
||||
});
|
||||
|
||||
it("should return null for invalid time format", () => {
|
||||
const result = CopyJobUtils.convertTime("invalid");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for incomplete time string", () => {
|
||||
const result = CopyJobUtils.convertTime("10:30");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should pad single digit values", () => {
|
||||
const result = CopyJobUtils.convertTime("1:5:9");
|
||||
expect(result).toBe("01 hours, 05 minutes, 09 seconds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUTCDateTime", () => {
|
||||
it("should format valid UTC date string", () => {
|
||||
const result = CopyJobUtils.formatUTCDateTime("2025-11-26T10:30:00Z");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.formattedDateTime).toContain("11/26/25, 10:30:00 AM");
|
||||
expect(result?.timestamp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should return null for invalid date string", () => {
|
||||
const result = CopyJobUtils.formatUTCDateTime("invalid-date");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return timestamp for valid date", () => {
|
||||
const result = CopyJobUtils.formatUTCDateTime("2025-01-01T00:00:00Z");
|
||||
expect(result).not.toBeNull();
|
||||
expect(typeof result?.timestamp).toBe("number");
|
||||
expect(result?.timestamp).toBe(new Date("2025-01-01T00:00:00Z").getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToCamelCase", () => {
|
||||
it("should convert string to camel case", () => {
|
||||
const result = CopyJobUtils.convertToCamelCase("hello world");
|
||||
expect(result).toBe("HelloWorld");
|
||||
});
|
||||
|
||||
it("should handle single word", () => {
|
||||
const result = CopyJobUtils.convertToCamelCase("hello");
|
||||
expect(result).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should handle multiple spaces", () => {
|
||||
const result = CopyJobUtils.convertToCamelCase("hello world test");
|
||||
expect(result).toBe("HelloWorldTest");
|
||||
});
|
||||
|
||||
it("should handle mixed case input", () => {
|
||||
const result = CopyJobUtils.convertToCamelCase("HELLO WORLD");
|
||||
expect(result).toBe("HelloWorld");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const result = CopyJobUtils.convertToCamelCase("");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractErrorMessage", () => {
|
||||
it("should extract first part of error message before line breaks", () => {
|
||||
const error: CopyJobErrorType = {
|
||||
message: "Error occurred\r\n\r\nAdditional details\r\n\r\nMore info",
|
||||
code: "500",
|
||||
};
|
||||
|
||||
const result = CopyJobUtils.extractErrorMessage(error);
|
||||
expect(result.message).toBe("Error occurred");
|
||||
expect(result.code).toBe("500");
|
||||
});
|
||||
|
||||
it("should return same message if no line breaks", () => {
|
||||
const error: CopyJobErrorType = {
|
||||
message: "Simple error message",
|
||||
code: "404",
|
||||
};
|
||||
|
||||
const result = CopyJobUtils.extractErrorMessage(error);
|
||||
expect(result.message).toBe("Simple error message");
|
||||
expect(result.code).toBe("404");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAccountDetailsFromResourceId", () => {
|
||||
it("should extract account details from valid resource ID", () => {
|
||||
const resourceId =
|
||||
"/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1";
|
||||
const details = CopyJobUtils.getAccountDetailsFromResourceId(resourceId);
|
||||
|
||||
expect(details).toEqual({
|
||||
subscriptionId: "sub123",
|
||||
resourceGroup: "rg1",
|
||||
accountName: "account1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should be case insensitive", () => {
|
||||
const resourceId =
|
||||
"/subscriptions/sub123/resourceGroups/rg1/providers/microsoft.documentdb/databaseAccounts/account1";
|
||||
const details = CopyJobUtils.getAccountDetailsFromResourceId(resourceId);
|
||||
|
||||
expect(details).toEqual({
|
||||
subscriptionId: "sub123",
|
||||
resourceGroup: "rg1",
|
||||
accountName: "account1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null for undefined resource ID", () => {
|
||||
const details = CopyJobUtils.getAccountDetailsFromResourceId(undefined);
|
||||
expect(details).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for invalid resource ID", () => {
|
||||
const details = CopyJobUtils.getAccountDetailsFromResourceId("invalid-resource-id");
|
||||
expect(details).toEqual({ accountName: undefined, resourceGroup: undefined, subscriptionId: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContainerIdentifiers", () => {
|
||||
it("should extract container identifiers", () => {
|
||||
const container = {
|
||||
account: {
|
||||
id: "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1",
|
||||
name: "account1",
|
||||
location: "eastus",
|
||||
type: "Microsoft.DocumentDB/databaseAccounts",
|
||||
kind: "GlobalDocumentDB",
|
||||
properties: {},
|
||||
},
|
||||
databaseId: "db1",
|
||||
containerId: "container1",
|
||||
} as CopyJobContextState["source"];
|
||||
|
||||
const identifiers = CopyJobUtils.getContainerIdentifiers(container);
|
||||
expect(identifiers).toEqual({
|
||||
accountId: container.account.id,
|
||||
databaseId: "db1",
|
||||
containerId: "container1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty strings for undefined values", () => {
|
||||
const container = {
|
||||
account: undefined,
|
||||
databaseId: undefined,
|
||||
containerId: undefined,
|
||||
} as CopyJobContextState["source"];
|
||||
|
||||
const identifiers = CopyJobUtils.getContainerIdentifiers(container);
|
||||
expect(identifiers).toEqual({
|
||||
accountId: "",
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIntraAccountCopy", () => {
|
||||
const sourceAccountId =
|
||||
"/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1";
|
||||
const targetAccountId =
|
||||
"/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1";
|
||||
const differentAccountId =
|
||||
"/subscriptions/sub456/resourceGroups/rg2/providers/Microsoft.DocumentDB/databaseAccounts/account2";
|
||||
|
||||
it("should return true for same account", () => {
|
||||
const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, targetAccountId);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for different accounts", () => {
|
||||
const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, differentAccountId);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for different subscriptions", () => {
|
||||
const differentSubId =
|
||||
"/subscriptions/sub999/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1";
|
||||
const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, differentSubId);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for different resource groups", () => {
|
||||
const differentRgId =
|
||||
"/subscriptions/sub123/resourceGroups/rg999/providers/Microsoft.DocumentDB/databaseAccounts/account1";
|
||||
const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, differentRgId);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for undefined source", () => {
|
||||
const result = CopyJobUtils.isIntraAccountCopy(undefined, targetAccountId);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for undefined target", () => {
|
||||
const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, undefined);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEqual", () => {
|
||||
const createMockJob = (name: string, status: string): CopyJobType => ({
|
||||
ID: name,
|
||||
Mode: "Online",
|
||||
Name: name,
|
||||
Status: status as any,
|
||||
CompletionPercentage: 50,
|
||||
Duration: "00:05:00",
|
||||
LastUpdatedTime: "2025-11-26T10:00:00Z",
|
||||
timestamp: Date.now(),
|
||||
Source: {} as any,
|
||||
Destination: {} as any,
|
||||
});
|
||||
|
||||
it("should return true for equal job arrays", () => {
|
||||
const jobs1 = [createMockJob("job1", "Running"), createMockJob("job2", "Completed")];
|
||||
const jobs2 = [createMockJob("job1", "Running"), createMockJob("job2", "Completed")];
|
||||
|
||||
const result = CopyJobUtils.isEqual(jobs1, jobs2);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for different lengths", () => {
|
||||
const jobs1 = [createMockJob("job1", "Running")];
|
||||
const jobs2 = [createMockJob("job1", "Running"), createMockJob("job2", "Completed")];
|
||||
|
||||
const result = CopyJobUtils.isEqual(jobs1, jobs2);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for different status", () => {
|
||||
const jobs1 = [createMockJob("job1", "Running")];
|
||||
const jobs2 = [createMockJob("job1", "Completed")];
|
||||
|
||||
const result = CopyJobUtils.isEqual(jobs1, jobs2);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for missing job in second array", () => {
|
||||
const jobs1 = [createMockJob("job1", "Running")];
|
||||
const jobs2 = [createMockJob("job2", "Running")];
|
||||
|
||||
const result = CopyJobUtils.isEqual(jobs1, jobs2);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for empty arrays", () => {
|
||||
const result = CopyJobUtils.isEqual([], []);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultJobName", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Date.prototype, "getTime").mockReturnValue(1234567890);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should generate default job name for single container", () => {
|
||||
const containers = [
|
||||
{
|
||||
sourceDatabaseName: "sourceDb",
|
||||
sourceContainerName: "sourceCont",
|
||||
targetDatabaseName: "targetDb",
|
||||
targetContainerName: "targetCont",
|
||||
},
|
||||
];
|
||||
|
||||
const jobName = CopyJobUtils.getDefaultJobName(containers);
|
||||
expect(jobName).toBe("sourc.sourc_targe.targe_1234567890");
|
||||
});
|
||||
|
||||
it("should truncate long names", () => {
|
||||
const containers = [
|
||||
{
|
||||
sourceDatabaseName: "veryLongSourceDatabaseName",
|
||||
sourceContainerName: "veryLongSourceContainerName",
|
||||
targetDatabaseName: "veryLongTargetDatabaseName",
|
||||
targetContainerName: "veryLongTargetContainerName",
|
||||
},
|
||||
];
|
||||
|
||||
const jobName = CopyJobUtils.getDefaultJobName(containers);
|
||||
expect(jobName).toBe("veryL.veryL_veryL.veryL_1234567890");
|
||||
});
|
||||
|
||||
it("should return empty string for multiple containers", () => {
|
||||
const containers = [
|
||||
{
|
||||
sourceDatabaseName: "db1",
|
||||
sourceContainerName: "cont1",
|
||||
targetDatabaseName: "db2",
|
||||
targetContainerName: "cont2",
|
||||
},
|
||||
{
|
||||
sourceDatabaseName: "db3",
|
||||
sourceContainerName: "cont3",
|
||||
targetDatabaseName: "db4",
|
||||
targetContainerName: "cont4",
|
||||
},
|
||||
];
|
||||
|
||||
const jobName = CopyJobUtils.getDefaultJobName(containers);
|
||||
expect(jobName).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string for empty array", () => {
|
||||
const jobName = CopyJobUtils.getDefaultJobName([]);
|
||||
expect(jobName).toBe("");
|
||||
});
|
||||
|
||||
it("should handle short names without truncation", () => {
|
||||
const containers = [
|
||||
{
|
||||
sourceDatabaseName: "src",
|
||||
sourceContainerName: "cont",
|
||||
targetDatabaseName: "tgt",
|
||||
targetContainerName: "dest",
|
||||
},
|
||||
];
|
||||
|
||||
const jobName = CopyJobUtils.getDefaultJobName(containers);
|
||||
expect(jobName).toBe("src.cont_tgt.dest_1234567890");
|
||||
});
|
||||
});
|
||||
|
||||
describe("constants", () => {
|
||||
it("should have correct COSMOS_SQL_COMPONENT value", () => {
|
||||
expect(CopyJobUtils.COSMOS_SQL_COMPONENT).toBe("CosmosDBSql");
|
||||
});
|
||||
|
||||
it("should have correct COPY_JOB_API_VERSION value", () => {
|
||||
expect(CopyJobUtils.COPY_JOB_API_VERSION).toBe("2025-05-01-preview");
|
||||
});
|
||||
});
|
||||
});
|
||||
171
src/Explorer/ContainerCopy/CopyJobUtils.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import { CopyJobContextState, CopyJobErrorType, CopyJobType } from "./Types/CopyJobTypes";
|
||||
|
||||
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
|
||||
|
||||
export const buildResourceLink = (resource: DatabaseAccount): string => {
|
||||
const resourceId = resource.id;
|
||||
let parentOrigin = window.location.ancestorOrigins?.[0] ?? window.location.origin;
|
||||
|
||||
if (/\/\/localhost:/.test(parentOrigin)) {
|
||||
parentOrigin = azurePortalMpacEndpoint;
|
||||
} else if (/\/\/cosmos\.azure/.test(parentOrigin)) {
|
||||
parentOrigin = parentOrigin.replace("cosmos.azure", "portal.azure");
|
||||
}
|
||||
|
||||
parentOrigin = parentOrigin.replace(/\/$/, "");
|
||||
|
||||
return `${parentOrigin}/#resource${resourceId}`;
|
||||
};
|
||||
|
||||
export const COSMOS_SQL_COMPONENT = "CosmosDBSql";
|
||||
|
||||
export const COPY_JOB_API_VERSION = "2025-05-01-preview";
|
||||
|
||||
export function buildDataTransferJobPath({
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
accountName,
|
||||
jobName,
|
||||
action,
|
||||
}: {
|
||||
subscriptionId: string;
|
||||
resourceGroup: string;
|
||||
accountName: string;
|
||||
jobName?: string;
|
||||
action?: string;
|
||||
}) {
|
||||
let path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/dataTransferJobs`;
|
||||
if (jobName) {
|
||||
path += `/${jobName}`;
|
||||
}
|
||||
if (action) {
|
||||
path += `/${action}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function convertTime(timeStr: string): string | null {
|
||||
const timeParts = timeStr.split(":").map(Number);
|
||||
|
||||
if (timeParts.length !== 3 || timeParts.some(isNaN)) {
|
||||
return null;
|
||||
}
|
||||
const formatPart = (value: number, unit: string) => {
|
||||
if (unit === "seconds") {
|
||||
value = Math.round(value);
|
||||
}
|
||||
return value > 0 ? `${value.toString().padStart(2, "0")} ${unit}` : "";
|
||||
};
|
||||
|
||||
const [hours, minutes, seconds] = timeParts;
|
||||
const formattedTimeParts = [
|
||||
formatPart(hours, "hours"),
|
||||
formatPart(minutes, "minutes"),
|
||||
formatPart(seconds, "seconds"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return formattedTimeParts || "0 seconds";
|
||||
}
|
||||
|
||||
export function formatUTCDateTime(utcStr: string): { formattedDateTime: string; timestamp: number } | null {
|
||||
const date = new Date(utcStr);
|
||||
if (isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
formattedDateTime: new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
timeZone: "UTC",
|
||||
}).format(date),
|
||||
timestamp: date.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
export function convertToCamelCase(str: string): string {
|
||||
const formattedStr = str
|
||||
.split(/\s+/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join("");
|
||||
return formattedStr;
|
||||
}
|
||||
|
||||
export function extractErrorMessage(error: CopyJobErrorType): CopyJobErrorType {
|
||||
return {
|
||||
...error,
|
||||
message: error.message.split("\r\n\r\n")[0],
|
||||
};
|
||||
}
|
||||
|
||||
export function getAccountDetailsFromResourceId(accountId: string | undefined) {
|
||||
if (!accountId) {
|
||||
return null;
|
||||
}
|
||||
const pattern = new RegExp(
|
||||
"/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\\.DocumentDB?/databaseAccounts/([^/]+)",
|
||||
"i",
|
||||
);
|
||||
const matches = accountId.match(pattern);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_, subscriptionId, resourceGroup, accountName] = matches || [];
|
||||
return { subscriptionId, resourceGroup, accountName };
|
||||
}
|
||||
|
||||
export function getContainerIdentifiers(container: CopyJobContextState["source"] | CopyJobContextState["target"]) {
|
||||
return {
|
||||
accountId: container?.account?.id || "",
|
||||
databaseId: container?.databaseId || "",
|
||||
containerId: container?.containerId || "",
|
||||
};
|
||||
}
|
||||
|
||||
export function isIntraAccountCopy(sourceAccountId: string | undefined, targetAccountId: string | undefined): boolean {
|
||||
const sourceAccountDetails = getAccountDetailsFromResourceId(sourceAccountId);
|
||||
const targetAccountDetails = getAccountDetailsFromResourceId(targetAccountId);
|
||||
return (
|
||||
sourceAccountDetails?.subscriptionId === targetAccountDetails?.subscriptionId &&
|
||||
sourceAccountDetails?.resourceGroup === targetAccountDetails?.resourceGroup &&
|
||||
sourceAccountDetails?.accountName === targetAccountDetails?.accountName
|
||||
);
|
||||
}
|
||||
|
||||
export function isEqual(prevJobs: CopyJobType[], newJobs: CopyJobType[]): boolean {
|
||||
if (prevJobs.length !== newJobs.length) {
|
||||
return false;
|
||||
}
|
||||
return prevJobs.every((prevJob: CopyJobType) => {
|
||||
const newJob = newJobs.find((job) => job.Name === prevJob.Name);
|
||||
if (!newJob) {
|
||||
return false;
|
||||
}
|
||||
return prevJob.Status === newJob.Status;
|
||||
});
|
||||
}
|
||||
|
||||
const truncateLength = 5;
|
||||
export const truncateName = (name: string, length: number = truncateLength): string => {
|
||||
return name.length <= length ? name : name.slice(0, length);
|
||||
};
|
||||
|
||||
export function getDefaultJobName(
|
||||
selectedDatabaseAndContainers: {
|
||||
sourceDatabaseName?: string;
|
||||
sourceContainerName?: string;
|
||||
targetDatabaseName?: string;
|
||||
targetContainerName?: string;
|
||||
}[],
|
||||
): string {
|
||||
if (selectedDatabaseAndContainers.length === 1) {
|
||||
const { sourceDatabaseName, sourceContainerName, targetDatabaseName, targetContainerName } =
|
||||
selectedDatabaseAndContainers[0];
|
||||
const timestamp = new Date().getTime().toString();
|
||||
const sourcePart = `${truncateName(sourceDatabaseName)}.${truncateName(sourceContainerName)}`;
|
||||
const targetPart = `${truncateName(targetDatabaseName)}.${truncateName(targetContainerName)}`;
|
||||
return `${sourcePart}_${targetPart}_${timestamp}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes";
|
||||
import React from "react";
|
||||
import { updateSystemIdentity } from "../../../../../Utils/arm/identityUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { CopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import AddManagedIdentity from "./AddManagedIdentity";
|
||||
|
||||
jest.mock("../../../../../Utils/arm/identityUtils", () => ({
|
||||
updateSystemIdentity: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@fluentui/react", () => ({
|
||||
...jest.requireActual("@fluentui/react"),
|
||||
getTheme: () => ({
|
||||
semanticColors: {
|
||||
bodySubtext: "#666666",
|
||||
errorIcon: "#d13438",
|
||||
successIcon: "#107c10",
|
||||
},
|
||||
palette: {
|
||||
themePrimary: "#0078d4",
|
||||
},
|
||||
}),
|
||||
mergeStyles: () => "mocked-styles",
|
||||
mergeStyleSets: (styleSet: any) => {
|
||||
const result: any = {};
|
||||
Object.keys(styleSet).forEach((key) => {
|
||||
result[key] = "mocked-style-" + key;
|
||||
});
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../CopyJobUtils", () => ({
|
||||
getAccountDetailsFromResourceId: jest.fn(() => ({
|
||||
subscriptionId: "test-subscription-id",
|
||||
resourceGroup: "test-resource-group",
|
||||
accountName: "test-account-name",
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../Common/Logger", () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUpdateSystemIdentity = updateSystemIdentity as jest.MockedFunction<typeof updateSystemIdentity>;
|
||||
|
||||
describe("AddManagedIdentity", () => {
|
||||
const mockCopyJobState = {
|
||||
jobName: "test-job",
|
||||
migrationType: "Offline" as any,
|
||||
source: {
|
||||
subscription: { subscriptionId: "source-sub-id" },
|
||||
account: { id: "source-account-id", name: "source-account-name" },
|
||||
databaseId: "source-db",
|
||||
containerId: "source-container",
|
||||
},
|
||||
target: {
|
||||
subscriptionId: "target-sub-id",
|
||||
account: {
|
||||
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
|
||||
name: "test-target-account",
|
||||
},
|
||||
databaseId: "target-db",
|
||||
containerId: "target-container",
|
||||
},
|
||||
sourceReadAccessFromTarget: false,
|
||||
};
|
||||
|
||||
const mockContextValue = {
|
||||
copyJobState: mockCopyJobState,
|
||||
setCopyJobState: jest.fn(),
|
||||
flow: { currentScreen: "AssignPermissions" },
|
||||
setFlow: jest.fn(),
|
||||
resetCopyJobState: jest.fn(),
|
||||
explorer: {} as any,
|
||||
contextError: "",
|
||||
setContextError: jest.fn(),
|
||||
} as unknown as CopyJobContextProviderType;
|
||||
|
||||
const renderWithContext = (contextValue = mockContextValue) => {
|
||||
return render(
|
||||
<CopyJobContext.Provider value={contextValue}>
|
||||
<AddManagedIdentity />
|
||||
</CopyJobContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUpdateSystemIdentity.mockResolvedValue({
|
||||
id: "updated-account-id",
|
||||
name: "updated-account-name",
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe("Snapshot Tests", () => {
|
||||
it("renders initial state correctly", () => {
|
||||
const { container } = renderWithContext();
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders with toggle on and popover visible", () => {
|
||||
const { container } = renderWithContext();
|
||||
|
||||
const toggle = screen.getByRole("switch");
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders loading state", async () => {
|
||||
mockUpdateSystemIdentity.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve({} as any), 100)),
|
||||
);
|
||||
|
||||
const { container } = renderWithContext();
|
||||
|
||||
const toggle = screen.getByRole("switch");
|
||||
fireEvent.click(toggle);
|
||||
|
||||
const primaryButton = screen.getByText("Yes");
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Component Rendering", () => {
|
||||
it("renders all required elements", () => {
|
||||
renderWithContext();
|
||||
|
||||
expect(screen.getByText(ContainerCopyMessages.addManagedIdentity.description)).toBeInTheDocument();
|
||||
expect(screen.getByText(ContainerCopyMessages.addManagedIdentity.descriptionHrefText)).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders description link with correct href", () => {
|
||||
renderWithContext();
|
||||
|
||||
const link = screen.getByText(ContainerCopyMessages.addManagedIdentity.descriptionHrefText);
|
||||
expect(link.closest("a")).toHaveAttribute("href", ContainerCopyMessages.addManagedIdentity.descriptionHref);
|
||||
expect(link.closest("a")).toHaveAttribute("target", "_blank");
|
||||
expect(link.closest("a")).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("toggle shows correct initial state", () => {
|
||||
renderWithContext();
|
||||
|
||||
const toggle = screen.getByRole("switch");
|
||||
expect(toggle).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toggle Functionality", () => {
|
||||
it("toggles state when clicked", () => {
|
||||
renderWithContext();
|
||||
|
||||
const toggle = screen.getByRole("switch");
|
||||
expect(toggle).not.toBeChecked();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("shows popover when toggle is on", () => {
|
||||
renderWithContext();
|
||||
|
||||
const toggle = screen.getByRole("switch");
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(screen.getByText(ContainerCopyMessages.addManagedIdentity.enablementTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides popover when toggle is off", () => {
|
||||
renderWithContext();
|
||||
|
||||
const toggle = screen.getByRole("switch");
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(screen.queryByText(ContainerCopyMessages.addManagedIdentity.enablementTitle)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Popover Functionality", () => {
|
||||
beforeEach(() => {
|
||||
renderWithContext();
|
||||
const toggle = screen.getByRole("switch");
|
||||
fireEvent.click(toggle);
|
||||
});
|
||||
|
||||
it("displays correct enablement description with account name", () => {
|
||||
const expectedDescription = ContainerCopyMessages.addManagedIdentity.enablementDescription(
|
||||
mockCopyJobState.target.account.name,
|
||||
);
|
||||
expect(screen.getByText(expectedDescription)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls handleAddSystemIdentity when primary button clicked", async () => {
|
||||
const primaryButton = screen.getByText("Yes");
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSystemIdentity).toHaveBeenCalledWith(
|
||||
"test-subscription-id",
|
||||
"test-resource-group",
|
||||
"test-account-name",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.skip("closes popover when cancel button clicked", () => {
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(screen.queryByText(ContainerCopyMessages.addManagedIdentity.enablementTitle)).not.toBeInTheDocument();
|
||||
|
||||
const toggle = screen.getByRole("switch");
|
||||
expect(toggle).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Managed Identity Operations", () => {
|
||||
it("successfully updates system identity", async () => {
|
||||
const setCopyJobState = jest.fn();
|
||||
const contextWithMockSetter = {
|
||||
...mockContextValue,
|
||||
setCopyJobState,
|
||||
};
|
||||
|
||||
renderWithContext(contextWithMockSetter);
|
||||
|
||||
const toggle = screen.getByRole("switch");
|
||||
fireEvent.click(toggle);
|
||||
|
||||
const primaryButton = screen.getByText("Yes");
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSystemIdentity).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
it("handles error during identity update", async () => {
|
||||
const setContextError = jest.fn();
|
||||
const contextWithErrorHandler = {
|
||||
...mockContextValue,
|
||||
setContextError,
|
||||
};
|
||||
|
||||
const errorMessage = "Failed to update identity";
|
||||
mockUpdateSystemIdentity.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
renderWithContext(contextWithErrorHandler);
|
||||
|
||||
const toggle = screen.getByRole("switch");
|
||||
fireEvent.click(toggle);
|
||||
|
||||
const primaryButton = screen.getByText("Yes");
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setContextError).toHaveBeenCalledWith(errorMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("handles missing target account gracefully", () => {
|
||||
const contextWithoutTargetAccount = {
|
||||
...mockContextValue,
|
||||
copyJobState: {
|
||||
...mockCopyJobState,
|
||||
target: {
|
||||
...mockCopyJobState.target,
|
||||
account: null as DatabaseAccount | null,
|
||||
},
|
||||
},
|
||||
} as unknown as CopyJobContextProviderType;
|
||||
|
||||
expect(() => renderWithContext(contextWithoutTargetAccount)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Link, Stack, Text, Toggle } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { updateSystemIdentity } from "../../../../../Utils/arm/identityUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import InfoTooltip from "../Components/InfoTooltip";
|
||||
import PopoverMessage from "../Components/PopoverContainer";
|
||||
import useManagedIdentity from "./hooks/useManagedIdentity";
|
||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
import useToggle from "./hooks/useToggle";
|
||||
|
||||
const managedIdentityTooltip = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.addManagedIdentity.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.addManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.addManagedIdentity.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
||||
|
||||
const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
|
||||
const { copyJobState } = useCopyJobContext();
|
||||
const [systemAssigned, onToggle] = useToggle(false);
|
||||
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateSystemIdentity);
|
||||
|
||||
return (
|
||||
<Stack className="addManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<Text>
|
||||
{ContainerCopyMessages.addManagedIdentity.description} 
|
||||
<Link href={ContainerCopyMessages.addManagedIdentity.descriptionHref} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.addManagedIdentity.descriptionHrefText}
|
||||
</Link>{" "}
|
||||
|
||||
<InfoTooltip content={managedIdentityTooltip} />
|
||||
</Text>
|
||||
<Toggle
|
||||
checked={systemAssigned}
|
||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
<PopoverMessage
|
||||
isLoading={loading}
|
||||
visible={systemAssigned}
|
||||
title={ContainerCopyMessages.addManagedIdentity.enablementTitle}
|
||||
onCancel={() => onToggle(null, false)}
|
||||
onPrimary={handleAddSystemIdentity}
|
||||
>
|
||||
{ContainerCopyMessages.addManagedIdentity.enablementDescription(copyJobState.target?.account?.name)}
|
||||
</PopoverMessage>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddManagedIdentity;
|
||||
@@ -0,0 +1,503 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { CopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobContextProviderType } from "../../../Types/CopyJobTypes";
|
||||
import AddReadPermissionToDefaultIdentity from "./AddReadPermissionToDefaultIdentity";
|
||||
|
||||
jest.mock("../../../../../Common/Logger", () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../Utils/arm/RbacUtils", () => ({
|
||||
assignRole: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../CopyJobUtils", () => ({
|
||||
getAccountDetailsFromResourceId: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../Components/InfoTooltip", () => {
|
||||
const MockInfoTooltip = ({ content }: { content: React.ReactNode }) => {
|
||||
return <div data-testid="info-tooltip">{content}</div>;
|
||||
};
|
||||
MockInfoTooltip.displayName = "MockInfoTooltip";
|
||||
return MockInfoTooltip;
|
||||
});
|
||||
|
||||
jest.mock("../Components/PopoverContainer", () => {
|
||||
const MockPopoverContainer = ({
|
||||
isLoading,
|
||||
visible,
|
||||
title,
|
||||
onCancel,
|
||||
onPrimary,
|
||||
children,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
visible: boolean;
|
||||
title: string;
|
||||
onCancel: () => void;
|
||||
onPrimary: () => void;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div data-testid="popover-message" data-loading={isLoading}>
|
||||
<div data-testid="popover-title">{title}</div>
|
||||
<div data-testid="popover-content">{children}</div>
|
||||
<button onClick={onCancel} data-testid="popover-cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={onPrimary} data-testid="popover-primary">
|
||||
Primary
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
MockPopoverContainer.displayName = "MockPopoverContainer";
|
||||
return MockPopoverContainer;
|
||||
});
|
||||
|
||||
jest.mock("./hooks/useToggle", () => {
|
||||
return jest.fn();
|
||||
});
|
||||
|
||||
import { Subscription } from "Contracts/DataModels";
|
||||
import { CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums";
|
||||
import { logError } from "../../../../../Common/Logger";
|
||||
import { assignRole, RoleAssignmentType } from "../../../../../Utils/arm/RbacUtils";
|
||||
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
||||
import useToggle from "./hooks/useToggle";
|
||||
|
||||
describe("AddReadPermissionToDefaultIdentity Component", () => {
|
||||
const mockUseToggle = useToggle as jest.MockedFunction<typeof useToggle>;
|
||||
const mockAssignRole = assignRole as jest.MockedFunction<typeof assignRole>;
|
||||
const mockGetAccountDetailsFromResourceId = getAccountDetailsFromResourceId as jest.MockedFunction<
|
||||
typeof getAccountDetailsFromResourceId
|
||||
>;
|
||||
const mockLogError = logError as jest.MockedFunction<typeof logError>;
|
||||
|
||||
const mockContextValue: CopyJobContextProviderType = {
|
||||
copyJobState: {
|
||||
jobName: "test-job",
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
source: {
|
||||
subscription: { subscriptionId: "source-sub-id" } as Subscription,
|
||||
account: {
|
||||
id: "/subscriptions/source-sub-id/resourceGroups/source-rg/providers/Microsoft.DocumentDB/databaseAccounts/source-account",
|
||||
name: "source-account",
|
||||
location: "East US",
|
||||
kind: "GlobalDocumentDB",
|
||||
type: "Microsoft.DocumentDB/databaseAccounts",
|
||||
properties: {
|
||||
documentEndpoint: "https://source-account.documents.azure.com:443/",
|
||||
},
|
||||
},
|
||||
databaseId: "source-db",
|
||||
containerId: "source-container",
|
||||
},
|
||||
target: {
|
||||
subscriptionId: "target-sub-id",
|
||||
account: {
|
||||
id: "/subscriptions/target-sub-id/resourceGroups/target-rg/providers/Microsoft.DocumentDB/databaseAccounts/target-account",
|
||||
name: "target-account",
|
||||
location: "West US",
|
||||
kind: "GlobalDocumentDB",
|
||||
type: "Microsoft.DocumentDB/databaseAccounts",
|
||||
properties: {
|
||||
documentEndpoint: "https://target-account.documents.azure.com:443/",
|
||||
},
|
||||
identity: {
|
||||
principalId: "target-principal-id",
|
||||
type: "SystemAssigned",
|
||||
},
|
||||
},
|
||||
databaseId: "target-db",
|
||||
containerId: "target-container",
|
||||
},
|
||||
sourceReadAccessFromTarget: false,
|
||||
},
|
||||
setCopyJobState: jest.fn(),
|
||||
setContextError: jest.fn(),
|
||||
contextError: null,
|
||||
flow: null,
|
||||
setFlow: jest.fn(),
|
||||
resetCopyJobState: jest.fn(),
|
||||
explorer: {} as any,
|
||||
};
|
||||
|
||||
const renderComponent = (contextValue = mockContextValue) => {
|
||||
return render(
|
||||
<CopyJobContext.Provider value={contextValue}>
|
||||
<AddReadPermissionToDefaultIdentity />
|
||||
</CopyJobContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseToggle.mockReturnValue([false, jest.fn()]);
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render correctly with default state", () => {
|
||||
const { container } = renderComponent();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render correctly when toggle is on", () => {
|
||||
mockUseToggle.mockReturnValue([true, jest.fn()]);
|
||||
const { container } = renderComponent();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render correctly with different context states", () => {
|
||||
const contextWithError = {
|
||||
...mockContextValue,
|
||||
contextError: "Test error message",
|
||||
};
|
||||
const { container } = renderComponent(contextWithError);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render correctly when sourceReadAccessFromTarget is true", () => {
|
||||
const contextWithAccess = {
|
||||
...mockContextValue,
|
||||
copyJobState: {
|
||||
...mockContextValue.copyJobState,
|
||||
sourceReadAccessFromTarget: true,
|
||||
},
|
||||
};
|
||||
const { container } = renderComponent(contextWithAccess);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Component Structure", () => {
|
||||
it("should display the description text", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText(ContainerCopyMessages.readPermissionAssigned.description)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the info tooltip", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByTestId("info-tooltip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the toggle component", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByRole("switch")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toggle Interaction", () => {
|
||||
it("should call onToggle when toggle is clicked", () => {
|
||||
const mockOnToggle = jest.fn();
|
||||
mockUseToggle.mockReturnValue([false, mockOnToggle]);
|
||||
|
||||
renderComponent();
|
||||
const toggle = screen.getByRole("switch");
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(mockOnToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should show popover when toggle is turned on", () => {
|
||||
mockUseToggle.mockReturnValue([true, jest.fn()]);
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByTestId("popover-message")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("popover-title")).toHaveTextContent(
|
||||
ContainerCopyMessages.readPermissionAssigned.popoverTitle,
|
||||
);
|
||||
expect(screen.getByTestId("popover-content")).toHaveTextContent(
|
||||
ContainerCopyMessages.readPermissionAssigned.popoverDescription,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not show popover when toggle is turned off", () => {
|
||||
mockUseToggle.mockReturnValue([false, jest.fn()]);
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByTestId("popover-message")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Popover Interactions", () => {
|
||||
beforeEach(() => {
|
||||
mockUseToggle.mockReturnValue([true, jest.fn()]);
|
||||
});
|
||||
|
||||
it("should call onToggle with false when cancel button is clicked", () => {
|
||||
const mockOnToggle = jest.fn();
|
||||
mockUseToggle.mockReturnValue([true, mockOnToggle]);
|
||||
|
||||
renderComponent();
|
||||
const cancelButton = screen.getByTestId("popover-cancel");
|
||||
|
||||
fireEvent.click(cancelButton);
|
||||
expect(mockOnToggle).toHaveBeenCalledWith(null, false);
|
||||
});
|
||||
|
||||
it("should call handleAddReadPermission when primary button is clicked", async () => {
|
||||
mockGetAccountDetailsFromResourceId.mockReturnValue({
|
||||
subscriptionId: "source-sub-id",
|
||||
resourceGroup: "source-rg",
|
||||
accountName: "source-account",
|
||||
});
|
||||
mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType);
|
||||
|
||||
renderComponent();
|
||||
const primaryButton = screen.getByTestId("popover-primary");
|
||||
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledWith(
|
||||
"/subscriptions/source-sub-id/resourceGroups/source-rg/providers/Microsoft.DocumentDB/databaseAccounts/source-account",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAddReadPermission Function", () => {
|
||||
beforeEach(() => {
|
||||
mockUseToggle.mockReturnValue([true, jest.fn()]);
|
||||
});
|
||||
|
||||
it("should successfully assign role and update context", async () => {
|
||||
mockGetAccountDetailsFromResourceId.mockReturnValue({
|
||||
subscriptionId: "source-sub-id",
|
||||
resourceGroup: "source-rg",
|
||||
accountName: "source-account",
|
||||
});
|
||||
mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType);
|
||||
|
||||
renderComponent();
|
||||
const primaryButton = screen.getByTestId("popover-primary");
|
||||
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAssignRole).toHaveBeenCalledWith(
|
||||
"source-sub-id",
|
||||
"source-rg",
|
||||
"source-account",
|
||||
"target-principal-id",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockContextValue.setCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle error when assignRole fails", async () => {
|
||||
mockGetAccountDetailsFromResourceId.mockReturnValue({
|
||||
subscriptionId: "source-sub-id",
|
||||
resourceGroup: "source-rg",
|
||||
accountName: "source-account",
|
||||
});
|
||||
mockAssignRole.mockRejectedValue(new Error("Permission denied"));
|
||||
|
||||
renderComponent();
|
||||
const primaryButton = screen.getByTestId("popover-primary");
|
||||
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogError).toHaveBeenCalledWith(
|
||||
"Permission denied",
|
||||
"CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockContextValue.setContextError).toHaveBeenCalledWith("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle error without message", async () => {
|
||||
mockGetAccountDetailsFromResourceId.mockReturnValue({
|
||||
subscriptionId: "source-sub-id",
|
||||
resourceGroup: "source-rg",
|
||||
accountName: "source-account",
|
||||
});
|
||||
mockAssignRole.mockRejectedValue({});
|
||||
|
||||
renderComponent();
|
||||
const primaryButton = screen.getByTestId("popover-primary");
|
||||
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogError).toHaveBeenCalledWith(
|
||||
"Error assigning read permission to default identity. Please try again later.",
|
||||
"CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockContextValue.setContextError).toHaveBeenCalledWith(
|
||||
"Error assigning read permission to default identity. Please try again later.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show loading state during role assignment", async () => {
|
||||
mockGetAccountDetailsFromResourceId.mockReturnValue({
|
||||
subscriptionId: "source-sub-id",
|
||||
resourceGroup: "source-rg",
|
||||
accountName: "source-account",
|
||||
});
|
||||
|
||||
mockAssignRole.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve({ id: "role-id" } as RoleAssignmentType), 100)),
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
const primaryButton = screen.getByTestId("popover-primary");
|
||||
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("popover-message")).toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
});
|
||||
|
||||
it.skip("should not assign role when assignRole returns falsy", async () => {
|
||||
mockGetAccountDetailsFromResourceId.mockReturnValue({
|
||||
subscriptionId: "source-sub-id",
|
||||
resourceGroup: "source-rg",
|
||||
accountName: "source-account",
|
||||
});
|
||||
mockAssignRole.mockResolvedValue(null);
|
||||
|
||||
renderComponent();
|
||||
const primaryButton = screen.getByTestId("popover-primary");
|
||||
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAssignRole).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockContextValue.setCopyJobState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle missing target account identity", () => {
|
||||
const contextWithoutIdentity = {
|
||||
...mockContextValue,
|
||||
copyJobState: {
|
||||
...mockContextValue.copyJobState,
|
||||
target: {
|
||||
...mockContextValue.copyJobState.target,
|
||||
account: {
|
||||
...mockContextValue.copyJobState.target.account!,
|
||||
identity: undefined as any,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderComponent(contextWithoutIdentity);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should handle missing source account", () => {
|
||||
const contextWithoutSource = {
|
||||
...mockContextValue,
|
||||
copyJobState: {
|
||||
...mockContextValue.copyJobState,
|
||||
source: {
|
||||
...mockContextValue.copyJobState.source,
|
||||
account: null as any,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderComponent(contextWithoutSource);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should handle empty string principal ID", async () => {
|
||||
const contextWithEmptyPrincipal = {
|
||||
...mockContextValue,
|
||||
copyJobState: {
|
||||
...mockContextValue.copyJobState,
|
||||
target: {
|
||||
...mockContextValue.copyJobState.target,
|
||||
account: {
|
||||
...mockContextValue.copyJobState.target.account!,
|
||||
identity: {
|
||||
principalId: "",
|
||||
type: "SystemAssigned",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockUseToggle.mockReturnValue([true, jest.fn()]);
|
||||
mockGetAccountDetailsFromResourceId.mockReturnValue({
|
||||
subscriptionId: "source-sub-id",
|
||||
resourceGroup: "source-rg",
|
||||
accountName: "source-account",
|
||||
});
|
||||
mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType);
|
||||
|
||||
renderComponent(contextWithEmptyPrincipal);
|
||||
const primaryButton = screen.getByTestId("popover-primary");
|
||||
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAssignRole).toHaveBeenCalledWith("source-sub-id", "source-rg", "source-account", "");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Component Integration", () => {
|
||||
it("should work with all context updates", async () => {
|
||||
const setCopyJobStateMock = jest.fn();
|
||||
const setContextErrorMock = jest.fn();
|
||||
|
||||
const fullContextValue = {
|
||||
...mockContextValue,
|
||||
setCopyJobState: setCopyJobStateMock,
|
||||
setContextError: setContextErrorMock,
|
||||
};
|
||||
|
||||
mockUseToggle.mockReturnValue([true, jest.fn()]);
|
||||
mockGetAccountDetailsFromResourceId.mockReturnValue({
|
||||
subscriptionId: "source-sub-id",
|
||||
resourceGroup: "source-rg",
|
||||
accountName: "source-account",
|
||||
});
|
||||
mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType);
|
||||
|
||||
renderComponent(fullContextValue);
|
||||
const primaryButton = screen.getByTestId("popover-primary");
|
||||
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setCopyJobStateMock).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
const setCopyJobStateCall = setCopyJobStateMock.mock.calls[0][0];
|
||||
const updatedState = setCopyJobStateCall(mockContextValue.copyJobState);
|
||||
|
||||
expect(updatedState).toEqual({
|
||||
...mockContextValue.copyJobState,
|
||||
sourceReadAccessFromTarget: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Link, Stack, Text, Toggle } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { logError } from "../../../../../Common/Logger";
|
||||
import { assignRole } from "../../../../../Utils/arm/RbacUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
||||
import InfoTooltip from "../Components/InfoTooltip";
|
||||
import PopoverMessage from "../Components/PopoverContainer";
|
||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
import useToggle from "./hooks/useToggle";
|
||||
|
||||
const TooltipContent = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.readPermissionAssigned.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.readPermissionAssigned.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
type AddReadPermissionToDefaultIdentityProps = Partial<PermissionSectionConfig>;
|
||||
|
||||
const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIdentityProps> = () => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
|
||||
const [readPermissionAssigned, onToggle] = useToggle(false);
|
||||
|
||||
const handleAddReadPermission = async () => {
|
||||
const { source, target } = copyJobState;
|
||||
const selectedSourceAccount = source?.account;
|
||||
try {
|
||||
const {
|
||||
subscriptionId: sourceSubscriptionId,
|
||||
resourceGroup: sourceResourceGroup,
|
||||
accountName: sourceAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
||||
|
||||
setLoading(true);
|
||||
const assignedRole = await assignRole(
|
||||
sourceSubscriptionId,
|
||||
sourceResourceGroup,
|
||||
sourceAccountName,
|
||||
target?.account?.identity?.principalId ?? "",
|
||||
);
|
||||
if (assignedRole) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
sourceReadAccessFromTarget: true,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.message || "Error assigning read permission to default identity. Please try again later.";
|
||||
logError(errorMessage, "CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission");
|
||||
setContextError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<Text className="toggle-label">
|
||||
{ContainerCopyMessages.readPermissionAssigned.description} 
|
||||
<InfoTooltip content={TooltipContent} />
|
||||
</Text>
|
||||
<Toggle
|
||||
checked={readPermissionAssigned}
|
||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
||||
onChange={onToggle}
|
||||
inlineLabel
|
||||
styles={{
|
||||
root: { marginTop: 8, marginBottom: 12 },
|
||||
label: { display: "none" },
|
||||
}}
|
||||
/>
|
||||
<PopoverMessage
|
||||
isLoading={loading}
|
||||
visible={readPermissionAssigned}
|
||||
title={ContainerCopyMessages.readPermissionAssigned.popoverTitle}
|
||||
onCancel={() => onToggle(null, false)}
|
||||
onPrimary={handleAddReadPermission}
|
||||
>
|
||||
{ContainerCopyMessages.readPermissionAssigned.popoverDescription}
|
||||
</PopoverMessage>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddReadPermissionToDefaultIdentity;
|
||||
@@ -0,0 +1,379 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, RenderResult } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { CopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes";
|
||||
import AssignPermissions from "./AssignPermissions";
|
||||
|
||||
jest.mock("../../Utils/useCopyJobPrerequisitesCache", () => ({
|
||||
useCopyJobPrerequisitesCache: () => ({
|
||||
validationCache: new Map<string, boolean>(),
|
||||
setValidationCache: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("../../../CopyJobUtils", () => ({
|
||||
isIntraAccountCopy: jest.fn((sourceId: string, targetId: string) => sourceId === targetId),
|
||||
}));
|
||||
|
||||
jest.mock("./hooks/usePermissionsSection", () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn((): any[] => []),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../Common/ShimmerTree/ShimmerTree", () => {
|
||||
const MockShimmerTree = (props: any) => {
|
||||
return (
|
||||
<div data-testid="shimmer-tree" {...props}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
};
|
||||
MockShimmerTree.displayName = "MockShimmerTree";
|
||||
return MockShimmerTree;
|
||||
});
|
||||
|
||||
jest.mock("./AddManagedIdentity", () => {
|
||||
const MockAddManagedIdentity = () => {
|
||||
return <div data-testid="add-managed-identity">Add Managed Identity Component</div>;
|
||||
};
|
||||
MockAddManagedIdentity.displayName = "MockAddManagedIdentity";
|
||||
return MockAddManagedIdentity;
|
||||
});
|
||||
|
||||
jest.mock("./AddReadPermissionToDefaultIdentity", () => {
|
||||
const MockAddReadPermissionToDefaultIdentity = () => {
|
||||
return <div data-testid="add-read-permission">Add Read Permission Component</div>;
|
||||
};
|
||||
MockAddReadPermissionToDefaultIdentity.displayName = "MockAddReadPermissionToDefaultIdentity";
|
||||
return MockAddReadPermissionToDefaultIdentity;
|
||||
});
|
||||
|
||||
jest.mock("./DefaultManagedIdentity", () => {
|
||||
const MockDefaultManagedIdentity = () => {
|
||||
return <div data-testid="default-managed-identity">Default Managed Identity Component</div>;
|
||||
};
|
||||
MockDefaultManagedIdentity.displayName = "MockDefaultManagedIdentity";
|
||||
return MockDefaultManagedIdentity;
|
||||
});
|
||||
|
||||
jest.mock("./OnlineCopyEnabled", () => {
|
||||
const MockOnlineCopyEnabled = () => {
|
||||
return <div data-testid="online-copy-enabled">Online Copy Enabled Component</div>;
|
||||
};
|
||||
MockOnlineCopyEnabled.displayName = "MockOnlineCopyEnabled";
|
||||
return MockOnlineCopyEnabled;
|
||||
});
|
||||
|
||||
jest.mock("./PointInTimeRestore", () => {
|
||||
const MockPointInTimeRestore = () => {
|
||||
return <div data-testid="point-in-time-restore">Point In Time Restore Component</div>;
|
||||
};
|
||||
MockPointInTimeRestore.displayName = "MockPointInTimeRestore";
|
||||
return MockPointInTimeRestore;
|
||||
});
|
||||
|
||||
jest.mock("../../../../../../images/successfulPopup.svg", () => "checkmark-icon");
|
||||
jest.mock("../../../../../../images/warning.svg", () => "warning-icon");
|
||||
|
||||
describe("AssignPermissions Component", () => {
|
||||
const mockExplorer = {} as any;
|
||||
|
||||
const createMockCopyJobState = (overrides: Partial<CopyJobContextState> = {}): CopyJobContextState => ({
|
||||
jobName: "test-job",
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
source: {
|
||||
subscription: { subscriptionId: "source-sub" } as any,
|
||||
account: { id: "source-account", name: "Source Account" } as any,
|
||||
databaseId: "source-db",
|
||||
containerId: "source-container",
|
||||
},
|
||||
target: {
|
||||
subscriptionId: "target-sub",
|
||||
account: { id: "target-account", name: "Target Account" } as any,
|
||||
databaseId: "target-db",
|
||||
containerId: "target-container",
|
||||
},
|
||||
sourceReadAccessFromTarget: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockContextValue = (copyJobState: CopyJobContextState): CopyJobContextProviderType => ({
|
||||
contextError: null,
|
||||
setContextError: jest.fn(),
|
||||
copyJobState,
|
||||
setCopyJobState: jest.fn(),
|
||||
flow: null,
|
||||
setFlow: jest.fn(),
|
||||
resetCopyJobState: jest.fn(),
|
||||
explorer: mockExplorer,
|
||||
});
|
||||
|
||||
const renderWithContext = (copyJobState: CopyJobContextState): RenderResult => {
|
||||
const contextValue = createMockContextValue(copyJobState);
|
||||
return render(
|
||||
<CopyJobContext.Provider value={contextValue}>
|
||||
<AssignPermissions />
|
||||
</CopyJobContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render without crashing with offline migration", () => {
|
||||
const copyJobState = createMockCopyJobState();
|
||||
const { container } = renderWithContext(copyJobState);
|
||||
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render without crashing with online migration", () => {
|
||||
const copyJobState = createMockCopyJobState({
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
});
|
||||
const { container } = renderWithContext(copyJobState);
|
||||
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display shimmer tree when no permission groups are available", () => {
|
||||
const copyJobState = createMockCopyJobState();
|
||||
const { getByTestId } = renderWithContext(copyJobState);
|
||||
|
||||
expect(getByTestId("shimmer-tree")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display cross account description for different accounts", () => {
|
||||
const copyJobState = createMockCopyJobState();
|
||||
const { getByText } = renderWithContext(copyJobState);
|
||||
|
||||
expect(getByText(ContainerCopyMessages.assignPermissions.crossAccountDescription)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display intra account description for same accounts with online migration", async () => {
|
||||
const { isIntraAccountCopy } = await import("../../../CopyJobUtils");
|
||||
(isIntraAccountCopy as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const copyJobState = createMockCopyJobState({
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
source: {
|
||||
subscription: { subscriptionId: "same-sub" } as any,
|
||||
account: { id: "same-account", name: "Same Account" } as any,
|
||||
databaseId: "source-db",
|
||||
containerId: "source-container",
|
||||
},
|
||||
target: {
|
||||
subscriptionId: "same-sub",
|
||||
account: { id: "same-account", name: "Same Account" } as any,
|
||||
databaseId: "target-db",
|
||||
containerId: "target-container",
|
||||
},
|
||||
});
|
||||
|
||||
const { getByText } = renderWithContext(copyJobState);
|
||||
expect(
|
||||
getByText(ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription("Same Account")),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Permission Groups", () => {
|
||||
it("should render permission groups when available", async () => {
|
||||
const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock;
|
||||
mockUsePermissionSections.mockReturnValue([
|
||||
{
|
||||
id: "crossAccountConfigs",
|
||||
title: "Cross Account Configuration",
|
||||
description: "Configure permissions for cross-account copy",
|
||||
sections: [
|
||||
{
|
||||
id: "addManagedIdentity",
|
||||
title: "Add Managed Identity",
|
||||
Component: () => <div data-testid="add-managed-identity">Add Managed Identity Component</div>,
|
||||
disabled: false,
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
id: "readPermissionAssigned",
|
||||
title: "Read Permission Assigned",
|
||||
Component: () => <div data-testid="add-read-permission">Add Read Permission Component</div>,
|
||||
disabled: false,
|
||||
completed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const copyJobState = createMockCopyJobState();
|
||||
const { container } = renderWithContext(copyJobState);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render online migration specific groups", async () => {
|
||||
const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock;
|
||||
mockUsePermissionSections.mockReturnValue([
|
||||
{
|
||||
id: "onlineConfigs",
|
||||
title: "Online Configuration",
|
||||
description: "Configure settings for online migration",
|
||||
sections: [
|
||||
{
|
||||
id: "pointInTimeRestore",
|
||||
title: "Point In Time Restore",
|
||||
Component: () => <div data-testid="point-in-time-restore">Point In Time Restore Component</div>,
|
||||
disabled: false,
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
id: "onlineCopyEnabled",
|
||||
title: "Online Copy Enabled",
|
||||
Component: () => <div data-testid="online-copy-enabled">Online Copy Enabled Component</div>,
|
||||
disabled: false,
|
||||
completed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const copyJobState = createMockCopyJobState({
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
});
|
||||
const { container } = renderWithContext(copyJobState);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render multiple permission groups", async () => {
|
||||
const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock;
|
||||
mockUsePermissionSections.mockReturnValue([
|
||||
{
|
||||
id: "crossAccountConfigs",
|
||||
title: "Cross Account Configuration",
|
||||
description: "Configure permissions for cross-account copy",
|
||||
sections: [
|
||||
{
|
||||
id: "addManagedIdentity",
|
||||
title: "Add Managed Identity",
|
||||
Component: () => <div data-testid="add-managed-identity">Add Managed Identity Component</div>,
|
||||
disabled: false,
|
||||
completed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "onlineConfigs",
|
||||
title: "Online Configuration",
|
||||
description: "Configure settings for online migration",
|
||||
sections: [
|
||||
{
|
||||
id: "onlineCopyEnabled",
|
||||
title: "Online Copy Enabled",
|
||||
Component: () => <div data-testid="online-copy-enabled">Online Copy Enabled Component</div>,
|
||||
disabled: false,
|
||||
completed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const copyJobState = createMockCopyJobState({
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
});
|
||||
const { container, getByText } = renderWithContext(copyJobState);
|
||||
|
||||
expect(getByText("Cross Account Configuration")).toBeInTheDocument();
|
||||
expect(getByText("Online Configuration")).toBeInTheDocument();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accordion Behavior", () => {
|
||||
it("should render accordion sections with proper status icons", async () => {
|
||||
const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock;
|
||||
mockUsePermissionSections.mockReturnValue([
|
||||
{
|
||||
id: "testGroup",
|
||||
title: "Test Group",
|
||||
description: "Test Description",
|
||||
sections: [
|
||||
{
|
||||
id: "completedSection",
|
||||
title: "Completed Section",
|
||||
Component: () => <div>Completed Component</div>,
|
||||
disabled: false,
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
id: "incompleteSection",
|
||||
title: "Incomplete Section",
|
||||
Component: () => <div>Incomplete Component</div>,
|
||||
disabled: false,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: "disabledSection",
|
||||
title: "Disabled Section",
|
||||
Component: () => <div>Disabled Component</div>,
|
||||
disabled: true,
|
||||
completed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const copyJobState = createMockCopyJobState();
|
||||
const { container, getByText, getAllByRole } = renderWithContext(copyJobState);
|
||||
|
||||
expect(getByText("Completed Section")).toBeInTheDocument();
|
||||
expect(getByText("Incomplete Section")).toBeInTheDocument();
|
||||
expect(getByText("Disabled Section")).toBeInTheDocument();
|
||||
|
||||
const images = getAllByRole("img");
|
||||
expect(images.length).toBeGreaterThan(0);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle missing account names", () => {
|
||||
const copyJobState = createMockCopyJobState({
|
||||
source: {
|
||||
subscription: { subscriptionId: "source-sub" } as any,
|
||||
account: { id: "source-account" } as any,
|
||||
databaseId: "source-db",
|
||||
containerId: "source-container",
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = renderWithContext(copyJobState);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should calculate correct indent levels for offline migration", () => {
|
||||
const copyJobState = createMockCopyJobState({
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
});
|
||||
|
||||
const { container } = renderWithContext(copyJobState);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should calculate correct indent levels for online migration", () => {
|
||||
const copyJobState = createMockCopyJobState({
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
});
|
||||
|
||||
const { container } = renderWithContext(copyJobState);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Image, Stack, Text } from "@fluentui/react";
|
||||
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel } from "@fluentui/react-components";
|
||||
import React, { useEffect } from "react";
|
||||
import CheckmarkIcon from "../../../../../../images/successfulPopup.svg";
|
||||
import WarningIcon from "../../../../../../images/warning.svg";
|
||||
import ShimmerTree, { IndentLevel } from "../../../../../Common/ShimmerTree/ShimmerTree";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { isIntraAccountCopy } from "../../../CopyJobUtils";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
import { useCopyJobPrerequisitesCache } from "../../Utils/useCopyJobPrerequisitesCache";
|
||||
import usePermissionSections, { PermissionGroupConfig, PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
|
||||
const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Component, completed, disabled }) => (
|
||||
<AccordionItem key={id} value={id} disabled={disabled}>
|
||||
<AccordionHeader className="accordionHeader">
|
||||
<Text className="accordionHeaderText" variant="medium">
|
||||
{title}
|
||||
</Text>
|
||||
<Image
|
||||
className="statusIcon"
|
||||
src={completed ? CheckmarkIcon : WarningIcon}
|
||||
alt={completed ? "Checkmark icon" : "Warning icon"}
|
||||
width={completed ? 20 : 24}
|
||||
height={completed ? 20 : 24}
|
||||
/>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel aria-disabled={disabled} className="accordionPanel">
|
||||
<Component />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
||||
const PermissionGroup: React.FC<PermissionGroupConfig> = ({ title, description, sections }) => {
|
||||
const [openItems, setOpenItems] = React.useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const firstIncompleteSection = sections.find((section) => !section.completed);
|
||||
const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : [];
|
||||
if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) {
|
||||
setOpenItems(nextOpenItems);
|
||||
}
|
||||
}, [sections]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
tokens={{ childrenGap: 15 }}
|
||||
styles={{
|
||||
root: {
|
||||
background: "#fafafa",
|
||||
border: "1px solid #e1e1e1",
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack tokens={{ childrenGap: 5 }}>
|
||||
<Text variant="medium" style={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text variant="small" styles={{ root: { color: "#605E5C" } }}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Accordion className="permissionsAccordion" collapsible openItems={openItems}>
|
||||
{sections.map((section) => (
|
||||
<PermissionSection key={section.id} {...section} />
|
||||
))}
|
||||
</Accordion>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const AssignPermissions = () => {
|
||||
const { setValidationCache } = useCopyJobPrerequisitesCache();
|
||||
const { copyJobState } = useCopyJobContext();
|
||||
const permissionGroups = usePermissionSections(copyJobState);
|
||||
|
||||
const totalSectionsCount = React.useMemo(
|
||||
() => permissionGroups.reduce((total, group) => total + group.sections.length, 0),
|
||||
[permissionGroups],
|
||||
);
|
||||
|
||||
const indentLevels = React.useMemo<IndentLevel[]>(
|
||||
() => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }),
|
||||
[copyJobState.migrationType],
|
||||
);
|
||||
|
||||
const isSameAccount = isIntraAccountCopy(copyJobState?.source?.account?.id, copyJobState?.target?.account?.id);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setValidationCache(new Map<string, boolean>());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 20 }}>
|
||||
<Text variant="medium">
|
||||
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
|
||||
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(
|
||||
copyJobState?.source?.account?.name || "",
|
||||
)
|
||||
: ContainerCopyMessages.assignPermissions.crossAccountDescription}
|
||||
</Text>
|
||||
|
||||
{totalSectionsCount === 0 ? (
|
||||
<ShimmerTree indentLevels={indentLevels} style={{ width: "100%" }} />
|
||||
) : (
|
||||
<Stack tokens={{ childrenGap: 25 }}>
|
||||
{permissionGroups.map((group) => (
|
||||
<PermissionGroup key={group.id} {...group} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignPermissions;
|
||||
@@ -0,0 +1,355 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { updateDefaultIdentity } from "../../../../../Utils/arm/identityUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { CopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import DefaultManagedIdentity from "./DefaultManagedIdentity";
|
||||
|
||||
jest.mock("./hooks/useManagedIdentity");
|
||||
jest.mock("./hooks/useToggle");
|
||||
|
||||
jest.mock("../../../../../Utils/arm/identityUtils", () => ({
|
||||
updateDefaultIdentity: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../Components/InfoTooltip", () => {
|
||||
const MockInfoTooltip = ({ content }: { content: React.ReactNode }) => {
|
||||
return <div data-testid="info-tooltip">{content}</div>;
|
||||
};
|
||||
MockInfoTooltip.displayName = "MockInfoTooltip";
|
||||
return MockInfoTooltip;
|
||||
});
|
||||
|
||||
jest.mock("../Components/PopoverContainer", () => {
|
||||
const MockPopoverContainer = ({
|
||||
children,
|
||||
isLoading,
|
||||
visible,
|
||||
title,
|
||||
onCancel,
|
||||
onPrimary,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isLoading: boolean;
|
||||
visible: boolean;
|
||||
title: string;
|
||||
onCancel: () => void;
|
||||
onPrimary: () => void;
|
||||
}) => {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div data-testid="popover-message">
|
||||
<div data-testid="popover-title">{title}</div>
|
||||
<div data-testid="popover-content">{children}</div>
|
||||
<div data-testid="popover-loading">{isLoading ? "Loading" : "Not Loading"}</div>
|
||||
<button data-testid="popover-cancel" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button data-testid="popover-primary" onClick={onPrimary}>
|
||||
Primary
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
MockPopoverContainer.displayName = "MockPopoverContainer";
|
||||
return MockPopoverContainer;
|
||||
});
|
||||
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes";
|
||||
import useManagedIdentity from "./hooks/useManagedIdentity";
|
||||
import useToggle from "./hooks/useToggle";
|
||||
|
||||
const mockUseManagedIdentity = useManagedIdentity as jest.MockedFunction<typeof useManagedIdentity>;
|
||||
const mockUseToggle = useToggle as jest.MockedFunction<typeof useToggle>;
|
||||
|
||||
describe("DefaultManagedIdentity", () => {
|
||||
const mockCopyJobContextValue = {
|
||||
copyJobState: {
|
||||
target: {
|
||||
account: {
|
||||
name: "test-cosmos-account",
|
||||
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-cosmos-account",
|
||||
},
|
||||
},
|
||||
},
|
||||
setCopyJobState: jest.fn(),
|
||||
setContextError: jest.fn(),
|
||||
contextError: "",
|
||||
flow: {},
|
||||
setFlow: jest.fn(),
|
||||
resetCopyJobState: jest.fn(),
|
||||
explorer: {} as any,
|
||||
};
|
||||
|
||||
const mockHandleAddSystemIdentity = jest.fn();
|
||||
const mockOnToggle = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseManagedIdentity.mockReturnValue({
|
||||
loading: false,
|
||||
handleAddSystemIdentity: mockHandleAddSystemIdentity,
|
||||
});
|
||||
|
||||
mockUseToggle.mockReturnValue([false, mockOnToggle]);
|
||||
});
|
||||
|
||||
const renderComponent = (contextValue = mockCopyJobContextValue) => {
|
||||
return render(
|
||||
<CopyJobContext.Provider value={contextValue as unknown as CopyJobContextProviderType}>
|
||||
<DefaultManagedIdentity />
|
||||
</CopyJobContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render correctly with default state", () => {
|
||||
const { container } = renderComponent();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render the description with account name", () => {
|
||||
renderComponent();
|
||||
|
||||
const description = screen.getByText(
|
||||
/Set the system-assigned managed identity as default for "test-cosmos-account"/,
|
||||
);
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the info tooltip", () => {
|
||||
renderComponent();
|
||||
|
||||
const tooltip = screen.getByTestId("info-tooltip");
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(tooltip).toHaveTextContent("Learn more about");
|
||||
expect(tooltip).toHaveTextContent("Default Managed Identities.");
|
||||
});
|
||||
|
||||
it("should render the toggle button with correct initial state", () => {
|
||||
renderComponent();
|
||||
|
||||
const toggle = screen.getByRole("switch");
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should not show popover when toggle is false", () => {
|
||||
renderComponent();
|
||||
|
||||
const popover = screen.queryByTestId("popover-message");
|
||||
expect(popover).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toggle Interactions", () => {
|
||||
it("should call onToggle when toggle is clicked", () => {
|
||||
renderComponent();
|
||||
|
||||
const toggle = screen.getByRole("switch");
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(mockOnToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should show popover when toggle is true", () => {
|
||||
mockUseToggle.mockReturnValue([true, mockOnToggle]);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const popover = screen.getByTestId("popover-message");
|
||||
expect(popover).toBeInTheDocument();
|
||||
|
||||
const title = screen.getByTestId("popover-title");
|
||||
expect(title).toHaveTextContent(ContainerCopyMessages.defaultManagedIdentity.popoverTitle);
|
||||
|
||||
const content = screen.getByTestId("popover-content");
|
||||
expect(content).toHaveTextContent(
|
||||
/Assign the system-assigned managed identity as the default for "test-cosmos-account"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should render toggle with checked state when toggle is true", () => {
|
||||
mockUseToggle.mockReturnValue([true, mockOnToggle]);
|
||||
|
||||
const { container } = renderComponent();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Loading States", () => {
|
||||
it("should show loading state in popover when loading is true", () => {
|
||||
mockUseToggle.mockReturnValue([true, mockOnToggle]);
|
||||
mockUseManagedIdentity.mockReturnValue({
|
||||
loading: true,
|
||||
handleAddSystemIdentity: mockHandleAddSystemIdentity,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
const loadingIndicator = screen.getByTestId("popover-loading");
|
||||
expect(loadingIndicator).toHaveTextContent("Loading");
|
||||
});
|
||||
|
||||
it("should not show loading state when loading is false", () => {
|
||||
mockUseToggle.mockReturnValue([true, mockOnToggle]);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const loadingIndicator = screen.getByTestId("popover-loading");
|
||||
expect(loadingIndicator).toHaveTextContent("Not Loading");
|
||||
});
|
||||
|
||||
it("should render loading state snapshot", () => {
|
||||
mockUseToggle.mockReturnValue([true, mockOnToggle]);
|
||||
mockUseManagedIdentity.mockReturnValue({
|
||||
loading: true,
|
||||
handleAddSystemIdentity: mockHandleAddSystemIdentity,
|
||||
});
|
||||
|
||||
const { container } = renderComponent();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Popover Interactions", () => {
|
||||
beforeEach(() => {
|
||||
mockUseToggle.mockReturnValue([true, mockOnToggle]);
|
||||
});
|
||||
|
||||
it("should call onToggle with false when cancel button is clicked", () => {
|
||||
renderComponent();
|
||||
|
||||
const cancelButton = screen.getByTestId("popover-cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockOnToggle).toHaveBeenCalledWith(null, false);
|
||||
});
|
||||
|
||||
it("should call handleAddSystemIdentity when primary button is clicked", () => {
|
||||
renderComponent();
|
||||
|
||||
const primaryButton = screen.getByTestId("popover-primary");
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
expect(mockHandleAddSystemIdentity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle primary button click correctly when loading", async () => {
|
||||
mockUseManagedIdentity.mockReturnValue({
|
||||
loading: true,
|
||||
handleAddSystemIdentity: mockHandleAddSystemIdentity,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
const primaryButton = screen.getByTestId("popover-primary");
|
||||
fireEvent.click(primaryButton);
|
||||
|
||||
expect(mockHandleAddSystemIdentity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle missing account name gracefully", () => {
|
||||
const contextValueWithoutAccount = {
|
||||
...mockCopyJobContextValue,
|
||||
copyJobState: {
|
||||
target: {
|
||||
account: {
|
||||
name: "",
|
||||
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderComponent(contextValueWithoutAccount);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should handle null account", () => {
|
||||
const contextValueWithNullAccount = {
|
||||
...mockCopyJobContextValue,
|
||||
copyJobState: {
|
||||
target: {
|
||||
account: null as DatabaseAccount | null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderComponent(contextValueWithNullAccount);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hook Integration", () => {
|
||||
it("should pass updateDefaultIdentity to useManagedIdentity hook", () => {
|
||||
renderComponent();
|
||||
|
||||
expect(mockUseManagedIdentity).toHaveBeenCalledWith(updateDefaultIdentity);
|
||||
});
|
||||
|
||||
it("should initialize useToggle with false", () => {
|
||||
renderComponent();
|
||||
|
||||
expect(mockUseToggle).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("should have proper ARIA attributes", () => {
|
||||
renderComponent();
|
||||
|
||||
const toggle = screen.getByRole("switch");
|
||||
expect(toggle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have proper link accessibility", () => {
|
||||
renderComponent();
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Component Structure", () => {
|
||||
it("should have correct CSS class", () => {
|
||||
const { container } = renderComponent();
|
||||
|
||||
const componentContainer = container.querySelector(".defaultManagedIdentityContainer");
|
||||
expect(componentContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all required FluentUI components", () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByRole("switch")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Messages and Text Content", () => {
|
||||
it("should display correct toggle button text", () => {
|
||||
renderComponent();
|
||||
|
||||
const onText = screen.queryByText(ContainerCopyMessages.toggleBtn.onText);
|
||||
const offText = screen.queryByText(ContainerCopyMessages.toggleBtn.offText);
|
||||
|
||||
expect(onText || offText).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should display correct link text in tooltip", () => {
|
||||
renderComponent();
|
||||
|
||||
const linkText = screen.getByText(ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText);
|
||||
expect(linkText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Link, Stack, Text, Toggle } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { updateDefaultIdentity } from "../../../../../Utils/arm/identityUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import InfoTooltip from "../Components/InfoTooltip";
|
||||
import PopoverMessage from "../Components/PopoverContainer";
|
||||
import useManagedIdentity from "./hooks/useManagedIdentity";
|
||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
import useToggle from "./hooks/useToggle";
|
||||
|
||||
const managedIdentityTooltip = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.defaultManagedIdentity.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.defaultManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
||||
|
||||
const DefaultManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
|
||||
const { copyJobState } = useCopyJobContext();
|
||||
const [defaultSystemAssigned, onToggle] = useToggle(false);
|
||||
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateDefaultIdentity);
|
||||
|
||||
return (
|
||||
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<div className="toggle-label">
|
||||
{ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account?.name)}
|
||||
<InfoTooltip content={managedIdentityTooltip} />
|
||||
</div>
|
||||
<Toggle
|
||||
checked={defaultSystemAssigned}
|
||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
||||
onChange={onToggle}
|
||||
inlineLabel
|
||||
styles={{
|
||||
root: { marginTop: 8, marginBottom: 12 },
|
||||
label: { display: "none" },
|
||||
}}
|
||||
/>
|
||||
<PopoverMessage
|
||||
isLoading={loading}
|
||||
visible={defaultSystemAssigned}
|
||||
title={ContainerCopyMessages.defaultManagedIdentity.popoverTitle}
|
||||
onCancel={() => onToggle(null, false)}
|
||||
onPrimary={handleAddSystemIdentity}
|
||||
>
|
||||
{ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account?.name)}
|
||||
</PopoverMessage>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultManagedIdentity;
|
||||
@@ -0,0 +1,579 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes";
|
||||
import React from "react";
|
||||
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
|
||||
import { CapabilityNames } from "../../../../../Common/Constants";
|
||||
import { logError } from "../../../../../Common/Logger";
|
||||
import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { CopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import OnlineCopyEnabled from "./OnlineCopyEnabled";
|
||||
|
||||
jest.mock("Utils/arm/databaseAccountUtils", () => ({
|
||||
fetchDatabaseAccount: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts", () => ({
|
||||
update: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../Common/Logger", () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../Common/LoadingOverlay", () => {
|
||||
const MockLoadingOverlay = ({ isLoading, label }: { isLoading: boolean; label: string }) => {
|
||||
return isLoading ? <div data-testid="loading-overlay">{label}</div> : null;
|
||||
};
|
||||
MockLoadingOverlay.displayName = "MockLoadingOverlay";
|
||||
return MockLoadingOverlay;
|
||||
});
|
||||
|
||||
const mockFetchDatabaseAccount = fetchDatabaseAccount as jest.MockedFunction<typeof fetchDatabaseAccount>;
|
||||
const mockUpdateDatabaseAccount = updateDatabaseAccount as jest.MockedFunction<typeof updateDatabaseAccount>;
|
||||
const mockLogError = logError as jest.MockedFunction<typeof logError>;
|
||||
|
||||
describe("OnlineCopyEnabled", () => {
|
||||
const mockSetContextError = jest.fn();
|
||||
const mockSetCopyJobState = jest.fn();
|
||||
|
||||
const mockSourceAccount: DatabaseAccount = {
|
||||
id: "/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
|
||||
name: "test-account",
|
||||
location: "East US",
|
||||
type: "Microsoft.DocumentDB/databaseAccounts",
|
||||
kind: "GlobalDocumentDB",
|
||||
properties: {
|
||||
capabilities: [],
|
||||
enableAllVersionsAndDeletesChangeFeed: false,
|
||||
locations: [],
|
||||
writeLocations: [],
|
||||
readLocations: [],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCopyJobContextValue = {
|
||||
copyJobState: {
|
||||
source: {
|
||||
account: mockSourceAccount,
|
||||
},
|
||||
},
|
||||
setCopyJobState: mockSetCopyJobState,
|
||||
setContextError: mockSetContextError,
|
||||
contextError: "",
|
||||
flow: { currentScreen: "" },
|
||||
setFlow: jest.fn(),
|
||||
resetCopyJobState: jest.fn(),
|
||||
explorer: {} as any,
|
||||
} as unknown as CopyJobContextProviderType;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.clearAllTimers();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const renderComponent = (contextValue = mockCopyJobContextValue) => {
|
||||
return render(
|
||||
<CopyJobContext.Provider value={contextValue}>
|
||||
<OnlineCopyEnabled />
|
||||
</CopyJobContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render correctly with initial state", () => {
|
||||
const { container } = renderComponent();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render the description with account name", () => {
|
||||
renderComponent();
|
||||
|
||||
const description = screen.getByText(ContainerCopyMessages.onlineCopyEnabled.description("test-account"));
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the learn more link", () => {
|
||||
renderComponent();
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.hrefText,
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", ContainerCopyMessages.onlineCopyEnabled.href);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should render the enable button with correct text when not loading", () => {
|
||||
renderComponent();
|
||||
|
||||
const button = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
|
||||
});
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should not show loading overlay initially", () => {
|
||||
renderComponent();
|
||||
|
||||
const loadingOverlay = screen.queryByTestId("loading-overlay");
|
||||
expect(loadingOverlay).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show refresh button initially", () => {
|
||||
renderComponent();
|
||||
|
||||
const refreshButton = screen.queryByRole("button", {
|
||||
name: ContainerCopyMessages.refreshButtonLabel,
|
||||
});
|
||||
expect(refreshButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enable Online Copy Flow", () => {
|
||||
it("should handle complete enable online copy flow successfully", async () => {
|
||||
const accountAfterChangeFeedUpdate = {
|
||||
...mockSourceAccount,
|
||||
properties: {
|
||||
...mockSourceAccount.properties,
|
||||
enableAllVersionsAndDeletesChangeFeed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const accountWithOnlineCopyEnabled: DatabaseAccount = {
|
||||
...accountAfterChangeFeedUpdate,
|
||||
properties: {
|
||||
...accountAfterChangeFeedUpdate.properties,
|
||||
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }],
|
||||
},
|
||||
};
|
||||
|
||||
mockFetchDatabaseAccount
|
||||
.mockResolvedValueOnce(mockSourceAccount)
|
||||
.mockResolvedValueOnce(accountWithOnlineCopyEnabled);
|
||||
|
||||
mockUpdateDatabaseAccount.mockResolvedValue({} as any);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const enableButton = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enableButton);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("loading-overlay")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(mockFetchDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account", {
|
||||
properties: {
|
||||
enableAllVersionsAndDeletesChangeFeed: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account", {
|
||||
properties: {
|
||||
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should skip change feed enablement if already enabled", async () => {
|
||||
const accountWithChangeFeedEnabled = {
|
||||
...mockSourceAccount,
|
||||
properties: {
|
||||
...mockSourceAccount.properties,
|
||||
enableAllVersionsAndDeletesChangeFeed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const accountWithOnlineCopyEnabled: DatabaseAccount = {
|
||||
...accountWithChangeFeedEnabled,
|
||||
properties: {
|
||||
...accountWithChangeFeedEnabled.properties,
|
||||
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }],
|
||||
},
|
||||
};
|
||||
|
||||
mockFetchDatabaseAccount
|
||||
.mockResolvedValueOnce(accountWithChangeFeedEnabled)
|
||||
.mockResolvedValueOnce(accountWithOnlineCopyEnabled);
|
||||
|
||||
mockUpdateDatabaseAccount.mockResolvedValue({} as any);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const enableButton = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enableButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateDatabaseAccount).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account", {
|
||||
properties: {
|
||||
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show correct loading messages during the process", async () => {
|
||||
mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
|
||||
mockUpdateDatabaseAccount.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
renderComponent();
|
||||
|
||||
const enableButton = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enableButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchDatabaseAccount).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel("test-account")),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle error during update operations", async () => {
|
||||
const errorMessage = "Failed to update account";
|
||||
mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
|
||||
mockUpdateDatabaseAccount.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
renderComponent();
|
||||
|
||||
const enableButton = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enableButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogError).toHaveBeenCalledWith(errorMessage, "CopyJob/OnlineCopyEnabled.handleOnlineCopyEnable");
|
||||
expect(mockSetContextError).toHaveBeenCalledWith(errorMessage);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("loading-overlay")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle refresh button click", async () => {
|
||||
const accountWithOnlineCopyEnabled: DatabaseAccount = {
|
||||
...mockSourceAccount,
|
||||
properties: {
|
||||
...mockSourceAccount.properties,
|
||||
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }],
|
||||
},
|
||||
};
|
||||
|
||||
mockFetchDatabaseAccount
|
||||
.mockResolvedValueOnce(mockSourceAccount)
|
||||
.mockResolvedValueOnce(mockSourceAccount)
|
||||
.mockResolvedValueOnce(accountWithOnlineCopyEnabled);
|
||||
|
||||
mockUpdateDatabaseAccount.mockResolvedValue({} as any);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const enableButton = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enableButton);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(10 * 60 * 1000);
|
||||
});
|
||||
|
||||
const refreshButton = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.refreshButtonLabel,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(refreshButton);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("loading-overlay")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopyJobState).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Account Validation and State Updates", () => {
|
||||
it("should update state when account capabilities change", async () => {
|
||||
const accountWithOnlineCopyEnabled: DatabaseAccount = {
|
||||
...mockSourceAccount,
|
||||
properties: {
|
||||
...mockSourceAccount.properties,
|
||||
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }],
|
||||
},
|
||||
};
|
||||
|
||||
mockFetchDatabaseAccount.mockResolvedValue(accountWithOnlineCopyEnabled);
|
||||
mockUpdateDatabaseAccount.mockResolvedValue({} as any);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const enableButton = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enableButton);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(30000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const newState = stateUpdateFunction({
|
||||
source: { account: mockSourceAccount },
|
||||
});
|
||||
|
||||
expect(newState.source.account).toEqual(accountWithOnlineCopyEnabled);
|
||||
});
|
||||
|
||||
it("should not update state when account capabilities remain unchanged", async () => {
|
||||
mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
|
||||
mockUpdateDatabaseAccount.mockResolvedValue({} as any);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const enableButton = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enableButton);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(30000);
|
||||
});
|
||||
|
||||
expect(mockSetCopyJobState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Button States and Interactions", () => {
|
||||
it("should disable button during loading", async () => {
|
||||
mockFetchDatabaseAccount.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
renderComponent();
|
||||
|
||||
const enableButton = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enableButton);
|
||||
});
|
||||
|
||||
const loadingButton = screen.getByRole("button");
|
||||
expect(loadingButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should show sync icon during loading", async () => {
|
||||
mockFetchDatabaseAccount.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
renderComponent();
|
||||
|
||||
const enableButton = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enableButton);
|
||||
});
|
||||
|
||||
const loadingButton = screen.getByRole("button");
|
||||
expect(loadingButton.querySelector("[data-icon-name='SyncStatusSolid']")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable refresh button during loading", async () => {
|
||||
mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
|
||||
mockUpdateDatabaseAccount.mockResolvedValue({} as any);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const enableButton = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enableButton);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(10 * 60 * 1000);
|
||||
});
|
||||
|
||||
mockFetchDatabaseAccount.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const refreshButton = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.refreshButtonLabel,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(refreshButton);
|
||||
});
|
||||
|
||||
expect(refreshButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle missing account name gracefully", () => {
|
||||
const contextWithoutAccountName = {
|
||||
...mockCopyJobContextValue,
|
||||
copyJobState: {
|
||||
source: {
|
||||
account: {
|
||||
...mockSourceAccount,
|
||||
name: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CopyJobContextProviderType;
|
||||
|
||||
const { container } = renderComponent(contextWithoutAccountName);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should handle null account", () => {
|
||||
const contextWithNullAccount = {
|
||||
...mockCopyJobContextValue,
|
||||
copyJobState: {
|
||||
source: {
|
||||
account: null as DatabaseAccount | null,
|
||||
},
|
||||
},
|
||||
} as CopyJobContextProviderType;
|
||||
|
||||
const { container } = renderComponent(contextWithNullAccount);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should handle account with existing online copy capability", () => {
|
||||
const accountWithExistingCapability = {
|
||||
...mockSourceAccount,
|
||||
properties: {
|
||||
...mockSourceAccount.properties,
|
||||
capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature }, { name: "SomeOtherCapability" }],
|
||||
},
|
||||
};
|
||||
|
||||
const contextWithExistingCapability = {
|
||||
...mockCopyJobContextValue,
|
||||
copyJobState: {
|
||||
source: {
|
||||
account: accountWithExistingCapability,
|
||||
},
|
||||
},
|
||||
} as CopyJobContextProviderType;
|
||||
|
||||
const { container } = renderComponent(contextWithExistingCapability);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should handle account with no capabilities array", () => {
|
||||
const accountWithNoCapabilities = {
|
||||
...mockSourceAccount,
|
||||
properties: {
|
||||
...mockSourceAccount.properties,
|
||||
capabilities: undefined,
|
||||
},
|
||||
} as DatabaseAccount;
|
||||
|
||||
const contextWithNoCapabilities = {
|
||||
...mockCopyJobContextValue,
|
||||
copyJobState: {
|
||||
source: {
|
||||
account: accountWithNoCapabilities,
|
||||
},
|
||||
},
|
||||
} as CopyJobContextProviderType;
|
||||
|
||||
renderComponent(contextWithNoCapabilities);
|
||||
|
||||
const enableButton = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
|
||||
});
|
||||
expect(enableButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("should have proper button role and accessibility attributes", () => {
|
||||
renderComponent();
|
||||
|
||||
const button = screen.getByRole("button", {
|
||||
name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
|
||||
});
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have proper link accessibility", () => {
|
||||
renderComponent();
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSS Classes and Styling", () => {
|
||||
it("should apply correct CSS class to container", () => {
|
||||
const { container } = renderComponent();
|
||||
|
||||
const onlineCopyContainer = container.querySelector(".onlineCopyContainer");
|
||||
expect(onlineCopyContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply fullWidth class to buttons", () => {
|
||||
renderComponent();
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toHaveClass("fullWidth");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Link, PrimaryButton, Stack } from "@fluentui/react";
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import React from "react";
|
||||
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
|
||||
import { CapabilityNames } from "../../../../../Common/Constants";
|
||||
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
|
||||
import { logError } from "../../../../../Common/Logger";
|
||||
import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
||||
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
|
||||
|
||||
const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => {
|
||||
const prevCapabilities = prev?.properties?.capabilities ?? [];
|
||||
const nextCapabilities = next?.properties?.capabilities ?? [];
|
||||
|
||||
return JSON.stringify(prevCapabilities) !== JSON.stringify(nextCapabilities);
|
||||
};
|
||||
|
||||
const OnlineCopyEnabled: React.FC = () => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [loaderMessage, setLoaderMessage] = React.useState("");
|
||||
const [showRefreshButton, setShowRefreshButton] = React.useState(false);
|
||||
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const { setContextError, copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
|
||||
const selectedSourceAccount = source?.account;
|
||||
const sourceAccountCapabilities = selectedSourceAccount?.properties?.capabilities ?? [];
|
||||
|
||||
const {
|
||||
subscriptionId: sourceSubscriptionId,
|
||||
resourceGroup: sourceResourceGroup,
|
||||
accountName: sourceAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id) || {};
|
||||
|
||||
const handleFetchAccount = async () => {
|
||||
try {
|
||||
const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName);
|
||||
if (account && validatorFn(selectedSourceAccount, account)) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
source: { ...prevState.source, account: account },
|
||||
}));
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.message || "Error fetching source account after enabling online copy. Please try again later.";
|
||||
logError(errorMessage, "CopyJob/OnlineCopyEnabled.handleFetchAccount");
|
||||
setContextError(errorMessage);
|
||||
clearAccountFetchInterval();
|
||||
}
|
||||
};
|
||||
|
||||
const clearAccountFetchInterval = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const clearIntervalAndShowRefresh = () => {
|
||||
clearAccountFetchInterval();
|
||||
setShowRefreshButton(true);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true);
|
||||
handleFetchAccount();
|
||||
};
|
||||
|
||||
const handleOnlineCopyEnable = async () => {
|
||||
setLoading(true);
|
||||
setShowRefreshButton(false);
|
||||
|
||||
try {
|
||||
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.validateAllVersionsAndDeletesChangeFeedSpinnerLabel);
|
||||
const sourAccountBeforeUpdate = await fetchDatabaseAccount(
|
||||
sourceSubscriptionId,
|
||||
sourceResourceGroup,
|
||||
sourceAccountName,
|
||||
);
|
||||
if (!sourAccountBeforeUpdate?.properties.enableAllVersionsAndDeletesChangeFeed) {
|
||||
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingAllVersionsAndDeletesChangeFeedSpinnerLabel);
|
||||
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
|
||||
properties: {
|
||||
enableAllVersionsAndDeletesChangeFeed: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel(sourceAccountName));
|
||||
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
|
||||
properties: {
|
||||
capabilities: [...sourceAccountCapabilities, { name: CapabilityNames.EnableOnlineCopyFeature }],
|
||||
},
|
||||
});
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
handleFetchAccount();
|
||||
}, 30 * 1000);
|
||||
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
clearIntervalAndShowRefresh();
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error.message || "Failed to enable online copy feature. Please try again later.";
|
||||
logError(errorMessage, "CopyJob/OnlineCopyEnabled.handleOnlineCopyEnable");
|
||||
setContextError(errorMessage);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack className="onlineCopyContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<LoadingOverlay isLoading={loading} label={loaderMessage} />
|
||||
<Stack.Item className="info-message">
|
||||
{ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")} 
|
||||
<Link href={ContainerCopyMessages.onlineCopyEnabled.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.onlineCopyEnabled.hrefText}
|
||||
</Link>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
{showRefreshButton ? (
|
||||
<PrimaryButton
|
||||
className="fullWidth"
|
||||
text={ContainerCopyMessages.refreshButtonLabel}
|
||||
iconProps={{ iconName: "Refresh" }}
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
/>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
className="fullWidth"
|
||||
text={loading ? "" : ContainerCopyMessages.onlineCopyEnabled.buttonText}
|
||||
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
|
||||
disabled={loading}
|
||||
onClick={handleOnlineCopyEnable}
|
||||
/>
|
||||
)}
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnlineCopyEnabled;
|
||||
@@ -0,0 +1,341 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { logError } from "Common/Logger";
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import React from "react";
|
||||
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
|
||||
import { CopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes";
|
||||
import PointInTimeRestore from "./PointInTimeRestore";
|
||||
|
||||
jest.mock("Utils/arm/databaseAccountUtils");
|
||||
jest.mock("Common/Logger");
|
||||
|
||||
const mockFetchDatabaseAccount = fetchDatabaseAccount as jest.MockedFunction<typeof fetchDatabaseAccount>;
|
||||
const mockLogError = logError as jest.MockedFunction<typeof logError>;
|
||||
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, "open", {
|
||||
value: mockWindowOpen,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
global.clearInterval = jest.fn();
|
||||
global.clearTimeout = jest.fn();
|
||||
|
||||
describe("PointInTimeRestore", () => {
|
||||
const mockSourceAccount: DatabaseAccount = {
|
||||
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
|
||||
name: "test-account",
|
||||
type: "Microsoft.DocumentDB/databaseAccounts",
|
||||
location: "East US",
|
||||
properties: {
|
||||
backupPolicy: {
|
||||
type: "Continuous",
|
||||
},
|
||||
},
|
||||
} as DatabaseAccount;
|
||||
|
||||
const mockUpdatedAccount: DatabaseAccount = {
|
||||
...mockSourceAccount,
|
||||
properties: {
|
||||
backupPolicy: {
|
||||
type: "Periodic",
|
||||
},
|
||||
},
|
||||
} as DatabaseAccount;
|
||||
|
||||
const defaultCopyJobState = {
|
||||
jobName: "test-job",
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
source: {
|
||||
subscription: { subscriptionId: "test-sub", displayName: "Test Subscription" },
|
||||
account: mockSourceAccount,
|
||||
databaseId: "test-db",
|
||||
containerId: "test-container",
|
||||
},
|
||||
target: {
|
||||
subscriptionId: "test-sub",
|
||||
account: mockSourceAccount,
|
||||
databaseId: "target-db",
|
||||
containerId: "target-container",
|
||||
},
|
||||
sourceReadAccessFromTarget: false,
|
||||
} as CopyJobContextState;
|
||||
|
||||
const mockSetCopyJobState = jest.fn();
|
||||
|
||||
const createMockContext = (overrides?: Partial<CopyJobContextProviderType>): CopyJobContextProviderType => ({
|
||||
copyJobState: defaultCopyJobState,
|
||||
setCopyJobState: mockSetCopyJobState,
|
||||
flow: null,
|
||||
setFlow: jest.fn(),
|
||||
contextError: null,
|
||||
setContextError: jest.fn(),
|
||||
resetCopyJobState: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderWithContext = (contextValue: CopyJobContextProviderType) => {
|
||||
return render(
|
||||
<CopyJobContext.Provider value={contextValue}>
|
||||
<PointInTimeRestore />
|
||||
</CopyJobContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchDatabaseAccount.mockClear();
|
||||
mockLogError.mockClear();
|
||||
mockWindowOpen.mockClear();
|
||||
mockSetCopyJobState.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
describe("Initial Render", () => {
|
||||
it("should render correctly with default props", () => {
|
||||
const mockContext = createMockContext();
|
||||
const { container } = renderWithContext(mockContext);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display the correct description with account name", () => {
|
||||
const mockContext = createMockContext();
|
||||
renderWithContext(mockContext);
|
||||
|
||||
expect(screen.getByText(/test-account/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the primary action button with correct text", () => {
|
||||
const mockContext = createMockContext();
|
||||
renderWithContext(mockContext);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should render with empty account name gracefully", () => {
|
||||
const contextWithoutAccount = createMockContext({
|
||||
copyJobState: {
|
||||
...defaultCopyJobState,
|
||||
source: {
|
||||
...defaultCopyJobState.source,
|
||||
account: { ...mockSourceAccount, name: "" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = renderWithContext(contextWithoutAccount);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Button Interactions", () => {
|
||||
it("should open window and start monitoring when button is clicked", () => {
|
||||
const mockContext = createMockContext();
|
||||
renderWithContext(mockContext);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/#resource\/subscriptions\/test-sub\/resourceGroups\/test-rg\/providers\/Microsoft.DocumentDB\/databaseAccounts\/test-account\/backupRestore$/,
|
||||
),
|
||||
"_blank",
|
||||
);
|
||||
});
|
||||
|
||||
it("should disable button and show loading state after click", () => {
|
||||
const mockContext = createMockContext();
|
||||
renderWithContext(mockContext);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(button).toBeDisabled();
|
||||
expect(screen.getByText(/Please wait while we process your request/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show refresh button when timeout occurs", async () => {
|
||||
jest.useFakeTimers();
|
||||
const mockContext = createMockContext();
|
||||
renderWithContext(mockContext);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
jest.advanceTimersByTime(10 * 60 * 1000 + 1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Refresh/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should fetch account periodically after button click", async () => {
|
||||
jest.useFakeTimers();
|
||||
mockFetchDatabaseAccount.mockResolvedValue(mockUpdatedAccount);
|
||||
|
||||
const mockContext = createMockContext();
|
||||
renderWithContext(mockContext);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
jest.advanceTimersByTime(30 * 1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchDatabaseAccount).toHaveBeenCalledWith("test-sub", "test-rg", "test-account");
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should not update context when account validation fails", async () => {
|
||||
jest.useFakeTimers();
|
||||
mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
|
||||
|
||||
const mockContext = createMockContext();
|
||||
renderWithContext(mockContext);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
jest.advanceTimersByTime(30 * 1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchDatabaseAccount).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockSetCopyJobState).not.toHaveBeenCalled();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Refresh Button Functionality", () => {
|
||||
it("should handle refresh button click", async () => {
|
||||
jest.useFakeTimers();
|
||||
mockFetchDatabaseAccount.mockResolvedValue(mockUpdatedAccount);
|
||||
|
||||
const mockContext = createMockContext();
|
||||
renderWithContext(mockContext);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
jest.advanceTimersByTime(10 * 60 * 1000 + 1000);
|
||||
|
||||
await waitFor(() => {
|
||||
const refreshButton = screen.getByText(/Refresh/);
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const refreshButton = screen.getByText(/Refresh/);
|
||||
fireEvent.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchDatabaseAccount).toHaveBeenCalledWith("test-sub", "test-rg", "test-account");
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should show loading state during refresh", async () => {
|
||||
jest.useFakeTimers();
|
||||
mockFetchDatabaseAccount.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve(mockUpdatedAccount), 1000)),
|
||||
);
|
||||
|
||||
const mockContext = createMockContext();
|
||||
renderWithContext(mockContext);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
jest.advanceTimersByTime(10 * 60 * 1000 + 1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Refresh/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const refreshButton = screen.getByText(/Refresh/);
|
||||
fireEvent.click(refreshButton);
|
||||
|
||||
expect(screen.getByText(/Please wait while we process your request/)).toBeInTheDocument();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle missing source account gracefully", () => {
|
||||
const contextWithoutSourceAccount = createMockContext({
|
||||
copyJobState: {
|
||||
...defaultCopyJobState,
|
||||
source: {
|
||||
...defaultCopyJobState.source,
|
||||
account: null as any,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = renderWithContext(contextWithoutSourceAccount);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should handle missing account ID gracefully", () => {
|
||||
const contextWithoutAccountId = createMockContext({
|
||||
copyJobState: {
|
||||
...defaultCopyJobState,
|
||||
source: {
|
||||
...defaultCopyJobState.source,
|
||||
account: { ...mockSourceAccount, id: undefined as any },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = renderWithContext(contextWithoutAccountId);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Snapshots", () => {
|
||||
it("should match snapshot in loading state", () => {
|
||||
const mockContext = createMockContext();
|
||||
const { container } = renderWithContext(mockContext);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match snapshot with refresh button", async () => {
|
||||
jest.useFakeTimers();
|
||||
const mockContext = createMockContext();
|
||||
const { container } = renderWithContext(mockContext);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
jest.advanceTimersByTime(10 * 60 * 1000 + 1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Refresh/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Link, PrimaryButton, Stack, Text } from "@fluentui/react";
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
|
||||
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
|
||||
import { logError } from "../../../../../Common/Logger";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { buildResourceLink, getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
||||
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
|
||||
import InfoTooltip from "../Components/InfoTooltip";
|
||||
|
||||
const tooltipContent = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.pointInTimeRestore.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.pointInTimeRestore.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.pointInTimeRestore.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
|
||||
const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => {
|
||||
const prevBackupPolicy = prev?.properties?.backupPolicy?.type ?? "";
|
||||
const nextBackupPolicy = next?.properties?.backupPolicy?.type ?? "";
|
||||
|
||||
return prevBackupPolicy !== nextBackupPolicy;
|
||||
};
|
||||
|
||||
const PointInTimeRestore: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showRefreshButton, setShowRefreshButton] = useState(false);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { copyJobState: { source } = {}, setCopyJobState, setContextError } = useCopyJobContext();
|
||||
if (!source?.account?.id) {
|
||||
setContextError("Invalid source account. Please select a valid source account for Point-in-Time Restore.");
|
||||
return null;
|
||||
}
|
||||
const sourceAccountLink = buildResourceLink(source?.account);
|
||||
const featureUrl = `${sourceAccountLink}/backupRestore`;
|
||||
const selectedSourceAccount = source?.account;
|
||||
const {
|
||||
subscriptionId: sourceSubscriptionId,
|
||||
resourceGroup: sourceResourceGroup,
|
||||
accountName: sourceAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id) || {};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFetchAccount = async () => {
|
||||
try {
|
||||
const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName);
|
||||
if (account && validatorFn(selectedSourceAccount, account)) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
source: { ...prevState.source, account: account },
|
||||
}));
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.message || "Error fetching source account after Point-in-Time Restore. Please try again later.";
|
||||
logError(errorMessage, "CopyJob/PointInTimeRestore.handleFetchAccount");
|
||||
clearAccountFetchInterval();
|
||||
}
|
||||
};
|
||||
|
||||
const clearAccountFetchInterval = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const clearIntervalAndShowRefresh = () => {
|
||||
clearAccountFetchInterval();
|
||||
setShowRefreshButton(true);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setLoading(true);
|
||||
await handleFetchAccount();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const openWindowAndMonitor = () => {
|
||||
setLoading(true);
|
||||
setShowRefreshButton(false);
|
||||
window.open(featureUrl, "_blank");
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
handleFetchAccount();
|
||||
}, 30 * 1000);
|
||||
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
clearIntervalAndShowRefresh();
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="pointInTimeRestoreContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<LoadingOverlay isLoading={loading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
|
||||
<Stack.Item className="toggle-label">
|
||||
{ContainerCopyMessages.pointInTimeRestore.description(source.account?.name ?? "")}
|
||||
{tooltipContent && (
|
||||
<>
|
||||
{" "}
|
||||
<InfoTooltip content={tooltipContent} />
|
||||
</>
|
||||
)}
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
{showRefreshButton ? (
|
||||
<PrimaryButton
|
||||
className="fullWidth"
|
||||
text={ContainerCopyMessages.refreshButtonLabel}
|
||||
iconProps={{ iconName: "Refresh" }}
|
||||
onClick={handleRefresh}
|
||||
/>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
className="fullWidth"
|
||||
text={loading ? "" : ContainerCopyMessages.pointInTimeRestore.buttonText}
|
||||
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
|
||||
disabled={loading}
|
||||
onClick={openWindowAndMonitor}
|
||||
/>
|
||||
)}
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PointInTimeRestore;
|
||||
@@ -0,0 +1,406 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AddManagedIdentity Snapshot Tests renders initial state correctly 1`] = `
|
||||
<div
|
||||
class="ms-Stack addManagedIdentityContainer css-109"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about Managed identities.
|
||||
</a>
|
||||
|
||||
|
||||
<div
|
||||
class="ms-TooltipHost root-105"
|
||||
role="none"
|
||||
>
|
||||
<div
|
||||
class="ms-Image root-112"
|
||||
style="width: 14px; height: 14px;"
|
||||
>
|
||||
<img
|
||||
alt="Information"
|
||||
class="ms-Image-image ms-Image-image--portrait is-notLoaded is-fadeIn image-113"
|
||||
src="[object Object]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
hidden=""
|
||||
id="tooltip0"
|
||||
style="position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0px; border: 0px; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Managed Identities.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<div
|
||||
class="ms-Toggle is-enabled root-114"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-116"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-labelledby="Toggle1-stateText"
|
||||
class="ms-Toggle-background pill-117"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle1"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-118"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-120"
|
||||
for="Toggle1"
|
||||
id="Toggle1-stateText"
|
||||
>
|
||||
Off
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = `
|
||||
<div
|
||||
class="ms-Stack addManagedIdentityContainer css-109"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about Managed identities.
|
||||
</a>
|
||||
|
||||
|
||||
<div
|
||||
class="ms-TooltipHost root-105"
|
||||
role="none"
|
||||
>
|
||||
<div
|
||||
class="ms-Image root-112"
|
||||
style="width: 14px; height: 14px;"
|
||||
>
|
||||
<img
|
||||
alt="Information"
|
||||
class="ms-Image-image ms-Image-image--portrait is-notLoaded is-fadeIn image-113"
|
||||
src="[object Object]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
hidden=""
|
||||
id="tooltip10"
|
||||
style="position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0px; border: 0px; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Managed Identities.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<div
|
||||
class="ms-Toggle is-checked is-enabled root-114"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-116"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
aria-labelledby="Toggle11-stateText"
|
||||
class="ms-Toggle-background pill-121"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle11"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-122"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-120"
|
||||
for="Toggle11"
|
||||
id="Toggle11-stateText"
|
||||
>
|
||||
On
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-Stack popover-container foreground loading css-123"
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<div
|
||||
class="ms-Overlay root-135"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner root-137"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner-circle ms-Spinner--large circle-138"
|
||||
/>
|
||||
<div
|
||||
class="ms-Spinner-label label-139"
|
||||
>
|
||||
Please wait while we process your request...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="css-124"
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Enable system assigned managed identity
|
||||
</span>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button.
|
||||
</span>
|
||||
<div
|
||||
class="ms-Stack css-125"
|
||||
>
|
||||
<button
|
||||
aria-disabled="true"
|
||||
class="ms-Button ms-Button--primary is-disabled root-140"
|
||||
data-is-focusable="false"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-flexContainer flexContainer-127"
|
||||
data-automationid="splitbuttonprimary"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-textContainer textContainer-128"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-label label-130"
|
||||
id="id__12"
|
||||
>
|
||||
Yes
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="true"
|
||||
class="ms-Button ms-Button--default is-disabled root-143"
|
||||
data-is-focusable="false"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-flexContainer flexContainer-127"
|
||||
data-automationid="splitbuttonprimary"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-textContainer textContainer-128"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-label label-130"
|
||||
id="id__15"
|
||||
>
|
||||
No
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover visible 1`] = `
|
||||
<div
|
||||
class="ms-Stack addManagedIdentityContainer css-109"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about Managed identities.
|
||||
</a>
|
||||
|
||||
|
||||
<div
|
||||
class="ms-TooltipHost root-105"
|
||||
role="none"
|
||||
>
|
||||
<div
|
||||
class="ms-Image root-112"
|
||||
style="width: 14px; height: 14px;"
|
||||
>
|
||||
<img
|
||||
alt="Information"
|
||||
class="ms-Image-image ms-Image-image--portrait is-notLoaded is-fadeIn image-113"
|
||||
src="[object Object]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
hidden=""
|
||||
id="tooltip2"
|
||||
style="position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0px; border: 0px; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Managed Identities.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<div
|
||||
class="ms-Toggle is-checked is-enabled root-114"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-116"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
aria-labelledby="Toggle3-stateText"
|
||||
class="ms-Toggle-background pill-121"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle3"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-122"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-120"
|
||||
for="Toggle3"
|
||||
id="Toggle3-stateText"
|
||||
>
|
||||
On
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-Stack popover-container foreground css-123"
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
class="css-124"
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Enable system assigned managed identity
|
||||
</span>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button.
|
||||
</span>
|
||||
<div
|
||||
class="ms-Stack css-125"
|
||||
>
|
||||
<button
|
||||
class="ms-Button ms-Button--primary root-126"
|
||||
data-is-focusable="true"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-flexContainer flexContainer-127"
|
||||
data-automationid="splitbuttonprimary"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-textContainer textContainer-128"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-label label-130"
|
||||
id="id__4"
|
||||
>
|
||||
Yes
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="ms-Button ms-Button--default root-134"
|
||||
data-is-focusable="true"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-flexContainer flexContainer-127"
|
||||
data-automationid="splitbuttonprimary"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-textContainer textContainer-128"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-label label-130"
|
||||
id="id__7"
|
||||
>
|
||||
No
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,398 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle missing source account 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack defaultManagedIdentityContainer css-109"
|
||||
>
|
||||
<span
|
||||
class="toggle-label css-110"
|
||||
>
|
||||
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
|
||||
|
||||
<div
|
||||
data-testid="info-tooltip"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Read permissions.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<div
|
||||
class="ms-Toggle is-enabled root-112"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-114"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-labelledby="Toggle17-stateText"
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle17"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-116"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-118"
|
||||
for="Toggle17"
|
||||
id="Toggle17-stateText"
|
||||
>
|
||||
Off
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle missing target account identity 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack defaultManagedIdentityContainer css-109"
|
||||
>
|
||||
<span
|
||||
class="toggle-label css-110"
|
||||
>
|
||||
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
|
||||
|
||||
<div
|
||||
data-testid="info-tooltip"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Read permissions.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<div
|
||||
class="ms-Toggle is-enabled root-112"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-114"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-labelledby="Toggle16-stateText"
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle16"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-116"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-118"
|
||||
for="Toggle16"
|
||||
id="Toggle16-stateText"
|
||||
>
|
||||
Off
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly when sourceReadAccessFromTarget is true 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack defaultManagedIdentityContainer css-109"
|
||||
>
|
||||
<span
|
||||
class="toggle-label css-110"
|
||||
>
|
||||
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
|
||||
|
||||
<div
|
||||
data-testid="info-tooltip"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Read permissions.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<div
|
||||
class="ms-Toggle is-enabled root-112"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-114"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-labelledby="Toggle3-stateText"
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle3"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-116"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-118"
|
||||
for="Toggle3"
|
||||
id="Toggle3-stateText"
|
||||
>
|
||||
Off
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly when toggle is on 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack defaultManagedIdentityContainer css-109"
|
||||
>
|
||||
<span
|
||||
class="toggle-label css-110"
|
||||
>
|
||||
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
|
||||
|
||||
<div
|
||||
data-testid="info-tooltip"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Read permissions.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<div
|
||||
class="ms-Toggle is-checked is-enabled root-112"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-114"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
aria-labelledby="Toggle1-stateText"
|
||||
class="ms-Toggle-background pill-119"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle1"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-120"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-118"
|
||||
for="Toggle1"
|
||||
id="Toggle1-stateText"
|
||||
>
|
||||
On
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-loading="false"
|
||||
data-testid="popover-message"
|
||||
>
|
||||
<div
|
||||
data-testid="popover-title"
|
||||
>
|
||||
Read permissions assigned to default identity.
|
||||
</div>
|
||||
<div
|
||||
data-testid="popover-content"
|
||||
>
|
||||
Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button.
|
||||
</div>
|
||||
<button
|
||||
data-testid="popover-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
data-testid="popover-primary"
|
||||
>
|
||||
Primary
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly with default state 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack defaultManagedIdentityContainer css-109"
|
||||
>
|
||||
<span
|
||||
class="toggle-label css-110"
|
||||
>
|
||||
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
|
||||
|
||||
<div
|
||||
data-testid="info-tooltip"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Read permissions.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<div
|
||||
class="ms-Toggle is-enabled root-112"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-114"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-labelledby="Toggle0-stateText"
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle0"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-116"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-118"
|
||||
for="Toggle0"
|
||||
id="Toggle0-stateText"
|
||||
>
|
||||
Off
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly with different context states 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack defaultManagedIdentityContainer css-109"
|
||||
>
|
||||
<span
|
||||
class="toggle-label css-110"
|
||||
>
|
||||
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
|
||||
|
||||
<div
|
||||
data-testid="info-tooltip"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Read permissions.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<div
|
||||
class="ms-Toggle is-enabled root-112"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-114"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-labelledby="Toggle2-stateText"
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle2"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-116"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-118"
|
||||
for="Toggle2"
|
||||
id="Toggle2-stateText"
|
||||
>
|
||||
Off
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,369 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`DefaultManagedIdentity Edge Cases should handle missing account name gracefully 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack defaultManagedIdentityContainer css-109"
|
||||
>
|
||||
<div
|
||||
class="toggle-label"
|
||||
>
|
||||
Set the system-assigned managed identity as default for "" by switching it on.
|
||||
|
||||
<div
|
||||
data-testid="info-tooltip"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Default Managed Identities.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-Toggle is-enabled root-112"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-114"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-labelledby="Toggle14-stateText"
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle14"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-116"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-118"
|
||||
for="Toggle14"
|
||||
id="Toggle14-stateText"
|
||||
>
|
||||
Off
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`DefaultManagedIdentity Edge Cases should handle null account 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack defaultManagedIdentityContainer css-109"
|
||||
>
|
||||
<div
|
||||
class="toggle-label"
|
||||
>
|
||||
Set the system-assigned managed identity as default for "undefined" by switching it on.
|
||||
|
||||
<div
|
||||
data-testid="info-tooltip"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Default Managed Identities.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-Toggle is-enabled root-112"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-114"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-labelledby="Toggle15-stateText"
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle15"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-116"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-118"
|
||||
for="Toggle15"
|
||||
id="Toggle15-stateText"
|
||||
>
|
||||
Off
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`DefaultManagedIdentity Loading States should render loading state snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack defaultManagedIdentityContainer css-109"
|
||||
>
|
||||
<div
|
||||
class="toggle-label"
|
||||
>
|
||||
Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on.
|
||||
|
||||
<div
|
||||
data-testid="info-tooltip"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Default Managed Identities.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-Toggle is-checked is-enabled root-112"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-114"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
aria-labelledby="Toggle10-stateText"
|
||||
class="ms-Toggle-background pill-119"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle10"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-120"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-118"
|
||||
for="Toggle10"
|
||||
id="Toggle10-stateText"
|
||||
>
|
||||
On
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="popover-message"
|
||||
>
|
||||
<div
|
||||
data-testid="popover-title"
|
||||
>
|
||||
System assigned managed identity set as default
|
||||
</div>
|
||||
<div
|
||||
data-testid="popover-content"
|
||||
>
|
||||
Assign the system-assigned managed identity as the default for "test-cosmos-account". To confirm, click the "Yes" button.
|
||||
</div>
|
||||
<div
|
||||
data-testid="popover-loading"
|
||||
>
|
||||
Loading
|
||||
</div>
|
||||
<button
|
||||
data-testid="popover-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
data-testid="popover-primary"
|
||||
>
|
||||
Primary
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`DefaultManagedIdentity Rendering should render correctly with default state 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack defaultManagedIdentityContainer css-109"
|
||||
>
|
||||
<div
|
||||
class="toggle-label"
|
||||
>
|
||||
Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on.
|
||||
|
||||
<div
|
||||
data-testid="info-tooltip"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Default Managed Identities.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-Toggle is-enabled root-112"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-114"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-labelledby="Toggle0-stateText"
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle0"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-116"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-118"
|
||||
for="Toggle0"
|
||||
id="Toggle0-stateText"
|
||||
>
|
||||
Off
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`DefaultManagedIdentity Toggle Interactions should render toggle with checked state when toggle is true 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack defaultManagedIdentityContainer css-109"
|
||||
>
|
||||
<div
|
||||
class="toggle-label"
|
||||
>
|
||||
Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on.
|
||||
|
||||
<div
|
||||
data-testid="info-tooltip"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
>
|
||||
Learn more about
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Default Managed Identities.
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-Toggle is-checked is-enabled root-112"
|
||||
>
|
||||
<div
|
||||
class="ms-Toggle-innerContainer container-114"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
aria-labelledby="Toggle7-stateText"
|
||||
class="ms-Toggle-background pill-119"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
id="Toggle7"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Toggle-thumb thumb-120"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="ms-Label ms-Toggle-stateText text-118"
|
||||
for="Toggle7"
|
||||
id="Toggle7-stateText"
|
||||
>
|
||||
On
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="popover-message"
|
||||
>
|
||||
<div
|
||||
data-testid="popover-title"
|
||||
>
|
||||
System assigned managed identity set as default
|
||||
</div>
|
||||
<div
|
||||
data-testid="popover-content"
|
||||
>
|
||||
Assign the system-assigned managed identity as the default for "test-cosmos-account". To confirm, click the "Yes" button.
|
||||
</div>
|
||||
<div
|
||||
data-testid="popover-loading"
|
||||
>
|
||||
Not Loading
|
||||
</div>
|
||||
<button
|
||||
data-testid="popover-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
data-testid="popover-primary"
|
||||
>
|
||||
Primary
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,193 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`OnlineCopyEnabled Edge Cases should handle account with existing online copy capability 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack onlineCopyContainer css-109"
|
||||
>
|
||||
<div
|
||||
class="ms-StackItem info-message css-110"
|
||||
>
|
||||
Enable online container copy by clicking the button below on your "test-account" account.
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about online copy jobs
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="ms-StackItem css-110"
|
||||
>
|
||||
<button
|
||||
class="ms-Button ms-Button--primary fullWidth root-112"
|
||||
data-is-focusable="true"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-flexContainer flexContainer-113"
|
||||
data-automationid="splitbuttonprimary"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-textContainer textContainer-114"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-label label-116"
|
||||
id="id__54"
|
||||
>
|
||||
Enable Online Copy
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`OnlineCopyEnabled Edge Cases should handle missing account name gracefully 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack onlineCopyContainer css-109"
|
||||
>
|
||||
<div
|
||||
class="ms-StackItem info-message css-110"
|
||||
>
|
||||
Enable online container copy by clicking the button below on your "" account.
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about online copy jobs
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="ms-StackItem css-110"
|
||||
>
|
||||
<button
|
||||
class="ms-Button ms-Button--primary fullWidth root-112"
|
||||
data-is-focusable="true"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-flexContainer flexContainer-113"
|
||||
data-automationid="splitbuttonprimary"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-textContainer textContainer-114"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-label label-116"
|
||||
id="id__48"
|
||||
>
|
||||
Enable Online Copy
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`OnlineCopyEnabled Edge Cases should handle null account 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack onlineCopyContainer css-109"
|
||||
>
|
||||
<div
|
||||
class="ms-StackItem info-message css-110"
|
||||
>
|
||||
Enable online container copy by clicking the button below on your "" account.
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about online copy jobs
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="ms-StackItem css-110"
|
||||
>
|
||||
<button
|
||||
class="ms-Button ms-Button--primary fullWidth root-112"
|
||||
data-is-focusable="true"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-flexContainer flexContainer-113"
|
||||
data-automationid="splitbuttonprimary"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-textContainer textContainer-114"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-label label-116"
|
||||
id="id__51"
|
||||
>
|
||||
Enable Online Copy
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`OnlineCopyEnabled Rendering should render correctly with initial state 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack onlineCopyContainer css-109"
|
||||
>
|
||||
<div
|
||||
class="ms-StackItem info-message css-110"
|
||||
>
|
||||
Enable online container copy by clicking the button below on your "test-account" account.
|
||||
|
||||
<a
|
||||
class="ms-Link root-111"
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about online copy jobs
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="ms-StackItem css-110"
|
||||
>
|
||||
<button
|
||||
class="ms-Button ms-Button--primary fullWidth root-112"
|
||||
data-is-focusable="true"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-flexContainer flexContainer-113"
|
||||
data-automationid="splitbuttonprimary"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-textContainer textContainer-114"
|
||||
>
|
||||
<span
|
||||
class="ms-Button-label label-116"
|
||||
id="id__0"
|
||||
>
|
||||
Enable Online Copy
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||