mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-06-08 13:37:29 +01:00
Merge branch 'master' into locfiles/486ca1c2-3f14-4217-8aae-5aae878d249c
This commit is contained in:
+1
-1
@@ -40,7 +40,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
"no-console": ["error", { allow: ["error", "warn", "dir"] }],
|
//CTODO uncomment when console debugging is reverted: "no-console": ["error", { allow: ["error", "warn", "dir"] }],
|
||||||
curly: "error",
|
curly: "error",
|
||||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||||
"@typescript-eslint/no-unused-vars": "error",
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
|
|||||||
@@ -314,6 +314,9 @@ jobs:
|
|||||||
needs: [playwright-tests]
|
needs: [playwright-tests]
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -338,3 +341,62 @@ jobs:
|
|||||||
name: html-report--attempt-${{ github.run_attempt }}
|
name: html-report--attempt-${{ github.run_attempt }}
|
||||||
path: playwright-report
|
path: playwright-report
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Comment Playwright results on PR
|
||||||
|
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
PR: ${{ github.event.pull_request.number }}
|
||||||
|
REPORT_URL: https://dataexplorerpreview.z5.web.core.windows.net/playwright-reports/${{ github.run_id }}-${{ github.run_attempt }}/index.html
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
PLAYWRIGHT_JSON_OUTPUT_NAME=results.json npx playwright merge-reports --reporter json ./all-blob-reports
|
||||||
|
read PASSED FAILED FLAKY SKIPPED DURATION < <(jq -r '[.stats.expected,.stats.unexpected,.stats.flaky,.stats.skipped,(.stats.duration/1000|floor)] | @tsv' results.json)
|
||||||
|
BROKEN=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}/jobs" \
|
||||||
|
--jq '[.jobs[] | select(.name | startswith("Run Playwright Tests")) | select(.conclusion == "failure")] | length')
|
||||||
|
if [ "$FAILED" -gt 0 ] || [ "$BROKEN" -gt 0 ]; then ICON="❌ failed"; else ICON="✅ passed"; fi
|
||||||
|
NOTE=""
|
||||||
|
[ "$BROKEN" -gt 0 ] && NOTE="
|
||||||
|
|
||||||
|
> ⚠️ $BROKEN shard(s) failed before tests ran (infra/auth issue). Stats below reflect only shards that executed."
|
||||||
|
gh pr comment "$PR" --body "### Playwright tests $ICON
|
||||||
|
|
||||||
|
| Passed | Failed | Flaky | Duration |
|
||||||
|
| :---: | :---: | :---: | :---: |
|
||||||
|
| $PASSED | $FAILED | $FLAKY | ${DURATION}s |
|
||||||
|
|
||||||
|
📊 [Open full report]($REPORT_URL) · [Workflow run]($RUN_URL)$NOTE"
|
||||||
|
|
||||||
|
publish-playwright-report:
|
||||||
|
name: "Publish Playwright Report to Blob"
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
needs: [merge-playwright-reports]
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Download HTML report artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: html-report--attempt-${{ github.run_attempt }}
|
||||||
|
path: playwright-report
|
||||||
|
|
||||||
|
- name: "Az CLI login"
|
||||||
|
uses: Azure/login@v2
|
||||||
|
with:
|
||||||
|
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
|
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
|
subscription-id: ${{ secrets.PREVIEW_SUBSCRIPTION_ID }}
|
||||||
|
|
||||||
|
- name: Upload Playwright report to blob storage
|
||||||
|
env:
|
||||||
|
KEY: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
BASE: https://dataexplorerpreview.z5.web.core.windows.net
|
||||||
|
run: |
|
||||||
|
az storage blob upload-batch -d '$web' -s playwright-report \
|
||||||
|
--destination-path "playwright-reports/${KEY}" \
|
||||||
|
--account-name ${{ secrets.PREVIEW_STORAGE_ACCOUNT_NAME }} \
|
||||||
|
--auth-mode login --overwrite true
|
||||||
|
echo "📊 [Open Playwright report](${BASE}/playwright-reports/${KEY}/index.html)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0)">
|
|
||||||
<path d="M4.75 0.999903C4.98438 0.999903 5.1849 1.02334 5.35156 1.07022C5.51823 1.11709 5.66927 1.17959 5.80469 1.25772C5.9401 1.33584 6.0599 1.41657 6.16406 1.4999C6.26823 1.58324 6.3724 1.66397 6.47656 1.74209C6.58073 1.82022 6.69531 1.88011 6.82031 1.92178C6.94531 1.96344 7.08854 1.98949 7.25 1.9999H15C15.1406 1.9999 15.2708 2.02594 15.3906 2.07803C15.5104 2.13011 15.6146 2.20042 15.7031 2.28897C15.7917 2.37751 15.8646 2.48428 15.9219 2.60928C15.9792 2.73428 16.0052 2.86449 16 2.9999V6.52334L15 5.52334V2.9999H7.25C7.08854 2.9999 6.94792 3.02334 6.82812 3.07022C6.70833 3.11709 6.59375 3.17959 6.48438 3.25772C6.375 3.33584 6.26823 3.41657 6.16406 3.4999C6.0599 3.58324 5.94271 3.66397 5.8125 3.74209C5.68229 3.82022 5.53125 3.88011 5.35938 3.92178C5.1875 3.96344 4.98438 3.98949 4.75 3.9999H1V12.9999H6V13.9999H0V1.9999C0 1.85928 0.0260417 1.72907 0.078125 1.60928C0.130208 1.48949 0.200521 1.38532 0.289062 1.29678C0.377604 1.20824 0.484375 1.13532 0.609375 1.07803C0.734375 1.02074 0.864583 0.994695 1 0.999903H4.75ZM4.75 2.9999C4.875 2.9999 4.98438 2.98949 5.07812 2.96865C5.17188 2.94782 5.25781 2.91397 5.33594 2.86709C5.41406 2.82022 5.48958 2.76813 5.5625 2.71084C5.63542 2.65355 5.71875 2.58324 5.8125 2.4999C5.72396 2.42178 5.64323 2.35407 5.57031 2.29678C5.4974 2.23949 5.41927 2.1874 5.33594 2.14053C5.2526 2.09365 5.16667 2.0598 5.07812 2.03897C4.98958 2.01813 4.88021 2.00511 4.75 1.9999H1V2.9999H4.75ZM12.7109 4.9999L16 8.28897V15.9999H7V4.9999H12.7109ZM13 7.9999H14.2891L13 6.71084V7.9999ZM15 8.9999H12V5.9999H8V14.9999H15V8.9999ZM9 12.9999H13V13.9999H9V12.9999ZM11 11.9999H9V10.9999H11V11.9999ZM11 9.9999H9V8.9999H11V9.9999ZM11 7.9999H9V6.9999H11V7.9999Z" fill="#0078D4"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0">
|
|
||||||
<rect y="-9.15527e-05" width="16" height="16" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
Generated
+16
-12
@@ -76,7 +76,7 @@
|
|||||||
"html2canvas": "1.0.0-rc.5",
|
"html2canvas": "1.0.0-rc.5",
|
||||||
"i18next": "23.11.5",
|
"i18next": "23.11.5",
|
||||||
"i18next-browser-languagedetector": "6.0.1",
|
"i18next-browser-languagedetector": "6.0.1",
|
||||||
"i18next-http-backend": "3.0.2",
|
"i18next-http-backend": "3.0.5",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"iframe-resizer-react": "1.1.0",
|
"iframe-resizer-react": "1.1.0",
|
||||||
"immer": "9.0.6",
|
"immer": "9.0.6",
|
||||||
@@ -12724,17 +12724,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cross-fetch": {
|
"node_modules/cross-fetch": {
|
||||||
"version": "4.0.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
|
||||||
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"node-fetch": "^2.6.12"
|
"node-fetch": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cross-fetch/node_modules/node-fetch": {
|
"node_modules/cross-fetch/node_modules/node-fetch": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"whatwg-url": "^5.0.0"
|
"whatwg-url": "^5.0.0"
|
||||||
},
|
},
|
||||||
@@ -16917,11 +16919,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next-http-backend": {
|
"node_modules/i18next-http-backend": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.5.tgz",
|
||||||
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
|
"integrity": "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-fetch": "4.0.0"
|
"cross-fetch": "4.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next-resources-for-ts": {
|
"node_modules/i18next-resources-for-ts": {
|
||||||
@@ -27107,9 +27110,10 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/tmp": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.14"
|
"node": ">=14.14"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@
|
|||||||
"html2canvas": "1.0.0-rc.5",
|
"html2canvas": "1.0.0-rc.5",
|
||||||
"i18next": "23.11.5",
|
"i18next": "23.11.5",
|
||||||
"i18next-browser-languagedetector": "6.0.1",
|
"i18next-browser-languagedetector": "6.0.1",
|
||||||
"i18next-http-backend": "3.0.2",
|
"i18next-http-backend": "3.0.5",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"iframe-resizer-react": "1.1.0",
|
"iframe-resizer-react": "1.1.0",
|
||||||
"immer": "9.0.6",
|
"immer": "9.0.6",
|
||||||
|
|||||||
Generated
+5
-4
@@ -10,7 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"body-parser": "^2.2.2",
|
"body-parser": "^2.2.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.16.0",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"node": "^20.19.5",
|
"node": "^20.19.5",
|
||||||
"node-fetch": "^2.6.1",
|
"node-fetch": "^2.6.1",
|
||||||
@@ -280,15 +280,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.6",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"body-parser": "^2.2.2",
|
"body-parser": "^2.2.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.16.0",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"node": "^20.19.5",
|
"node": "^20.19.5",
|
||||||
"node-fetch": "^2.6.1",
|
"node-fetch": "^2.6.1",
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
export class CodeOfConductEndpoints {
|
|
||||||
public static privacyStatement: string = "https://aka.ms/ms-privacy-policy";
|
|
||||||
public static codeOfConduct: string = "https://aka.ms/cosmos-code-of-conduct";
|
|
||||||
public static termsOfUse: string = "https://aka.ms/ms-terms-of-use";
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EndpointsRegex {
|
export class EndpointsRegex {
|
||||||
public static readonly cassandra = [
|
public static readonly cassandra = [
|
||||||
"AccountEndpoint=(.*).cassandra.cosmosdb.azure.com",
|
"AccountEndpoint=(.*).cassandra.cosmosdb.azure.com",
|
||||||
@@ -118,7 +112,6 @@ export class Flights {
|
|||||||
public static readonly PhoenixNotebooks = "phoenixnotebooks";
|
public static readonly PhoenixNotebooks = "phoenixnotebooks";
|
||||||
public static readonly PhoenixFeatures = "phoenixfeatures";
|
public static readonly PhoenixFeatures = "phoenixfeatures";
|
||||||
public static readonly NotebooksDownBanner = "notebooksdownbanner";
|
public static readonly NotebooksDownBanner = "notebooksdownbanner";
|
||||||
public static readonly PublicGallery = "publicgallery";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AfecFeatures {
|
export class AfecFeatures {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { stringifyError } from "Common/stringifyError";
|
||||||
import { MessageTypes } from "../Contracts/ExplorerContracts";
|
import { MessageTypes } from "../Contracts/ExplorerContracts";
|
||||||
import { SubscriptionType } from "../Contracts/SubscriptionType";
|
import { SubscriptionType } from "../Contracts/SubscriptionType";
|
||||||
import { isExpectedError } from "../Metrics/ErrorClassification";
|
import { isExpectedError } from "../Metrics/ErrorClassification";
|
||||||
@@ -20,6 +21,7 @@ export const handleError = (
|
|||||||
consoleErrorPrefix?: string,
|
consoleErrorPrefix?: string,
|
||||||
options?: HandleErrorOptions,
|
options?: HandleErrorOptions,
|
||||||
): void => {
|
): void => {
|
||||||
|
console.log("{{cdbp}} in handleError(): raw error: " + stringifyError(error)); //CTODO in case a stray error happens
|
||||||
const errorMessage = getErrorMessage(error);
|
const errorMessage = getErrorMessage(error);
|
||||||
const errorCode = error instanceof ARMError ? error.code : undefined;
|
const errorCode = error instanceof ARMError ? error.code : undefined;
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ export const handleError = (
|
|||||||
export const getErrorMessage = (error: string | Error = ""): string => {
|
export const getErrorMessage = (error: string | Error = ""): string => {
|
||||||
let errorMessage = typeof error === "string" ? error : error.message;
|
let errorMessage = typeof error === "string" ? error : error.message;
|
||||||
if (!errorMessage) {
|
if (!errorMessage) {
|
||||||
errorMessage = JSON.stringify(error);
|
errorMessage = stringifyError(error);
|
||||||
}
|
}
|
||||||
return replaceKnownError(errorMessage);
|
return replaceKnownError(errorMessage);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,22 +19,38 @@ export interface MinimalQueryIterator {
|
|||||||
// Pick<QueryIterator<any>, "fetchNext">;
|
// Pick<QueryIterator<any>, "fetchNext">;
|
||||||
|
|
||||||
export function nextPage(documentsIterator: MinimalQueryIterator, firstItemIndex: number): Promise<QueryResults> {
|
export function nextPage(documentsIterator: MinimalQueryIterator, firstItemIndex: number): Promise<QueryResults> {
|
||||||
TelemetryProcessor.traceStart(Action.ExecuteQuery);
|
const startKey = TelemetryProcessor.traceStart(Action.ExecuteQuery);
|
||||||
return documentsIterator.fetchNext().then((response) => {
|
return documentsIterator
|
||||||
TelemetryProcessor.traceSuccess(Action.ExecuteQuery, { dataExplorerArea: Constants.Areas.Tab });
|
.fetchNext()
|
||||||
const documents = response.resources;
|
.then((response) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const durationMs = Date.now() - startKey;
|
||||||
const headers = (response as any).headers || {}; // TODO this is a private key. Remove any
|
TelemetryProcessor.traceSuccess(
|
||||||
const itemCount = (documents && documents.length) || 0;
|
Action.ExecuteQuery,
|
||||||
return {
|
{ dataExplorerArea: Constants.Areas.Tab, durationMs },
|
||||||
documents,
|
startKey,
|
||||||
hasMoreResults: response.hasMoreResults,
|
);
|
||||||
itemCount,
|
const documents = response.resources;
|
||||||
firstItemIndex: Number(firstItemIndex) + 1,
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
lastItemIndex: Number(firstItemIndex) + Number(itemCount),
|
const headers = (response as any).headers || {}; // TODO this is a private key. Remove any
|
||||||
headers,
|
const itemCount = (documents && documents.length) || 0;
|
||||||
activityId: response.activityId,
|
return {
|
||||||
requestCharge: response.requestCharge,
|
documents,
|
||||||
};
|
hasMoreResults: response.hasMoreResults,
|
||||||
});
|
itemCount,
|
||||||
|
firstItemIndex: Number(firstItemIndex) + 1,
|
||||||
|
lastItemIndex: Number(firstItemIndex) + Number(itemCount),
|
||||||
|
headers,
|
||||||
|
activityId: response.activityId,
|
||||||
|
requestCharge: response.requestCharge,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const durationMs = Date.now() - startKey;
|
||||||
|
TelemetryProcessor.traceFailure(
|
||||||
|
Action.ExecuteQuery,
|
||||||
|
{ dataExplorerArea: Constants.Areas.Tab, durationMs, error: error.message },
|
||||||
|
startKey,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { SeverityLevel } from "@microsoft/applicationinsights-web";
|
||||||
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
||||||
import Q from "q";
|
import Q from "q";
|
||||||
import * as _ from "underscore";
|
import * as _ from "underscore";
|
||||||
import * as Logger from "../Common/Logger";
|
|
||||||
import { MessageTypes } from "../Contracts/ExplorerContracts";
|
import { MessageTypes } from "../Contracts/ExplorerContracts";
|
||||||
|
import { trackTrace } from "../Shared/appInsights";
|
||||||
import { getDataExplorerWindow } from "../Utils/WindowUtils";
|
import { getDataExplorerWindow } from "../Utils/WindowUtils";
|
||||||
import * as Constants from "./Constants";
|
import * as Constants from "./Constants";
|
||||||
|
|
||||||
@@ -97,18 +98,66 @@ const _sendMessage = (message: any): void => {
|
|||||||
const portalChildWindow = getDataExplorerWindow(window) || window;
|
const portalChildWindow = getDataExplorerWindow(window) || window;
|
||||||
if (portalChildWindow === window) {
|
if (portalChildWindow === window) {
|
||||||
// Current window is a child of portal, send message to portal window
|
// Current window is a child of portal, send message to portal window
|
||||||
if (portalChildWindow.document.referrer) {
|
const portalTargetOrigin = _getPortalTargetOrigin(portalChildWindow);
|
||||||
portalChildWindow.parent.postMessage(message, portalChildWindow.document.referrer);
|
if (portalTargetOrigin) {
|
||||||
|
portalChildWindow.parent.postMessage(message, portalTargetOrigin);
|
||||||
} else {
|
} else {
|
||||||
Logger.logError("Iframe failed to send message to portal", "MessageHandler");
|
_reportPostMessageFailure("Iframe failed to send message to portal: no target origin");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Current window is not a child of portal, send message to the child window instead (which is data explorer)
|
// Current window is not a child of portal, send message to the child window instead (which is data explorer)
|
||||||
if (portalChildWindow.location.origin) {
|
if (portalChildWindow.location.origin) {
|
||||||
portalChildWindow.postMessage(message, portalChildWindow.location.origin);
|
portalChildWindow.postMessage(message, portalChildWindow.location.origin);
|
||||||
} else {
|
} else {
|
||||||
Logger.logError("Iframe failed to send message to data explorer", "MessageHandler");
|
_reportPostMessageFailure("Iframe failed to send message to data explorer: no origin");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports a postMessage failure to telemetry without routing through Logger.logError.
|
||||||
|
*
|
||||||
|
* IMPORTANT: Logger.logError calls sendMessage, which re-enters _sendMessage. If the
|
||||||
|
* failure here is itself "no target origin", that would cause infinite recursion
|
||||||
|
* ("too much recursion"). We call trackTrace directly so telemetry is preserved
|
||||||
|
* without triggering the recursive postMessage path.
|
||||||
|
*/
|
||||||
|
const _reportPostMessageFailure = (errorMessage: string): void => {
|
||||||
|
try {
|
||||||
|
trackTrace({ message: errorMessage, severityLevel: SeverityLevel.Error }, { area: "MessageHandler" });
|
||||||
|
} catch {
|
||||||
|
// Telemetry should never throw, but guard defensively so we never recurse.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the portal's origin to use as postMessage targetOrigin.
|
||||||
|
*
|
||||||
|
* Primary source: document.referrer (the origin that loaded this iframe).
|
||||||
|
*
|
||||||
|
* Fallback: the `trustedAuthority` query string parameter that the Azure Portal
|
||||||
|
* embeds in the iframe URL. This is needed because some browser privacy modes
|
||||||
|
* (notably Firefox 's dynamic state partitioning / ETP Strict) can empty
|
||||||
|
* document.referrer for cross-origin third-party iframes. Without this fallback,
|
||||||
|
* postMessage would be called with an empty targetOrigin (which throws) and the
|
||||||
|
* error logger itself routes through sendMessage, causing infinite recursion.
|
||||||
|
*
|
||||||
|
* Returns an empty string when no trustworthy origin can be derived, in which
|
||||||
|
* case the caller must NOT call postMessage.
|
||||||
|
*/
|
||||||
|
const _getPortalTargetOrigin = (childWindow: Window): string => {
|
||||||
|
const referrer = childWindow.document.referrer;
|
||||||
|
if (referrer) {
|
||||||
|
return referrer;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const trustedAuthority = new URLSearchParams(childWindow.location.search).get("trustedAuthority");
|
||||||
|
if (trustedAuthority) {
|
||||||
|
return new URL(trustedAuthority).origin;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed URL — fall through and return empty string.
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { configContext } from "ConfigContext";
|
import { configContext } from "ConfigContext";
|
||||||
|
import { Keys, t } from "Localization";
|
||||||
import { ApiType, userContext } from "UserContext";
|
import { ApiType, userContext } from "UserContext";
|
||||||
import * as NotificationConsoleUtils from "Utils/NotificationConsoleUtils";
|
import * as NotificationConsoleUtils from "Utils/NotificationConsoleUtils";
|
||||||
import {
|
import {
|
||||||
cancel,
|
cancel,
|
||||||
|
complete,
|
||||||
create,
|
create,
|
||||||
get,
|
get,
|
||||||
listByDatabaseAccount,
|
listByDatabaseAccount,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
} from "Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
|
} from "Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
|
||||||
import {
|
import {
|
||||||
CosmosCassandraDataTransferDataSourceSink,
|
CosmosCassandraDataTransferDataSourceSink,
|
||||||
@@ -31,6 +35,7 @@ export interface DataTransferParams {
|
|||||||
sourceCollectionName: string;
|
sourceCollectionName: string;
|
||||||
targetDatabaseName: string;
|
targetDatabaseName: string;
|
||||||
targetCollectionName: string;
|
targetCollectionName: string;
|
||||||
|
mode?: "Offline" | "Online";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDataTransferJobs = async (
|
export const getDataTransferJobs = async (
|
||||||
@@ -80,6 +85,7 @@ export const initiateDataTransfer = async (params: DataTransferParams): Promise<
|
|||||||
sourceCollectionName,
|
sourceCollectionName,
|
||||||
targetDatabaseName,
|
targetDatabaseName,
|
||||||
targetCollectionName,
|
targetCollectionName,
|
||||||
|
mode,
|
||||||
} = params;
|
} = params;
|
||||||
const sourcePayload = createPayload(apiType, sourceDatabaseName, sourceCollectionName);
|
const sourcePayload = createPayload(apiType, sourceDatabaseName, sourceCollectionName);
|
||||||
const targetPayload = createPayload(apiType, targetDatabaseName, targetCollectionName);
|
const targetPayload = createPayload(apiType, targetDatabaseName, targetCollectionName);
|
||||||
@@ -87,6 +93,7 @@ export const initiateDataTransfer = async (params: DataTransferParams): Promise<
|
|||||||
properties: {
|
properties: {
|
||||||
source: sourcePayload,
|
source: sourcePayload,
|
||||||
destination: targetPayload,
|
destination: targetPayload,
|
||||||
|
...(mode ? { mode } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return create(subscriptionId, resourceGroupName, accountName, jobName, body);
|
return create(subscriptionId, resourceGroupName, accountName, jobName, body);
|
||||||
@@ -137,30 +144,52 @@ const pollDataTransferJobOperation = async (
|
|||||||
if (status === "Cancelled") {
|
if (status === "Cancelled") {
|
||||||
removeFromPolling(jobName);
|
removeFromPolling(jobName);
|
||||||
clearMessage && clearMessage();
|
clearMessage && clearMessage();
|
||||||
const cancelMessage = `Data transfer job ${jobName} cancelled`;
|
const cancelMessage = t(Keys.containerCopy.dataTransfers.polling.cancelConsoleMessage, { jobName: jobName });
|
||||||
NotificationConsoleUtils.logConsoleError(cancelMessage);
|
NotificationConsoleUtils.logConsoleError(cancelMessage);
|
||||||
throw new AbortError(cancelMessage);
|
throw new AbortError(cancelMessage);
|
||||||
}
|
}
|
||||||
|
if (status === "Paused") {
|
||||||
|
removeFromPolling(jobName);
|
||||||
|
clearMessage && clearMessage();
|
||||||
|
NotificationConsoleUtils.logConsoleInfo(
|
||||||
|
t(Keys.containerCopy.dataTransfers.polling.pauseConsoleMessage, { jobName: jobName }),
|
||||||
|
);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
if (status === "Failed" || status === "Faulted") {
|
if (status === "Failed" || status === "Faulted") {
|
||||||
removeFromPolling(jobName);
|
removeFromPolling(jobName);
|
||||||
const errorMessage = body?.properties?.error
|
const errorMessage = body?.properties?.error
|
||||||
? JSON.stringify(body?.properties?.error)
|
? JSON.stringify(body?.properties?.error)
|
||||||
: "Operation could not be completed";
|
: t(Keys.containerCopy.dataTransfers.polling.defaultErrorMessage);
|
||||||
const error = new Error(errorMessage);
|
const error = new Error(errorMessage);
|
||||||
clearMessage && clearMessage();
|
clearMessage && clearMessage();
|
||||||
NotificationConsoleUtils.logConsoleError(`Data transfer job ${jobName} failed: ${errorMessage}`);
|
NotificationConsoleUtils.logConsoleError(
|
||||||
|
t(Keys.containerCopy.dataTransfers.polling.errorConsoleMessage, {
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
jobName: jobName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
throw new AbortError(error);
|
throw new AbortError(error);
|
||||||
}
|
}
|
||||||
if (status === "Completed") {
|
if (status === "Completed") {
|
||||||
removeFromPolling(jobName);
|
removeFromPolling(jobName);
|
||||||
clearMessage && clearMessage();
|
clearMessage && clearMessage();
|
||||||
NotificationConsoleUtils.logConsoleInfo(`Data transfer job ${jobName} completed`);
|
NotificationConsoleUtils.logConsoleInfo(
|
||||||
|
t(Keys.containerCopy.dataTransfers.polling.completedConsoleMessage, {
|
||||||
|
jobName: jobName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
const processedCount = body.properties.processedCount;
|
const processedCount = body.properties.processedCount;
|
||||||
const totalCount = body.properties.totalCount;
|
const totalCount = body.properties.totalCount;
|
||||||
const retryMessage = `Data transfer job ${jobName} in progress, total count: ${totalCount}, processed count: ${processedCount}`;
|
throw new Error(
|
||||||
throw new Error(retryMessage);
|
t(Keys.containerCopy.dataTransfers.polling.retryConsoleMessage, {
|
||||||
|
jobName: jobName,
|
||||||
|
processedCount: processedCount,
|
||||||
|
totalCount: totalCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cancelDataTransferJob = async (
|
export const cancelDataTransferJob = async (
|
||||||
@@ -174,6 +203,43 @@ export const cancelDataTransferJob = async (
|
|||||||
removeFromPolling(cancelResult?.properties?.jobName);
|
removeFromPolling(cancelResult?.properties?.jobName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const pauseDataTransferJob = async (
|
||||||
|
subscriptionId: string,
|
||||||
|
resourceGroupName: string,
|
||||||
|
accountName: string,
|
||||||
|
jobName: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const pauseResult: DataTransferJobGetResults = await pause(subscriptionId, resourceGroupName, accountName, jobName);
|
||||||
|
updateDataTransferJob(pauseResult);
|
||||||
|
removeFromPolling(pauseResult?.properties?.jobName);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resumeDataTransferJob = async (
|
||||||
|
subscriptionId: string,
|
||||||
|
resourceGroupName: string,
|
||||||
|
accountName: string,
|
||||||
|
jobName: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const resumeResult: DataTransferJobGetResults = await resume(subscriptionId, resourceGroupName, accountName, jobName);
|
||||||
|
updateDataTransferJob(resumeResult);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const completeDataTransferJob = async (
|
||||||
|
subscriptionId: string,
|
||||||
|
resourceGroupName: string,
|
||||||
|
accountName: string,
|
||||||
|
jobName: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const completeResult: DataTransferJobGetResults = await complete(
|
||||||
|
subscriptionId,
|
||||||
|
resourceGroupName,
|
||||||
|
accountName,
|
||||||
|
jobName,
|
||||||
|
);
|
||||||
|
updateDataTransferJob(completeResult);
|
||||||
|
removeFromPolling(completeResult?.properties?.jobName);
|
||||||
|
};
|
||||||
|
|
||||||
const createPayload = (
|
const createPayload = (
|
||||||
apiType: ApiType,
|
apiType: ApiType,
|
||||||
databaseName: string,
|
databaseName: string,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { stringifyError } from "Common/stringifyError";
|
||||||
import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
|
import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
|
||||||
import { isFabric, isFabricMirroredKey, isFabricNative } from "Platform/Fabric/FabricUtil";
|
import { isFabric, isFabricMirroredKey, isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||||
import { AuthType } from "../../AuthType";
|
import { AuthType } from "../../AuthType";
|
||||||
@@ -26,6 +27,7 @@ export async function readDatabases(): Promise<DataModels.Database[]> {
|
|||||||
(userContext.fabricContext?.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY]).resourceTokenInfo
|
(userContext.fabricContext?.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY]).resourceTokenInfo
|
||||||
.resourceTokens
|
.resourceTokens
|
||||||
) {
|
) {
|
||||||
|
console.log("{{cdbp}} in readDatabases(): isFabricMirroredKey && has resourceTokens"); //CTODO should not get here
|
||||||
const tokensData = (userContext.fabricContext.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY])
|
const tokensData = (userContext.fabricContext.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY])
|
||||||
.resourceTokenInfo;
|
.resourceTokenInfo;
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ export async function readDatabases(): Promise<DataModels.Database[]> {
|
|||||||
clearMessage();
|
clearMessage();
|
||||||
return databases;
|
return databases;
|
||||||
} else if (isFabricNative() && userContext.fabricContext?.databaseName) {
|
} else if (isFabricNative() && userContext.fabricContext?.databaseName) {
|
||||||
|
console.log("{{cdbp}} in readDatabases(): isFabricNative"); //CTODO should not get here
|
||||||
const databaseId = userContext.fabricContext.databaseName;
|
const databaseId = userContext.fabricContext.databaseName;
|
||||||
databases = [
|
databases = [
|
||||||
{
|
{
|
||||||
@@ -81,9 +84,15 @@ export async function readDatabases(): Promise<DataModels.Database[]> {
|
|||||||
userContext.apiType !== "Tables" &&
|
userContext.apiType !== "Tables" &&
|
||||||
!isFabric()
|
!isFabric()
|
||||||
) {
|
) {
|
||||||
|
console.log("{{cdbp}} in readDatabases(): authType == AAD, enableSDKOperations, apiType != Tables, !isFabric");
|
||||||
|
console.log("{{cdbp}} in readDatabases(): databaseaccount: " + userContext.databaseAccount);
|
||||||
|
console.log("{{cdbp}} in readDatabases(): calling readDatabasesWithARM");
|
||||||
databases = await readDatabasesWithARM();
|
databases = await readDatabasesWithARM();
|
||||||
|
console.log("{{cdbp}} in readDatabases(): done readDatabasesWithARM");
|
||||||
} else {
|
} else {
|
||||||
|
console.log("{{cdbp}} in readDatabases(): calling SDK");
|
||||||
const sdkResponse = await client().databases.readAll().fetchAll();
|
const sdkResponse = await client().databases.readAll().fetchAll();
|
||||||
|
console.log("{{cdbp}} in readDatabases(): done SDK");
|
||||||
databases = sdkResponse.resources as DataModels.Database[];
|
databases = sdkResponse.resources as DataModels.Database[];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -108,22 +117,30 @@ export async function readDatabasesWithARM(accountOverride?: {
|
|||||||
const accountName = accountOverride?.accountName ?? userContext?.databaseAccount?.name ?? "";
|
const accountName = accountOverride?.accountName ?? userContext?.databaseAccount?.name ?? "";
|
||||||
const apiType = accountOverride?.apiType ?? userContext.apiType;
|
const apiType = accountOverride?.apiType ?? userContext.apiType;
|
||||||
|
|
||||||
switch (apiType) {
|
try {
|
||||||
case "SQL":
|
switch (apiType) {
|
||||||
rpResponse = await listSqlDatabases(subscriptionId, resourceGroup, accountName);
|
case "SQL":
|
||||||
break;
|
console.log("{{cdbp}} in readDatabasesWithARM(): calling listSqlDatabases");
|
||||||
case "Mongo":
|
rpResponse = await listSqlDatabases(subscriptionId, resourceGroup, accountName);
|
||||||
rpResponse = await listMongoDBDatabases(subscriptionId, resourceGroup, accountName);
|
console.log("{{cdbp}} in readDatabasesWithARM(): done listSqlDatabases");
|
||||||
break;
|
break;
|
||||||
case "Cassandra":
|
case "Mongo":
|
||||||
rpResponse = await listCassandraKeyspaces(subscriptionId, resourceGroup, accountName);
|
rpResponse = await listMongoDBDatabases(subscriptionId, resourceGroup, accountName);
|
||||||
break;
|
break;
|
||||||
case "Gremlin":
|
case "Cassandra":
|
||||||
rpResponse = await listGremlinDatabases(subscriptionId, resourceGroup, accountName);
|
rpResponse = await listCassandraKeyspaces(subscriptionId, resourceGroup, accountName);
|
||||||
break;
|
break;
|
||||||
default:
|
case "Gremlin":
|
||||||
throw new Error(`Unsupported default experience type: ${apiType}`);
|
rpResponse = await listGremlinDatabases(subscriptionId, resourceGroup, accountName);
|
||||||
}
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported default experience type: ${apiType}`);
|
||||||
|
}
|
||||||
|
|
||||||
return rpResponse?.value?.map((database) => database.properties?.resource as DataModels.Database) ?? [];
|
console.log("{{cdbp}} in readDatabasesWithARM(): response: " + JSON.stringify(rpResponse));
|
||||||
|
return rpResponse?.value?.map((database) => database.properties?.resource as DataModels.Database) ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
console.log("{{cdbp}} in readDatabasesWithARM(): ERROR: " + stringifyError(error));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const stringifyError = (error: unknown): string => {
|
||||||
|
const plainObject: Record<string, unknown> = {};
|
||||||
|
Object.getOwnPropertyNames(error as object).forEach((key) => {
|
||||||
|
plainObject[key] = (error as Record<string, unknown>)[key];
|
||||||
|
});
|
||||||
|
return JSON.stringify(plainObject, null, "\r\n");
|
||||||
|
};
|
||||||
@@ -393,7 +393,7 @@ export enum CollectionTabKind {
|
|||||||
Terminal = 14,
|
Terminal = 14,
|
||||||
NotebookV2 = 15,
|
NotebookV2 = 15,
|
||||||
SparkMasterTab = 16 /* Deprecated */,
|
SparkMasterTab = 16 /* Deprecated */,
|
||||||
Gallery = 17,
|
Gallery = 17 /* Deprecated */,
|
||||||
NotebookViewer = 18,
|
NotebookViewer = 18,
|
||||||
Schema = 19,
|
Schema = 19,
|
||||||
CollectionSettingsV2 = 20,
|
CollectionSettingsV2 = 20,
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export const getCopyJobs = async (): Promise<CopyJobType[]> => {
|
|||||||
});
|
});
|
||||||
return formattedJobs;
|
return formattedJobs;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorContent = JSON.stringify(error.content || error.message || error);
|
const errorContent = String(error.content || error.message || error);
|
||||||
if (errorContent.includes("signal is aborted without reason")) {
|
if (errorContent.includes("signal is aborted without reason")) {
|
||||||
throw {
|
throw {
|
||||||
message: "Previous copy job request was cancelled.",
|
message: "Previous copy job request was cancelled.",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import RefreshIcon from "../../../../images/refresh-cosmos.svg";
|
|||||||
import SunIcon from "../../../../images/SunIcon.svg";
|
import SunIcon from "../../../../images/SunIcon.svg";
|
||||||
import { configContext, Platform } from "../../../ConfigContext";
|
import { configContext, Platform } from "../../../ConfigContext";
|
||||||
import { useThemeStore } from "../../../hooks/useTheme";
|
import { useThemeStore } from "../../../hooks/useTheme";
|
||||||
import { Keys, t } from "../../../Localization";
|
import { Keys, t } from "Localization";
|
||||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||||
import Explorer from "../../Explorer";
|
import Explorer from "../../Explorer";
|
||||||
import * as Actions from "../Actions/CopyJobActions";
|
import * as Actions from "../Actions/CopyJobActions";
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import React from "react";
|
|
||||||
import { Keys, t } from "Localization";
|
import { Keys, t } from "Localization";
|
||||||
|
import React from "react";
|
||||||
import { CopyJobContext } from "../../../Context/CopyJobContext";
|
import { CopyJobContext } from "../../../Context/CopyJobContext";
|
||||||
import { CopyJobContextProviderType } from "../../../Types/CopyJobTypes";
|
import { CopyJobContextProviderType } from "../../../Types/CopyJobTypes";
|
||||||
import AddReadWritePermissionToDefaultIdentity from "./AddReadWritePermissionToDefaultIdentity";
|
import AddReadWritePermissionToDefaultIdentity from "./AddReadWritePermissionToDefaultIdentity";
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { render, RenderResult } from "@testing-library/react";
|
import { render, RenderResult } from "@testing-library/react";
|
||||||
import React from "react";
|
|
||||||
import { Keys, t } from "Localization";
|
import { Keys, t } from "Localization";
|
||||||
|
import React from "react";
|
||||||
import { CopyJobContext } from "../../../Context/CopyJobContext";
|
import { CopyJobContext } from "../../../Context/CopyJobContext";
|
||||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||||
import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes";
|
import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes";
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import React from "react";
|
|
||||||
import { Keys, t } from "Localization";
|
import { Keys, t } from "Localization";
|
||||||
|
import React from "react";
|
||||||
import PopoverMessage from "./PopoverContainer";
|
import PopoverMessage from "./PopoverContainer";
|
||||||
|
|
||||||
jest.mock("../../../../../Common/LoadingOverlay", () => {
|
jest.mock("../../../../../Common/LoadingOverlay", () => {
|
||||||
|
|||||||
+1
-1
@@ -1,11 +1,11 @@
|
|||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { Keys, t } from "Localization";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { configContext, Platform } from "../../../../../../ConfigContext";
|
import { configContext, Platform } from "../../../../../../ConfigContext";
|
||||||
import { DatabaseAccount } from "../../../../../../Contracts/DataModels";
|
import { DatabaseAccount } from "../../../../../../Contracts/DataModels";
|
||||||
import * as useDatabaseAccountsHook from "../../../../../../hooks/useDatabaseAccounts";
|
import * as useDatabaseAccountsHook from "../../../../../../hooks/useDatabaseAccounts";
|
||||||
import { apiType, userContext } from "../../../../../../UserContext";
|
import { apiType, userContext } from "../../../../../../UserContext";
|
||||||
import { Keys, t } from "Localization";
|
|
||||||
import { CopyJobContext } from "../../../../Context/CopyJobContext";
|
import { CopyJobContext } from "../../../../Context/CopyJobContext";
|
||||||
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
||||||
import { CopyJobContextProviderType, CopyJobContextState } from "../../../../Types/CopyJobTypes";
|
import { CopyJobContextProviderType, CopyJobContextState } from "../../../../Types/CopyJobTypes";
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import React from "react";
|
|
||||||
import { Keys, t } from "Localization";
|
import { Keys, t } from "Localization";
|
||||||
|
import React from "react";
|
||||||
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
|
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
|
||||||
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
||||||
import { MigrationType } from "./MigrationType";
|
import { MigrationType } from "./MigrationType";
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import React from "react";
|
|
||||||
import { Keys, t } from "Localization";
|
import { Keys, t } from "Localization";
|
||||||
|
import React from "react";
|
||||||
import { DatabaseContainerSectionProps, DropdownOptionType } from "../../../../Types/CopyJobTypes";
|
import { DatabaseContainerSectionProps, DropdownOptionType } from "../../../../Types/CopyJobTypes";
|
||||||
import { DatabaseContainerSection } from "./DatabaseContainerSection";
|
import { DatabaseContainerSection } from "./DatabaseContainerSection";
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import { CommandButton, FontIcon, FontWeights, ITextProps, Separator, Stack, Text } from "@fluentui/react";
|
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
export class GalleryHeaderComponent extends React.Component {
|
|
||||||
private static readonly azureText = "Microsoft Azure";
|
|
||||||
private static readonly cosmosdbText = "Cosmos DB";
|
|
||||||
private static readonly galleryText = "Gallery";
|
|
||||||
private static readonly loginText = "Sign In";
|
|
||||||
private static readonly openPortal = () => window.open("https://portal.azure.com", "_blank");
|
|
||||||
private static readonly openDataExplorer = () => (window.location.href = new URL("./", window.location.href).href);
|
|
||||||
private static readonly headerItemStyle: React.CSSProperties = {
|
|
||||||
color: "white",
|
|
||||||
};
|
|
||||||
private static readonly mainHeaderTextProps: ITextProps = {
|
|
||||||
style: GalleryHeaderComponent.headerItemStyle,
|
|
||||||
variant: "mediumPlus",
|
|
||||||
styles: {
|
|
||||||
root: {
|
|
||||||
fontWeight: FontWeights.semibold,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
private static readonly headerItemTextProps: ITextProps = { style: GalleryHeaderComponent.headerItemStyle };
|
|
||||||
|
|
||||||
private renderHeaderItem = (text: string, onClick: () => void, textProps: ITextProps): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<CommandButton onClick={onClick} ariaLabel={text}>
|
|
||||||
<Text {...textProps}>{text}</Text>
|
|
||||||
</CommandButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
public render(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
tokens={{ childrenGap: 10 }}
|
|
||||||
horizontal
|
|
||||||
styles={{ root: { background: "#0078d4", paddingLeft: 20, paddingRight: 20 } }}
|
|
||||||
verticalAlign="center"
|
|
||||||
>
|
|
||||||
<Stack.Item>
|
|
||||||
{this.renderHeaderItem(
|
|
||||||
GalleryHeaderComponent.azureText,
|
|
||||||
GalleryHeaderComponent.openPortal,
|
|
||||||
GalleryHeaderComponent.mainHeaderTextProps,
|
|
||||||
)}
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item>
|
|
||||||
<Separator vertical />
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item>
|
|
||||||
{this.renderHeaderItem(
|
|
||||||
GalleryHeaderComponent.cosmosdbText,
|
|
||||||
GalleryHeaderComponent.openDataExplorer,
|
|
||||||
GalleryHeaderComponent.headerItemTextProps,
|
|
||||||
)}
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item>
|
|
||||||
<FontIcon style={GalleryHeaderComponent.headerItemStyle} iconName="ChevronRight" />
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item>
|
|
||||||
{this.renderHeaderItem(
|
|
||||||
GalleryHeaderComponent.galleryText,
|
|
||||||
() => "",
|
|
||||||
GalleryHeaderComponent.headerItemTextProps,
|
|
||||||
)}
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item grow>
|
|
||||||
<></>
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item>
|
|
||||||
{this.renderHeaderItem(
|
|
||||||
GalleryHeaderComponent.loginText,
|
|
||||||
GalleryHeaderComponent.openDataExplorer,
|
|
||||||
GalleryHeaderComponent.headerItemTextProps,
|
|
||||||
)}
|
|
||||||
</Stack.Item>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { shallow } from "enzyme";
|
|
||||||
import React from "react";
|
|
||||||
import { GalleryCardComponent, GalleryCardComponentProps } from "./GalleryCardComponent";
|
|
||||||
|
|
||||||
describe("GalleryCardComponent", () => {
|
|
||||||
it("renders", () => {
|
|
||||||
const props: GalleryCardComponentProps = {
|
|
||||||
data: {
|
|
||||||
id: "id",
|
|
||||||
name: "name",
|
|
||||||
description: "description",
|
|
||||||
author: "author",
|
|
||||||
thumbnailUrl: "thumbnailUrl",
|
|
||||||
created: "created",
|
|
||||||
gitSha: "gitSha",
|
|
||||||
tags: ["tag"],
|
|
||||||
isSample: false,
|
|
||||||
downloads: 0,
|
|
||||||
favorites: 0,
|
|
||||||
views: 0,
|
|
||||||
newCellId: undefined,
|
|
||||||
policyViolations: undefined,
|
|
||||||
pendingScanJobIds: undefined,
|
|
||||||
},
|
|
||||||
isFavorite: false,
|
|
||||||
showDownload: true,
|
|
||||||
showDelete: true,
|
|
||||||
onClick: undefined,
|
|
||||||
onTagClick: undefined,
|
|
||||||
onFavoriteClick: undefined,
|
|
||||||
onUnfavoriteClick: undefined,
|
|
||||||
onDownloadClick: undefined,
|
|
||||||
onDeleteClick: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapper = shallow(<GalleryCardComponent {...props} />);
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import {
|
|
||||||
BaseButton,
|
|
||||||
Button,
|
|
||||||
DocumentCard,
|
|
||||||
DocumentCardActivity,
|
|
||||||
DocumentCardDetails,
|
|
||||||
DocumentCardPreview,
|
|
||||||
DocumentCardTitle,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
IDocumentCardPreviewProps,
|
|
||||||
IDocumentCardStyles,
|
|
||||||
ImageFit,
|
|
||||||
Link,
|
|
||||||
Separator,
|
|
||||||
Spinner,
|
|
||||||
SpinnerSize,
|
|
||||||
Text,
|
|
||||||
TooltipHost,
|
|
||||||
} from "@fluentui/react";
|
|
||||||
import React, { FunctionComponent, useState } from "react";
|
|
||||||
import CosmosDBLogo from "../../../../../images/CosmosDB-logo.svg";
|
|
||||||
import { IGalleryItem } from "../../../../Juno/JunoClient";
|
|
||||||
import * as FileSystemUtil from "../../../Notebook/FileSystemUtil";
|
|
||||||
|
|
||||||
export interface GalleryCardComponentProps {
|
|
||||||
data: IGalleryItem;
|
|
||||||
isFavorite: boolean;
|
|
||||||
showDownload: boolean;
|
|
||||||
showDelete: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
onTagClick: (tag: string) => void;
|
|
||||||
onFavoriteClick: () => void;
|
|
||||||
onUnfavoriteClick: () => void;
|
|
||||||
onDownloadClick: () => void;
|
|
||||||
onDeleteClick: (beforeDelete: () => void, afterDelete: () => void) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GalleryCardComponent: FunctionComponent<GalleryCardComponentProps> = ({
|
|
||||||
data,
|
|
||||||
isFavorite,
|
|
||||||
showDownload,
|
|
||||||
showDelete,
|
|
||||||
onClick,
|
|
||||||
onTagClick,
|
|
||||||
onFavoriteClick,
|
|
||||||
onUnfavoriteClick,
|
|
||||||
onDownloadClick,
|
|
||||||
onDeleteClick,
|
|
||||||
}: GalleryCardComponentProps) => {
|
|
||||||
const CARD_WIDTH = 256;
|
|
||||||
const cardImageHeight = 144;
|
|
||||||
const cardDescriptionMaxChars = 80;
|
|
||||||
const cardItemGapSmall = 8;
|
|
||||||
const cardDeleteSpinnerHeight = 360;
|
|
||||||
const smallTextLineHeight = 18;
|
|
||||||
|
|
||||||
const [isDeletingPublishedNotebook, setIsDeletingPublishedNotebook] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const cardButtonsVisible = isFavorite !== undefined || showDownload || showDelete;
|
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
};
|
|
||||||
const dateString = new Date(data.created).toLocaleString("default", options);
|
|
||||||
const cardTitle = FileSystemUtil.stripExtension(data.name, "ipynb");
|
|
||||||
|
|
||||||
const renderTruncated = (text: string, totalLength: number): string => {
|
|
||||||
let truncatedDescription = text.substr(0, totalLength);
|
|
||||||
if (text.length > totalLength) {
|
|
||||||
truncatedDescription = `${truncatedDescription} ...`;
|
|
||||||
}
|
|
||||||
return truncatedDescription;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateIconText = (iconName: string, text: string): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<Text variant="tiny" styles={{ root: { color: "#605E5C", paddingRight: cardItemGapSmall } }}>
|
|
||||||
<Icon iconName={iconName} styles={{ root: { verticalAlign: "middle" } }} /> {text}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Fluent UI doesn't support tooltips on IconButtons out of the box. In the meantime the recommendation is
|
|
||||||
* to do the following (from https://developer.microsoft.com/en-us/fluentui#/controls/web/button)
|
|
||||||
*/
|
|
||||||
const generateIconButtonWithTooltip = (
|
|
||||||
iconName: string,
|
|
||||||
title: string,
|
|
||||||
horizontalAlign: "right" | "left",
|
|
||||||
activate: () => void,
|
|
||||||
): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<TooltipHost
|
|
||||||
content={title}
|
|
||||||
id={`TooltipHost-IconButton-${iconName}`}
|
|
||||||
calloutProps={{ gapSpace: 0 }}
|
|
||||||
styles={{ root: { display: "inline-block", float: horizontalAlign } }}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
iconProps={{ iconName }}
|
|
||||||
title={title}
|
|
||||||
ariaLabel={title}
|
|
||||||
onClick={(event) => handlerOnClick(event, activate)}
|
|
||||||
/>
|
|
||||||
</TooltipHost>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlerOnClick = (
|
|
||||||
event:
|
|
||||||
| React.MouseEvent<HTMLElement | HTMLAnchorElement | HTMLButtonElement | MouseEvent>
|
|
||||||
| React.MouseEvent<
|
|
||||||
HTMLAnchorElement | HTMLButtonElement | HTMLDivElement | BaseButton | Button | HTMLSpanElement,
|
|
||||||
MouseEvent
|
|
||||||
>,
|
|
||||||
activate: () => void,
|
|
||||||
): void => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
activate();
|
|
||||||
};
|
|
||||||
const DocumentCardActivityPeople = [{ name: data.author, profileImageSrc: data.isSample && CosmosDBLogo }];
|
|
||||||
const previewProps: IDocumentCardPreviewProps = {
|
|
||||||
previewImages: [
|
|
||||||
{
|
|
||||||
previewImageSrc: data.thumbnailUrl,
|
|
||||||
imageFit: ImageFit.cover,
|
|
||||||
width: CARD_WIDTH,
|
|
||||||
height: cardImageHeight,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const cardStyles: IDocumentCardStyles = {
|
|
||||||
root: { display: "inline-block", marginRight: 20, width: CARD_WIDTH },
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<DocumentCard aria-label={cardTitle} styles={cardStyles} onClick={onClick}>
|
|
||||||
{isDeletingPublishedNotebook && (
|
|
||||||
<Spinner
|
|
||||||
size={SpinnerSize.large}
|
|
||||||
label={`Deleting '${cardTitle}'`}
|
|
||||||
styles={{ root: { height: cardDeleteSpinnerHeight } }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!isDeletingPublishedNotebook && (
|
|
||||||
<>
|
|
||||||
<DocumentCardActivity activity={dateString} people={DocumentCardActivityPeople} />
|
|
||||||
<DocumentCardPreview {...previewProps} />
|
|
||||||
<DocumentCardDetails>
|
|
||||||
<Text variant="small" nowrap styles={{ root: { height: smallTextLineHeight, padding: "2px 16px" } }}>
|
|
||||||
{data.tags ? (
|
|
||||||
data.tags.map((tag, index, array) => (
|
|
||||||
<span key={tag}>
|
|
||||||
<Link onClick={(event) => handlerOnClick(event, () => onTagClick(tag))}>{tag}</Link>
|
|
||||||
{index === array.length - 1 ? <></> : ", "}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<br />
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<DocumentCardTitle title={renderTruncated(cardTitle, 20)} shouldTruncate />
|
|
||||||
<DocumentCardTitle
|
|
||||||
title={renderTruncated(data.description, cardDescriptionMaxChars)}
|
|
||||||
showAsSecondaryTitle
|
|
||||||
/>
|
|
||||||
<span style={{ padding: "8px 16px" }}>
|
|
||||||
{data.views !== undefined && generateIconText("RedEye", data.views.toString())}
|
|
||||||
{data.downloads !== undefined && generateIconText("Download", data.downloads.toString())}
|
|
||||||
{data.favorites !== undefined && generateIconText("Heart", data.favorites.toString())}
|
|
||||||
</span>
|
|
||||||
</DocumentCardDetails>
|
|
||||||
{cardButtonsVisible && (
|
|
||||||
<DocumentCardDetails>
|
|
||||||
<Separator styles={{ root: { padding: 0, height: 1 } }} />
|
|
||||||
|
|
||||||
<span style={{ padding: "0px 16px" }}>
|
|
||||||
{isFavorite !== undefined &&
|
|
||||||
generateIconButtonWithTooltip(
|
|
||||||
isFavorite ? "HeartFill" : "Heart",
|
|
||||||
isFavorite ? "Unfavorite" : "Favorite",
|
|
||||||
"left",
|
|
||||||
isFavorite ? onUnfavoriteClick : onFavoriteClick,
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showDownload && generateIconButtonWithTooltip("Download", "Download", "left", onDownloadClick)}
|
|
||||||
|
|
||||||
{showDelete &&
|
|
||||||
generateIconButtonWithTooltip("Delete", "Remove", "right", () =>
|
|
||||||
onDeleteClick(
|
|
||||||
() => setIsDeletingPublishedNotebook(true),
|
|
||||||
() => setIsDeletingPublishedNotebook(false),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</DocumentCardDetails>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DocumentCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-256
@@ -1,256 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`GalleryCardComponent renders 1`] = `
|
|
||||||
<StyledDocumentCardBase
|
|
||||||
aria-label="name"
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"display": "inline-block",
|
|
||||||
"marginRight": 20,
|
|
||||||
"width": 256,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<StyledDocumentCardActivityBase
|
|
||||||
activity="Invalid Date"
|
|
||||||
people={
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "author",
|
|
||||||
"profileImageSrc": false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StyledDocumentCardPreviewBase
|
|
||||||
previewImages={
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"height": 144,
|
|
||||||
"imageFit": 2,
|
|
||||||
"previewImageSrc": "thumbnailUrl",
|
|
||||||
"width": 256,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StyledDocumentCardDetailsBase>
|
|
||||||
<Text
|
|
||||||
nowrap={true}
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"height": 18,
|
|
||||||
"padding": "2px 16px",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
variant="small"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
key="tag"
|
|
||||||
>
|
|
||||||
<StyledLinkBase
|
|
||||||
onClick={[Function]}
|
|
||||||
>
|
|
||||||
tag
|
|
||||||
</StyledLinkBase>
|
|
||||||
</span>
|
|
||||||
</Text>
|
|
||||||
<StyledDocumentCardTitleBase
|
|
||||||
shouldTruncate={true}
|
|
||||||
title="name"
|
|
||||||
/>
|
|
||||||
<StyledDocumentCardTitleBase
|
|
||||||
showAsSecondaryTitle={true}
|
|
||||||
title="description"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"padding": "8px 16px",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"color": "#605E5C",
|
|
||||||
"paddingRight": 8,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
variant="tiny"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
iconName="RedEye"
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"verticalAlign": "middle",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
0
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"color": "#605E5C",
|
|
||||||
"paddingRight": 8,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
variant="tiny"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
iconName="Download"
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"verticalAlign": "middle",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
0
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"color": "#605E5C",
|
|
||||||
"paddingRight": 8,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
variant="tiny"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
iconName="Heart"
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"verticalAlign": "middle",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
0
|
|
||||||
</Text>
|
|
||||||
</span>
|
|
||||||
</StyledDocumentCardDetailsBase>
|
|
||||||
<StyledDocumentCardDetailsBase>
|
|
||||||
<Separator
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"height": 1,
|
|
||||||
"padding": 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"padding": "0px 16px",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<StyledTooltipHostBase
|
|
||||||
calloutProps={
|
|
||||||
{
|
|
||||||
"gapSpace": 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
content="Favorite"
|
|
||||||
id="TooltipHost-IconButton-Heart"
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"display": "inline-block",
|
|
||||||
"float": "left",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CustomizedIconButton
|
|
||||||
ariaLabel="Favorite"
|
|
||||||
iconProps={
|
|
||||||
{
|
|
||||||
"iconName": "Heart",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClick={[Function]}
|
|
||||||
title="Favorite"
|
|
||||||
/>
|
|
||||||
</StyledTooltipHostBase>
|
|
||||||
<StyledTooltipHostBase
|
|
||||||
calloutProps={
|
|
||||||
{
|
|
||||||
"gapSpace": 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
content="Download"
|
|
||||||
id="TooltipHost-IconButton-Download"
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"display": "inline-block",
|
|
||||||
"float": "left",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CustomizedIconButton
|
|
||||||
ariaLabel="Download"
|
|
||||||
iconProps={
|
|
||||||
{
|
|
||||||
"iconName": "Download",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClick={[Function]}
|
|
||||||
title="Download"
|
|
||||||
/>
|
|
||||||
</StyledTooltipHostBase>
|
|
||||||
<StyledTooltipHostBase
|
|
||||||
calloutProps={
|
|
||||||
{
|
|
||||||
"gapSpace": 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
content="Remove"
|
|
||||||
id="TooltipHost-IconButton-Delete"
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"display": "inline-block",
|
|
||||||
"float": "right",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CustomizedIconButton
|
|
||||||
ariaLabel="Remove"
|
|
||||||
iconProps={
|
|
||||||
{
|
|
||||||
"iconName": "Delete",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClick={[Function]}
|
|
||||||
title="Remove"
|
|
||||||
/>
|
|
||||||
</StyledTooltipHostBase>
|
|
||||||
</span>
|
|
||||||
</StyledDocumentCardDetailsBase>
|
|
||||||
</StyledDocumentCardBase>
|
|
||||||
`;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
jest.mock("../../../../Juno/JunoClient");
|
|
||||||
import { shallow } from "enzyme";
|
|
||||||
import React from "react";
|
|
||||||
import { HttpStatusCodes } from "../../../../Common/Constants";
|
|
||||||
import { JunoClient } from "../../../../Juno/JunoClient";
|
|
||||||
import { CodeOfConduct, CodeOfConductProps } from "./CodeOfConduct";
|
|
||||||
|
|
||||||
describe("CodeOfConduct", () => {
|
|
||||||
let codeOfConductProps: CodeOfConductProps;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const junoClient = new JunoClient();
|
|
||||||
junoClient.acceptCodeOfConduct = jest.fn().mockReturnValue({
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
data: true,
|
|
||||||
});
|
|
||||||
codeOfConductProps = {
|
|
||||||
junoClient: junoClient,
|
|
||||||
onAcceptCodeOfConduct: jest.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders", () => {
|
|
||||||
const wrapper = shallow(<CodeOfConduct {...codeOfConductProps} />);
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("onAcceptedCodeOfConductCalled", async () => {
|
|
||||||
const wrapper = shallow(<CodeOfConduct {...codeOfConductProps} />);
|
|
||||||
wrapper.find(".genericPaneSubmitBtn").first().simulate("click");
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(codeOfConductProps.onAcceptCodeOfConduct).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { Checkbox, Link, PrimaryButton, Stack, Text } from "@fluentui/react";
|
|
||||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
|
||||||
import { CodeOfConductEndpoints, HttpStatusCodes } from "../../../../Common/Constants";
|
|
||||||
import { getErrorMessage, getErrorStack, handleError } from "../../../../Common/ErrorHandlingUtils";
|
|
||||||
import { JunoClient } from "../../../../Juno/JunoClient";
|
|
||||||
import { Action } from "../../../../Shared/Telemetry/TelemetryConstants";
|
|
||||||
import { trace, traceFailure, traceStart, traceSuccess } from "../../../../Shared/Telemetry/TelemetryProcessor";
|
|
||||||
|
|
||||||
export interface CodeOfConductProps {
|
|
||||||
junoClient: JunoClient;
|
|
||||||
onAcceptCodeOfConduct: (result: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CodeOfConduct: FunctionComponent<CodeOfConductProps> = ({
|
|
||||||
junoClient,
|
|
||||||
onAcceptCodeOfConduct,
|
|
||||||
}: CodeOfConductProps) => {
|
|
||||||
const descriptionPara1 = "Azure Cosmos DB Notebook Gallery - Code of Conduct";
|
|
||||||
const descriptionPara2 = "The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB.";
|
|
||||||
const descriptionPara3 = "In order to view and publish your samples to the gallery, you must accept the ";
|
|
||||||
const link1: { label: string; url: string } = {
|
|
||||||
label: "code of conduct.",
|
|
||||||
url: CodeOfConductEndpoints.codeOfConduct,
|
|
||||||
};
|
|
||||||
|
|
||||||
const [readCodeOfConduct, setReadCodeOfConduct] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const acceptCodeOfConduct = async (): Promise<void> => {
|
|
||||||
const startKey = traceStart(Action.NotebooksGalleryAcceptCodeOfConduct);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await junoClient.acceptCodeOfConduct();
|
|
||||||
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
|
|
||||||
}
|
|
||||||
|
|
||||||
traceSuccess(Action.NotebooksGalleryAcceptCodeOfConduct, {}, startKey);
|
|
||||||
|
|
||||||
onAcceptCodeOfConduct(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
traceFailure(
|
|
||||||
Action.NotebooksGalleryAcceptCodeOfConduct,
|
|
||||||
{
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
errorStack: getErrorStack(error),
|
|
||||||
},
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
handleError(error, "CodeOfConduct/acceptCodeOfConduct", "Failed to accept code of conduct");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onChangeCheckbox = (): void => {
|
|
||||||
setReadCodeOfConduct(!readCodeOfConduct);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
trace(Action.NotebooksGalleryViewCodeOfConduct);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack tokens={{ childrenGap: 20 }}>
|
|
||||||
<Stack.Item>
|
|
||||||
<Text style={{ fontWeight: 500, fontSize: "20px" }}>{descriptionPara1}</Text>
|
|
||||||
</Stack.Item>
|
|
||||||
|
|
||||||
<Stack.Item>
|
|
||||||
<Text>{descriptionPara2}</Text>
|
|
||||||
</Stack.Item>
|
|
||||||
|
|
||||||
<Stack.Item>
|
|
||||||
<Text>
|
|
||||||
{descriptionPara3}
|
|
||||||
<Link href={link1.url} target="_blank">
|
|
||||||
{link1.label}
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
</Stack.Item>
|
|
||||||
|
|
||||||
<Stack.Item>
|
|
||||||
<Checkbox
|
|
||||||
styles={{
|
|
||||||
label: {
|
|
||||||
margin: 0,
|
|
||||||
padding: "2 0 2 0",
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
label="I have read and accept the code of conduct."
|
|
||||||
onChange={onChangeCheckbox}
|
|
||||||
/>
|
|
||||||
</Stack.Item>
|
|
||||||
|
|
||||||
<Stack.Item>
|
|
||||||
<PrimaryButton
|
|
||||||
ariaLabel="Continue"
|
|
||||||
title="Continue"
|
|
||||||
onClick={async () => await acceptCodeOfConduct()}
|
|
||||||
tabIndex={0}
|
|
||||||
className="genericPaneSubmitBtn"
|
|
||||||
text="Continue"
|
|
||||||
disabled={!readCodeOfConduct}
|
|
||||||
/>
|
|
||||||
</Stack.Item>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-68
@@ -1,68 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`CodeOfConduct renders 1`] = `
|
|
||||||
<Stack
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 20,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<StackItem>
|
|
||||||
<Text
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"fontSize": "20px",
|
|
||||||
"fontWeight": 500,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Azure Cosmos DB Notebook Gallery - Code of Conduct
|
|
||||||
</Text>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<Text>
|
|
||||||
The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB.
|
|
||||||
</Text>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<Text>
|
|
||||||
In order to view and publish your samples to the gallery, you must accept the
|
|
||||||
<StyledLinkBase
|
|
||||||
href="https://aka.ms/cosmos-code-of-conduct"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
code of conduct.
|
|
||||||
</StyledLinkBase>
|
|
||||||
</Text>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<StyledCheckboxBase
|
|
||||||
label="I have read and accept the code of conduct."
|
|
||||||
onChange={[Function]}
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"label": {
|
|
||||||
"margin": 0,
|
|
||||||
"padding": "2 0 2 0",
|
|
||||||
},
|
|
||||||
"text": {
|
|
||||||
"fontSize": 12,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<CustomizedPrimaryButton
|
|
||||||
ariaLabel="Continue"
|
|
||||||
className="genericPaneSubmitBtn"
|
|
||||||
disabled={true}
|
|
||||||
onClick={[Function]}
|
|
||||||
tabIndex={0}
|
|
||||||
text="Continue"
|
|
||||||
title="Continue"
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
</Stack>
|
|
||||||
`;
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { JunoClient, IGalleryItem } from "../../../Juno/JunoClient";
|
|
||||||
import { GalleryTab, SortBy, GalleryViewerComponentProps, GalleryViewerComponent } from "./GalleryViewerComponent";
|
|
||||||
import { NotebookViewerComponentProps, NotebookViewerComponent } from "../NotebookViewer/NotebookViewerComponent";
|
|
||||||
import * as GalleryUtils from "../../../Utils/GalleryUtils";
|
|
||||||
import Explorer from "../../Explorer";
|
|
||||||
|
|
||||||
export interface GalleryAndNotebookViewerComponentProps {
|
|
||||||
container?: Explorer;
|
|
||||||
junoClient: JunoClient;
|
|
||||||
notebookUrl?: string;
|
|
||||||
galleryItem?: IGalleryItem;
|
|
||||||
isFavorite?: boolean;
|
|
||||||
selectedTab: GalleryTab;
|
|
||||||
sortBy: SortBy;
|
|
||||||
searchText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GalleryAndNotebookViewerComponentState {
|
|
||||||
notebookUrl: string;
|
|
||||||
galleryItem: IGalleryItem;
|
|
||||||
isFavorite: boolean;
|
|
||||||
selectedTab: GalleryTab;
|
|
||||||
sortBy: SortBy;
|
|
||||||
searchText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GalleryAndNotebookViewerComponent extends React.Component<
|
|
||||||
GalleryAndNotebookViewerComponentProps,
|
|
||||||
GalleryAndNotebookViewerComponentState
|
|
||||||
> {
|
|
||||||
constructor(props: GalleryAndNotebookViewerComponentProps) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
notebookUrl: props.notebookUrl,
|
|
||||||
galleryItem: props.galleryItem,
|
|
||||||
isFavorite: props.isFavorite,
|
|
||||||
selectedTab: props.selectedTab,
|
|
||||||
sortBy: props.sortBy,
|
|
||||||
searchText: props.searchText,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public render(): JSX.Element {
|
|
||||||
if (this.state.notebookUrl) {
|
|
||||||
const props: NotebookViewerComponentProps = {
|
|
||||||
container: this.props.container,
|
|
||||||
junoClient: this.props.junoClient,
|
|
||||||
notebookUrl: this.state.notebookUrl,
|
|
||||||
galleryItem: this.state.galleryItem,
|
|
||||||
isFavorite: this.state.isFavorite,
|
|
||||||
backNavigationText: GalleryUtils.getTabTitle(this.state.selectedTab),
|
|
||||||
onBackClick: this.onBackClick,
|
|
||||||
onTagClick: this.loadTaggedItems,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <NotebookViewerComponent {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props: GalleryViewerComponentProps = {
|
|
||||||
container: this.props.container,
|
|
||||||
junoClient: this.props.junoClient,
|
|
||||||
selectedTab: this.state.selectedTab,
|
|
||||||
sortBy: this.state.sortBy,
|
|
||||||
searchText: this.state.searchText,
|
|
||||||
openNotebook: this.openNotebook,
|
|
||||||
onSelectedTabChange: this.onSelectedTabChange,
|
|
||||||
onSortByChange: this.onSortByChange,
|
|
||||||
onSearchTextChange: this.onSearchTextChange,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <GalleryViewerComponent {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
private onBackClick = (): void => {
|
|
||||||
this.setState({
|
|
||||||
notebookUrl: undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private loadTaggedItems = (tag: string): void => {
|
|
||||||
this.setState({
|
|
||||||
notebookUrl: undefined,
|
|
||||||
searchText: tag,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private openNotebook = (data: IGalleryItem, isFavorite: boolean): void => {
|
|
||||||
this.setState({
|
|
||||||
notebookUrl: this.props.junoClient.getNotebookContentUrl(data.id),
|
|
||||||
galleryItem: data,
|
|
||||||
isFavorite,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private onSelectedTabChange = (selectedTab: GalleryTab): void => {
|
|
||||||
this.setState({
|
|
||||||
selectedTab,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private onSortByChange = (sortBy: SortBy): void => {
|
|
||||||
this.setState({
|
|
||||||
sortBy,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private onSearchTextChange = (searchText: string): void => {
|
|
||||||
this.setState({
|
|
||||||
searchText,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
@import "../../../../less/Common/Constants";
|
|
||||||
|
|
||||||
.galleryContainer {
|
|
||||||
padding: @LargeSpace @LargeSpace 30px @LargeSpace;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
width: 100%;
|
|
||||||
font-family: @DataExplorerFont;
|
|
||||||
background: @GalleryBackgroundColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.publicGalleryTabContainer {
|
|
||||||
position: relative;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.publicGalleryTabOverlayContent {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 10%;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { shallow } from "enzyme";
|
|
||||||
import React from "react";
|
|
||||||
import { GalleryViewerComponent, GalleryViewerComponentProps, GalleryTab, SortBy } from "./GalleryViewerComponent";
|
|
||||||
|
|
||||||
describe("GalleryViewerComponent", () => {
|
|
||||||
it("renders", () => {
|
|
||||||
const props: GalleryViewerComponentProps = {
|
|
||||||
junoClient: undefined,
|
|
||||||
selectedTab: GalleryTab.OfficialSamples,
|
|
||||||
sortBy: SortBy.MostViewed,
|
|
||||||
searchText: undefined,
|
|
||||||
openNotebook: undefined,
|
|
||||||
onSelectedTabChange: undefined,
|
|
||||||
onSortByChange: undefined,
|
|
||||||
onSearchTextChange: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapper = shallow(<GalleryViewerComponent {...props} />);
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,760 +0,0 @@
|
|||||||
import {
|
|
||||||
Dropdown,
|
|
||||||
FocusZone,
|
|
||||||
FontIcon,
|
|
||||||
FontWeights,
|
|
||||||
IDropdownOption,
|
|
||||||
IPageSpecification,
|
|
||||||
IPivotItemProps,
|
|
||||||
IPivotProps,
|
|
||||||
IRectangle,
|
|
||||||
Label,
|
|
||||||
Link,
|
|
||||||
List,
|
|
||||||
Overlay,
|
|
||||||
Pivot,
|
|
||||||
PivotItem,
|
|
||||||
SearchBox,
|
|
||||||
Spinner,
|
|
||||||
SpinnerSize,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
} from "@fluentui/react";
|
|
||||||
import * as React from "react";
|
|
||||||
import { userContext } from "UserContext";
|
|
||||||
import { HttpStatusCodes } from "../../../Common/Constants";
|
|
||||||
import { handleError } from "../../../Common/ErrorHandlingUtils";
|
|
||||||
import { IGalleryItem, IJunoResponse, IPublicGalleryData, JunoClient } from "../../../Juno/JunoClient";
|
|
||||||
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
|
|
||||||
import { trace } from "../../../Shared/Telemetry/TelemetryProcessor";
|
|
||||||
import * as GalleryUtils from "../../../Utils/GalleryUtils";
|
|
||||||
import Explorer from "../../Explorer";
|
|
||||||
import { GalleryCardComponent, GalleryCardComponentProps } from "./Cards/GalleryCardComponent";
|
|
||||||
import { CodeOfConduct } from "./CodeOfConduct/CodeOfConduct";
|
|
||||||
import "./GalleryViewerComponent.less";
|
|
||||||
import { InfoComponent } from "./InfoComponent/InfoComponent";
|
|
||||||
|
|
||||||
export interface GalleryViewerComponentProps {
|
|
||||||
container?: Explorer;
|
|
||||||
junoClient: JunoClient;
|
|
||||||
selectedTab: GalleryTab;
|
|
||||||
sortBy: SortBy;
|
|
||||||
searchText: string;
|
|
||||||
openNotebook: (data: IGalleryItem, isFavorite: boolean) => void;
|
|
||||||
onSelectedTabChange: (newTab: GalleryTab) => void;
|
|
||||||
onSortByChange: (sortBy: SortBy) => void;
|
|
||||||
onSearchTextChange: (searchText: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum GalleryTab {
|
|
||||||
PublicGallery,
|
|
||||||
OfficialSamples,
|
|
||||||
Favorites,
|
|
||||||
Published,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum SortBy {
|
|
||||||
MostViewed,
|
|
||||||
MostDownloaded,
|
|
||||||
MostFavorited,
|
|
||||||
MostRecent,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GalleryViewerComponentState {
|
|
||||||
sampleNotebooks: IGalleryItem[];
|
|
||||||
publicNotebooks: IGalleryItem[];
|
|
||||||
favoriteNotebooks: IGalleryItem[];
|
|
||||||
publishedNotebooks: IGalleryItem[];
|
|
||||||
selectedTab: GalleryTab;
|
|
||||||
sortBy: SortBy;
|
|
||||||
searchText: string;
|
|
||||||
isCodeOfConductAccepted: boolean;
|
|
||||||
isFetchingPublishedNotebooks: boolean;
|
|
||||||
isFetchingFavouriteNotebooks: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GalleryTabInfo {
|
|
||||||
tab: GalleryTab;
|
|
||||||
content: JSX.Element;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GalleryViewerComponent extends React.Component<GalleryViewerComponentProps, GalleryViewerComponentState> {
|
|
||||||
public static readonly OfficialSamplesTitle = "Official samples";
|
|
||||||
public static readonly PublicGalleryTitle = "Public gallery";
|
|
||||||
public static readonly FavoritesTitle = "My favorites";
|
|
||||||
public static readonly PublishedTitle = "My published work";
|
|
||||||
|
|
||||||
private static readonly rowsPerPage = 5;
|
|
||||||
private static readonly CARD_WIDTH = 256;
|
|
||||||
private static readonly mostViewedText = "Most viewed";
|
|
||||||
private static readonly mostDownloadedText = "Most downloaded";
|
|
||||||
private static readonly mostFavoritedText = "Most favorited";
|
|
||||||
private static readonly mostRecentText = "Most recent";
|
|
||||||
|
|
||||||
private readonly sortingOptions: IDropdownOption[];
|
|
||||||
|
|
||||||
private viewGalleryTraced: boolean;
|
|
||||||
private viewOfficialSamplesTraced: boolean;
|
|
||||||
private viewPublicGalleryTraced: boolean;
|
|
||||||
private viewFavoritesTraced: boolean;
|
|
||||||
private viewPublishedNotebooksTraced: boolean;
|
|
||||||
|
|
||||||
private sampleNotebooks: IGalleryItem[];
|
|
||||||
private publicNotebooks: IGalleryItem[];
|
|
||||||
private favoriteNotebooks: IGalleryItem[];
|
|
||||||
private publishedNotebooks: IGalleryItem[];
|
|
||||||
private isCodeOfConductAccepted: boolean;
|
|
||||||
private columnCount: number;
|
|
||||||
private rowCount: number;
|
|
||||||
|
|
||||||
constructor(props: GalleryViewerComponentProps) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
sampleNotebooks: undefined,
|
|
||||||
publicNotebooks: undefined,
|
|
||||||
favoriteNotebooks: undefined,
|
|
||||||
publishedNotebooks: undefined,
|
|
||||||
selectedTab: props.selectedTab,
|
|
||||||
sortBy: props.sortBy,
|
|
||||||
searchText: props.searchText,
|
|
||||||
isCodeOfConductAccepted: undefined,
|
|
||||||
isFetchingFavouriteNotebooks: true,
|
|
||||||
isFetchingPublishedNotebooks: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.sortingOptions = [
|
|
||||||
{
|
|
||||||
key: SortBy.MostViewed,
|
|
||||||
text: GalleryViewerComponent.mostViewedText,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: SortBy.MostDownloaded,
|
|
||||||
text: GalleryViewerComponent.mostDownloadedText,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: SortBy.MostRecent,
|
|
||||||
text: GalleryViewerComponent.mostRecentText,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: SortBy.MostFavorited,
|
|
||||||
text: GalleryViewerComponent.mostFavoritedText,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
this.loadTabContent(this.state.selectedTab, this.state.searchText, this.state.sortBy, false);
|
|
||||||
this.loadFavoriteNotebooks(this.state.searchText, this.state.sortBy, false); // Need this to show correct favorite button state
|
|
||||||
}
|
|
||||||
|
|
||||||
public render(): JSX.Element {
|
|
||||||
this.traceViewGallery();
|
|
||||||
|
|
||||||
const tabs: GalleryTabInfo[] = [];
|
|
||||||
if (userContext.features.publicGallery) {
|
|
||||||
tabs.push(
|
|
||||||
this.createPublicGalleryTab(
|
|
||||||
GalleryTab.PublicGallery,
|
|
||||||
this.state.publicNotebooks,
|
|
||||||
this.state.isCodeOfConductAccepted,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
tabs.push(this.createSamplesTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks));
|
|
||||||
|
|
||||||
if (this.props.container) {
|
|
||||||
tabs.push(this.createFavoritesTab(GalleryTab.Favorites, this.state.favoriteNotebooks));
|
|
||||||
if (userContext.features.publicGallery) {
|
|
||||||
tabs.push(this.createPublishedNotebooksTab(GalleryTab.Published, this.state.publishedNotebooks));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pivotProps: IPivotProps = {
|
|
||||||
onLinkClick: this.onPivotChange,
|
|
||||||
selectedKey: GalleryTab[this.state.selectedTab],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pivotItems = tabs.map((tab) => {
|
|
||||||
const pivotItemProps: IPivotItemProps = {
|
|
||||||
itemKey: GalleryTab[tab.tab],
|
|
||||||
style: { marginTop: 20 },
|
|
||||||
headerText: GalleryUtils.getTabTitle(tab.tab),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PivotItem key={pivotItemProps.itemKey} {...pivotItemProps}>
|
|
||||||
{tab.content}
|
|
||||||
</PivotItem>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="galleryContainer">
|
|
||||||
<Pivot {...pivotProps}>{pivotItems}</Pivot>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private traceViewGallery = (): void => {
|
|
||||||
if (!this.viewGalleryTraced) {
|
|
||||||
this.viewGalleryTraced = true;
|
|
||||||
trace(Action.NotebooksGalleryViewGallery);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (this.state.selectedTab) {
|
|
||||||
case GalleryTab.PublicGallery:
|
|
||||||
if (!this.viewPublicGalleryTraced) {
|
|
||||||
this.resetViewGalleryTabTracedFlags();
|
|
||||||
this.viewPublicGalleryTraced = true;
|
|
||||||
trace(Action.NotebooksGalleryViewPublicGallery);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case GalleryTab.OfficialSamples:
|
|
||||||
if (!this.viewOfficialSamplesTraced) {
|
|
||||||
this.resetViewGalleryTabTracedFlags();
|
|
||||||
this.viewOfficialSamplesTraced = true;
|
|
||||||
trace(Action.NotebooksGalleryViewOfficialSamples);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case GalleryTab.Favorites:
|
|
||||||
if (!this.viewFavoritesTraced) {
|
|
||||||
this.resetViewGalleryTabTracedFlags();
|
|
||||||
this.viewFavoritesTraced = true;
|
|
||||||
trace(Action.NotebooksGalleryViewFavorites);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case GalleryTab.Published:
|
|
||||||
if (!this.viewPublishedNotebooksTraced) {
|
|
||||||
this.resetViewGalleryTabTracedFlags();
|
|
||||||
this.viewPublishedNotebooksTraced = true;
|
|
||||||
trace(Action.NotebooksGalleryViewPublishedNotebooks);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown selected tab ${this.state.selectedTab}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private resetViewGalleryTabTracedFlags = (): void => {
|
|
||||||
this.viewOfficialSamplesTraced = false;
|
|
||||||
this.viewPublicGalleryTraced = false;
|
|
||||||
this.viewFavoritesTraced = false;
|
|
||||||
this.viewPublishedNotebooksTraced = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
private isEmptyData = (data: IGalleryItem[]): boolean => {
|
|
||||||
return !data || data.length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
private createEmptyTabContent = (iconName: string, line1: JSX.Element, line2: JSX.Element): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<Stack horizontalAlign="center" tokens={{ childrenGap: 10 }}>
|
|
||||||
<FontIcon iconName={iconName} style={{ fontSize: 100, color: "lightgray", marginTop: 20 }} />
|
|
||||||
<Text styles={{ root: { fontWeight: FontWeights.semibold } }}>{line1}</Text>
|
|
||||||
<Text>{line2}</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private createSamplesTab = (tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo => {
|
|
||||||
return {
|
|
||||||
tab,
|
|
||||||
content: this.createSearchBarHeader(this.createCardsTabContent(data)),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
private createPublicGalleryTab(
|
|
||||||
tab: GalleryTab,
|
|
||||||
data: IGalleryItem[],
|
|
||||||
acceptedCodeOfConduct: boolean,
|
|
||||||
): GalleryTabInfo {
|
|
||||||
return {
|
|
||||||
tab,
|
|
||||||
content: this.createPublicGalleryTabContent(data, acceptedCodeOfConduct),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFavouriteNotebooksTabContent = (data: IGalleryItem[]) => {
|
|
||||||
if (this.isEmptyData(data)) {
|
|
||||||
if (this.state.isFetchingFavouriteNotebooks) {
|
|
||||||
return <Spinner size={SpinnerSize.large} />;
|
|
||||||
}
|
|
||||||
return this.createEmptyTabContent(
|
|
||||||
"ContactHeart",
|
|
||||||
<>You don't have any favorites yet</>,
|
|
||||||
<>
|
|
||||||
Favorite any notebook from the{" "}
|
|
||||||
<Link onClick={() => this.setState({ selectedTab: GalleryTab.OfficialSamples })}>official samples</Link> or{" "}
|
|
||||||
<Link onClick={() => this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery</Link>
|
|
||||||
</>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.createSearchBarHeader(this.createCardsTabContent(data));
|
|
||||||
};
|
|
||||||
|
|
||||||
private createFavoritesTab(tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo {
|
|
||||||
return {
|
|
||||||
tab,
|
|
||||||
content: this.getFavouriteNotebooksTabContent(data),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPublishedNotebooksTabContent = (data: IGalleryItem[]) => {
|
|
||||||
if (this.isEmptyData(data)) {
|
|
||||||
if (this.state.isFetchingPublishedNotebooks) {
|
|
||||||
return <Spinner size={SpinnerSize.large} />;
|
|
||||||
}
|
|
||||||
return this.createEmptyTabContent(
|
|
||||||
"Contact",
|
|
||||||
<>
|
|
||||||
You have not published anything to the{" "}
|
|
||||||
<Link onClick={() => this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery</Link> yet
|
|
||||||
</>,
|
|
||||||
<>Publish your notebooks to share your work with other users</>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.createPublishedNotebooksTabContent(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
private createPublishedNotebooksTab = (tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo => {
|
|
||||||
return {
|
|
||||||
tab,
|
|
||||||
content: this.getPublishedNotebooksTabContent(data),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
private createPublishedNotebooksTabContent = (data: IGalleryItem[]): JSX.Element => {
|
|
||||||
const { published, underReview, removed } = GalleryUtils.filterPublishedNotebooks(data);
|
|
||||||
const content = (
|
|
||||||
<Stack tokens={{ childrenGap: 20 }}>
|
|
||||||
{published?.length > 0 &&
|
|
||||||
this.createPublishedNotebooksSectionContent(
|
|
||||||
undefined,
|
|
||||||
"You have successfully published and shared the following notebook(s) to the public gallery.",
|
|
||||||
this.createCardsTabContent(published),
|
|
||||||
)}
|
|
||||||
{underReview?.length > 0 &&
|
|
||||||
this.createPublishedNotebooksSectionContent(
|
|
||||||
"Under Review",
|
|
||||||
"Content of a notebook you published is currently being scanned for illegal content. It will not be available to public gallery until the review is completed (may take a few days)",
|
|
||||||
this.createCardsTabContent(underReview),
|
|
||||||
)}
|
|
||||||
{removed?.length > 0 &&
|
|
||||||
this.createPublishedNotebooksSectionContent(
|
|
||||||
"Removed",
|
|
||||||
"These notebooks were found to contain illegal content and has been taken down.",
|
|
||||||
this.createPolicyViolationsListContent(removed),
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.createSearchBarHeader(content);
|
|
||||||
};
|
|
||||||
|
|
||||||
private createPublishedNotebooksSectionContent = (
|
|
||||||
title: string,
|
|
||||||
description: string,
|
|
||||||
content: JSX.Element,
|
|
||||||
): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<Stack tokens={{ childrenGap: 10 }}>
|
|
||||||
{title && (
|
|
||||||
<Text styles={{ root: { fontWeight: FontWeights.semibold, marginLeft: 10, marginRight: 10 } }}>{title}</Text>
|
|
||||||
)}
|
|
||||||
{description && <Text styles={{ root: { marginLeft: 10, marginRight: 10 } }}>{description}</Text>}
|
|
||||||
{content}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private createPublicGalleryTabContent(data: IGalleryItem[], acceptedCodeOfConduct: boolean): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div className="publicGalleryTabContainer">
|
|
||||||
{this.createSearchBarHeader(this.createCardsTabContent(data))}
|
|
||||||
{acceptedCodeOfConduct === false && (
|
|
||||||
<Overlay isDarkThemed>
|
|
||||||
<div className="publicGalleryTabOverlayContent">
|
|
||||||
<CodeOfConduct
|
|
||||||
junoClient={this.props.junoClient}
|
|
||||||
onAcceptCodeOfConduct={(result: boolean) => {
|
|
||||||
this.setState({ isCodeOfConductAccepted: result });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Overlay>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private createSearchBarHeader(content: JSX.Element): JSX.Element {
|
|
||||||
return (
|
|
||||||
<Stack tokens={{ childrenGap: 10 }}>
|
|
||||||
<Stack horizontal wrap tokens={{ childrenGap: 20, padding: 10 }}>
|
|
||||||
<Stack.Item grow>
|
|
||||||
<SearchBox value={this.state.searchText} placeholder="Search" onChange={this.onSearchBoxChange} />
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item>
|
|
||||||
<Label>Sort by</Label>
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item styles={{ root: { minWidth: 200 } }}>
|
|
||||||
<Dropdown options={this.sortingOptions} selectedKey={this.state.sortBy} onChange={this.onDropdownChange} />
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item>
|
|
||||||
<InfoComponent />
|
|
||||||
</Stack.Item>
|
|
||||||
</Stack>
|
|
||||||
<Stack.Item>{content}</Stack.Item>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private createCardsTabContent(data: IGalleryItem[]): JSX.Element {
|
|
||||||
return data ? (
|
|
||||||
<FocusZone>
|
|
||||||
<List
|
|
||||||
items={data}
|
|
||||||
getPageSpecification={this.getPageSpecification}
|
|
||||||
renderedWindowsAhead={3}
|
|
||||||
onRenderCell={this.onRenderCell}
|
|
||||||
/>
|
|
||||||
</FocusZone>
|
|
||||||
) : (
|
|
||||||
<Spinner size={SpinnerSize.large} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private createPolicyViolationsListContent(data: IGalleryItem[]): JSX.Element {
|
|
||||||
return (
|
|
||||||
<table style={{ margin: 10 }}>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Policy violations</th>
|
|
||||||
</tr>
|
|
||||||
{data.map((item) => (
|
|
||||||
<tr key={`policy-violations-tr-${item.id}`}>
|
|
||||||
<td>{item.name}</td>
|
|
||||||
<td>{item.policyViolations.join(", ")}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadTabContent(tab: GalleryTab, searchText: string, sortBy: SortBy, offline: boolean): void {
|
|
||||||
switch (tab) {
|
|
||||||
case GalleryTab.PublicGallery:
|
|
||||||
this.loadPublicNotebooks(searchText, sortBy, offline);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case GalleryTab.OfficialSamples:
|
|
||||||
this.loadSampleNotebooks(searchText, sortBy, offline);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case GalleryTab.Favorites:
|
|
||||||
this.loadFavoriteNotebooks(searchText, sortBy, offline);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case GalleryTab.Published:
|
|
||||||
this.loadPublishedNotebooks(searchText, sortBy, offline);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown tab ${tab}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadSampleNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
|
|
||||||
if (!offline) {
|
|
||||||
try {
|
|
||||||
const response = await this.props.junoClient.getSampleNotebooks();
|
|
||||||
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when loading sample notebooks`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sampleNotebooks = response.data;
|
|
||||||
|
|
||||||
trace(Action.NotebooksGalleryOfficialSamplesCount, ActionModifiers.Mark, {
|
|
||||||
count: this.sampleNotebooks?.length,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, "GalleryViewerComponent/loadSampleNotebooks", "Failed to load sample notebooks");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
sampleNotebooks: this.sampleNotebooks && [...this.sort(sortBy, this.search(searchText, this.sampleNotebooks))],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadPublicNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
|
|
||||||
if (!offline) {
|
|
||||||
try {
|
|
||||||
let response: IJunoResponse<IGalleryItem[]> | IJunoResponse<IPublicGalleryData>;
|
|
||||||
if (this.props.container) {
|
|
||||||
response = await this.props.junoClient.getPublicGalleryData();
|
|
||||||
this.isCodeOfConductAccepted = response.data?.metadata.acceptedCodeOfConduct;
|
|
||||||
this.publicNotebooks = response.data?.notebooksData;
|
|
||||||
} else {
|
|
||||||
response = await this.props.junoClient.getPublicNotebooks();
|
|
||||||
this.publicNotebooks = response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when loading public notebooks`);
|
|
||||||
}
|
|
||||||
|
|
||||||
trace(Action.NotebooksGalleryPublicGalleryCount, ActionModifiers.Mark, { count: this.publicNotebooks?.length });
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, "GalleryViewerComponent/loadPublicNotebooks", "Failed to load public notebooks");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
publicNotebooks: this.publicNotebooks && [...this.sort(sortBy, this.search(searchText, this.publicNotebooks))],
|
|
||||||
isCodeOfConductAccepted: this.isCodeOfConductAccepted,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadFavoriteNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
|
|
||||||
if (!offline) {
|
|
||||||
try {
|
|
||||||
this.setState({ isFetchingFavouriteNotebooks: true });
|
|
||||||
const response = await this.props.junoClient.getFavoriteNotebooks();
|
|
||||||
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when loading favorite notebooks`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.favoriteNotebooks = response.data;
|
|
||||||
|
|
||||||
trace(Action.NotebooksGalleryFavoritesCount, ActionModifiers.Mark, { count: this.favoriteNotebooks?.length });
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, "GalleryViewerComponent/loadFavoriteNotebooks", "Failed to load favorite notebooks");
|
|
||||||
} finally {
|
|
||||||
this.setState({ isFetchingFavouriteNotebooks: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
favoriteNotebooks: this.favoriteNotebooks && [
|
|
||||||
...this.sort(sortBy, this.search(searchText, this.favoriteNotebooks)),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh favorite button state
|
|
||||||
if (this.state.selectedTab !== GalleryTab.Favorites) {
|
|
||||||
this.refreshSelectedTab();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadPublishedNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
|
|
||||||
if (!offline) {
|
|
||||||
try {
|
|
||||||
this.setState({ isFetchingPublishedNotebooks: true });
|
|
||||||
const response = await this.props.junoClient.getPublishedNotebooks();
|
|
||||||
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when loading published notebooks`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.publishedNotebooks = response.data;
|
|
||||||
|
|
||||||
const { published, underReview, removed } = GalleryUtils.filterPublishedNotebooks(this.publishedNotebooks);
|
|
||||||
trace(Action.NotebooksGalleryPublishedCount, ActionModifiers.Mark, {
|
|
||||||
count: this.publishedNotebooks?.length,
|
|
||||||
publishedCount: published.length,
|
|
||||||
underReviewCount: underReview.length,
|
|
||||||
removedCount: removed.length,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, "GalleryViewerComponent/loadPublishedNotebooks", "Failed to load published notebooks");
|
|
||||||
} finally {
|
|
||||||
this.setState({ isFetchingPublishedNotebooks: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
publishedNotebooks: this.publishedNotebooks && [
|
|
||||||
...this.sort(sortBy, this.search(searchText, this.publishedNotebooks)),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private search(searchText: string, data: IGalleryItem[]): IGalleryItem[] {
|
|
||||||
if (searchText) {
|
|
||||||
return data?.filter((item) => this.isGalleryItemPresent(searchText, item));
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isGalleryItemPresent(searchText: string, item: IGalleryItem): boolean {
|
|
||||||
const toSearch = searchText.trim().toUpperCase();
|
|
||||||
const searchData: string[] = [item.author.toUpperCase(), item.description.toUpperCase(), item.name.toUpperCase()];
|
|
||||||
|
|
||||||
if (item.tags) {
|
|
||||||
searchData.push(...item.tags.map((tag) => tag.toUpperCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const data of searchData) {
|
|
||||||
if (data?.indexOf(toSearch) !== -1) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sort(sortBy: SortBy, data: IGalleryItem[]): IGalleryItem[] {
|
|
||||||
return data?.sort((a, b) => {
|
|
||||||
switch (sortBy) {
|
|
||||||
case SortBy.MostViewed:
|
|
||||||
return b.views - a.views;
|
|
||||||
case SortBy.MostDownloaded:
|
|
||||||
return b.downloads - a.downloads;
|
|
||||||
case SortBy.MostFavorited:
|
|
||||||
return b.favorites - a.favorites;
|
|
||||||
case SortBy.MostRecent:
|
|
||||||
return Date.parse(b.created) - Date.parse(a.created);
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown sorting condition ${sortBy}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private refreshSelectedTab(item?: IGalleryItem): void {
|
|
||||||
if (item) {
|
|
||||||
this.updateGalleryItem(item);
|
|
||||||
}
|
|
||||||
this.loadTabContent(this.state.selectedTab, this.state.searchText, this.state.sortBy, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateGalleryItem(updatedItem: IGalleryItem): void {
|
|
||||||
this.replaceGalleryItem(updatedItem, this.sampleNotebooks);
|
|
||||||
this.replaceGalleryItem(updatedItem, this.publicNotebooks);
|
|
||||||
this.replaceGalleryItem(updatedItem, this.favoriteNotebooks);
|
|
||||||
this.replaceGalleryItem(updatedItem, this.publishedNotebooks);
|
|
||||||
}
|
|
||||||
|
|
||||||
private replaceGalleryItem(item: IGalleryItem, items?: IGalleryItem[]): void {
|
|
||||||
const index = items?.findIndex((value) => value.id === item.id);
|
|
||||||
if (index !== -1) {
|
|
||||||
items?.splice(index, 1, item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPageSpecification = (itemIndex?: number, visibleRect?: IRectangle): IPageSpecification => {
|
|
||||||
if (itemIndex === 0) {
|
|
||||||
this.columnCount = Math.floor(visibleRect.width / GalleryViewerComponent.CARD_WIDTH) || this.columnCount;
|
|
||||||
this.rowCount = GalleryViewerComponent.rowsPerPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
height: visibleRect.height,
|
|
||||||
itemCount: this.columnCount * this.rowCount,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
private onRenderCell = (data?: IGalleryItem): JSX.Element => {
|
|
||||||
const isFavorite =
|
|
||||||
this.props.container && this.favoriteNotebooks?.find((item) => item.id === data.id) !== undefined;
|
|
||||||
const props: GalleryCardComponentProps = {
|
|
||||||
data,
|
|
||||||
isFavorite,
|
|
||||||
showDownload: !!this.props.container,
|
|
||||||
showDelete: this.state.selectedTab === GalleryTab.Published,
|
|
||||||
onClick: () => this.props.openNotebook(data, isFavorite),
|
|
||||||
onTagClick: this.loadTaggedItems,
|
|
||||||
onFavoriteClick: () => this.favoriteItem(data),
|
|
||||||
onUnfavoriteClick: () => this.unfavoriteItem(data),
|
|
||||||
onDownloadClick: () => this.downloadItem(data),
|
|
||||||
onDeleteClick: (beforeDelete: () => void, afterDelete: () => void) =>
|
|
||||||
this.deleteItem(data, beforeDelete, afterDelete),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ float: "left", padding: 5 }}>
|
|
||||||
<GalleryCardComponent {...props} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private loadTaggedItems = (tag: string): void => {
|
|
||||||
const searchText = tag;
|
|
||||||
this.setState({
|
|
||||||
searchText,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.loadTabContent(this.state.selectedTab, searchText, this.state.sortBy, true);
|
|
||||||
this.props.onSearchTextChange && this.props.onSearchTextChange(searchText);
|
|
||||||
};
|
|
||||||
|
|
||||||
private favoriteItem = async (data: IGalleryItem): Promise<void> => {
|
|
||||||
GalleryUtils.favoriteItem(this.props.container, this.props.junoClient, data, (item: IGalleryItem) => {
|
|
||||||
if (this.favoriteNotebooks) {
|
|
||||||
this.favoriteNotebooks.push(item);
|
|
||||||
} else {
|
|
||||||
this.favoriteNotebooks = [item];
|
|
||||||
}
|
|
||||||
this.refreshSelectedTab(item);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private unfavoriteItem = async (data: IGalleryItem): Promise<void> => {
|
|
||||||
GalleryUtils.unfavoriteItem(this.props.container, this.props.junoClient, data, (item: IGalleryItem) => {
|
|
||||||
this.favoriteNotebooks = this.favoriteNotebooks?.filter((value) => value.id !== item.id);
|
|
||||||
this.refreshSelectedTab(item);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private downloadItem = async (data: IGalleryItem): Promise<void> => {
|
|
||||||
GalleryUtils.downloadItem(this.props.container, this.props.junoClient, data, (item) =>
|
|
||||||
this.refreshSelectedTab(item),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private deleteItem = async (data: IGalleryItem, beforeDelete: () => void, afterDelete: () => void): Promise<void> => {
|
|
||||||
GalleryUtils.deleteItem(
|
|
||||||
this.props.container,
|
|
||||||
this.props.junoClient,
|
|
||||||
data,
|
|
||||||
(item) => {
|
|
||||||
this.publishedNotebooks = this.publishedNotebooks?.filter((notebook) => item.id !== notebook.id);
|
|
||||||
this.refreshSelectedTab(item);
|
|
||||||
},
|
|
||||||
beforeDelete,
|
|
||||||
afterDelete,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private onPivotChange = (item: PivotItem): void => {
|
|
||||||
const selectedTab = GalleryTab[item.props.itemKey as keyof typeof GalleryTab];
|
|
||||||
const searchText: string = undefined;
|
|
||||||
this.setState({
|
|
||||||
selectedTab,
|
|
||||||
searchText,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.loadTabContent(selectedTab, searchText, this.state.sortBy, false);
|
|
||||||
this.props.onSelectedTabChange && this.props.onSelectedTabChange(selectedTab);
|
|
||||||
};
|
|
||||||
|
|
||||||
private onSearchBoxChange = (event?: React.ChangeEvent<HTMLInputElement>, newValue?: string): void => {
|
|
||||||
const searchText = newValue;
|
|
||||||
this.setState({
|
|
||||||
searchText,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.loadTabContent(this.state.selectedTab, searchText, this.state.sortBy, true);
|
|
||||||
this.props.onSearchTextChange && this.props.onSearchTextChange(searchText);
|
|
||||||
};
|
|
||||||
|
|
||||||
private onDropdownChange = (event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption): void => {
|
|
||||||
const sortBy = option.key as SortBy;
|
|
||||||
this.setState({
|
|
||||||
sortBy,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.loadTabContent(this.state.selectedTab, this.state.searchText, sortBy, true);
|
|
||||||
this.props.onSortByChange && this.props.onSortByChange(sortBy);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
@import "../../../../../less/Common/Constants.less";
|
|
||||||
.infoPanel, .infoPanelMain {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.infoPanel {
|
|
||||||
padding-left: 5px;
|
|
||||||
padding-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.infoLabel, .infoLabelMain {
|
|
||||||
padding-left: 5px
|
|
||||||
}
|
|
||||||
|
|
||||||
.infoLabel {
|
|
||||||
font-weight: 400
|
|
||||||
}
|
|
||||||
|
|
||||||
.infoIconMain {
|
|
||||||
color: @AccentMedium
|
|
||||||
}
|
|
||||||
|
|
||||||
.infoIconMain:hover {
|
|
||||||
color: @BaseMedium
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { shallow } from "enzyme";
|
|
||||||
import React from "react";
|
|
||||||
import { InfoComponent } from "./InfoComponent";
|
|
||||||
|
|
||||||
describe("InfoComponent", () => {
|
|
||||||
it("renders", () => {
|
|
||||||
const wrapper = shallow(<InfoComponent />);
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { HoverCard, HoverCardType, Icon, Label, Link, Stack } from "@fluentui/react";
|
|
||||||
import * as React from "react";
|
|
||||||
import { CodeOfConductEndpoints } from "../../../../Common/Constants";
|
|
||||||
import "./InfoComponent.less";
|
|
||||||
|
|
||||||
export interface InfoComponentProps {
|
|
||||||
onReportAbuseClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InfoComponent extends React.Component<InfoComponentProps> {
|
|
||||||
private getInfoPanel = (iconName: string, labelText: string, url?: string, onClick?: () => void): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<Link href={url} target={url && "_blank"} onClick={onClick}>
|
|
||||||
<div className="infoPanel">
|
|
||||||
<Icon iconName={iconName} styles={{ root: { verticalAlign: "middle" } }} />
|
|
||||||
<Label className="infoLabel">{labelText}</Label>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private onHover = (): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<Stack tokens={{ childrenGap: 5, padding: 5 }}>
|
|
||||||
<Stack.Item>{this.getInfoPanel("Script", "Code of Conduct", CodeOfConductEndpoints.codeOfConduct)}</Stack.Item>
|
|
||||||
<Stack.Item>
|
|
||||||
{this.getInfoPanel("RedEye", "Privacy Statement", CodeOfConductEndpoints.privacyStatement)}
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item>
|
|
||||||
{this.getInfoPanel("KnowledgeArticle", "Microsoft Terms of Use", CodeOfConductEndpoints.termsOfUse)}
|
|
||||||
</Stack.Item>
|
|
||||||
{this.props.onReportAbuseClick && (
|
|
||||||
<Stack.Item>
|
|
||||||
{this.getInfoPanel("ReportHacked", "Report Abuse", undefined, () => this.props.onReportAbuseClick())}
|
|
||||||
</Stack.Item>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
public render(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<HoverCard plainCardProps={{ onRenderPlainCard: this.onHover }} instantOpenOnClick type={HoverCardType.plain}>
|
|
||||||
<div className="infoPanelMain" tabIndex={0}>
|
|
||||||
<Icon className="infoIconMain" iconName="Help" styles={{ root: { verticalAlign: "middle" } }} />
|
|
||||||
<Label className="infoLabelMain">Help</Label>
|
|
||||||
</div>
|
|
||||||
</HoverCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`InfoComponent renders 1`] = `
|
|
||||||
<StyledHoverCardBase
|
|
||||||
instantOpenOnClick={true}
|
|
||||||
plainCardProps={
|
|
||||||
{
|
|
||||||
"onRenderPlainCard": [Function],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
type="PlainCard"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="infoPanelMain"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className="infoIconMain"
|
|
||||||
iconName="Help"
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"verticalAlign": "middle",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StyledLabelBase
|
|
||||||
className="infoLabelMain"
|
|
||||||
>
|
|
||||||
Help
|
|
||||||
</StyledLabelBase>
|
|
||||||
</div>
|
|
||||||
</StyledHoverCardBase>
|
|
||||||
`;
|
|
||||||
-98
@@ -1,98 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`GalleryViewerComponent renders 1`] = `
|
|
||||||
<div
|
|
||||||
className="galleryContainer"
|
|
||||||
>
|
|
||||||
<StyledPivot
|
|
||||||
onLinkClick={[Function]}
|
|
||||||
selectedKey="OfficialSamples"
|
|
||||||
>
|
|
||||||
<PivotItem
|
|
||||||
headerText="Official samples"
|
|
||||||
itemKey="OfficialSamples"
|
|
||||||
key="OfficialSamples"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"marginTop": 20,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 10,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
horizontal={true}
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 20,
|
|
||||||
"padding": 10,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wrap={true}
|
|
||||||
>
|
|
||||||
<StackItem
|
|
||||||
grow={true}
|
|
||||||
>
|
|
||||||
<StyledSearchBox
|
|
||||||
onChange={[Function]}
|
|
||||||
placeholder="Search"
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<StyledLabelBase>
|
|
||||||
Sort by
|
|
||||||
</StyledLabelBase>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"minWidth": 200,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Dropdown
|
|
||||||
onChange={[Function]}
|
|
||||||
options={
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"key": 0,
|
|
||||||
"text": "Most viewed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": 1,
|
|
||||||
"text": "Most downloaded",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": 3,
|
|
||||||
"text": "Most recent",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": 2,
|
|
||||||
"text": "Most favorited",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
selectedKey={0}
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<InfoComponent />
|
|
||||||
</StackItem>
|
|
||||||
</Stack>
|
|
||||||
<StackItem>
|
|
||||||
<StyledSpinnerBase
|
|
||||||
size={3}
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
</Stack>
|
|
||||||
</PivotItem>
|
|
||||||
</StyledPivot>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
@@ -25,10 +25,6 @@ describe("NotebookMetadataComponent", () => {
|
|||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
downloadButtonText: "Download",
|
downloadButtonText: "Download",
|
||||||
onTagClick: undefined,
|
onTagClick: undefined,
|
||||||
onDownloadClick: undefined,
|
|
||||||
onFavoriteClick: undefined,
|
|
||||||
onUnfavoriteClick: undefined,
|
|
||||||
onReportAbuseClick: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrapper = shallow(<NotebookMetadataComponent {...props} />);
|
const wrapper = shallow(<NotebookMetadataComponent {...props} />);
|
||||||
@@ -57,10 +53,6 @@ describe("NotebookMetadataComponent", () => {
|
|||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
downloadButtonText: "Download",
|
downloadButtonText: "Download",
|
||||||
onTagClick: undefined,
|
onTagClick: undefined,
|
||||||
onDownloadClick: undefined,
|
|
||||||
onFavoriteClick: undefined,
|
|
||||||
onUnfavoriteClick: undefined,
|
|
||||||
onReportAbuseClick: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrapper = shallow(<NotebookMetadataComponent {...props} />);
|
const wrapper = shallow(<NotebookMetadataComponent {...props} />);
|
||||||
|
|||||||
@@ -1,46 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Wrapper around Notebook metadata
|
* Wrapper around Notebook metadata
|
||||||
*/
|
*/
|
||||||
import { FontWeights, Icon, IconButton, Link, Persona, PersonaSize, PrimaryButton, Stack, Text } from "@fluentui/react";
|
import { FontWeights, Icon, Link, Persona, PersonaSize, Stack, Text } from "@fluentui/react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { IGalleryItem } from "../../../Juno/JunoClient";
|
import { IGalleryItem } from "../../../Juno/JunoClient";
|
||||||
import * as FileSystemUtil from "../../Notebook/FileSystemUtil";
|
import * as FileSystemUtil from "../../Notebook/FileSystemUtil";
|
||||||
import "./NotebookViewerComponent.less";
|
import "./NotebookViewerComponent.less";
|
||||||
import CosmosDBLogo from "../../../../images/CosmosDB-logo.svg";
|
import CosmosDBLogo from "../../../../images/CosmosDB-logo.svg";
|
||||||
import { InfoComponent } from "../NotebookGallery/InfoComponent/InfoComponent";
|
|
||||||
|
|
||||||
export interface NotebookMetadataComponentProps {
|
export interface NotebookMetadataComponentProps {
|
||||||
data: IGalleryItem;
|
data: IGalleryItem;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
downloadButtonText?: string;
|
downloadButtonText?: string;
|
||||||
onTagClick: (tag: string) => void;
|
onTagClick: (tag: string) => void;
|
||||||
onFavoriteClick: () => void;
|
|
||||||
onUnfavoriteClick: () => void;
|
|
||||||
onDownloadClick: () => void;
|
|
||||||
onReportAbuseClick: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NotebookMetadataComponent extends React.Component<NotebookMetadataComponentProps> {
|
export class NotebookMetadataComponent extends React.Component<NotebookMetadataComponentProps> {
|
||||||
private renderFavouriteButton = (): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<Text>
|
|
||||||
{this.props.isFavorite !== undefined ? (
|
|
||||||
<>
|
|
||||||
<IconButton
|
|
||||||
iconProps={{ iconName: this.props.isFavorite ? "HeartFill" : "Heart" }}
|
|
||||||
onClick={this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick}
|
|
||||||
/>
|
|
||||||
{this.props.data.favorites} likes
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon iconName="Heart" /> {this.props.data.favorites} likes
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
public render(): JSX.Element {
|
public render(): JSX.Element {
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -59,20 +34,10 @@ export class NotebookMetadataComponent extends React.Component<NotebookMetadataC
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack.Item>
|
</Stack.Item>
|
||||||
|
|
||||||
<Stack.Item>{this.renderFavouriteButton()}</Stack.Item>
|
|
||||||
|
|
||||||
{this.props.downloadButtonText && (
|
|
||||||
<Stack.Item>
|
|
||||||
<PrimaryButton text={this.props.downloadButtonText} onClick={this.props.onDownloadClick} />
|
|
||||||
</Stack.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack.Item grow>
|
|
||||||
<></>
|
|
||||||
</Stack.Item>
|
|
||||||
|
|
||||||
<Stack.Item>
|
<Stack.Item>
|
||||||
<InfoComponent onReportAbuseClick={this.props.onReportAbuseClick} />
|
<Text>
|
||||||
|
<Icon iconName="Heart" /> {this.props.data.favorites} likes
|
||||||
|
</Text>
|
||||||
</Stack.Item>
|
</Stack.Item>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Wrapper around Notebook Viewer Read only content
|
* Wrapper around Notebook Viewer Read only content
|
||||||
*/
|
*/
|
||||||
import { IChoiceGroupProps, Icon, IProgressIndicatorProps, Link, ProgressIndicator } from "@fluentui/react";
|
import { Icon, Link, ProgressIndicator } from "@fluentui/react";
|
||||||
import { Notebook } from "@nteract/commutable";
|
import { Notebook } from "@nteract/commutable";
|
||||||
import { createContentRef } from "@nteract/core";
|
import { createContentRef } from "@nteract/core";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -11,14 +11,11 @@ import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient";
|
|||||||
import { SessionStorageUtility } from "../../../Shared/StorageUtility";
|
import { SessionStorageUtility } from "../../../Shared/StorageUtility";
|
||||||
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||||
import { traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor";
|
import { traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||||
import * as GalleryUtils from "../../../Utils/GalleryUtils";
|
|
||||||
import { DialogHost } from "../../../Utils/GalleryUtils";
|
|
||||||
import Explorer from "../../Explorer";
|
import Explorer from "../../Explorer";
|
||||||
import { NotebookClientV2 } from "../../Notebook/NotebookClientV2";
|
import { NotebookClientV2 } from "../../Notebook/NotebookClientV2";
|
||||||
import { NotebookComponentBootstrapper } from "../../Notebook/NotebookComponent/NotebookComponentBootstrapper";
|
import { NotebookComponentBootstrapper } from "../../Notebook/NotebookComponent/NotebookComponentBootstrapper";
|
||||||
import NotebookReadOnlyRenderer from "../../Notebook/NotebookRenderer/NotebookReadOnlyRenderer";
|
import NotebookReadOnlyRenderer from "../../Notebook/NotebookRenderer/NotebookReadOnlyRenderer";
|
||||||
import { useNotebook } from "../../Notebook/useNotebook";
|
import { useNotebook } from "../../Notebook/useNotebook";
|
||||||
import { Dialog, TextFieldProps, useDialog } from "../Dialog";
|
|
||||||
import { NotebookMetadataComponent } from "./NotebookMetadataComponent";
|
import { NotebookMetadataComponent } from "./NotebookMetadataComponent";
|
||||||
import "./NotebookViewerComponent.less";
|
import "./NotebookViewerComponent.less";
|
||||||
|
|
||||||
@@ -42,10 +39,10 @@ interface NotebookViewerComponentState {
|
|||||||
showProgressBar: boolean;
|
showProgressBar: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NotebookViewerComponent
|
export class NotebookViewerComponent extends React.Component<
|
||||||
extends React.Component<NotebookViewerComponentProps, NotebookViewerComponentState>
|
NotebookViewerComponentProps,
|
||||||
implements DialogHost
|
NotebookViewerComponentState
|
||||||
{
|
> {
|
||||||
private clientManager: NotebookClientV2;
|
private clientManager: NotebookClientV2;
|
||||||
private notebookComponentBootstrapper: NotebookComponentBootstrapper;
|
private notebookComponentBootstrapper: NotebookComponentBootstrapper;
|
||||||
|
|
||||||
@@ -102,7 +99,6 @@ export class NotebookViewerComponent
|
|||||||
);
|
);
|
||||||
|
|
||||||
const notebook: Notebook = await response.json();
|
const notebook: Notebook = await response.json();
|
||||||
GalleryUtils.removeNotebookViewerLink(notebook, this.props.galleryItem?.newCellId);
|
|
||||||
this.notebookComponentBootstrapper.setContent("json", notebook);
|
this.notebookComponentBootstrapper.setContent("json", notebook);
|
||||||
this.setState({ content: notebook, showProgressBar: false });
|
this.setState({ content: notebook, showProgressBar: false });
|
||||||
|
|
||||||
@@ -150,10 +146,6 @@ export class NotebookViewerComponent
|
|||||||
isFavorite={this.state.isFavorite}
|
isFavorite={this.state.isFavorite}
|
||||||
downloadButtonText={this.props.container && `Download to ${useNotebook.getState().notebookFolderName}`}
|
downloadButtonText={this.props.container && `Download to ${useNotebook.getState().notebookFolderName}`}
|
||||||
onTagClick={this.props.onTagClick}
|
onTagClick={this.props.onTagClick}
|
||||||
onFavoriteClick={this.favoriteItem}
|
|
||||||
onUnfavoriteClick={this.unfavoriteItem}
|
|
||||||
onDownloadClick={this.downloadItem}
|
|
||||||
onReportAbuseClick={this.state.galleryItem.isSample ? undefined : this.reportAbuse}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -166,7 +158,6 @@ export class NotebookViewerComponent
|
|||||||
hideInputs: this.props.hideInputs,
|
hideInputs: this.props.hideInputs,
|
||||||
hidePrompts: this.props.hidePrompts,
|
hidePrompts: this.props.hidePrompts,
|
||||||
})}
|
})}
|
||||||
<Dialog />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -191,81 +182,4 @@ export class NotebookViewerComponent
|
|||||||
isFavorite,
|
isFavorite,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
showOkModalDialog(
|
|
||||||
title: string,
|
|
||||||
msg: string,
|
|
||||||
okLabel: string,
|
|
||||||
onOk: () => void,
|
|
||||||
progressIndicatorProps?: IProgressIndicatorProps,
|
|
||||||
): void {
|
|
||||||
useDialog.getState().openDialog({
|
|
||||||
isModal: true,
|
|
||||||
title,
|
|
||||||
subText: msg,
|
|
||||||
primaryButtonText: okLabel,
|
|
||||||
onPrimaryButtonClick: () => {
|
|
||||||
useDialog.getState().closeDialog();
|
|
||||||
onOk && onOk();
|
|
||||||
},
|
|
||||||
secondaryButtonText: undefined,
|
|
||||||
onSecondaryButtonClick: undefined,
|
|
||||||
progressIndicatorProps,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showOkCancelModalDialog(
|
|
||||||
title: string,
|
|
||||||
msg: string,
|
|
||||||
okLabel: string,
|
|
||||||
onOk: () => void,
|
|
||||||
cancelLabel: string,
|
|
||||||
onCancel: () => void,
|
|
||||||
progressIndicatorProps?: IProgressIndicatorProps,
|
|
||||||
choiceGroupProps?: IChoiceGroupProps,
|
|
||||||
textFieldProps?: TextFieldProps,
|
|
||||||
primaryButtonDisabled?: boolean,
|
|
||||||
): void {
|
|
||||||
useDialog.getState().openDialog({
|
|
||||||
isModal: true,
|
|
||||||
title,
|
|
||||||
subText: msg,
|
|
||||||
primaryButtonText: okLabel,
|
|
||||||
secondaryButtonText: cancelLabel,
|
|
||||||
onPrimaryButtonClick: () => {
|
|
||||||
useDialog.getState().closeDialog();
|
|
||||||
onOk && onOk();
|
|
||||||
},
|
|
||||||
onSecondaryButtonClick: () => {
|
|
||||||
useDialog.getState().closeDialog();
|
|
||||||
onCancel && onCancel();
|
|
||||||
},
|
|
||||||
progressIndicatorProps,
|
|
||||||
choiceGroupProps,
|
|
||||||
textFieldProps,
|
|
||||||
primaryButtonDisabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private favoriteItem = async (): Promise<void> => {
|
|
||||||
GalleryUtils.favoriteItem(this.props.container, this.props.junoClient, this.state.galleryItem, (item) =>
|
|
||||||
this.setState({ galleryItem: item, isFavorite: true }),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private unfavoriteItem = async (): Promise<void> => {
|
|
||||||
GalleryUtils.unfavoriteItem(this.props.container, this.props.junoClient, this.state.galleryItem, (item) =>
|
|
||||||
this.setState({ galleryItem: item, isFavorite: false }),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private downloadItem = async (): Promise<void> => {
|
|
||||||
GalleryUtils.downloadItem(this.props.container, this.props.junoClient, this.state.galleryItem, (item) =>
|
|
||||||
this.setState({ galleryItem: item }),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private reportAbuse = (): void => {
|
|
||||||
GalleryUtils.reportAbuse(this.props.junoClient, this.state.galleryItem, this, () => {});
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-34
@@ -27,28 +27,14 @@ exports[`NotebookMetadataComponent renders liked notebook 1`] = `
|
|||||||
</StackItem>
|
</StackItem>
|
||||||
<StackItem>
|
<StackItem>
|
||||||
<Text>
|
<Text>
|
||||||
<CustomizedIconButton
|
<Icon
|
||||||
iconProps={
|
iconName="Heart"
|
||||||
{
|
|
||||||
"iconName": "HeartFill",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
0
|
0
|
||||||
likes
|
likes
|
||||||
</Text>
|
</Text>
|
||||||
</StackItem>
|
</StackItem>
|
||||||
<StackItem>
|
|
||||||
<CustomizedPrimaryButton
|
|
||||||
text="Download"
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem
|
|
||||||
grow={true}
|
|
||||||
/>
|
|
||||||
<StackItem>
|
|
||||||
<InfoComponent />
|
|
||||||
</StackItem>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack
|
<Stack
|
||||||
horizontal={true}
|
horizontal={true}
|
||||||
@@ -139,28 +125,14 @@ exports[`NotebookMetadataComponent renders un-liked notebook 1`] = `
|
|||||||
</StackItem>
|
</StackItem>
|
||||||
<StackItem>
|
<StackItem>
|
||||||
<Text>
|
<Text>
|
||||||
<CustomizedIconButton
|
<Icon
|
||||||
iconProps={
|
iconName="Heart"
|
||||||
{
|
|
||||||
"iconName": "Heart",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
0
|
0
|
||||||
likes
|
likes
|
||||||
</Text>
|
</Text>
|
||||||
</StackItem>
|
</StackItem>
|
||||||
<StackItem>
|
|
||||||
<CustomizedPrimaryButton
|
|
||||||
text="Download"
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem
|
|
||||||
grow={true}
|
|
||||||
/>
|
|
||||||
<StackItem>
|
|
||||||
<InfoComponent />
|
|
||||||
</StackItem>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack
|
<Stack
|
||||||
horizontal={true}
|
horizontal={true}
|
||||||
|
|||||||
+115
-15
@@ -1,23 +1,85 @@
|
|||||||
import { shallow } from "enzyme";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { DatabaseAccount } from "Contracts/DataModels";
|
||||||
import {
|
import {
|
||||||
PartitionKeyComponent,
|
PartitionKeyComponent,
|
||||||
PartitionKeyComponentProps,
|
PartitionKeyComponentProps,
|
||||||
} from "Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent";
|
} from "Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent";
|
||||||
import Explorer from "Explorer/Explorer";
|
import { useDataTransferJobs } from "hooks/useDataTransferJobs";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { updateUserContext } from "UserContext";
|
||||||
|
import { DataTransferJobGetResults } from "Utils/arm/generatedClients/dataTransferService/types";
|
||||||
|
import Explorer from "../../../Explorer";
|
||||||
|
|
||||||
|
jest.mock("Common/dataAccess/dataTransfers", () => ({
|
||||||
|
cancelDataTransferJob: jest.fn().mockResolvedValue(undefined),
|
||||||
|
pauseDataTransferJob: jest.fn().mockResolvedValue(undefined),
|
||||||
|
resumeDataTransferJob: jest.fn().mockResolvedValue(undefined),
|
||||||
|
completeDataTransferJob: jest.fn().mockResolvedValue(undefined),
|
||||||
|
pollDataTransferJob: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("hooks/useDataTransferJobs", () => ({
|
||||||
|
useDataTransferJobs: jest.fn(() => ({ dataTransferJobs: [] })),
|
||||||
|
refreshDataTransferJobs: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("hooks/useSidePanel", () => ({
|
||||||
|
useSidePanel: {
|
||||||
|
getState: () => ({
|
||||||
|
openSidePanel: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("ConfigContext", () => ({
|
||||||
|
configContext: { platform: "Portal" },
|
||||||
|
Platform: { Emulator: "Emulator", Portal: "Portal" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("Explorer/Explorer", () => {
|
||||||
|
return jest.fn().mockImplementation(() => ({
|
||||||
|
refreshAllDatabases: jest.fn(),
|
||||||
|
refreshExplorer: jest.fn(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockOfflineJob = {
|
||||||
|
properties: {
|
||||||
|
jobName: "Portal_test_123",
|
||||||
|
source: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "testCol" },
|
||||||
|
destination: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "newCol" },
|
||||||
|
status: "InProgress",
|
||||||
|
processedCount: 50,
|
||||||
|
totalCount: 100,
|
||||||
|
mode: "Offline" as const,
|
||||||
|
},
|
||||||
|
} as DataTransferJobGetResults;
|
||||||
|
|
||||||
|
const mockOnlineJob = {
|
||||||
|
properties: {
|
||||||
|
jobName: "Portal_test_456",
|
||||||
|
source: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "testCol" },
|
||||||
|
destination: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "newCol" },
|
||||||
|
status: "InProgress",
|
||||||
|
processedCount: 50,
|
||||||
|
totalCount: 100,
|
||||||
|
mode: "Online" as const,
|
||||||
|
},
|
||||||
|
} as DataTransferJobGetResults;
|
||||||
|
|
||||||
describe("PartitionKeyComponent", () => {
|
describe("PartitionKeyComponent", () => {
|
||||||
// Create a test setup function to get fresh instances for each test
|
|
||||||
const setupTest = () => {
|
const setupTest = () => {
|
||||||
// Create an instance of the mocked Explorer
|
|
||||||
const explorer = new Explorer();
|
const explorer = new Explorer();
|
||||||
// Create minimal mock objects for database and collection
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const mockDatabase = {} as any as import("../../../../Contracts/ViewModels").Database;
|
const mockDatabase = {} as any as import("../../../../Contracts/ViewModels").Database;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const mockCollection = {
|
||||||
const mockCollection = {} as any as import("../../../../Contracts/ViewModels").Collection;
|
id: jest.fn().mockReturnValue("testCol"),
|
||||||
|
databaseId: "testDb",
|
||||||
|
partitionKey: { kind: "Hash", paths: ["/id"], version: 2 },
|
||||||
|
partitionKeyProperties: ["id"],
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any as import("../../../../Contracts/ViewModels").Collection;
|
||||||
|
|
||||||
// Create props with the mocked Explorer instance
|
|
||||||
const props: PartitionKeyComponentProps = {
|
const props: PartitionKeyComponentProps = {
|
||||||
database: mockDatabase,
|
database: mockDatabase,
|
||||||
collection: mockCollection,
|
collection: mockCollection,
|
||||||
@@ -27,15 +89,53 @@ describe("PartitionKeyComponent", () => {
|
|||||||
return { explorer, props };
|
return { explorer, props };
|
||||||
};
|
};
|
||||||
|
|
||||||
it("renders default component and matches snapshot", () => {
|
beforeEach(() => {
|
||||||
const { props } = setupTest();
|
jest.clearAllMocks();
|
||||||
const wrapper = shallow(<PartitionKeyComponent {...props} />);
|
updateUserContext({
|
||||||
expect(wrapper).toMatchSnapshot();
|
databaseAccount: {
|
||||||
|
name: "testAccount",
|
||||||
|
id: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/testAccount",
|
||||||
|
properties: {
|
||||||
|
documentEndpoint: "https://test.documents.azure.com",
|
||||||
|
},
|
||||||
|
} as unknown as DatabaseAccount,
|
||||||
|
subscriptionId: "sub1",
|
||||||
|
resourceGroup: "rg1",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders read-only component and matches snapshot", () => {
|
it("renders partition key value", () => {
|
||||||
const { props } = setupTest();
|
const { props } = setupTest();
|
||||||
const wrapper = shallow(<PartitionKeyComponent {...props} isReadOnly={true} />);
|
render(<PartitionKeyComponent {...props} />);
|
||||||
expect(wrapper).toMatchSnapshot();
|
expect(screen.getByText("/id")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders read-only component without change button", () => {
|
||||||
|
const { props } = setupTest();
|
||||||
|
const { container } = render(<PartitionKeyComponent {...props} isReadOnly={true} />);
|
||||||
|
expect(container.querySelector("[data-test='change-partition-key-button']")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows cancel button for offline job in progress", () => {
|
||||||
|
(useDataTransferJobs as unknown as jest.Mock).mockReturnValue({
|
||||||
|
dataTransferJobs: [mockOfflineJob],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { props } = setupTest();
|
||||||
|
const { container } = render(<PartitionKeyComponent {...props} />);
|
||||||
|
// For offline jobs, the online action menu should not be present
|
||||||
|
expect(container.querySelector("[data-test='online-job-action-menu']")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows ellipsis action menu for online job in progress", async () => {
|
||||||
|
(useDataTransferJobs as unknown as jest.Mock).mockReturnValue({
|
||||||
|
dataTransferJobs: [mockOnlineJob],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { props } = setupTest();
|
||||||
|
const { container } = render(<PartitionKeyComponent {...props} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector("[data-test='online-job-action-menu']")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
DefaultButton,
|
DefaultButton,
|
||||||
|
DirectionalHint,
|
||||||
FontWeights,
|
FontWeights,
|
||||||
|
IContextualMenuProps,
|
||||||
IMessageBarStyles,
|
IMessageBarStyles,
|
||||||
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
MessageBar,
|
MessageBar,
|
||||||
MessageBarType,
|
MessageBarType,
|
||||||
@@ -14,8 +17,16 @@ import * as React from "react";
|
|||||||
import * as ViewModels from "../../../../Contracts/ViewModels";
|
import * as ViewModels from "../../../../Contracts/ViewModels";
|
||||||
|
|
||||||
import { handleError } from "Common/ErrorHandlingUtils";
|
import { handleError } from "Common/ErrorHandlingUtils";
|
||||||
import { cancelDataTransferJob, pollDataTransferJob } from "Common/dataAccess/dataTransfers";
|
import {
|
||||||
|
cancelDataTransferJob,
|
||||||
|
completeDataTransferJob,
|
||||||
|
pauseDataTransferJob,
|
||||||
|
pollDataTransferJob,
|
||||||
|
resumeDataTransferJob,
|
||||||
|
} from "Common/dataAccess/dataTransfers";
|
||||||
import { Platform, configContext } from "ConfigContext";
|
import { Platform, configContext } from "ConfigContext";
|
||||||
|
import { CopyJobActions, CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums";
|
||||||
|
import { useDialog } from "Explorer/Controls/Dialog";
|
||||||
import Explorer from "Explorer/Explorer";
|
import Explorer from "Explorer/Explorer";
|
||||||
import { ChangePartitionKeyPane } from "Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane";
|
import { ChangePartitionKeyPane } from "Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane";
|
||||||
import { Keys, t } from "Localization";
|
import { Keys, t } from "Localization";
|
||||||
@@ -94,11 +105,11 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({
|
|||||||
const textSubHeadingStyle1 = {
|
const textSubHeadingStyle1 = {
|
||||||
root: { color: "var(--colorNeutralForeground1)" },
|
root: { color: "var(--colorNeutralForeground1)" },
|
||||||
};
|
};
|
||||||
const startPollingforUpdate = (currentJob: DataTransferJobGetResults) => {
|
const startPollingforUpdate = async (currentJob: DataTransferJobGetResults) => {
|
||||||
if (isCurrentJobInProgress(currentJob)) {
|
if (isCurrentJobInProgress(currentJob)) {
|
||||||
const jobName = currentJob?.properties?.jobName;
|
const jobName = currentJob?.properties?.jobName;
|
||||||
try {
|
try {
|
||||||
pollDataTransferJob(
|
await pollDataTransferJob(
|
||||||
jobName,
|
jobName,
|
||||||
userContext.subscriptionId,
|
userContext.subscriptionId,
|
||||||
userContext.resourceGroup,
|
userContext.resourceGroup,
|
||||||
@@ -119,6 +130,124 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pauseRunningDataTransferJob = async (currentJob: DataTransferJobGetResults) => {
|
||||||
|
await pauseDataTransferJob(
|
||||||
|
userContext.subscriptionId,
|
||||||
|
userContext.resourceGroup,
|
||||||
|
userContext.databaseAccount.name,
|
||||||
|
currentJob?.properties?.jobName,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumePausedDataTransferJob = async (currentJob: DataTransferJobGetResults) => {
|
||||||
|
await resumeDataTransferJob(
|
||||||
|
userContext.subscriptionId,
|
||||||
|
userContext.resourceGroup,
|
||||||
|
userContext.databaseAccount.name,
|
||||||
|
currentJob?.properties?.jobName,
|
||||||
|
);
|
||||||
|
startPollingforUpdate(currentJob);
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeOnlineDataTransferJob = async (currentJob: DataTransferJobGetResults) => {
|
||||||
|
await completeDataTransferJob(
|
||||||
|
userContext.subscriptionId,
|
||||||
|
userContext.resourceGroup,
|
||||||
|
userContext.databaseAccount.name,
|
||||||
|
currentJob?.properties?.jobName,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOnlineJob = (currentJob: DataTransferJobGetResults): boolean => {
|
||||||
|
const mode = (currentJob?.properties?.mode ?? "").toLowerCase();
|
||||||
|
return mode === CopyJobMigrationType.Online;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showActionConfirmationDialog = (
|
||||||
|
currentJob: DataTransferJobGetResults,
|
||||||
|
action: CopyJobActions,
|
||||||
|
onConfirm: () => void,
|
||||||
|
): void => {
|
||||||
|
const jobName = currentJob?.properties?.jobName;
|
||||||
|
const dialogBody =
|
||||||
|
action === CopyJobActions.cancel ? (
|
||||||
|
<Stack tokens={{ childrenGap: 10 }}>
|
||||||
|
<Stack.Item>
|
||||||
|
{t(Keys.controls.settings.partitionKeyEditor.confirmCancel1)}
|
||||||
|
<br />
|
||||||
|
<b>{jobName}</b>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item>{t(Keys.controls.settings.partitionKeyEditor.confirmCancel2)}</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
) : action === CopyJobActions.complete ? (
|
||||||
|
<Stack tokens={{ childrenGap: 10 }}>
|
||||||
|
<Stack.Item>
|
||||||
|
{t(Keys.controls.settings.partitionKeyEditor.confirmComplete1)}
|
||||||
|
<br />
|
||||||
|
<b>{jobName}</b>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item>{t(Keys.controls.settings.partitionKeyEditor.confrimComplete2)}</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
useDialog
|
||||||
|
.getState()
|
||||||
|
.showOkCancelModalDialog("", null, t(Keys.common.confirm), onConfirm, t(Keys.common.cancel), null, dialogBody);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOnlineJobMenuProps = (currentJob: DataTransferJobGetResults): IContextualMenuProps => {
|
||||||
|
const jobStatus = currentJob?.properties?.status;
|
||||||
|
const isPaused = jobStatus === "Paused";
|
||||||
|
|
||||||
|
const items: IContextualMenuProps["items"] = [];
|
||||||
|
|
||||||
|
if (!isPaused) {
|
||||||
|
items.push({
|
||||||
|
key: CopyJobActions.pause,
|
||||||
|
text: t(Keys.containerCopy.monitorJobs.actions.pause),
|
||||||
|
iconProps: { iconName: "Pause" },
|
||||||
|
onClick: () => {
|
||||||
|
pauseRunningDataTransferJob(currentJob);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPaused) {
|
||||||
|
items.push({
|
||||||
|
key: CopyJobActions.resume,
|
||||||
|
text: t(Keys.containerCopy.monitorJobs.actions.resume),
|
||||||
|
iconProps: { iconName: "Play" },
|
||||||
|
onClick: () => {
|
||||||
|
resumePausedDataTransferJob(currentJob);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
key: CopyJobActions.cancel,
|
||||||
|
text: t(Keys.common.cancel),
|
||||||
|
iconProps: { iconName: "Cancel" },
|
||||||
|
onClick: () =>
|
||||||
|
showActionConfirmationDialog(currentJob, CopyJobActions.cancel, () => cancelRunningDataTransferJob(currentJob)),
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
key: CopyJobActions.complete,
|
||||||
|
text: t(Keys.containerCopy.monitorJobs.actions.complete),
|
||||||
|
iconProps: { iconName: "CheckMark" },
|
||||||
|
onClick: () =>
|
||||||
|
showActionConfirmationDialog(currentJob, CopyJobActions.complete, () =>
|
||||||
|
completeOnlineDataTransferJob(currentJob),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
directionalHint: DirectionalHint.leftTopEdge,
|
||||||
|
directionalHintFixed: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const isCurrentJobInProgress = (currentJob: DataTransferJobGetResults) => {
|
const isCurrentJobInProgress = (currentJob: DataTransferJobGetResults) => {
|
||||||
const jobStatus = currentJob?.properties?.status;
|
const jobStatus = currentJob?.properties?.status;
|
||||||
return (
|
return (
|
||||||
@@ -269,12 +398,26 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
></ProgressIndicator>
|
></ProgressIndicator>
|
||||||
{isCurrentJobInProgress(portalDataTransferJob) && (
|
{isCurrentJobInProgress(portalDataTransferJob) &&
|
||||||
<DefaultButton
|
(isOnlineJob(portalDataTransferJob) ? (
|
||||||
text={t(Keys.controls.settings.partitionKeyEditor.cancelButton)}
|
<IconButton
|
||||||
onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)}
|
data-test="online-job-action-menu"
|
||||||
/>
|
role="button"
|
||||||
)}
|
iconProps={{
|
||||||
|
iconName: "More",
|
||||||
|
styles: { root: { fontSize: "20px", fontWeight: "bold" } },
|
||||||
|
}}
|
||||||
|
menuProps={getOnlineJobMenuProps(portalDataTransferJob)}
|
||||||
|
menuIconProps={{ iconName: "", className: "hidden" }}
|
||||||
|
ariaLabel={t(Keys.containerCopy.monitorJobs.columns.actions)}
|
||||||
|
title={t(Keys.containerCopy.monitorJobs.columns.actions)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DefaultButton
|
||||||
|
text={t(Keys.controls.settings.partitionKeyEditor.cancelButton)}
|
||||||
|
onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|||||||
-271
@@ -1,271 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`PartitionKeyComponent renders default component and matches snapshot 1`] = `
|
|
||||||
<Stack
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"maxWidth": 600,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 20,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 10,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"color": "var(--colorNeutralForeground1)",
|
|
||||||
"fontSize": 16,
|
|
||||||
"fontWeight": 600,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Change partition key
|
|
||||||
</Text>
|
|
||||||
<Stack
|
|
||||||
horizontal={true}
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 20,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 5,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"color": "var(--colorNeutralForeground1)",
|
|
||||||
"fontWeight": 600,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Current partition key
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"color": "var(--colorNeutralForeground1)",
|
|
||||||
"fontWeight": 600,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Partitioning
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack
|
|
||||||
data-test="partition-key-values"
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 5,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"color": "var(--colorNeutralForeground1)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"color": "var(--colorNeutralForeground1)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Non-hierarchical
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
<StyledMessageBar
|
|
||||||
data-test="partition-key-warning"
|
|
||||||
messageBarIconProps={
|
|
||||||
{
|
|
||||||
"className": "messageBarWarningIcon",
|
|
||||||
"iconName": "WarningSolid",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messageBarType={5}
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"selectors": {
|
|
||||||
"&.ms-MessageBar--warning": {
|
|
||||||
"backgroundColor": "var(--colorStatusWarningBackground1)",
|
|
||||||
"border": "1px solid var(--colorStatusWarningBorder1)",
|
|
||||||
},
|
|
||||||
".ms-MessageBar-icon": {
|
|
||||||
"color": "var(--colorNeutralForeground1)",
|
|
||||||
},
|
|
||||||
".ms-MessageBar-text": {
|
|
||||||
"color": "var(--colorNeutralForeground1)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the source container for the entire duration of the partition key change process.
|
|
||||||
<StyledLinkBase
|
|
||||||
href="https://learn.microsoft.com/azure/cosmos-db/container-copy#how-does-container-copy-work"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"color": "var(--colorBrandForeground1)",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
target="_blank"
|
|
||||||
underline={true}
|
|
||||||
>
|
|
||||||
Learn more
|
|
||||||
</StyledLinkBase>
|
|
||||||
</StyledMessageBar>
|
|
||||||
<Text
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"color": "var(--colorNeutralForeground1)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
To change the partition key, a new destination container must be created or an existing destination container selected. Data will then be copied to the destination container.
|
|
||||||
</Text>
|
|
||||||
<CustomizedPrimaryButton
|
|
||||||
data-test="change-partition-key-button"
|
|
||||||
onClick={[Function]}
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"width": "fit-content",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
text="Change"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`PartitionKeyComponent renders read-only component and matches snapshot 1`] = `
|
|
||||||
<Stack
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"maxWidth": 600,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 20,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 10,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
horizontal={true}
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 20,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 5,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"color": "var(--colorNeutralForeground1)",
|
|
||||||
"fontWeight": 600,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Current partition key
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"color": "var(--colorNeutralForeground1)",
|
|
||||||
"fontWeight": 600,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Partitioning
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack
|
|
||||||
data-test="partition-key-values"
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 5,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"color": "var(--colorNeutralForeground1)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
styles={
|
|
||||||
{
|
|
||||||
"root": {
|
|
||||||
"color": "var(--colorNeutralForeground1)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Non-hierarchical
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
`;
|
|
||||||
@@ -291,7 +291,7 @@ describe("SettingsUtils", () => {
|
|||||||
|
|
||||||
it("handles partition key tab title based on fabric native", () => {
|
it("handles partition key tab title based on fabric native", () => {
|
||||||
// Assuming initially not fabric native
|
// Assuming initially not fabric native
|
||||||
expect(getTabTitle(SettingsV2TabTypes.PartitionKeyTab)).toBe("Partition Keys (preview)");
|
expect(getTabTitle(SettingsV2TabTypes.PartitionKeyTab)).toBe("Partition Keys");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws error for unknown tab type", () => {
|
it("throws error for unknown tab type", () => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Keys, t } from "Localization";
|
|||||||
import * as Constants from "../../../Common/Constants";
|
import * as Constants from "../../../Common/Constants";
|
||||||
import * as DataModels from "../../../Contracts/DataModels";
|
import * as DataModels from "../../../Contracts/DataModels";
|
||||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
import { isFabricNative } from "../../../Platform/Fabric/FabricUtil";
|
|
||||||
import { userContext } from "../../../UserContext";
|
import { userContext } from "../../../UserContext";
|
||||||
import { isCapabilityEnabled } from "../../../Utils/CapabilityUtils";
|
import { isCapabilityEnabled } from "../../../Utils/CapabilityUtils";
|
||||||
import { MongoIndex } from "../../../Utils/arm/generatedClients/cosmos/types";
|
import { MongoIndex } from "../../../Utils/arm/generatedClients/cosmos/types";
|
||||||
@@ -185,9 +184,7 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => {
|
|||||||
case SettingsV2TabTypes.IndexingPolicyTab:
|
case SettingsV2TabTypes.IndexingPolicyTab:
|
||||||
return t(Keys.controls.settings.tabTitles.indexingPolicy);
|
return t(Keys.controls.settings.tabTitles.indexingPolicy);
|
||||||
case SettingsV2TabTypes.PartitionKeyTab:
|
case SettingsV2TabTypes.PartitionKeyTab:
|
||||||
return isFabricNative()
|
return t(Keys.controls.settings.tabTitles.partitionKeys);
|
||||||
? t(Keys.controls.settings.tabTitles.partitionKeys)
|
|
||||||
: t(Keys.controls.settings.tabTitles.partitionKeysPreview);
|
|
||||||
case SettingsV2TabTypes.ComputedPropertiesTab:
|
case SettingsV2TabTypes.ComputedPropertiesTab:
|
||||||
return t(Keys.controls.settings.tabTitles.computedProperties);
|
return t(Keys.controls.settings.tabTitles.computedProperties);
|
||||||
case SettingsV2TabTypes.ContainerVectorPolicyTab:
|
case SettingsV2TabTypes.ContainerVectorPolicyTab:
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ exports[`SettingsComponent renders 1`] = `
|
|||||||
"data-test": "settings-tab-header/PartitionKeyTab",
|
"data-test": "settings-tab-header/PartitionKeyTab",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
headerText="Partition Keys (preview)"
|
headerText="Partition Keys"
|
||||||
itemKey="PartitionKeyTab"
|
itemKey="PartitionKeyTab"
|
||||||
key="PartitionKeyTab"
|
key="PartitionKeyTab"
|
||||||
style={
|
style={
|
||||||
|
|||||||
+13
-62
@@ -2,10 +2,10 @@ import * as msal from "@azure/msal-browser";
|
|||||||
import { Link } from "@fluentui/react/lib/Link";
|
import { Link } from "@fluentui/react/lib/Link";
|
||||||
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
|
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
|
||||||
import { sendMessage } from "Common/MessageHandler";
|
import { sendMessage } from "Common/MessageHandler";
|
||||||
|
import { stringifyError } from "Common/stringifyError";
|
||||||
import { Platform, configContext } from "ConfigContext";
|
import { Platform, configContext } from "ConfigContext";
|
||||||
import { MessageTypes } from "Contracts/ExplorerContracts";
|
import { MessageTypes } from "Contracts/ExplorerContracts";
|
||||||
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
|
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
|
||||||
import { IGalleryItem } from "Juno/JunoClient";
|
|
||||||
import {
|
import {
|
||||||
isFabricMirrored,
|
isFabricMirrored,
|
||||||
isFabricMirroredKey,
|
isFabricMirroredKey,
|
||||||
@@ -52,13 +52,10 @@ import { useSidePanel } from "../hooks/useSidePanel";
|
|||||||
import { ReactTabKind, useTabs } from "../hooks/useTabs";
|
import { ReactTabKind, useTabs } from "../hooks/useTabs";
|
||||||
import "./ComponentRegisterer";
|
import "./ComponentRegisterer";
|
||||||
import { DialogProps, useDialog } from "./Controls/Dialog";
|
import { DialogProps, useDialog } from "./Controls/Dialog";
|
||||||
import { GalleryTab as GalleryTabKind } from "./Controls/NotebookGallery/GalleryViewerComponent";
|
|
||||||
import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter";
|
import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter";
|
||||||
import * as FileSystemUtil from "./Notebook/FileSystemUtil";
|
import * as FileSystemUtil from "./Notebook/FileSystemUtil";
|
||||||
import { SnapshotRequest } from "./Notebook/NotebookComponent/types";
|
|
||||||
import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem";
|
import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem";
|
||||||
import type NotebookManager from "./Notebook/NotebookManager";
|
import type NotebookManager from "./Notebook/NotebookManager";
|
||||||
import { NotebookPaneContent } from "./Notebook/NotebookManager";
|
|
||||||
import { NotebookUtil } from "./Notebook/NotebookUtil";
|
import { NotebookUtil } from "./Notebook/NotebookUtil";
|
||||||
import { useNotebook } from "./Notebook/useNotebook";
|
import { useNotebook } from "./Notebook/useNotebook";
|
||||||
import { AddCollectionPanel } from "./Panes/AddCollectionPanel/AddCollectionPanel";
|
import { AddCollectionPanel } from "./Panes/AddCollectionPanel/AddCollectionPanel";
|
||||||
@@ -291,7 +288,7 @@ export default class Explorer {
|
|||||||
"We were unable to establish authorization for this account, due to pop-ups being disabled in the browser.\nPlease enable pop-ups for this site and try again",
|
"We were unable to establish authorization for this account, due to pop-ups being disabled in the browser.\nPlease enable pop-ups for this site and try again",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const errorJson = JSON.stringify(error);
|
const errorJson = stringifyError(error);
|
||||||
logConsoleError(
|
logConsoleError(
|
||||||
`Failed to perform authorization for this account, due to the following error: \n${errorJson}`,
|
`Failed to perform authorization for this account, due to the following error: \n${errorJson}`,
|
||||||
);
|
);
|
||||||
@@ -405,19 +402,27 @@ export default class Explorer {
|
|||||||
},
|
},
|
||||||
startKey,
|
startKey,
|
||||||
);
|
);
|
||||||
|
console.log("{{cdbp}} in refreshAllDatabases(): done readDatabases");
|
||||||
const currentDatabases = useDatabases.getState().databases;
|
const currentDatabases = useDatabases.getState().databases;
|
||||||
|
console.log("{{cdbp}} in refreshAllDatabases(): currentDatabases: " + currentDatabases);
|
||||||
const deltaDatabases = this.getDeltaDatabases(databases, currentDatabases);
|
const deltaDatabases = this.getDeltaDatabases(databases, currentDatabases);
|
||||||
|
console.log("{{cdbp}} in refreshAllDatabases(): deltaDatabases: " + deltaDatabases);
|
||||||
let updatedDatabases = currentDatabases.filter(
|
let updatedDatabases = currentDatabases.filter(
|
||||||
(database) => !deltaDatabases.toDelete.some((deletedDatabase) => deletedDatabase.id() === database.id()),
|
(database) => !deltaDatabases.toDelete.some((deletedDatabase) => deletedDatabase.id() === database.id()),
|
||||||
);
|
);
|
||||||
|
console.log("{{cdbp}} in refreshAllDatabases(): updatedDatabases after filter: " + updatedDatabases);
|
||||||
updatedDatabases = [...updatedDatabases, ...deltaDatabases.toAdd].sort((db1, db2) =>
|
updatedDatabases = [...updatedDatabases, ...deltaDatabases.toAdd].sort((db1, db2) =>
|
||||||
db1.id().localeCompare(db2.id()),
|
db1.id().localeCompare(db2.id()),
|
||||||
);
|
);
|
||||||
|
console.log("{{cdbp}} in refreshAllDatabases(): updatedDatabases after sort: " + updatedDatabases);
|
||||||
useDatabases.setState({ databases: updatedDatabases, databasesFetchedSuccessfully: true });
|
useDatabases.setState({ databases: updatedDatabases, databasesFetchedSuccessfully: true });
|
||||||
scenarioMonitor.completePhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched);
|
scenarioMonitor.completePhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched);
|
||||||
|
|
||||||
|
console.log("{{cdbp}} in refreshAllDatabases(): calling refreshAndExpandNewDatabases");
|
||||||
await this.refreshAndExpandNewDatabases(deltaDatabases.toAdd, updatedDatabases);
|
await this.refreshAndExpandNewDatabases(deltaDatabases.toAdd, updatedDatabases);
|
||||||
|
console.log("{{cdbp}} in refreshAllDatabases(): done refreshAndExpandNewDatabases");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log("{{cdbp}} in refreshAllDatabases(): ERROR: " + stringifyError(error)); //CTODO this should be logged already but just in case
|
||||||
const errorMessage = getErrorMessage(error);
|
const errorMessage = getErrorMessage(error);
|
||||||
TelemetryProcessor.traceFailure(
|
TelemetryProcessor.traceFailure(
|
||||||
Action.LoadDatabases,
|
Action.LoadDatabases,
|
||||||
@@ -607,6 +612,7 @@ export default class Explorer {
|
|||||||
? databases
|
? databases
|
||||||
: databases.filter((db) => db.isDatabaseExpanded() || db.id() === Constants.SavedQueries.DatabaseName);
|
: databases.filter((db) => db.isDatabaseExpanded() || db.id() === Constants.SavedQueries.DatabaseName);
|
||||||
|
|
||||||
|
console.log("{{cdbp}} in refreshAndExpandNewDatabases(): databasesToLoad: " + databasesToLoad);
|
||||||
const startKey: number = TelemetryProcessor.traceStart(Action.LoadCollections, {
|
const startKey: number = TelemetryProcessor.traceStart(Action.LoadCollections, {
|
||||||
dataExplorerArea: Constants.Areas.ResourceTree,
|
dataExplorerArea: Constants.Areas.ResourceTree,
|
||||||
});
|
});
|
||||||
@@ -615,6 +621,7 @@ export default class Explorer {
|
|||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
databasesToLoad.map(async (database: ViewModels.Database) => {
|
databasesToLoad.map(async (database: ViewModels.Database) => {
|
||||||
|
console.log("{{cdbp}} in refreshAndExpandNewDatabases(): loadCollections for database: " + database.id);
|
||||||
await database.loadCollections(true);
|
await database.loadCollections(true);
|
||||||
const isNewDatabase: boolean = _.some(newDatabases, (db: ViewModels.Database) => db.id() === database.id());
|
const isNewDatabase: boolean = _.some(newDatabases, (db: ViewModels.Database) => db.id() === database.id());
|
||||||
if (isNewDatabase) {
|
if (isNewDatabase) {
|
||||||
@@ -634,6 +641,7 @@ export default class Explorer {
|
|||||||
// Start DatabaseTreeRendered — React render cycle will complete it in ResourceTree
|
// Start DatabaseTreeRendered — React render cycle will complete it in ResourceTree
|
||||||
scenarioMonitor.startPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabaseTreeRendered);
|
scenarioMonitor.startPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabaseTreeRendered);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log("{{cdbp}} in refreshAndExpandNewDatabases(): ERROR: " + stringifyError(error)); //CTODO this should be logged already but just in case
|
||||||
TelemetryProcessor.traceFailure(
|
TelemetryProcessor.traceFailure(
|
||||||
Action.LoadCollections,
|
Action.LoadCollections,
|
||||||
{
|
{
|
||||||
@@ -714,24 +722,6 @@ export default class Explorer {
|
|||||||
return Promise.resolve(false);
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async publishNotebook(
|
|
||||||
name: string,
|
|
||||||
content: NotebookPaneContent,
|
|
||||||
notebookContentRef?: string,
|
|
||||||
onTakeSnapshot?: (request: SnapshotRequest) => void,
|
|
||||||
onClosePanel?: () => void,
|
|
||||||
): Promise<void> {
|
|
||||||
if (this.notebookManager) {
|
|
||||||
await this.notebookManager.openPublishNotebookPane(
|
|
||||||
name,
|
|
||||||
content,
|
|
||||||
notebookContentRef,
|
|
||||||
onTakeSnapshot,
|
|
||||||
onClosePanel,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public copyNotebook(name: string, content: string): void {
|
public copyNotebook(name: string, content: string): void {
|
||||||
this.notebookManager?.openCopyNotebookPane(name, content);
|
this.notebookManager?.openCopyNotebookPane(name, content);
|
||||||
}
|
}
|
||||||
@@ -1051,45 +1041,6 @@ export default class Explorer {
|
|||||||
useTabs.getState().activateNewTab(newTab);
|
useTabs.getState().activateNewTab(newTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async openGallery(
|
|
||||||
selectedTab?: GalleryTabKind,
|
|
||||||
notebookUrl?: string,
|
|
||||||
galleryItem?: IGalleryItem,
|
|
||||||
isFavorite?: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
const title = "Gallery";
|
|
||||||
const GalleryTab = await (await import(/* webpackChunkName: "GalleryTab" */ "./Tabs/GalleryTab")).default;
|
|
||||||
const galleryTab = useTabs
|
|
||||||
.getState()
|
|
||||||
.getTabs(ViewModels.CollectionTabKind.Gallery)
|
|
||||||
.find((tab) => tab.tabTitle() === title);
|
|
||||||
|
|
||||||
if (galleryTab instanceof GalleryTab) {
|
|
||||||
useTabs.getState().activateTab(galleryTab);
|
|
||||||
} else {
|
|
||||||
useTabs.getState().activateNewTab(
|
|
||||||
new GalleryTab(
|
|
||||||
{
|
|
||||||
tabKind: ViewModels.CollectionTabKind.Gallery,
|
|
||||||
title,
|
|
||||||
tabPath: title,
|
|
||||||
onLoadStartKey: undefined,
|
|
||||||
isTabsContentExpanded: ko.observable(true),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
account: userContext.databaseAccount,
|
|
||||||
container: this,
|
|
||||||
junoClient: this.notebookManager?.junoClient,
|
|
||||||
selectedTab: selectedTab || GalleryTabKind.OfficialSamples,
|
|
||||||
notebookUrl,
|
|
||||||
galleryItem,
|
|
||||||
isFavorite,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async onNewCollectionClicked(
|
public async onNewCollectionClicked(
|
||||||
options: {
|
options: {
|
||||||
databaseId?: string;
|
databaseId?: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { FeedOptions, ItemDefinition, QueryIterator, Resource } from "@azure/cosmos";
|
import { FeedOptions, ItemDefinition, QueryIterator, Resource } from "@azure/cosmos";
|
||||||
|
import { stringifyError } from "Common/stringifyError";
|
||||||
import * as Q from "q";
|
import * as Q from "q";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import LoadGraphIcon from "../../../../images/LoadGraph.png";
|
import LoadGraphIcon from "../../../../images/LoadGraph.png";
|
||||||
@@ -1092,8 +1093,8 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
|
|||||||
public static reportToConsole(type: ConsoleDataType, msg: string, ...errorData: any[]): void | (() => void) {
|
public static reportToConsole(type: ConsoleDataType, msg: string, ...errorData: any[]): void | (() => void) {
|
||||||
let errorDataStr = "";
|
let errorDataStr = "";
|
||||||
if (errorData && errorData.length > 0) {
|
if (errorData && errorData.length > 0) {
|
||||||
console.error(msg, errorData);
|
console.error(msg + String(errorData));
|
||||||
errorDataStr = ": " + JSON.stringify(errorData);
|
errorDataStr = ": " + stringifyError(errorData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const consoleMessage = `${msg}${errorDataStr}`;
|
const consoleMessage = `${msg}${errorDataStr}`;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AppState, epics as coreEpics, IContentProvider, reducers } from "@nteract/core";
|
import { AppState, epics as coreEpics, IContentProvider, reducers } from "@nteract/core";
|
||||||
import { configuration } from "@nteract/mythic-configuration";
|
import { configuration } from "@nteract/mythic-configuration";
|
||||||
import { makeConfigureStore } from "@nteract/myths";
|
import { makeConfigureStore } from "@nteract/myths";
|
||||||
|
import { stringifyError } from "Common/stringifyError";
|
||||||
import { AnyAction, compose, Dispatch, Middleware, MiddlewareAPI, Store } from "redux";
|
import { AnyAction, compose, Dispatch, Middleware, MiddlewareAPI, Store } from "redux";
|
||||||
import { Epic } from "redux-observable";
|
import { Epic } from "redux-observable";
|
||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
@@ -44,7 +45,7 @@ export default function configureStore(
|
|||||||
|
|
||||||
const traceFailure = (title: string, error: any) => {
|
const traceFailure = (title: string, error: any) => {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
onTraceFailure(title, `${error.message} ${JSON.stringify(error.stack)}`);
|
onTraceFailure(title, `${error.message} ${stringifyError(error.stack)}`);
|
||||||
console.error(error);
|
console.error(error);
|
||||||
} else {
|
} else {
|
||||||
onTraceFailure(title, error.message);
|
onTraceFailure(title, error.message);
|
||||||
|
|||||||
@@ -17,16 +17,13 @@ import { JunoClient } from "../../Juno/JunoClient";
|
|||||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||||
import { userContext } from "../../UserContext";
|
import { userContext } from "../../UserContext";
|
||||||
import { getFullName } from "../../Utils/UserUtils";
|
|
||||||
import { useDialog } from "../Controls/Dialog";
|
import { useDialog } from "../Controls/Dialog";
|
||||||
import Explorer from "../Explorer";
|
import Explorer from "../Explorer";
|
||||||
import { CopyNotebookPane } from "../Panes/CopyNotebookPane/CopyNotebookPane";
|
import { CopyNotebookPane } from "../Panes/CopyNotebookPane/CopyNotebookPane";
|
||||||
import { GitHubReposPanel } from "../Panes/GitHubReposPanel/GitHubReposPanel";
|
import { GitHubReposPanel } from "../Panes/GitHubReposPanel/GitHubReposPanel";
|
||||||
import { PublishNotebookPane } from "../Panes/PublishNotebookPane/PublishNotebookPane";
|
|
||||||
import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter";
|
import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter";
|
||||||
import { InMemoryContentProvider } from "./NotebookComponent/ContentProviders/InMemoryContentProvider";
|
import { InMemoryContentProvider } from "./NotebookComponent/ContentProviders/InMemoryContentProvider";
|
||||||
import { NotebookContentProvider } from "./NotebookComponent/NotebookContentProvider";
|
import { NotebookContentProvider } from "./NotebookComponent/NotebookContentProvider";
|
||||||
import { SnapshotRequest } from "./NotebookComponent/types";
|
|
||||||
import { NotebookContainerClient } from "./NotebookContainerClient";
|
import { NotebookContainerClient } from "./NotebookContainerClient";
|
||||||
import { NotebookContentClient } from "./NotebookContentClient";
|
import { NotebookContentClient } from "./NotebookContentClient";
|
||||||
import { SchemaAnalyzerNotebook } from "./SchemaAnalyzer/SchemaAnalyzerUtils";
|
import { SchemaAnalyzerNotebook } from "./SchemaAnalyzer/SchemaAnalyzerUtils";
|
||||||
@@ -124,31 +121,6 @@ export default class NotebookManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async openPublishNotebookPane(
|
|
||||||
name: string,
|
|
||||||
content: NotebookPaneContent,
|
|
||||||
notebookContentRef: string,
|
|
||||||
onTakeSnapshot: (request: SnapshotRequest) => void,
|
|
||||||
onClosePanel: () => void,
|
|
||||||
): Promise<void> {
|
|
||||||
useSidePanel
|
|
||||||
.getState()
|
|
||||||
.openSidePanel(
|
|
||||||
"Publish Notebook",
|
|
||||||
<PublishNotebookPane
|
|
||||||
explorer={this.params.container}
|
|
||||||
junoClient={this.junoClient}
|
|
||||||
name={name}
|
|
||||||
author={getFullName()}
|
|
||||||
notebookContent={content}
|
|
||||||
notebookContentRef={notebookContentRef}
|
|
||||||
onTakeSnapshot={onTakeSnapshot}
|
|
||||||
/>,
|
|
||||||
"440px",
|
|
||||||
onClosePanel,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public openCopyNotebookPane(name: string, content: string): void {
|
public openCopyNotebookPane(name: string, content: string): void {
|
||||||
const { container } = this.params;
|
const { container } = this.params;
|
||||||
useSidePanel
|
useSidePanel
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { initiateDataTransfer } from "Common/dataAccess/dataTransfers";
|
||||||
|
import { DatabaseAccount } from "Contracts/DataModels";
|
||||||
|
import * as ViewModels from "Contracts/ViewModels";
|
||||||
|
import Explorer from "Explorer/Explorer";
|
||||||
|
import * as React from "react";
|
||||||
|
import { updateUserContext } from "UserContext";
|
||||||
|
import { ChangePartitionKeyPane } from "./ChangePartitionKeyPane";
|
||||||
|
|
||||||
|
jest.mock("Common/ErrorHandlingUtils", () => ({
|
||||||
|
handleError: jest.fn(),
|
||||||
|
getErrorMessage: jest.fn().mockReturnValue("error"),
|
||||||
|
getErrorStack: jest.fn().mockReturnValue(""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("Common/dataAccess/createCollection", () => ({
|
||||||
|
createCollection: jest.fn().mockResolvedValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("Common/dataAccess/readDatabases", () => ({
|
||||||
|
readDatabases: jest.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("Common/dataAccess/dataTransfers", () => ({
|
||||||
|
initiateDataTransfer: jest.fn().mockResolvedValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("Utils/arm/databaseAccountUtils", () => ({
|
||||||
|
fetchDatabaseAccount: jest.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("Utils/arm/generatedClients/cosmos/databaseAccounts", () => ({
|
||||||
|
update: jest.fn().mockResolvedValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("hooks/useSidePanel", () => ({
|
||||||
|
useSidePanel: {
|
||||||
|
getState: () => ({
|
||||||
|
closeSidePanel: jest.fn(),
|
||||||
|
openSidePanel: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("Explorer/useDatabases", () => {
|
||||||
|
const state: Record<string, unknown> = {
|
||||||
|
databases: [],
|
||||||
|
resourceTokenCollection: undefined,
|
||||||
|
resourceTokenDatabase: undefined,
|
||||||
|
sampleDataResourceTokenCollection: undefined,
|
||||||
|
};
|
||||||
|
const mockStore = Object.assign(
|
||||||
|
jest.fn(() => state),
|
||||||
|
{
|
||||||
|
getState: () => state,
|
||||||
|
setState: jest.fn(),
|
||||||
|
subscribe: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { useDatabases: mockStore };
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("Common/LoadingOverlay", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ isLoading, label }: { isLoading: boolean; label: string }) =>
|
||||||
|
isLoading ? <div data-testid="loading-overlay">{label}</div> : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockCollection = (id: string): ViewModels.Collection => {
|
||||||
|
const mockCollection = {
|
||||||
|
id: jest.fn().mockReturnValue(id),
|
||||||
|
offer: jest.fn().mockReturnValue(undefined),
|
||||||
|
partitionKey: { kind: "Hash", paths: ["/id"], version: 2 },
|
||||||
|
partitionKeyProperties: ["id"],
|
||||||
|
databaseId: "testDb",
|
||||||
|
} as unknown as ViewModels.Collection;
|
||||||
|
return mockCollection;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMockDatabase = (id: string, collections: ViewModels.Collection[] = []): ViewModels.Database => {
|
||||||
|
return {
|
||||||
|
id: jest.fn().mockReturnValue(id),
|
||||||
|
collections: jest.fn().mockReturnValue(collections),
|
||||||
|
} as unknown as ViewModels.Database;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ChangePartitionKeyPane", () => {
|
||||||
|
const mockExplorer = new Explorer();
|
||||||
|
const mockOnClose = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const mockCollection = createMockCollection("testCollection");
|
||||||
|
const mockDatabase = createMockDatabase("testDb", [mockCollection]);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
updateUserContext({
|
||||||
|
databaseAccount: {
|
||||||
|
name: "testAccount",
|
||||||
|
id: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/testAccount",
|
||||||
|
properties: {
|
||||||
|
documentEndpoint: "https://test.documents.azure.com",
|
||||||
|
capabilities: [],
|
||||||
|
backupPolicy: { type: "Periodic" },
|
||||||
|
},
|
||||||
|
} as unknown as DatabaseAccount,
|
||||||
|
subscriptionId: "sub1",
|
||||||
|
resourceGroup: "rg1",
|
||||||
|
apiType: "SQL",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderPane = () => {
|
||||||
|
return render(
|
||||||
|
<ChangePartitionKeyPane
|
||||||
|
sourceDatabase={mockDatabase}
|
||||||
|
sourceCollection={mockCollection}
|
||||||
|
explorer={mockExplorer}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders migration type choice group", () => {
|
||||||
|
renderPane();
|
||||||
|
expect(screen.getByText("Migration type")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Offline mode")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Online mode")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to offline migration type", () => {
|
||||||
|
renderPane();
|
||||||
|
const offlineRadio = screen.getByRole("radio", { name: "Offline mode" }) as HTMLInputElement;
|
||||||
|
expect(offlineRadio.checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show online prerequisites section when offline is selected", () => {
|
||||||
|
const { container } = renderPane();
|
||||||
|
expect(container.querySelector("[data-test='online-prerequisites-section']")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows online prerequisites section when online is selected", () => {
|
||||||
|
renderPane();
|
||||||
|
const onlineRadio = screen.getByRole("radio", { name: "Online mode" });
|
||||||
|
fireEvent.click(onlineRadio);
|
||||||
|
expect(screen.getByText("Online container copy")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Point In Time Restore enabled")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Online copy enabled")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows prerequisite sections when online prerequisites are not met", () => {
|
||||||
|
renderPane();
|
||||||
|
const onlineRadio = screen.getByRole("radio", { name: "Online mode" });
|
||||||
|
fireEvent.click(onlineRadio);
|
||||||
|
// When prerequisites aren't met, the enable buttons should be visible
|
||||||
|
expect(screen.getByText("Enable Point In Time Restore")).toBeTruthy();
|
||||||
|
expect(screen.getAllByRole("button", { name: "Enable Online Copy" }).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows enable PITR button when PITR is not enabled", () => {
|
||||||
|
renderPane();
|
||||||
|
const onlineRadio = screen.getByRole("radio", { name: "Online mode" });
|
||||||
|
fireEvent.click(onlineRadio);
|
||||||
|
expect(screen.getByText("Enable Point In Time Restore")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show PITR enable button when PITR is already enabled", () => {
|
||||||
|
updateUserContext({
|
||||||
|
databaseAccount: {
|
||||||
|
name: "testAccount",
|
||||||
|
id: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/testAccount",
|
||||||
|
properties: {
|
||||||
|
documentEndpoint: "https://test.documents.azure.com",
|
||||||
|
capabilities: [],
|
||||||
|
backupPolicy: { type: "Continuous" },
|
||||||
|
},
|
||||||
|
} as unknown as DatabaseAccount,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPane();
|
||||||
|
const onlineRadio = screen.getByRole("radio", { name: "Online mode" });
|
||||||
|
fireEvent.click(onlineRadio);
|
||||||
|
expect(screen.queryByText("Enable Point In Time Restore")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables online copy button when PITR is not enabled", () => {
|
||||||
|
renderPane();
|
||||||
|
const onlineRadio = screen.getByRole("radio", { name: "Online mode" });
|
||||||
|
fireEvent.click(onlineRadio);
|
||||||
|
const enableOnlineCopyBtns = screen.getAllByRole("button", { name: "Enable Online Copy" });
|
||||||
|
expect(enableOnlineCopyBtns.length).toBeGreaterThan(0);
|
||||||
|
expect((enableOnlineCopyBtns[0] as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes mode to initiateDataTransfer when submitting", async () => {
|
||||||
|
const mockInitiateDataTransfer = jest.mocked(initiateDataTransfer);
|
||||||
|
// Mock refreshAllDatabases on the prototype to catch all calls
|
||||||
|
const refreshSpy = jest.spyOn(Explorer.prototype, "refreshAllDatabases").mockResolvedValue();
|
||||||
|
const { container } = renderPane();
|
||||||
|
|
||||||
|
// Fill in partition key (required for createContainer — state starts undefined)
|
||||||
|
const partitionKeyInput = container.querySelector("#addCollection-partitionKeyValue") as HTMLInputElement;
|
||||||
|
expect(partitionKeyInput).not.toBeNull();
|
||||||
|
fireEvent.change(partitionKeyInput, { target: { value: "/myKey" } });
|
||||||
|
|
||||||
|
const form = container.querySelector("form");
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.submit(form!);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockInitiateDataTransfer).toHaveBeenCalled();
|
||||||
|
expect(mockInitiateDataTransfer.mock.calls[0][0].mode).toBe("Offline");
|
||||||
|
refreshSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,16 +7,24 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
MessageBar,
|
MessageBar,
|
||||||
|
MessageBarType,
|
||||||
|
PrimaryButton,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TooltipHost,
|
TooltipHost,
|
||||||
} from "@fluentui/react";
|
} from "@fluentui/react";
|
||||||
|
import MarkdownRender from "@nteract/markdown";
|
||||||
import * as Constants from "Common/Constants";
|
import * as Constants from "Common/Constants";
|
||||||
|
import { CapabilityNames } from "Common/Constants";
|
||||||
import { handleError } from "Common/ErrorHandlingUtils";
|
import { handleError } from "Common/ErrorHandlingUtils";
|
||||||
|
import LoadingOverlay from "Common/LoadingOverlay";
|
||||||
|
import { logError } from "Common/Logger";
|
||||||
import { createCollection } from "Common/dataAccess/createCollection";
|
import { createCollection } from "Common/dataAccess/createCollection";
|
||||||
import { DataTransferParams, initiateDataTransfer } from "Common/dataAccess/dataTransfers";
|
import { DataTransferParams, initiateDataTransfer } from "Common/dataAccess/dataTransfers";
|
||||||
import * as DataModels from "Contracts/DataModels";
|
import * as DataModels from "Contracts/DataModels";
|
||||||
import * as ViewModels from "Contracts/ViewModels";
|
import * as ViewModels from "Contracts/ViewModels";
|
||||||
|
import { buildResourceLink } from "Explorer/ContainerCopy/CopyJobUtils";
|
||||||
|
import { BackupPolicyType } from "Explorer/ContainerCopy/Enums/CopyJobEnums";
|
||||||
import {
|
import {
|
||||||
getPartitionKeyName,
|
getPartitionKeyName,
|
||||||
getPartitionKeyPlaceHolder,
|
getPartitionKeyPlaceHolder,
|
||||||
@@ -30,6 +38,8 @@ import { Keys, t } from "Localization";
|
|||||||
import { userContext } from "UserContext";
|
import { userContext } from "UserContext";
|
||||||
import { getCollectionName } from "Utils/APITypeUtils";
|
import { getCollectionName } from "Utils/APITypeUtils";
|
||||||
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
|
import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils";
|
||||||
|
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
|
||||||
|
import { update as updateDatabaseAccount } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||||
import { useSidePanel } from "hooks/useSidePanel";
|
import { useSidePanel } from "hooks/useSidePanel";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
@@ -40,6 +50,15 @@ export interface ChangePartitionKeyPaneProps {
|
|||||||
onClose: () => Promise<void>;
|
onClose: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const checkPitrEnabled = (account: DataModels.DatabaseAccount): boolean => {
|
||||||
|
return account?.properties?.backupPolicy?.type === BackupPolicyType.Continuous;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkOnlineCopyEnabled = (account: DataModels.DatabaseAccount): boolean => {
|
||||||
|
const capabilities = account?.properties?.capabilities ?? [];
|
||||||
|
return capabilities.some((cap) => cap.name === CapabilityNames.EnableOnlineCopyFeature);
|
||||||
|
};
|
||||||
|
|
||||||
export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||||
sourceDatabase,
|
sourceDatabase,
|
||||||
sourceCollection,
|
sourceCollection,
|
||||||
@@ -52,6 +71,118 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
|||||||
const [isExecuting, setIsExecuting] = React.useState<boolean>(false);
|
const [isExecuting, setIsExecuting] = React.useState<boolean>(false);
|
||||||
const [subPartitionKeys, setSubPartitionKeys] = React.useState<string[]>([]);
|
const [subPartitionKeys, setSubPartitionKeys] = React.useState<string[]>([]);
|
||||||
const [partitionKey, setPartitionKey] = React.useState<string>();
|
const [partitionKey, setPartitionKey] = React.useState<string>();
|
||||||
|
const [onlineMode, setOnlineMode] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
// Pane-local account state for tracking prerequisite enablement
|
||||||
|
const [localAccount, setLocalAccount] = React.useState<DataModels.DatabaseAccount>(userContext.databaseAccount);
|
||||||
|
const [isEnablingPrerequisite, setIsEnablingPrerequisite] = React.useState<boolean>(false);
|
||||||
|
const [prerequisiteLoaderMessage, setPrerequisiteLoaderMessage] = React.useState<string>("");
|
||||||
|
|
||||||
|
const pitrEnabled = checkPitrEnabled(localAccount);
|
||||||
|
const onlineCopyFeatureEnabled = checkOnlineCopyEnabled(localAccount);
|
||||||
|
const onlinePrerequisitesMet = pitrEnabled && onlineCopyFeatureEnabled;
|
||||||
|
|
||||||
|
const accountName = localAccount?.name ?? "";
|
||||||
|
const subscriptionId = userContext.subscriptionId;
|
||||||
|
const resourceGroup = userContext.resourceGroup;
|
||||||
|
|
||||||
|
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshAccount = async (): Promise<DataModels.DatabaseAccount | null> => {
|
||||||
|
try {
|
||||||
|
const account = await fetchDatabaseAccount(subscriptionId, resourceGroup, accountName);
|
||||||
|
if (account) {
|
||||||
|
setLocalAccount(account);
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
} catch (error) {
|
||||||
|
logError(
|
||||||
|
error.message || "Error fetching account after enabling prerequisite.",
|
||||||
|
"ChangePartitionKey/refreshAccount",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearPollingTimers = () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPollingForAccountUpdate = () => {
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
refreshAccount();
|
||||||
|
}, 30 * 1000);
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(
|
||||||
|
() => {
|
||||||
|
clearPollingTimers();
|
||||||
|
setIsEnablingPrerequisite(false);
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnablePitr = () => {
|
||||||
|
const sourceAccountLink = buildResourceLink(localAccount);
|
||||||
|
const featureUrl = `${sourceAccountLink}/backupRestore`;
|
||||||
|
setIsEnablingPrerequisite(true);
|
||||||
|
setPrerequisiteLoaderMessage(t(Keys.containerCopy.popoverOverlaySpinnerLabel));
|
||||||
|
window.open(featureUrl, "_blank");
|
||||||
|
startPollingForAccountUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnableOnlineCopy = async () => {
|
||||||
|
setIsEnablingPrerequisite(true);
|
||||||
|
try {
|
||||||
|
setPrerequisiteLoaderMessage(
|
||||||
|
t(Keys.containerCopy.onlineCopyEnabled.validateAllVersionsAndDeletesChangeFeedSpinnerLabel),
|
||||||
|
);
|
||||||
|
const currentAccount = await fetchDatabaseAccount(subscriptionId, resourceGroup, accountName);
|
||||||
|
if (!currentAccount?.properties?.enableAllVersionsAndDeletesChangeFeed) {
|
||||||
|
setPrerequisiteLoaderMessage(
|
||||||
|
t(Keys.containerCopy.onlineCopyEnabled.enablingAllVersionsAndDeletesChangeFeedSpinnerLabel),
|
||||||
|
);
|
||||||
|
await updateDatabaseAccount(subscriptionId, resourceGroup, accountName, {
|
||||||
|
properties: {
|
||||||
|
enableAllVersionsAndDeletesChangeFeed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const capabilities = currentAccount?.properties?.capabilities ?? [];
|
||||||
|
setPrerequisiteLoaderMessage(
|
||||||
|
t(Keys.containerCopy.onlineCopyEnabled.enablingOnlineCopySpinnerLabel, { accountName }),
|
||||||
|
);
|
||||||
|
await updateDatabaseAccount(subscriptionId, resourceGroup, accountName, {
|
||||||
|
properties: {
|
||||||
|
capabilities: [...capabilities, { name: CapabilityNames.EnableOnlineCopyFeature }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
startPollingForAccountUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
logError(error.message || "Failed to enable online copy feature.", "ChangePartitionKey/handleEnableOnlineCopy");
|
||||||
|
setFormError("Failed to enable online copy feature. Please try again.");
|
||||||
|
setIsEnablingPrerequisite(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getCollectionOptions = (): IDropdownOption[] => {
|
const getCollectionOptions = (): IDropdownOption[] => {
|
||||||
return sourceDatabase
|
return sourceDatabase
|
||||||
@@ -84,9 +215,17 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
|||||||
setFormError("Choose an existing container");
|
setFormError("Choose an existing container");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (onlineMode && !onlinePrerequisitesMet) {
|
||||||
|
setFormError("Online migration prerequisites must be enabled before proceeding.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getModeForApi = (): "Offline" | "Online" => {
|
||||||
|
return onlineMode ? "Online" : "Offline";
|
||||||
|
};
|
||||||
|
|
||||||
const createDataTransferJob = async () => {
|
const createDataTransferJob = async () => {
|
||||||
const jobName = `Portal_${targetCollectionId}_${Math.floor(Date.now() / 1000)}`;
|
const jobName = `Portal_${targetCollectionId}_${Math.floor(Date.now() / 1000)}`;
|
||||||
const dataTransferParams: DataTransferParams = {
|
const dataTransferParams: DataTransferParams = {
|
||||||
@@ -99,6 +238,7 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
|||||||
sourceCollectionName: sourceCollection.id(),
|
sourceCollectionName: sourceCollection.id(),
|
||||||
targetDatabaseName: sourceDatabase.id(),
|
targetDatabaseName: sourceDatabase.id(),
|
||||||
targetCollectionName: targetCollectionId,
|
targetCollectionName: targetCollectionId,
|
||||||
|
mode: getModeForApi(),
|
||||||
};
|
};
|
||||||
await initiateDataTransfer(dataTransferParams);
|
await initiateDataTransfer(dataTransferParams);
|
||||||
};
|
};
|
||||||
@@ -133,12 +273,18 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
|||||||
return !!selectedDatabase?.offer();
|
return !!selectedDatabase?.offer();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSubmitDisabled = onlineMode && !onlinePrerequisitesMet;
|
||||||
|
|
||||||
|
const migrationTypeLowercase = getModeForApi().toLowerCase() as keyof typeof Keys.containerCopy.migrationType;
|
||||||
|
const migrationTypeContent = Keys.containerCopy.migrationType[migrationTypeLowercase];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RightPaneForm
|
<RightPaneForm
|
||||||
formError={formError}
|
formError={formError}
|
||||||
isExecuting={isExecuting}
|
isExecuting={isExecuting}
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
submitButtonText={t(Keys.common.ok)}
|
submitButtonText={t(Keys.common.ok)}
|
||||||
|
isSubmitButtonDisabled={isSubmitDisabled}
|
||||||
>
|
>
|
||||||
<Stack tokens={{ childrenGap: 10 }} className="panelMainContent">
|
<Stack tokens={{ childrenGap: 10 }} className="panelMainContent">
|
||||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||||
@@ -151,11 +297,58 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
|||||||
{t(Keys.common.learnMore)}
|
{t(Keys.common.learnMore)}
|
||||||
</Link>
|
</Link>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{/* Migration Type */}
|
||||||
|
<Stack data-test="migration-type-section">
|
||||||
|
<Text className="panelTextBold" variant="small" style={{ marginBottom: 4 }}>
|
||||||
|
{t(Keys.containerCopy.migrationTypeTitle)}
|
||||||
|
</Text>
|
||||||
|
<Stack className="panelGroupSpacing" horizontal verticalAlign="center">
|
||||||
|
<div role="radiogroup">
|
||||||
|
<input
|
||||||
|
className="panelRadioBtn"
|
||||||
|
checked={!onlineMode}
|
||||||
|
aria-label="Offline mode"
|
||||||
|
aria-checked={!onlineMode}
|
||||||
|
name="migrationType"
|
||||||
|
type="radio"
|
||||||
|
role="radio"
|
||||||
|
id="migrationTypeOffline"
|
||||||
|
tabIndex={0}
|
||||||
|
onChange={() => setOnlineMode(false)}
|
||||||
|
/>
|
||||||
|
<span className="panelRadioBtnLabel">{t(Keys.containerCopy.migrationType.offline.title)}</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="panelRadioBtn"
|
||||||
|
checked={onlineMode}
|
||||||
|
aria-label="Online mode"
|
||||||
|
aria-checked={onlineMode}
|
||||||
|
name="migrationType"
|
||||||
|
type="radio"
|
||||||
|
role="radio"
|
||||||
|
tabIndex={0}
|
||||||
|
onChange={() => setOnlineMode(true)}
|
||||||
|
/>
|
||||||
|
<span className="panelRadioBtnLabel">{t(Keys.containerCopy.migrationType.online.title)}</span>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
{migrationTypeContent && (
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
style={{ color: "var(--colorNeutralForeground1)", marginTop: 4 }}
|
||||||
|
data-test={`migration-type-description-${migrationTypeLowercase}`}
|
||||||
|
>
|
||||||
|
<MarkdownRender source={t(migrationTypeContent.description)} linkTarget="_blank" />
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack horizontal>
|
<Stack horizontal>
|
||||||
<span className="mandatoryStar">* </span>
|
<span className="mandatoryStar">* </span>
|
||||||
<Text className="panelTextBold" variant="small">
|
<Text className="panelTextBold" variant="small">
|
||||||
Database id
|
{t(Keys.panes.addDatabase.databaseIdLabel)}
|
||||||
</Text>
|
</Text>
|
||||||
<TooltipHost
|
<TooltipHost
|
||||||
directionalHint={DirectionalHint.bottomLeftEdge}
|
directionalHint={DirectionalHint.bottomLeftEdge}
|
||||||
@@ -420,6 +613,89 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Online prerequisites section */}
|
||||||
|
{onlineMode && (
|
||||||
|
<Stack data-test="online-prerequisites-section" tokens={{ childrenGap: 10 }}>
|
||||||
|
<LoadingOverlay isLoading={isEnablingPrerequisite} label={prerequisiteLoaderMessage} />
|
||||||
|
<Text className="panelTextBold" variant="small">
|
||||||
|
{t(Keys.containerCopy.assignPermissions.onlineConfiguration.title)}
|
||||||
|
</Text>
|
||||||
|
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||||
|
{t(Keys.containerCopy.assignPermissions.onlineConfiguration.description, { accountName })}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Point In Time Restore */}
|
||||||
|
<Stack tokens={{ childrenGap: 5 }}>
|
||||||
|
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 5 }}>
|
||||||
|
<Icon
|
||||||
|
iconName={pitrEnabled ? "SkypeCircleCheck" : "StatusCircleRing"}
|
||||||
|
styles={{
|
||||||
|
root: { color: pitrEnabled ? "green" : "var(--colorNeutralForeground1)", fontSize: 16 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text variant="small" style={{ fontWeight: 600, color: "var(--colorNeutralForeground1)" }}>
|
||||||
|
{t(Keys.containerCopy.pointInTimeRestore.title)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{!pitrEnabled && (
|
||||||
|
<Stack tokens={{ childrenGap: 10, padding: "0 0 0 20px" }}>
|
||||||
|
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||||
|
{t(Keys.containerCopy.pointInTimeRestore.description, { accessName: accountName })}
|
||||||
|
</Text>
|
||||||
|
<PrimaryButton
|
||||||
|
data-test="enable-pitr-button"
|
||||||
|
text={t(Keys.containerCopy.pointInTimeRestore.buttonText)}
|
||||||
|
disabled={isEnablingPrerequisite}
|
||||||
|
onClick={handleEnablePitr}
|
||||||
|
styles={{ root: { width: "fit-content" } }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Online Copy Enabled */}
|
||||||
|
<Stack tokens={{ childrenGap: 5 }}>
|
||||||
|
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 5 }}>
|
||||||
|
<Icon
|
||||||
|
iconName={onlineCopyFeatureEnabled ? "SkypeCircleCheck" : "StatusCircleRing"}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
color: onlineCopyFeatureEnabled ? "green" : "var(--colorNeutralForeground1)",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text variant="small" style={{ fontWeight: 600, color: "var(--colorNeutralForeground1)" }}>
|
||||||
|
{t(Keys.containerCopy.onlineCopyEnabled.title)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{!onlineCopyFeatureEnabled && (
|
||||||
|
<Stack tokens={{ childrenGap: 10, padding: "0 0 0 20px" }}>
|
||||||
|
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||||
|
{t(Keys.containerCopy.onlineCopyEnabled.description, { accountName })} 
|
||||||
|
<Link href={t(Keys.containerCopy.onlineCopyEnabled.href)} target="_blank" rel="noopener noreferrer">
|
||||||
|
{t(Keys.containerCopy.onlineCopyEnabled.hrefText)}
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
<PrimaryButton
|
||||||
|
data-test="enable-online-copy-button"
|
||||||
|
text={t(Keys.containerCopy.onlineCopyEnabled.buttonText)}
|
||||||
|
disabled={isEnablingPrerequisite || !pitrEnabled}
|
||||||
|
onClick={handleEnableOnlineCopy}
|
||||||
|
styles={{ root: { width: "fit-content" } }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!onlinePrerequisitesMet && (
|
||||||
|
<MessageBar messageBarType={MessageBarType.warning} data-test="online-prerequisites-warning">
|
||||||
|
{t(Keys.containerCopy.onlineCopyEnabled.onlineMigrationPrerequisitesMessage)}
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</RightPaneForm>
|
</RightPaneForm>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { shallow } from "enzyme";
|
|
||||||
import React from "react";
|
|
||||||
import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent";
|
|
||||||
|
|
||||||
describe("PublishNotebookPaneComponent", () => {
|
|
||||||
it("renders", () => {
|
|
||||||
const props: PublishNotebookPaneProps = {
|
|
||||||
notebookName: "SampleNotebook.ipynb",
|
|
||||||
notebookDescription: "sample description",
|
|
||||||
notebookTags: "tag1, tag2",
|
|
||||||
imageSrc: "https://i.ytimg.com/vi/E_lByLdKeKY/maxresdefault.jpg",
|
|
||||||
notebookAuthor: "CosmosDB",
|
|
||||||
notebookCreatedDate: "2020-07-17T00:00:00Z",
|
|
||||||
notebookObject: undefined,
|
|
||||||
notebookContentRef: undefined,
|
|
||||||
setNotebookName: undefined,
|
|
||||||
setNotebookDescription: undefined,
|
|
||||||
setNotebookTags: undefined,
|
|
||||||
setImageSrc: undefined,
|
|
||||||
onError: undefined,
|
|
||||||
clearFormError: undefined,
|
|
||||||
onTakeSnapshot: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapper = shallow(<PublishNotebookPaneComponent {...props} />);
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
import { ImmutableNotebook, toJS } from "@nteract/commutable";
|
|
||||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
|
||||||
import { HttpStatusCodes } from "../../../Common/Constants";
|
|
||||||
import { getErrorMessage, getErrorStack, handleError } from "../../../Common/ErrorHandlingUtils";
|
|
||||||
import { useNotebookSnapshotStore } from "../../../hooks/useNotebookSnapshotStore";
|
|
||||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
|
||||||
import { JunoClient } from "../../../Juno/JunoClient";
|
|
||||||
import { Keys, t } from "Localization";
|
|
||||||
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
|
||||||
import { traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor";
|
|
||||||
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
|
|
||||||
import { CodeOfConduct } from "../../Controls/NotebookGallery/CodeOfConduct/CodeOfConduct";
|
|
||||||
import { GalleryTab } from "../../Controls/NotebookGallery/GalleryViewerComponent";
|
|
||||||
import Explorer from "../../Explorer";
|
|
||||||
import * as FileSystemUtil from "../../Notebook/FileSystemUtil";
|
|
||||||
import { SnapshotRequest } from "../../Notebook/NotebookComponent/types";
|
|
||||||
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
|
||||||
import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent";
|
|
||||||
|
|
||||||
export interface PublishNotebookPaneAProps {
|
|
||||||
explorer: Explorer;
|
|
||||||
junoClient: JunoClient;
|
|
||||||
name: string;
|
|
||||||
author: string;
|
|
||||||
notebookContent: string | ImmutableNotebook;
|
|
||||||
notebookContentRef: string;
|
|
||||||
onTakeSnapshot: (request: SnapshotRequest) => void;
|
|
||||||
}
|
|
||||||
export const PublishNotebookPane: FunctionComponent<PublishNotebookPaneAProps> = ({
|
|
||||||
explorer: container,
|
|
||||||
junoClient,
|
|
||||||
name,
|
|
||||||
author,
|
|
||||||
notebookContent,
|
|
||||||
notebookContentRef,
|
|
||||||
onTakeSnapshot,
|
|
||||||
}: PublishNotebookPaneAProps): JSX.Element => {
|
|
||||||
const closeSidePanel = useSidePanel((state) => state.closeSidePanel);
|
|
||||||
|
|
||||||
const [isCodeOfConductAccepted, setIsCodeOfConductAccepted] = useState<boolean>(false);
|
|
||||||
const [content, setContent] = useState<string>("");
|
|
||||||
const [formError, setFormError] = useState<string>("");
|
|
||||||
const [formErrorDetail, setFormErrorDetail] = useState<string>("");
|
|
||||||
const [isExecuting, setIsExecuting] = useState<boolean>();
|
|
||||||
|
|
||||||
const [notebookName, setNotebookName] = useState<string>(name);
|
|
||||||
const [notebookDescription, setNotebookDescription] = useState<string>("");
|
|
||||||
const [notebookTags, setNotebookTags] = useState<string>("");
|
|
||||||
const [imageSrc, setImageSrc] = useState<string>();
|
|
||||||
const { snapshot: notebookSnapshot, error: notebookSnapshotError } = useNotebookSnapshotStore();
|
|
||||||
|
|
||||||
const CodeOfConductAccepted = async () => {
|
|
||||||
try {
|
|
||||||
const response = await junoClient.isCodeOfConductAccepted();
|
|
||||||
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
|
|
||||||
}
|
|
||||||
setIsCodeOfConductAccepted(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
handleError(
|
|
||||||
error,
|
|
||||||
"PublishNotebookPaneAdapter/isCodeOfConductAccepted",
|
|
||||||
"Failed to check if code of conduct was accepted",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const [notebookObject, setNotebookObject] = useState<ImmutableNotebook>();
|
|
||||||
useEffect(() => {
|
|
||||||
CodeOfConductAccepted();
|
|
||||||
let newContent;
|
|
||||||
if (typeof notebookContent === "string") {
|
|
||||||
newContent = notebookContent as string;
|
|
||||||
} else {
|
|
||||||
newContent = JSON.stringify(toJS(notebookContent));
|
|
||||||
setNotebookObject(notebookContent);
|
|
||||||
}
|
|
||||||
setContent(newContent);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setImageSrc(notebookSnapshot);
|
|
||||||
}, [notebookSnapshot]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFormError(notebookSnapshotError);
|
|
||||||
}, [notebookSnapshotError]);
|
|
||||||
|
|
||||||
const submit = async (): Promise<void> => {
|
|
||||||
const clearPublishingMessage = NotificationConsoleUtils.logConsoleProgress(`Publishing ${name} to gallery`);
|
|
||||||
setIsExecuting(true);
|
|
||||||
|
|
||||||
let startKey: number;
|
|
||||||
|
|
||||||
if (!notebookName || !notebookDescription || !author || !imageSrc) {
|
|
||||||
setFormError(t(Keys.panes.publishNotebook.publishFailedError, { notebookName }));
|
|
||||||
setFormErrorDetail("Name, description, author and cover image are required");
|
|
||||||
createFormError(formError, formErrorDetail, "PublishNotebookPaneAdapter/submit");
|
|
||||||
setIsExecuting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
startKey = traceStart(Action.NotebooksGalleryPublish, {});
|
|
||||||
|
|
||||||
const response = await junoClient.publishNotebook(
|
|
||||||
notebookName,
|
|
||||||
notebookDescription,
|
|
||||||
notebookTags?.split(","),
|
|
||||||
imageSrc,
|
|
||||||
content,
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = response.data;
|
|
||||||
if (data) {
|
|
||||||
let isPublishPending = false;
|
|
||||||
|
|
||||||
if (data.pendingScanJobIds?.length > 0) {
|
|
||||||
isPublishPending = true;
|
|
||||||
NotificationConsoleUtils.logConsoleInfo(
|
|
||||||
`Content of ${name} is currently being scanned for illegal content. It will not be available in the public gallery until the review is complete (may take a few days).`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
NotificationConsoleUtils.logConsoleInfo(`Published ${notebookName} to gallery`);
|
|
||||||
container.openGallery(GalleryTab.Published);
|
|
||||||
}
|
|
||||||
|
|
||||||
traceSuccess(
|
|
||||||
Action.NotebooksGalleryPublish,
|
|
||||||
{
|
|
||||||
notebookId: data.id,
|
|
||||||
isPublishPending,
|
|
||||||
},
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
traceFailure(
|
|
||||||
Action.NotebooksGalleryPublish,
|
|
||||||
{
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
errorStack: getErrorStack(error),
|
|
||||||
},
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
const errorMessage = getErrorMessage(error);
|
|
||||||
setFormError(
|
|
||||||
t(Keys.panes.publishNotebook.publishFailedError, {
|
|
||||||
notebookName: FileSystemUtil.stripExtension(notebookName, "ipynb"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
setFormErrorDetail(`${errorMessage}`);
|
|
||||||
handleError(errorMessage, "PublishNotebookPaneAdapter/submit", formError);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
clearPublishingMessage();
|
|
||||||
setIsExecuting(false);
|
|
||||||
}
|
|
||||||
closeSidePanel();
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFormError = (formError: string, formErrorDetail: string, area: string): void => {
|
|
||||||
setFormError(formError);
|
|
||||||
setFormErrorDetail(formErrorDetail);
|
|
||||||
handleError(formErrorDetail, area, formError);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearFormError = (): void => {
|
|
||||||
setFormError("");
|
|
||||||
setFormErrorDetail("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const props: RightPaneFormProps = {
|
|
||||||
formError: formError,
|
|
||||||
isExecuting: isExecuting,
|
|
||||||
submitButtonText: "Publish",
|
|
||||||
onSubmit: () => submit(),
|
|
||||||
isSubmitButtonHidden: !isCodeOfConductAccepted,
|
|
||||||
};
|
|
||||||
|
|
||||||
const publishNotebookPaneProps: PublishNotebookPaneProps = {
|
|
||||||
notebookDescription,
|
|
||||||
notebookTags,
|
|
||||||
imageSrc,
|
|
||||||
notebookName,
|
|
||||||
notebookAuthor: author,
|
|
||||||
notebookCreatedDate: new Date().toISOString(),
|
|
||||||
notebookObject: notebookObject,
|
|
||||||
notebookContentRef,
|
|
||||||
onError: createFormError,
|
|
||||||
clearFormError: clearFormError,
|
|
||||||
setNotebookName,
|
|
||||||
setNotebookDescription,
|
|
||||||
setNotebookTags,
|
|
||||||
setImageSrc,
|
|
||||||
onTakeSnapshot,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<RightPaneForm {...props}>
|
|
||||||
{!isCodeOfConductAccepted ? (
|
|
||||||
<div style={{ padding: "25px", marginTop: "10px" }}>
|
|
||||||
<CodeOfConduct
|
|
||||||
junoClient={junoClient}
|
|
||||||
onAcceptCodeOfConduct={(isAccepted) => {
|
|
||||||
setIsCodeOfConductAccepted(isAccepted);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<PublishNotebookPaneComponent {...publishNotebookPaneProps} />
|
|
||||||
)}
|
|
||||||
</RightPaneForm>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
import { Dropdown, IDropdownProps, ITextFieldProps, Stack, Text, TextField } from "@fluentui/react";
|
|
||||||
import { ImmutableNotebook } from "@nteract/commutable";
|
|
||||||
import React, { FunctionComponent, useState } from "react";
|
|
||||||
import { Keys, t } from "Localization";
|
|
||||||
import { GalleryCardComponent } from "../../Controls/NotebookGallery/Cards/GalleryCardComponent";
|
|
||||||
import * as FileSystemUtil from "../../Notebook/FileSystemUtil";
|
|
||||||
import { SnapshotRequest } from "../../Notebook/NotebookComponent/types";
|
|
||||||
import { NotebookUtil } from "../../Notebook/NotebookUtil";
|
|
||||||
import "./styled.less";
|
|
||||||
|
|
||||||
export interface PublishNotebookPaneProps {
|
|
||||||
notebookName: string;
|
|
||||||
notebookAuthor: string;
|
|
||||||
notebookTags: string;
|
|
||||||
notebookDescription: string;
|
|
||||||
notebookCreatedDate: string;
|
|
||||||
notebookObject: ImmutableNotebook;
|
|
||||||
notebookContentRef: string;
|
|
||||||
imageSrc: string;
|
|
||||||
|
|
||||||
onError: (formError: string, formErrorDetail: string, area: string) => void;
|
|
||||||
clearFormError: () => void;
|
|
||||||
setNotebookName: (newValue: string) => void;
|
|
||||||
setNotebookDescription: (newValue: string) => void;
|
|
||||||
setNotebookTags: (newValue: string) => void;
|
|
||||||
setImageSrc: (newValue: string) => void;
|
|
||||||
onTakeSnapshot: (request: SnapshotRequest) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ImageTypes {
|
|
||||||
Url = "URL",
|
|
||||||
CustomImage = "Custom Image",
|
|
||||||
TakeScreenshot = "Take Screenshot",
|
|
||||||
UseFirstDisplayOutput = "Use First Display Output",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPaneProps> = ({
|
|
||||||
notebookName,
|
|
||||||
notebookTags,
|
|
||||||
notebookDescription,
|
|
||||||
notebookAuthor,
|
|
||||||
notebookCreatedDate,
|
|
||||||
notebookObject,
|
|
||||||
notebookContentRef,
|
|
||||||
imageSrc,
|
|
||||||
onError,
|
|
||||||
clearFormError,
|
|
||||||
setNotebookName,
|
|
||||||
setNotebookDescription,
|
|
||||||
setNotebookTags,
|
|
||||||
setImageSrc,
|
|
||||||
onTakeSnapshot,
|
|
||||||
}: PublishNotebookPaneProps) => {
|
|
||||||
const [type, setType] = useState<string>(ImageTypes.CustomImage);
|
|
||||||
const CARD_WIDTH = 256;
|
|
||||||
const cardImageHeight = 144;
|
|
||||||
const cardHeightToWidthRatio = cardImageHeight / CARD_WIDTH;
|
|
||||||
|
|
||||||
const maxImageSizeInMib = 1.5;
|
|
||||||
|
|
||||||
const descriptionPara1 = t(Keys.panes.publishNotebook.publishDescription);
|
|
||||||
|
|
||||||
const descriptionPara2 = t(Keys.panes.publishNotebook.publishPrompt, {
|
|
||||||
name: FileSystemUtil.stripExtension(notebookName, "ipynb"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const options: ImageTypes[] = [ImageTypes.CustomImage, ImageTypes.Url];
|
|
||||||
if (onTakeSnapshot) {
|
|
||||||
options.push(ImageTypes.TakeScreenshot);
|
|
||||||
if (notebookObject) {
|
|
||||||
options.push(ImageTypes.UseFirstDisplayOutput);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const thumbnailSelectorProps: IDropdownProps = {
|
|
||||||
label: t(Keys.panes.publishNotebook.coverImage),
|
|
||||||
selectedKey: type,
|
|
||||||
ariaLabel: t(Keys.panes.publishNotebook.coverImage),
|
|
||||||
options: options.map((value: string) => ({ text: value, key: value })),
|
|
||||||
onChange: async (event, options) => {
|
|
||||||
setImageSrc("");
|
|
||||||
clearFormError();
|
|
||||||
if (options.text === ImageTypes.TakeScreenshot) {
|
|
||||||
onTakeSnapshot({
|
|
||||||
aspectRatio: cardHeightToWidthRatio,
|
|
||||||
requestId: new Date().getTime().toString(),
|
|
||||||
type: "notebook",
|
|
||||||
notebookContentRef,
|
|
||||||
});
|
|
||||||
} else if (options.text === ImageTypes.UseFirstDisplayOutput) {
|
|
||||||
const cellIds = NotebookUtil.findCodeCellWithDisplay(notebookObject);
|
|
||||||
if (cellIds.length > 0) {
|
|
||||||
onTakeSnapshot({
|
|
||||||
aspectRatio: cardHeightToWidthRatio,
|
|
||||||
requestId: new Date().getTime().toString(),
|
|
||||||
type: "celloutput",
|
|
||||||
cellId: cellIds[0],
|
|
||||||
notebookContentRef,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
firstOutputErrorHandler(new Error(t(Keys.panes.publishNotebook.outputDoesNotExist)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setType(options.text);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const thumbnailUrlProps: ITextFieldProps = {
|
|
||||||
label: t(Keys.panes.publishNotebook.coverImageUrl),
|
|
||||||
ariaLabel: t(Keys.panes.publishNotebook.coverImageUrl),
|
|
||||||
required: true,
|
|
||||||
onChange: (event, newValue) => {
|
|
||||||
setImageSrc(newValue);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstOutputErrorHandler = (error: Error) => {
|
|
||||||
const formError = t(Keys.panes.publishNotebook.failedToCaptureOutput);
|
|
||||||
const formErrorDetail = `${error}`;
|
|
||||||
const area = "PublishNotebookPaneComponent/UseFirstOutput";
|
|
||||||
onError(formError, formErrorDetail, area);
|
|
||||||
};
|
|
||||||
|
|
||||||
const imageToBase64 = (file: File, updateImageSrc: (result: string) => void) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
reader.onload = () => {
|
|
||||||
updateImageSrc(reader.result.toString());
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.onerror = (error) => {
|
|
||||||
const formError = t(Keys.panes.publishNotebook.failedToConvertError, { fileName: file.name });
|
|
||||||
const formErrorDetail = `${error}`;
|
|
||||||
const area = "PublishNotebookPaneComponent/selectImageFile";
|
|
||||||
onError(formError, formErrorDetail, area);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderThumbnailSelectors = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case ImageTypes.Url:
|
|
||||||
return <TextField {...thumbnailUrlProps} />;
|
|
||||||
case ImageTypes.CustomImage:
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
id="selectImageFile"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(event) => {
|
|
||||||
const file = event.target.files[0];
|
|
||||||
if (file.size / 1024 ** 2 > maxImageSizeInMib) {
|
|
||||||
event.target.value = "";
|
|
||||||
const formError = t(Keys.panes.publishNotebook.failedToUploadError, { fileName: file.name });
|
|
||||||
const formErrorDetail = `Image is larger than ${maxImageSizeInMib} MiB. Please Choose a different image.`;
|
|
||||||
const area = "PublishNotebookPaneComponent/selectImageFile";
|
|
||||||
|
|
||||||
onError(formError, formErrorDetail, area);
|
|
||||||
setImageSrc("");
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
clearFormError();
|
|
||||||
}
|
|
||||||
imageToBase64(file, (result: string) => {
|
|
||||||
setImageSrc(result);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="publishNotebookPanelContent">
|
|
||||||
<Stack className="panelMainContent" tokens={{ childrenGap: 20 }}>
|
|
||||||
<Stack.Item>
|
|
||||||
<Text>{descriptionPara1}</Text>
|
|
||||||
</Stack.Item>
|
|
||||||
|
|
||||||
<Stack.Item>
|
|
||||||
<Text>{descriptionPara2}</Text>
|
|
||||||
</Stack.Item>
|
|
||||||
|
|
||||||
<Stack.Item>
|
|
||||||
<TextField
|
|
||||||
label={t(Keys.panes.publishNotebook.name)}
|
|
||||||
ariaLabel={t(Keys.panes.publishNotebook.name)}
|
|
||||||
defaultValue={FileSystemUtil.stripExtension(notebookName, "ipynb")}
|
|
||||||
required
|
|
||||||
onChange={(event, newValue) => {
|
|
||||||
const notebookName = newValue + ".ipynb";
|
|
||||||
setNotebookName(notebookName);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack.Item>
|
|
||||||
|
|
||||||
<Stack.Item>
|
|
||||||
<TextField
|
|
||||||
label={t(Keys.panes.publishNotebook.description)}
|
|
||||||
ariaLabel={t(Keys.panes.publishNotebook.description)}
|
|
||||||
multiline
|
|
||||||
rows={3}
|
|
||||||
required
|
|
||||||
onChange={(event, newValue) => {
|
|
||||||
setNotebookDescription(newValue);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack.Item>
|
|
||||||
|
|
||||||
<Stack.Item>
|
|
||||||
<TextField
|
|
||||||
label={t(Keys.panes.publishNotebook.tags)}
|
|
||||||
ariaLabel={t(Keys.panes.publishNotebook.tags)}
|
|
||||||
placeholder={t(Keys.panes.publishNotebook.tagsPlaceholder)}
|
|
||||||
onChange={(event, newValue) => {
|
|
||||||
setNotebookTags(newValue);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack.Item>
|
|
||||||
|
|
||||||
<Stack.Item>
|
|
||||||
<Dropdown {...thumbnailSelectorProps} />
|
|
||||||
</Stack.Item>
|
|
||||||
|
|
||||||
<Stack.Item>{renderThumbnailSelectors(type)}</Stack.Item>
|
|
||||||
|
|
||||||
<Stack.Item>
|
|
||||||
<Text>{t(Keys.panes.publishNotebook.preview)}</Text>
|
|
||||||
</Stack.Item>
|
|
||||||
<Stack.Item>
|
|
||||||
<GalleryCardComponent
|
|
||||||
data={{
|
|
||||||
id: undefined,
|
|
||||||
name: notebookName,
|
|
||||||
description: notebookDescription,
|
|
||||||
gitSha: undefined,
|
|
||||||
tags: notebookTags.split(","),
|
|
||||||
author: notebookAuthor,
|
|
||||||
thumbnailUrl: imageSrc,
|
|
||||||
created: notebookCreatedDate,
|
|
||||||
isSample: false,
|
|
||||||
downloads: undefined,
|
|
||||||
favorites: undefined,
|
|
||||||
views: undefined,
|
|
||||||
newCellId: undefined,
|
|
||||||
policyViolations: undefined,
|
|
||||||
pendingScanJobIds: undefined,
|
|
||||||
}}
|
|
||||||
isFavorite={undefined}
|
|
||||||
showDownload={false}
|
|
||||||
showDelete={false}
|
|
||||||
onClick={() => undefined}
|
|
||||||
onTagClick={undefined}
|
|
||||||
onFavoriteClick={undefined}
|
|
||||||
onUnfavoriteClick={undefined}
|
|
||||||
onDownloadClick={undefined}
|
|
||||||
onDeleteClick={undefined}
|
|
||||||
/>
|
|
||||||
</Stack.Item>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-116
@@ -1,116 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`PublishNotebookPaneComponent renders 1`] = `
|
|
||||||
<div
|
|
||||||
className="publishNotebookPanelContent"
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
className="panelMainContent"
|
|
||||||
tokens={
|
|
||||||
{
|
|
||||||
"childrenGap": 20,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<StackItem>
|
|
||||||
<Text>
|
|
||||||
When published, this notebook will appear in the Azure Cosmos DB notebooks public gallery. Make sure you have removed any sensitive data or output before publishing.
|
|
||||||
</Text>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<Text>
|
|
||||||
Would you like to publish and share "SampleNotebook" to the gallery?
|
|
||||||
</Text>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<StyledTextFieldBase
|
|
||||||
ariaLabel="Name"
|
|
||||||
defaultValue="SampleNotebook"
|
|
||||||
label="Name"
|
|
||||||
onChange={[Function]}
|
|
||||||
required={true}
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<StyledTextFieldBase
|
|
||||||
ariaLabel="Description"
|
|
||||||
label="Description"
|
|
||||||
multiline={true}
|
|
||||||
onChange={[Function]}
|
|
||||||
required={true}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<StyledTextFieldBase
|
|
||||||
ariaLabel="Tags"
|
|
||||||
label="Tags"
|
|
||||||
onChange={[Function]}
|
|
||||||
placeholder="Optional tag 1, Optional tag 2"
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<Dropdown
|
|
||||||
ariaLabel="Cover image"
|
|
||||||
label="Cover image"
|
|
||||||
onChange={[Function]}
|
|
||||||
options={
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"key": "Custom Image",
|
|
||||||
"text": "Custom Image",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "URL",
|
|
||||||
"text": "URL",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
selectedKey="Custom Image"
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<input
|
|
||||||
accept="image/*"
|
|
||||||
id="selectImageFile"
|
|
||||||
onChange={[Function]}
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<Text>
|
|
||||||
Preview
|
|
||||||
</Text>
|
|
||||||
</StackItem>
|
|
||||||
<StackItem>
|
|
||||||
<GalleryCardComponent
|
|
||||||
data={
|
|
||||||
{
|
|
||||||
"author": "CosmosDB",
|
|
||||||
"created": "2020-07-17T00:00:00Z",
|
|
||||||
"description": "sample description",
|
|
||||||
"downloads": undefined,
|
|
||||||
"favorites": undefined,
|
|
||||||
"gitSha": undefined,
|
|
||||||
"id": undefined,
|
|
||||||
"isSample": false,
|
|
||||||
"name": "SampleNotebook.ipynb",
|
|
||||||
"newCellId": undefined,
|
|
||||||
"pendingScanJobIds": undefined,
|
|
||||||
"policyViolations": undefined,
|
|
||||||
"tags": [
|
|
||||||
"tag1",
|
|
||||||
" tag2",
|
|
||||||
],
|
|
||||||
"thumbnailUrl": "https://i.ytimg.com/vi/E_lByLdKeKY/maxresdefault.jpg",
|
|
||||||
"views": undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onClick={[Function]}
|
|
||||||
showDelete={false}
|
|
||||||
showDownload={false}
|
|
||||||
/>
|
|
||||||
</StackItem>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
.publishNotebookPanelContent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
@@ -93,7 +93,7 @@ function createDataTable(
|
|||||||
|
|
||||||
for (var i = 0; i < tableEntityListViewModel.headers.length; i++) {
|
for (var i = 0; i < tableEntityListViewModel.headers.length; i++) {
|
||||||
jsonColTable.push({
|
jsonColTable.push({
|
||||||
sTitle: tableEntityListViewModel.headers[i],
|
sTitle: Utilities.htmlEncode(tableEntityListViewModel.headers[i]),
|
||||||
data: tableEntityListViewModel.headers[i],
|
data: tableEntityListViewModel.headers[i],
|
||||||
aTargets: [i],
|
aTargets: [i],
|
||||||
mRender: bindColumn,
|
mRender: bindColumn,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { stringifyError } from "Common/stringifyError";
|
||||||
import * as DataTables from "datatables.net";
|
import * as DataTables from "datatables.net";
|
||||||
import * as ko from "knockout";
|
import * as ko from "knockout";
|
||||||
import Q from "q";
|
import Q from "q";
|
||||||
@@ -37,7 +38,7 @@ function parseError(err: any): ErrorDataModel[] {
|
|||||||
try {
|
try {
|
||||||
return _parse(err);
|
return _parse(err);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return [<ErrorDataModel>{ message: JSON.stringify(err) }];
|
return [<ErrorDataModel>{ message: stringifyError(err) }];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FeedOptions } from "@azure/cosmos";
|
import { FeedOptions } from "@azure/cosmos";
|
||||||
|
import { stringifyError } from "Common/stringifyError";
|
||||||
import * as ko from "knockout";
|
import * as ko from "knockout";
|
||||||
import Q from "q";
|
import Q from "q";
|
||||||
import { AuthType } from "../../AuthType";
|
import { AuthType } from "../../AuthType";
|
||||||
@@ -172,7 +173,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
|||||||
deferred.resolve(entity);
|
deferred.resolve(entity);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
const errorText = error.responseJSON?.message ?? JSON.stringify(error);
|
const errorText = error.responseJSON?.message ?? stringifyError(error);
|
||||||
handleError(errorText, "AddRowCassandra", `Error while adding new row to table ${collection.id()}`);
|
handleError(errorText, "AddRowCassandra", `Error while adding new row to table ${collection.id()}`);
|
||||||
deferred.reject(errorText);
|
deferred.reject(errorText);
|
||||||
},
|
},
|
||||||
@@ -361,7 +362,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
|||||||
deferred.resolve();
|
deferred.resolve();
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
const errorText = error.responseJSON?.message ?? JSON.stringify(error);
|
const errorText = error.responseJSON?.message ?? stringifyError(error);
|
||||||
handleError(
|
handleError(
|
||||||
errorText,
|
errorText,
|
||||||
"CreateKeyspaceCassandra",
|
"CreateKeyspaceCassandra",
|
||||||
@@ -400,7 +401,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
|||||||
deferred.resolve();
|
deferred.resolve();
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
const errorText = error.responseJSON?.message ?? JSON.stringify(error);
|
const errorText = error.responseJSON?.message ?? stringifyError(error);
|
||||||
handleError(
|
handleError(
|
||||||
errorText,
|
errorText,
|
||||||
"CreateTableCassandra",
|
"CreateTableCassandra",
|
||||||
@@ -450,7 +451,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
|||||||
deferred.resolve(data);
|
deferred.resolve(data);
|
||||||
},
|
},
|
||||||
(error: any) => {
|
(error: any) => {
|
||||||
const errorText = error.responseJSON?.message ?? JSON.stringify(error);
|
const errorText = error.responseJSON?.message ?? stringifyError(error);
|
||||||
handleError(errorText, "FetchKeysCassandra", `Error fetching keys for table ${collection.id()}`);
|
handleError(errorText, "FetchKeysCassandra", `Error fetching keys for table ${collection.id()}`);
|
||||||
deferred.reject(errorText);
|
deferred.reject(errorText);
|
||||||
},
|
},
|
||||||
@@ -492,7 +493,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
|||||||
deferred.resolve(data.columns);
|
deferred.resolve(data.columns);
|
||||||
},
|
},
|
||||||
(error: any) => {
|
(error: any) => {
|
||||||
const errorText = error.responseJSON?.message ?? JSON.stringify(error);
|
const errorText = error.responseJSON?.message ?? stringifyError(error);
|
||||||
handleError(errorText, "FetchSchemaCassandra", `Error fetching schema for table ${collection.id()}`);
|
handleError(errorText, "FetchSchemaCassandra", `Error fetching schema for table ${collection.id()}`);
|
||||||
deferred.reject(errorText);
|
deferred.reject(errorText);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useTabs } from "hooks/useTabs";
|
||||||
|
import * as ko from "knockout";
|
||||||
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
|
import { DocumentsTabV2 } from "./DocumentsTabV2";
|
||||||
|
|
||||||
|
jest.mock("hooks/useTabs", () => ({
|
||||||
|
useTabs: {
|
||||||
|
getState: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("UserContext", () => ({
|
||||||
|
userContext: { apiType: "SQL" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("Explorer/Menus/CommandBar/CommandBarComponentAdapter", () => ({
|
||||||
|
useCommandBar: { getState: jest.fn(() => ({ setContextButtons: jest.fn() })) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
|
||||||
|
EditorReact: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockCollection = {
|
||||||
|
id: ko.observable<string>("testContainer"),
|
||||||
|
databaseId: "testDb",
|
||||||
|
partitionKey: { paths: ["/pk"], kind: "Hash", version: 2 },
|
||||||
|
selectedSubnodeKind: jest.fn(),
|
||||||
|
container: {},
|
||||||
|
} as unknown as ViewModels.Collection;
|
||||||
|
|
||||||
|
const buildTab = () =>
|
||||||
|
new DocumentsTabV2({
|
||||||
|
partitionKey: mockCollection.partitionKey,
|
||||||
|
documentIds: ko.observableArray([]),
|
||||||
|
tabKind: ViewModels.CollectionTabKind.Documents,
|
||||||
|
title: "Items",
|
||||||
|
collection: mockCollection,
|
||||||
|
node: mockCollection,
|
||||||
|
tabPath: "testDb>testContainer>Documents",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DocumentsTabV2.duplicateTab", () => {
|
||||||
|
let activateNewTab: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
activateNewTab = jest.fn();
|
||||||
|
(useTabs.getState as jest.Mock).mockReturnValue({ activateNewTab });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it("calls activateNewTab with a new DocumentsTabV2 instance", () => {
|
||||||
|
const tab = buildTab();
|
||||||
|
tab.duplicateTab();
|
||||||
|
|
||||||
|
expect(activateNewTab).toHaveBeenCalledTimes(1);
|
||||||
|
const newTab = activateNewTab.mock.calls[0][0];
|
||||||
|
expect(newTab).toBeInstanceOf(DocumentsTabV2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a duplicate with the same collection", () => {
|
||||||
|
const tab = buildTab();
|
||||||
|
tab.duplicateTab();
|
||||||
|
|
||||||
|
const newTab = activateNewTab.mock.calls[0][0] as DocumentsTabV2;
|
||||||
|
expect(newTab.collection).toBe(mockCollection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a duplicate with the same partitionKey", () => {
|
||||||
|
const tab = buildTab();
|
||||||
|
tab.duplicateTab();
|
||||||
|
|
||||||
|
const newTab = activateNewTab.mock.calls[0][0] as DocumentsTabV2;
|
||||||
|
expect(newTab.partitionKey).toEqual(mockCollection.partitionKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a distinct tab instance", () => {
|
||||||
|
const tab = buildTab();
|
||||||
|
tab.duplicateTab();
|
||||||
|
|
||||||
|
const newTab = activateNewTab.mock.calls[0][0];
|
||||||
|
expect(newTab).not.toBe(tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the raw title (not the display title) to avoid double-prefixing", () => {
|
||||||
|
const tab = buildTab();
|
||||||
|
tab.duplicateTab();
|
||||||
|
|
||||||
|
const newTab = activateNewTab.mock.calls[0][0] as DocumentsTabV2;
|
||||||
|
// The original tabTitle() is "testC…Items" (collection "testContainer" is > 8 chars so it's truncated).
|
||||||
|
// If duplicateTab() incorrectly used tabTitle() as the new title, the duplicate's tabTitle()
|
||||||
|
// would double-prefix to "testC…testC…Items". Using the raw title "Items" keeps it "testC…Items".
|
||||||
|
expect(newTab.tabTitle()).toBe("testC\u2026Items");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,6 +49,8 @@ import { userContext } from "UserContext";
|
|||||||
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
|
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
|
||||||
import { Allotment } from "allotment";
|
import { Allotment } from "allotment";
|
||||||
import { useClientWriteEnabled } from "hooks/useClientWriteEnabled";
|
import { useClientWriteEnabled } from "hooks/useClientWriteEnabled";
|
||||||
|
import { useTabs } from "hooks/useTabs";
|
||||||
|
import ko from "knockout";
|
||||||
import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { format } from "react-string-format";
|
import { format } from "react-string-format";
|
||||||
import DeleteDocumentIcon from "../../../../images/DeleteDocument.svg";
|
import DeleteDocumentIcon from "../../../../images/DeleteDocument.svg";
|
||||||
@@ -176,6 +178,25 @@ export class DocumentsTabV2 extends TabsBase {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public canDuplicate(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public duplicateTab(): void {
|
||||||
|
const newTab = new DocumentsTabV2({
|
||||||
|
partitionKey: this.partitionKey,
|
||||||
|
documentIds: ko.observableArray<DocumentId>([]),
|
||||||
|
tabKind: ViewModels.CollectionTabKind.Documents,
|
||||||
|
title: this.title,
|
||||||
|
collection: this.collection,
|
||||||
|
node: this.collection,
|
||||||
|
tabPath: `${this.collection.databaseId}>${this.collection.id()}>Documents`,
|
||||||
|
isPreferredApiMongoDB: userContext.apiType === "Mongo",
|
||||||
|
resourceTokenPartitionKey: this.resourceTokenPartitionKey,
|
||||||
|
});
|
||||||
|
useTabs.getState().activateNewTab(newTab);
|
||||||
|
}
|
||||||
|
|
||||||
public render(): JSX.Element {
|
public render(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<DocumentsTabComponent
|
<DocumentsTabComponent
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import type { DatabaseAccount } from "../../Contracts/DataModels";
|
|
||||||
import type { TabOptions } from "../../Contracts/ViewModels";
|
|
||||||
import type { IGalleryItem, JunoClient } from "../../Juno/JunoClient";
|
|
||||||
import { GalleryAndNotebookViewerComponent as GalleryViewer } from "../Controls/NotebookGallery/GalleryAndNotebookViewerComponent";
|
|
||||||
import type { GalleryTab as GalleryViewerTab } from "../Controls/NotebookGallery/GalleryViewerComponent";
|
|
||||||
import { SortBy } from "../Controls/NotebookGallery/GalleryViewerComponent";
|
|
||||||
import type Explorer from "../Explorer";
|
|
||||||
import TabsBase from "./TabsBase";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
account: DatabaseAccount;
|
|
||||||
container: Explorer;
|
|
||||||
junoClient: JunoClient;
|
|
||||||
selectedTab: GalleryViewerTab;
|
|
||||||
notebookUrl?: string;
|
|
||||||
galleryItem?: IGalleryItem;
|
|
||||||
isFavorite?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class GalleryTab extends TabsBase {
|
|
||||||
constructor(
|
|
||||||
options: TabOptions,
|
|
||||||
private props: Props,
|
|
||||||
) {
|
|
||||||
super(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
return <GalleryViewer {...this.props} sortBy={SortBy.MostRecent} searchText={undefined} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getContainer(): Explorer {
|
|
||||||
return this.props.container;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ import { ActionType, TabKind } from "Contracts/ActionContracts";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import MongoUtility from "../../../Common/MongoUtility";
|
import MongoUtility from "../../../Common/MongoUtility";
|
||||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
|
import { useTabs } from "../../../hooks/useTabs";
|
||||||
import Explorer from "../../Explorer";
|
import Explorer from "../../Explorer";
|
||||||
import { NewQueryTab } from "../QueryTab/QueryTab";
|
import { NewQueryTab } from "../QueryTab/QueryTab";
|
||||||
import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../QueryTab/QueryTabComponent";
|
import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../QueryTab/QueryTabComponent";
|
||||||
@@ -67,6 +68,30 @@ export class NewMongoQueryTab extends NewQueryTab {
|
|||||||
return MongoUtility.tojson(value, undefined, false);
|
return MongoUtility.tojson(value, undefined, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public canDuplicate(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public duplicateTab(): void {
|
||||||
|
const id = useTabs.getState().getTabs(ViewModels.CollectionTabKind.Query).length + 1;
|
||||||
|
const queryText = this.iTabAccessor?.onSaveClickEvent() ?? this.persistedState?.query?.text ?? "";
|
||||||
|
const newTab = new NewMongoQueryTab(
|
||||||
|
{
|
||||||
|
tabKind: ViewModels.CollectionTabKind.Query,
|
||||||
|
title: `Query ${id}`,
|
||||||
|
tabPath: "",
|
||||||
|
collection: this.collection,
|
||||||
|
node: this.collection,
|
||||||
|
queryText,
|
||||||
|
partitionKey: this.partitionKey,
|
||||||
|
splitterDirection: this.persistedState?.splitterDirection,
|
||||||
|
queryViewSizePercent: this.persistedState?.queryViewSizePercent,
|
||||||
|
},
|
||||||
|
this.mongoQueryTabProps,
|
||||||
|
);
|
||||||
|
useTabs.getState().activateNewTab(newTab);
|
||||||
|
}
|
||||||
|
|
||||||
public render(): JSX.Element {
|
public render(): JSX.Element {
|
||||||
return <QueryTabComponent {...this.iMongoQueryTabComponentProps} />;
|
return <QueryTabComponent {...this.iMongoQueryTabComponentProps} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { stringifyNotebook, toJS } from "@nteract/commutable";
|
import { stringifyNotebook, toJS } from "@nteract/commutable";
|
||||||
import * as ko from "knockout";
|
import * as ko from "knockout";
|
||||||
import * as Q from "q";
|
import * as Q from "q";
|
||||||
import { userContext } from "UserContext";
|
|
||||||
import ClearAllOutputsIcon from "../../../images/notebook/Notebook-clear-all-outputs.svg";
|
import ClearAllOutputsIcon from "../../../images/notebook/Notebook-clear-all-outputs.svg";
|
||||||
import CopyIcon from "../../../images/notebook/Notebook-copy.svg";
|
import CopyIcon from "../../../images/notebook/Notebook-copy.svg";
|
||||||
import CutIcon from "../../../images/notebook/Notebook-cut.svg";
|
import CutIcon from "../../../images/notebook/Notebook-cut.svg";
|
||||||
@@ -12,8 +11,7 @@ import RunAllIcon from "../../../images/notebook/Notebook-run-all.svg";
|
|||||||
import RunIcon from "../../../images/notebook/Notebook-run.svg";
|
import RunIcon from "../../../images/notebook/Notebook-run.svg";
|
||||||
import { default as InterruptKernelIcon, default as KillKernelIcon } from "../../../images/notebook/Notebook-stop.svg";
|
import { default as InterruptKernelIcon, default as KillKernelIcon } from "../../../images/notebook/Notebook-stop.svg";
|
||||||
import SaveIcon from "../../../images/save-cosmos.svg";
|
import SaveIcon from "../../../images/save-cosmos.svg";
|
||||||
import { useNotebookSnapshotStore } from "../../hooks/useNotebookSnapshotStore";
|
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||||
import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants";
|
|
||||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||||
import * as NotebookConfigurationUtils from "../../Utils/NotebookConfigurationUtils";
|
import * as NotebookConfigurationUtils from "../../Utils/NotebookConfigurationUtils";
|
||||||
import { logConsoleInfo } from "../../Utils/NotificationConsoleUtils";
|
import { logConsoleInfo } from "../../Utils/NotificationConsoleUtils";
|
||||||
@@ -21,9 +19,7 @@ import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandBu
|
|||||||
import { useDialog } from "../Controls/Dialog";
|
import { useDialog } from "../Controls/Dialog";
|
||||||
import * as CommandBarComponentButtonFactory from "../Menus/CommandBar/CommandBarComponentButtonFactory";
|
import * as CommandBarComponentButtonFactory from "../Menus/CommandBar/CommandBarComponentButtonFactory";
|
||||||
import { KernelSpecsDisplay } from "../Notebook/NotebookClientV2";
|
import { KernelSpecsDisplay } from "../Notebook/NotebookClientV2";
|
||||||
import * as CdbActions from "../Notebook/NotebookComponent/actions";
|
|
||||||
import { NotebookComponentAdapter } from "../Notebook/NotebookComponent/NotebookComponentAdapter";
|
import { NotebookComponentAdapter } from "../Notebook/NotebookComponent/NotebookComponentAdapter";
|
||||||
import { CdbAppState, SnapshotRequest } from "../Notebook/NotebookComponent/types";
|
|
||||||
import { NotebookContentItem } from "../Notebook/NotebookContentItem";
|
import { NotebookContentItem } from "../Notebook/NotebookContentItem";
|
||||||
import { NotebookUtil } from "../Notebook/NotebookUtil";
|
import { NotebookUtil } from "../Notebook/NotebookUtil";
|
||||||
import { useNotebook } from "../Notebook/useNotebook";
|
import { useNotebook } from "../Notebook/useNotebook";
|
||||||
@@ -97,7 +93,6 @@ export default class NotebookTabV2 extends NotebookTabBase {
|
|||||||
|
|
||||||
const saveLabel = "Save";
|
const saveLabel = "Save";
|
||||||
const copyToLabel = "Copy to ...";
|
const copyToLabel = "Copy to ...";
|
||||||
const publishLabel = "Publish to gallery";
|
|
||||||
const kernelLabel = "No Kernel";
|
const kernelLabel = "No Kernel";
|
||||||
const runLabel = "Run";
|
const runLabel = "Run";
|
||||||
const runActiveCellLabel = "Run Active Cell";
|
const runActiveCellLabel = "Run Active Cell";
|
||||||
@@ -130,17 +125,6 @@ export default class NotebookTabV2 extends NotebookTabBase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userContext.features.publicGallery) {
|
|
||||||
saveButtonChildren.push({
|
|
||||||
iconName: "PublishContent",
|
|
||||||
onCommandClick: async () => await this.publishToGallery(),
|
|
||||||
commandButtonLabel: publishLabel,
|
|
||||||
hasPopup: false,
|
|
||||||
disabled: false,
|
|
||||||
ariaLabel: publishLabel,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let buttons: CommandButtonComponentProps[] = [
|
let buttons: CommandButtonComponentProps[] = [
|
||||||
{
|
{
|
||||||
iconSrc: SaveIcon,
|
iconSrc: SaveIcon,
|
||||||
@@ -383,40 +367,6 @@ export default class NotebookTabV2 extends NotebookTabBase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private publishToGallery = async () => {
|
|
||||||
TelemetryProcessor.trace(Action.NotebooksGalleryClickPublishToGallery, ActionModifiers.Mark, {
|
|
||||||
source: Source.CommandBarMenu,
|
|
||||||
});
|
|
||||||
|
|
||||||
const notebookReduxStore = NotebookTabV2.clientManager.getStore();
|
|
||||||
const unsubscribe = notebookReduxStore.subscribe(() => {
|
|
||||||
const cdbState = (notebookReduxStore.getState() as CdbAppState).cdb;
|
|
||||||
useNotebookSnapshotStore.setState({
|
|
||||||
snapshot: cdbState.notebookSnapshot?.imageSrc,
|
|
||||||
error: cdbState.notebookSnapshotError,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const notebookContent = this.notebookComponentAdapter.getContent();
|
|
||||||
const notebookContentRef = this.notebookComponentAdapter.contentRef;
|
|
||||||
const onPanelClose = (): void => {
|
|
||||||
unsubscribe();
|
|
||||||
useNotebookSnapshotStore.setState({
|
|
||||||
snapshot: undefined,
|
|
||||||
error: undefined,
|
|
||||||
});
|
|
||||||
notebookReduxStore.dispatch(CdbActions.takeNotebookSnapshot(undefined));
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.container.publishNotebook(
|
|
||||||
notebookContent.name,
|
|
||||||
notebookContent.content,
|
|
||||||
notebookContentRef,
|
|
||||||
(request: SnapshotRequest) => notebookReduxStore.dispatch(CdbActions.takeNotebookSnapshot(request)),
|
|
||||||
onPanelClose,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private copyNotebook = () => {
|
private copyNotebook = () => {
|
||||||
const notebookContent = this.notebookComponentAdapter.getContent();
|
const notebookContent = this.notebookComponentAdapter.getContent();
|
||||||
let content: string;
|
let content: string;
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useTabs } from "hooks/useTabs";
|
||||||
|
import * as ko from "knockout";
|
||||||
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
|
import { IQueryTabProps, NewQueryTab } from "./QueryTab";
|
||||||
|
|
||||||
|
jest.mock("hooks/useTabs", () => ({
|
||||||
|
useTabs: {
|
||||||
|
getState: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("Explorer/Menus/CommandBar/CommandBarComponentAdapter", () => ({
|
||||||
|
useCommandBar: { getState: jest.fn(() => ({ setContextButtons: jest.fn() })) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("Shared/AppStatePersistenceUtility", () => ({
|
||||||
|
loadState: jest.fn(),
|
||||||
|
AppStateComponentNames: {},
|
||||||
|
readSubComponentState: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("Common/MessageHandler", () => ({
|
||||||
|
sendMessage: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockCollection = {
|
||||||
|
id: ko.observable<string>("testContainer"),
|
||||||
|
databaseId: "testDb",
|
||||||
|
partitionKey: { paths: ["/pk"], kind: "Hash", version: 2 },
|
||||||
|
selectedSubnodeKind: jest.fn(),
|
||||||
|
container: {},
|
||||||
|
} as unknown as ViewModels.Collection;
|
||||||
|
|
||||||
|
const mockProps = { container: {} as IQueryTabProps["container"] };
|
||||||
|
|
||||||
|
const buildTab = (queryText = "SELECT * FROM c") =>
|
||||||
|
new NewQueryTab(
|
||||||
|
{
|
||||||
|
tabKind: ViewModels.CollectionTabKind.Query,
|
||||||
|
title: "Query 1",
|
||||||
|
tabPath: "",
|
||||||
|
collection: mockCollection,
|
||||||
|
node: mockCollection,
|
||||||
|
queryText,
|
||||||
|
partitionKey: mockCollection.partitionKey,
|
||||||
|
},
|
||||||
|
mockProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("NewQueryTab.duplicateTab", () => {
|
||||||
|
let activateNewTab: jest.Mock;
|
||||||
|
let getTabs: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
activateNewTab = jest.fn();
|
||||||
|
getTabs = jest.fn().mockReturnValue([]);
|
||||||
|
(useTabs.getState as jest.Mock).mockReturnValue({ activateNewTab, getTabs });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it("calls activateNewTab with a new NewQueryTab instance", () => {
|
||||||
|
const tab = buildTab();
|
||||||
|
tab.duplicateTab();
|
||||||
|
|
||||||
|
expect(activateNewTab).toHaveBeenCalledTimes(1);
|
||||||
|
const newTab = activateNewTab.mock.calls[0][0];
|
||||||
|
expect(newTab).toBeInstanceOf(NewQueryTab);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the current query text in the duplicate", () => {
|
||||||
|
const queryText = "SELECT * FROM c WHERE c.id = '123'";
|
||||||
|
const tab = buildTab(queryText);
|
||||||
|
tab.duplicateTab();
|
||||||
|
|
||||||
|
const newTab = activateNewTab.mock.calls[0][0] as NewQueryTab;
|
||||||
|
expect(newTab.iQueryTabComponentProps.queryText).toBe(queryText);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a duplicate with the same collection", () => {
|
||||||
|
const tab = buildTab();
|
||||||
|
tab.duplicateTab();
|
||||||
|
|
||||||
|
const newTab = activateNewTab.mock.calls[0][0] as NewQueryTab;
|
||||||
|
expect(newTab.collection).toBe(mockCollection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a distinct tab instance", () => {
|
||||||
|
const tab = buildTab();
|
||||||
|
tab.duplicateTab();
|
||||||
|
|
||||||
|
const newTab = activateNewTab.mock.calls[0][0];
|
||||||
|
expect(newTab).not.toBe(tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns an auto-incremented title based on existing query tabs", () => {
|
||||||
|
getTabs.mockReturnValue([{}, {}]); // 2 existing tabs → new title = "Query 3"
|
||||||
|
const tab = buildTab();
|
||||||
|
tab.duplicateTab();
|
||||||
|
|
||||||
|
const newTab = activateNewTab.mock.calls[0][0] as NewQueryTab;
|
||||||
|
expect(newTab.tabTitle()).toContain("Query 3");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,8 @@ import { MessageTypes } from "Contracts/MessageTypes";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import * as DataModels from "../../../Contracts/DataModels";
|
import * as DataModels from "../../../Contracts/DataModels";
|
||||||
import type { QueryTabOptions } from "../../../Contracts/ViewModels";
|
import type { QueryTabOptions } from "../../../Contracts/ViewModels";
|
||||||
import { useTabs } from "../../../hooks/useTabs";
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
|
import { useTabs } from "hooks/useTabs";
|
||||||
import Explorer from "../../Explorer";
|
import Explorer from "../../Explorer";
|
||||||
import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../../Tabs/QueryTab/QueryTabComponent";
|
import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../../Tabs/QueryTab/QueryTabComponent";
|
||||||
import TabsBase from "../TabsBase";
|
import TabsBase from "../TabsBase";
|
||||||
@@ -72,6 +73,30 @@ export class NewQueryTab extends TabsBase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public canDuplicate(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public duplicateTab(): void {
|
||||||
|
const queryText = this.iTabAccessor?.onSaveClickEvent() ?? this.persistedState?.query?.text ?? "";
|
||||||
|
const id = useTabs.getState().getTabs(ViewModels.CollectionTabKind.Query).length + 1;
|
||||||
|
const newTab = new NewQueryTab(
|
||||||
|
{
|
||||||
|
tabKind: ViewModels.CollectionTabKind.Query,
|
||||||
|
title: `Query ${id}`,
|
||||||
|
tabPath: "",
|
||||||
|
collection: this.collection,
|
||||||
|
node: this.collection,
|
||||||
|
queryText,
|
||||||
|
partitionKey: this.partitionKey,
|
||||||
|
splitterDirection: this.persistedState?.splitterDirection,
|
||||||
|
queryViewSizePercent: this.persistedState?.queryViewSizePercent,
|
||||||
|
},
|
||||||
|
this.props,
|
||||||
|
);
|
||||||
|
useTabs.getState().activateNewTab(newTab);
|
||||||
|
}
|
||||||
|
|
||||||
public render(): JSX.Element {
|
public render(): JSX.Element {
|
||||||
return <QueryTabComponent {...this.iQueryTabComponentProps} />;
|
return <QueryTabComponent {...this.iQueryTabComponentProps} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Spinner, SpinnerSize, TooltipHost } from "@fluentui/react";
|
import { Spinner, SpinnerSize, TooltipHost } from "@fluentui/react";
|
||||||
|
import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from "@fluentui/react-components";
|
||||||
import { CollectionTabKind } from "Contracts/ViewModels";
|
import { CollectionTabKind } from "Contracts/ViewModels";
|
||||||
import Explorer from "Explorer/Explorer";
|
import Explorer from "Explorer/Explorer";
|
||||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||||
@@ -10,6 +11,7 @@ import { QuickstartTab } from "Explorer/Tabs/QuickstartTab";
|
|||||||
import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab";
|
import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab";
|
||||||
import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab";
|
import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab";
|
||||||
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||||
|
import { Keys, t } from "Localization";
|
||||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||||
import { userContext } from "UserContext";
|
import { userContext } from "UserContext";
|
||||||
import { useTeachingBubble } from "hooks/useTeachingBubble";
|
import { useTeachingBubble } from "hooks/useTeachingBubble";
|
||||||
@@ -85,8 +87,9 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
|
|||||||
focusTab.current.focus();
|
focusTab.current.focus();
|
||||||
}
|
}
|
||||||
}, [active]);
|
}, [active]);
|
||||||
return (
|
const liElement = (
|
||||||
<li
|
<li
|
||||||
|
data-test={`TabNav:${tab !== undefined ? tab.tabId : ReactTabKind[tabKind!]}`}
|
||||||
onMouseOver={() => setHovering(true)}
|
onMouseOver={() => setHovering(true)}
|
||||||
onMouseLeave={() => setHovering(false)}
|
onMouseLeave={() => setHovering(false)}
|
||||||
className={active ? "active tabList" : "tabList"}
|
className={active ? "active tabList" : "tabList"}
|
||||||
@@ -155,6 +158,22 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!tab?.canDuplicate()) {
|
||||||
|
return liElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu openOnContext>
|
||||||
|
<MenuTrigger disableButtonEnhancement>{liElement}</MenuTrigger>
|
||||||
|
<MenuPopover>
|
||||||
|
<MenuList>
|
||||||
|
<MenuItem onClick={() => tab.duplicateTab()}>{t(Keys.tabs.tabMenu.duplicateTab)}</MenuItem>
|
||||||
|
<MenuItem onClick={() => tab.onCloseTabButtonClick()}>{t(Keys.tabs.tabMenu.closeTab)}</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</MenuPopover>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const onKeyPressReactTabClose = (e: KeyboardEvent, tabKind: ReactTabKind): void => {
|
const onKeyPressReactTabClose = (e: KeyboardEvent, tabKind: ReactTabKind): void => {
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ export default class TabsBase extends WaitsForTemplateViewModel {
|
|||||||
public getPersistedState = (): OpenTab | null => this.persistedState;
|
public getPersistedState = (): OpenTab | null => this.persistedState;
|
||||||
public triggerPersistState: () => void = undefined;
|
public triggerPersistState: () => void = undefined;
|
||||||
|
|
||||||
|
public canDuplicate(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public duplicateTab(): void {
|
||||||
|
// Subclasses override this to support tab duplication
|
||||||
|
}
|
||||||
|
|
||||||
public onCloseTabButtonClick(): void {
|
public onCloseTabButtonClick(): void {
|
||||||
useTabs.getState().closeTab(this);
|
useTabs.getState().closeTab(this);
|
||||||
TelemetryProcessor.trace(Action.Tab, ActionModifiers.Close, {
|
TelemetryProcessor.trace(Action.Tab, ActionModifiers.Close, {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import CopyIcon from "../../../images/notebook/Notebook-copy.svg";
|
|||||||
import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg";
|
import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg";
|
||||||
import NotebookIcon from "../../../images/notebook/Notebook-resource.svg";
|
import NotebookIcon from "../../../images/notebook/Notebook-resource.svg";
|
||||||
import FileIcon from "../../../images/notebook/file-cosmos.svg";
|
import FileIcon from "../../../images/notebook/file-cosmos.svg";
|
||||||
import PublishIcon from "../../../images/notebook/publish_content.svg";
|
|
||||||
import RefreshIcon from "../../../images/refresh-cosmos.svg";
|
import RefreshIcon from "../../../images/refresh-cosmos.svg";
|
||||||
import CollectionIcon from "../../../images/tree-collection.svg";
|
import CollectionIcon from "../../../images/tree-collection.svg";
|
||||||
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
|
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
|
||||||
@@ -18,7 +17,7 @@ import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtili
|
|||||||
import * as DataModels from "../../Contracts/DataModels";
|
import * as DataModels from "../../Contracts/DataModels";
|
||||||
import * as ViewModels from "../../Contracts/ViewModels";
|
import * as ViewModels from "../../Contracts/ViewModels";
|
||||||
import { IPinnedRepo } from "../../Juno/JunoClient";
|
import { IPinnedRepo } from "../../Juno/JunoClient";
|
||||||
import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants";
|
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||||
import { userContext } from "../../UserContext";
|
import { userContext } from "../../UserContext";
|
||||||
import { isServerlessAccount } from "../../Utils/CapabilityUtils";
|
import { isServerlessAccount } from "../../Utils/CapabilityUtils";
|
||||||
@@ -49,7 +48,6 @@ export class ResourceTreeAdapter implements ReactAdapter {
|
|||||||
|
|
||||||
public parameters: ko.Observable<number>;
|
public parameters: ko.Observable<number>;
|
||||||
|
|
||||||
public galleryContentRoot: NotebookContentItem;
|
|
||||||
public myNotebooksContentRoot: NotebookContentItem;
|
public myNotebooksContentRoot: NotebookContentItem;
|
||||||
public gitHubNotebooksContentRoot: NotebookContentItem;
|
public gitHubNotebooksContentRoot: NotebookContentItem;
|
||||||
|
|
||||||
@@ -102,11 +100,6 @@ export class ResourceTreeAdapter implements ReactAdapter {
|
|||||||
public async initialize(): Promise<void[]> {
|
public async initialize(): Promise<void[]> {
|
||||||
const refreshTasks: Promise<void>[] = [];
|
const refreshTasks: Promise<void>[] = [];
|
||||||
|
|
||||||
this.galleryContentRoot = {
|
|
||||||
name: "Gallery",
|
|
||||||
path: "Gallery",
|
|
||||||
type: NotebookContentItemType.File,
|
|
||||||
};
|
|
||||||
this.myNotebooksContentRoot = {
|
this.myNotebooksContentRoot = {
|
||||||
name: useNotebook.getState().notebookFolderName,
|
name: useNotebook.getState().notebookFolderName,
|
||||||
path: useNotebook.getState().notebookBasePath,
|
path: useNotebook.getState().notebookBasePath,
|
||||||
@@ -538,20 +531,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (item.type === NotebookContentItemType.Notebook) {
|
if (item.type === NotebookContentItemType.Notebook) {
|
||||||
items.push({
|
// Additional notebook-specific context menu items can be added here
|
||||||
label: "Publish to gallery",
|
|
||||||
iconSrc: PublishIcon,
|
|
||||||
onClick: async () => {
|
|
||||||
TelemetryProcessor.trace(Action.NotebooksGalleryClickPublishToGallery, ActionModifiers.Mark, {
|
|
||||||
source: Source.ResourceTreeMenu,
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = await this.container.readFile(item);
|
|
||||||
if (content) {
|
|
||||||
await this.container.publishNotebook(item.name, content);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Copy to ..." isn't needed if github locations are not available
|
// "Copy to ..." isn't needed if github locations are not available
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
@import "../../less/Common/Constants";
|
|
||||||
|
|
||||||
.standalone-gallery-root {
|
|
||||||
background: @GalleryBackgroundColor;
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { initializeIcons, Link, Text } from "@fluentui/react";
|
|
||||||
import "bootstrap/dist/css/bootstrap.css";
|
|
||||||
import * as React from "react";
|
|
||||||
import * as ReactDOM from "react-dom";
|
|
||||||
import { userContext } from "UserContext";
|
|
||||||
import { initializeConfiguration } from "../ConfigContext";
|
|
||||||
import { GalleryHeaderComponent } from "../Explorer/Controls/Header/GalleryHeaderComponent";
|
|
||||||
import {
|
|
||||||
GalleryAndNotebookViewerComponent,
|
|
||||||
GalleryAndNotebookViewerComponentProps,
|
|
||||||
} from "../Explorer/Controls/NotebookGallery/GalleryAndNotebookViewerComponent";
|
|
||||||
import { GalleryTab, SortBy } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
|
|
||||||
import { JunoClient } from "../Juno/JunoClient";
|
|
||||||
import * as GalleryUtils from "../Utils/GalleryUtils";
|
|
||||||
import "./GalleryViewer.less";
|
|
||||||
|
|
||||||
const enableNotebooksUrl = "https://aka.ms/cosmos-enable-notebooks";
|
|
||||||
const createAccountUrl = "https://aka.ms/cosmos-create-account-portal";
|
|
||||||
|
|
||||||
const onInit = async () => {
|
|
||||||
const dataExplorerUrl = new URL("./", window.location.href).href;
|
|
||||||
|
|
||||||
initializeIcons();
|
|
||||||
await initializeConfiguration();
|
|
||||||
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search);
|
|
||||||
|
|
||||||
const props: GalleryAndNotebookViewerComponentProps = {
|
|
||||||
junoClient: new JunoClient(),
|
|
||||||
selectedTab:
|
|
||||||
galleryViewerProps.selectedTab ||
|
|
||||||
(userContext.features.publicGallery ? GalleryTab.PublicGallery : GalleryTab.OfficialSamples),
|
|
||||||
sortBy: galleryViewerProps.sortBy || SortBy.MostRecent,
|
|
||||||
searchText: galleryViewerProps.searchText,
|
|
||||||
};
|
|
||||||
|
|
||||||
const element = (
|
|
||||||
<div className="standalone-gallery-root">
|
|
||||||
<header>
|
|
||||||
<GalleryHeaderComponent />
|
|
||||||
</header>
|
|
||||||
<div style={{ margin: "auto", width: "85%" }}>
|
|
||||||
<div style={{ paddingLeft: 26, paddingRight: 26, paddingTop: 20 }}>
|
|
||||||
<Text block>
|
|
||||||
Welcome to the Azure Cosmos DB notebooks gallery! View the sample notebooks to learn about use cases, best
|
|
||||||
practices, and how to get started with Azure Cosmos DB.
|
|
||||||
</Text>
|
|
||||||
<Text styles={{ root: { marginTop: 10 } }} block>
|
|
||||||
If {`you'd`} like to run or edit the notebook in your own Azure Cosmos DB account,{" "}
|
|
||||||
<Link href={dataExplorerUrl}>sign in</Link> and select an account with{" "}
|
|
||||||
<Link href={enableNotebooksUrl}>notebooks enabled</Link>. From there, you can download the sample to your
|
|
||||||
account. If you {`don't`} have an account yet, you can{" "}
|
|
||||||
<Link href={createAccountUrl}>create one from the Azure portal</Link>.
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GalleryAndNotebookViewerComponent {...props} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
ReactDOM.render(element, document.getElementById("galleryContent"));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Entry point
|
|
||||||
window.addEventListener("load", onInit);
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="height=device-height, width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Gallery Viewer</title>
|
|
||||||
<link rel="shortcut icon" href="../../images/CosmosDB_rgb_ui_lighttheme.ico" type="image/x-icon" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body style="overflow-y: scroll">
|
|
||||||
<div class="galleryContent" id="galleryContent"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
jest.mock("./hooks/useAADAuth");
|
||||||
|
jest.mock("./hooks/useConfig");
|
||||||
|
jest.mock("./hooks/usePortalAccessToken");
|
||||||
|
jest.mock("./Platform/Hosted/Components/ConnectExplorer");
|
||||||
|
jest.mock("./Shared/appInsights");
|
||||||
|
jest.mock("./Platform/Hosted/Components/AccountSwitcher", () => ({
|
||||||
|
AccountSwitcher: () => null,
|
||||||
|
}));
|
||||||
|
jest.mock("./Platform/Hosted/Components/DirectoryPickerPanel", () => ({
|
||||||
|
DirectoryPickerPanel: () => null,
|
||||||
|
}));
|
||||||
|
jest.mock("./Platform/Hosted/Components/FeedbackCommandButton", () => ({
|
||||||
|
FeedbackCommandButton: () => null,
|
||||||
|
}));
|
||||||
|
jest.mock("./Platform/Hosted/Components/MeControl", () => ({
|
||||||
|
MeControl: () => null,
|
||||||
|
}));
|
||||||
|
jest.mock("./Platform/Hosted/Components/SignInButton", () => ({
|
||||||
|
SignInButton: () => null,
|
||||||
|
}));
|
||||||
|
jest.mock("./Platform/Hosted/Components/AadAuthorizationFailure", () => ({
|
||||||
|
AadAuthorizationFailure: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { act, render } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
import { useAADAuth } from "./hooks/useAADAuth";
|
||||||
|
import { useConfig } from "./hooks/useConfig";
|
||||||
|
import { useTokenMetadata } from "./hooks/usePortalAccessToken";
|
||||||
|
import { App } from "./HostedExplorer";
|
||||||
|
import { ConnectExplorer, fetchEncryptedToken } from "./Platform/Hosted/Components/ConnectExplorer";
|
||||||
|
|
||||||
|
const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction<typeof fetchEncryptedToken>;
|
||||||
|
|
||||||
|
(ConnectExplorer as jest.Mock).mockImplementation(() => <div data-testid="connect-explorer" />);
|
||||||
|
|
||||||
|
import type { AccountInfo } from "@azure/msal-browser";
|
||||||
|
import type { AadAuthFailure } from "./hooks/useAADAuth";
|
||||||
|
|
||||||
|
const defaultAADAuth = {
|
||||||
|
isLoggedIn: false,
|
||||||
|
armToken: "",
|
||||||
|
graphToken: "",
|
||||||
|
account: undefined as AccountInfo | null | undefined,
|
||||||
|
tenantId: "",
|
||||||
|
logout: jest.fn(),
|
||||||
|
login: jest.fn(),
|
||||||
|
switchTenant: jest.fn(),
|
||||||
|
authFailure: undefined as AadAuthFailure | null | undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(useAADAuth as jest.Mock).mockReturnValue(defaultAADAuth);
|
||||||
|
(useConfig as jest.Mock).mockReturnValue({});
|
||||||
|
(useTokenMetadata as jest.Mock).mockReturnValue(undefined);
|
||||||
|
mockFetchEncryptedToken.mockResolvedValue("encrypted-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
const dispatchPostMessage = (data: unknown, origin: string) => {
|
||||||
|
const event = new MessageEvent("message", { data, origin });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("HostedExplorer tryCosmosDB postMessage handler", () => {
|
||||||
|
it("accepts a valid SQL connection string from an allowed origin", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const validConnStr = "AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=dGVzdGtleQ==;";
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
dispatchPostMessage(
|
||||||
|
{ type: "tryCosmosDBConnectionString", connectionString: validConnStr },
|
||||||
|
"https://cosmos.azure.com",
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(validConnStr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a valid Mongo connection string from an allowed origin", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const mongoConnStr = "mongodb://myaccount:dGVzdGtleQ==@myaccount.documents.azure.com:10255";
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
dispatchPostMessage(
|
||||||
|
{ type: "tryCosmosDBConnectionString", connectionString: mongoConnStr },
|
||||||
|
"https://cosmos.azure.com",
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(mongoConnStr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a valid Cassandra connection string from an allowed origin", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const cassandraConnStr =
|
||||||
|
"AccountEndpoint=https://myaccount.cassandra.cosmosdb.azure.com:443/;AccountKey=dGVzdGtleQ==;";
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
dispatchPostMessage(
|
||||||
|
{ type: "tryCosmosDBConnectionString", connectionString: cassandraConnStr },
|
||||||
|
"https://cosmos.azure.com",
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(cassandraConnStr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a valid Table connection string from an allowed origin", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const tableConnStr =
|
||||||
|
"DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=dGVzdGtleQ==;TableEndpoint=https://myaccount.table.cosmosdb.azure.com:443/;";
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
dispatchPostMessage(
|
||||||
|
{ type: "tryCosmosDBConnectionString", connectionString: tableConnStr },
|
||||||
|
"https://cosmos.azure.com",
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(tableConnStr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a valid Gremlin connection string from an allowed origin", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const gremlinConnStr =
|
||||||
|
"AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=dGVzdGtleQ==;ApiKind=Gremlin;";
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
dispatchPostMessage(
|
||||||
|
{ type: "tryCosmosDBConnectionString", connectionString: gremlinConnStr },
|
||||||
|
"https://cosmos.azure.com",
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(gremlinConnStr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects messages from a disallowed origin", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const validConnStr = "AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=dGVzdGtleQ==;";
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
dispatchPostMessage(
|
||||||
|
{ type: "tryCosmosDBConnectionString", connectionString: validConnStr },
|
||||||
|
"https://evil.example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects messages with an invalid connection string format", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
dispatchPostMessage(
|
||||||
|
{ type: "tryCosmosDBConnectionString", connectionString: "not-a-real-connection-string" },
|
||||||
|
"https://cosmos.azure.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects messages with a non-string connection string value", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
dispatchPostMessage({ type: "tryCosmosDBConnectionString", connectionString: 12345 }, "https://cosmos.azure.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects messages with a missing connection string", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
dispatchPostMessage({ type: "tryCosmosDBConnectionString" }, "https://cosmos.azure.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores messages with an unrelated type", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const validConnStr = "AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=dGVzdGtleQ==;";
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
dispatchPostMessage({ type: "someOtherMessage", connectionString: validConnStr }, "https://cosmos.azure.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends tryCosmosDBReady to opener when present", () => {
|
||||||
|
const mockPostMessage = jest.fn();
|
||||||
|
const originalOpener = window.opener;
|
||||||
|
Object.defineProperty(window, "opener", {
|
||||||
|
value: { postMessage: mockPostMessage },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(mockPostMessage).toHaveBeenCalledWith({ type: "tryCosmosDBReady" }, "https://cosmos.azure.com");
|
||||||
|
|
||||||
|
Object.defineProperty(window, "opener", {
|
||||||
|
value: originalOpener,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not crash when there is no opener", () => {
|
||||||
|
const originalOpener = window.opener;
|
||||||
|
Object.defineProperty(window, "opener", {
|
||||||
|
value: null,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => render(<App />)).not.toThrow();
|
||||||
|
|
||||||
|
Object.defineProperty(window, "opener", {
|
||||||
|
value: originalOpener,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+82
-14
@@ -6,18 +6,22 @@ import { render } from "react-dom";
|
|||||||
import ChevronRight from "../images/chevron-right.svg";
|
import ChevronRight from "../images/chevron-right.svg";
|
||||||
import "../less/hostedexplorer.less";
|
import "../less/hostedexplorer.less";
|
||||||
import { AuthType } from "./AuthType";
|
import { AuthType } from "./AuthType";
|
||||||
|
import { logError } from "./Common/Logger";
|
||||||
import { DatabaseAccount } from "./Contracts/DataModels";
|
import { DatabaseAccount } from "./Contracts/DataModels";
|
||||||
import "./Explorer/Menus/NavBar/MeControlComponent.less";
|
import "./Explorer/Menus/NavBar/MeControlComponent.less";
|
||||||
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
|
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
|
||||||
import { AccountSwitcher } from "./Platform/Hosted/Components/AccountSwitcher";
|
import { AccountSwitcher } from "./Platform/Hosted/Components/AccountSwitcher";
|
||||||
import { ConnectExplorer } from "./Platform/Hosted/Components/ConnectExplorer";
|
import { ConnectExplorer, fetchEncryptedToken } from "./Platform/Hosted/Components/ConnectExplorer";
|
||||||
import { DirectoryPickerPanel } from "./Platform/Hosted/Components/DirectoryPickerPanel";
|
import { DirectoryPickerPanel } from "./Platform/Hosted/Components/DirectoryPickerPanel";
|
||||||
import { FeedbackCommandButton } from "./Platform/Hosted/Components/FeedbackCommandButton";
|
import { FeedbackCommandButton } from "./Platform/Hosted/Components/FeedbackCommandButton";
|
||||||
import { MeControl } from "./Platform/Hosted/Components/MeControl";
|
import { MeControl } from "./Platform/Hosted/Components/MeControl";
|
||||||
import { SignInButton } from "./Platform/Hosted/Components/SignInButton";
|
import { SignInButton } from "./Platform/Hosted/Components/SignInButton";
|
||||||
import "./Platform/Hosted/ConnectScreen.less";
|
import "./Platform/Hosted/ConnectScreen.less";
|
||||||
|
import { parseConnectionString } from "./Platform/Hosted/Helpers/ConnectionStringParser";
|
||||||
|
import { isResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils";
|
||||||
import { extractMasterKeyfromConnectionString } from "./Platform/Hosted/HostedUtils";
|
import { extractMasterKeyfromConnectionString } from "./Platform/Hosted/HostedUtils";
|
||||||
import "./Shared/appInsights";
|
import "./Shared/appInsights";
|
||||||
|
import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils";
|
||||||
import { useAADAuth } from "./hooks/useAADAuth";
|
import { useAADAuth } from "./hooks/useAADAuth";
|
||||||
import { useConfig } from "./hooks/useConfig";
|
import { useConfig } from "./hooks/useConfig";
|
||||||
import { useTokenMetadata } from "./hooks/usePortalAccessToken";
|
import { useTokenMetadata } from "./hooks/usePortalAccessToken";
|
||||||
@@ -42,20 +46,71 @@ const App: React.FunctionComponent = () => {
|
|||||||
|
|
||||||
const ref = React.useRef<HTMLIFrameElement>();
|
const ref = React.useRef<HTMLIFrameElement>();
|
||||||
|
|
||||||
|
const connectWithConnectionString = React.useCallback(
|
||||||
|
(connStr: string) => {
|
||||||
|
if (!connStr || authType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConnectionString(connStr);
|
||||||
|
if (isResourceTokenConnectionString(connStr)) {
|
||||||
|
setAuthType(AuthType.ResourceToken);
|
||||||
|
} else {
|
||||||
|
fetchEncryptedToken(connStr)
|
||||||
|
.then((token) => {
|
||||||
|
setEncryptedToken(token);
|
||||||
|
setAuthType(AuthType.ConnectionString);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logError(
|
||||||
|
`Failed to connect with connection string: ${error}`,
|
||||||
|
"HostedExplorer/connectWithConnectionString",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authType],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen for connection string sent via postMessage (from TryCosmosDB)
|
||||||
|
React.useEffect(() => {
|
||||||
|
const MSG_READY = "tryCosmosDBReady";
|
||||||
|
const MSG_CONNECTION_STRING = "tryCosmosDBConnectionString";
|
||||||
|
|
||||||
|
// Signal to the opener that we are ready to receive a connection string
|
||||||
|
if (window.opener) {
|
||||||
|
try {
|
||||||
|
for (const origin of allowedHostedExplorerEndpoints) {
|
||||||
|
window.opener.postMessage({ type: MSG_READY }, origin);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// opener may be cross-origin, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = (event: MessageEvent) => {
|
||||||
|
if (!allowedHostedExplorerEndpoints.includes(event.origin)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.data?.type === MSG_CONNECTION_STRING) {
|
||||||
|
const connStr: string = event.data.connectionString;
|
||||||
|
if (parseConnectionString(connStr)) {
|
||||||
|
connectWithConnectionString(connStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", handler);
|
||||||
|
return () => window.removeEventListener("message", handler);
|
||||||
|
}, [connectWithConnectionString]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// If ref.current is undefined no iframe has been rendered
|
// If ref.current is undefined no iframe has been rendered
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
// In hosted mode, we can set global properties directly on the child iframe.
|
// In hosted mode, we can set global properties directly on the child iframe.
|
||||||
// This is not possible in the portal where the iframes have different origins
|
// This is not possible in the portal where the iframes have different origins
|
||||||
const frameWindow = ref.current.contentWindow as HostedExplorerChildFrame;
|
const frameWindow = ref.current.contentWindow as HostedExplorerChildFrame;
|
||||||
// AAD authenticated uses ALWAYS using AAD authType
|
|
||||||
if (isLoggedIn) {
|
if (authType === AuthType.EncryptedToken) {
|
||||||
frameWindow.hostedConfig = {
|
|
||||||
authType: AuthType.AAD,
|
|
||||||
databaseAccount,
|
|
||||||
authorizationToken: armToken,
|
|
||||||
};
|
|
||||||
} else if (authType === AuthType.EncryptedToken) {
|
|
||||||
frameWindow.hostedConfig = {
|
frameWindow.hostedConfig = {
|
||||||
authType: AuthType.EncryptedToken,
|
authType: AuthType.EncryptedToken,
|
||||||
encryptedToken,
|
encryptedToken,
|
||||||
@@ -73,12 +128,18 @@ const App: React.FunctionComponent = () => {
|
|||||||
authType: AuthType.ResourceToken,
|
authType: AuthType.ResourceToken,
|
||||||
resourceToken: connectionString,
|
resourceToken: connectionString,
|
||||||
};
|
};
|
||||||
|
} else if (isLoggedIn && !connectionString) {
|
||||||
|
frameWindow.hostedConfig = {
|
||||||
|
authType: AuthType.AAD,
|
||||||
|
databaseAccount,
|
||||||
|
authorizationToken: armToken,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const showExplorer =
|
const showExplorer =
|
||||||
(config && isLoggedIn && databaseAccount) ||
|
(config && isLoggedIn && databaseAccount && !connectionString) ||
|
||||||
(encryptedTokenMetadata && encryptedTokenMetadata) ||
|
(encryptedTokenMetadata && encryptedTokenMetadata) ||
|
||||||
(authType === AuthType.ResourceToken && connectionString);
|
(authType === AuthType.ResourceToken && connectionString);
|
||||||
|
|
||||||
@@ -99,12 +160,12 @@ const App: React.FunctionComponent = () => {
|
|||||||
{(isLoggedIn || encryptedTokenMetadata?.accountName) && (
|
{(isLoggedIn || encryptedTokenMetadata?.accountName) && (
|
||||||
<img className="chevronRight" src={ChevronRight} alt="account separator" />
|
<img className="chevronRight" src={ChevronRight} alt="account separator" />
|
||||||
)}
|
)}
|
||||||
{isLoggedIn && (
|
{isLoggedIn && !connectionString && (
|
||||||
<span className="accountSwitchComponentContainer">
|
<span className="accountSwitchComponentContainer">
|
||||||
<AccountSwitcher armToken={armToken} setDatabaseAccount={setDatabaseAccount} />
|
<AccountSwitcher armToken={armToken} setDatabaseAccount={setDatabaseAccount} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isLoggedIn && encryptedTokenMetadata?.accountName && (
|
{(!isLoggedIn || connectionString) && encryptedTokenMetadata?.accountName && (
|
||||||
<span className="accountSwitchComponentContainer">
|
<span className="accountSwitchComponentContainer">
|
||||||
<span className="accountNameHeader">{encryptedTokenMetadata?.accountName}</span>
|
<span className="accountNameHeader">{encryptedTokenMetadata?.accountName}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -127,7 +188,9 @@ const App: React.FunctionComponent = () => {
|
|||||||
// It's possible this can be changed once all knockout code has been removed.
|
// It's possible this can be changed once all knockout code has been removed.
|
||||||
<iframe
|
<iframe
|
||||||
// Setting key is needed so React will re-render this element on any account change
|
// Setting key is needed so React will re-render this element on any account change
|
||||||
key={databaseAccount?.id || encryptedTokenMetadata?.accountName || authType}
|
key={
|
||||||
|
authType ? `${authType}-${encryptedTokenMetadata?.accountName || connectionString}` : databaseAccount?.id
|
||||||
|
}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-test="DataExplorerFrame"
|
data-test="DataExplorerFrame"
|
||||||
id="explorerMenu"
|
id="explorerMenu"
|
||||||
@@ -148,4 +211,9 @@ const App: React.FunctionComponent = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<App />, document.getElementById("App"));
|
export { App };
|
||||||
|
|
||||||
|
const appElement = document.getElementById("App");
|
||||||
|
if (appElement) {
|
||||||
|
render(<App />, appElement);
|
||||||
|
}
|
||||||
|
|||||||
+2
-297
@@ -1,24 +1,5 @@
|
|||||||
import { HttpHeaders, HttpStatusCodes } from "../Common/Constants";
|
import { HttpStatusCodes } from "../Common/Constants";
|
||||||
import { DatabaseAccount } from "../Contracts/DataModels";
|
import { IPinnedRepo, JunoClient } from "./JunoClient";
|
||||||
import { updateUserContext, userContext } from "../UserContext";
|
|
||||||
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
|
|
||||||
import { IPinnedRepo, IPublishNotebookRequest, JunoClient } from "./JunoClient";
|
|
||||||
|
|
||||||
const sampleSubscriptionId = "subscriptionId";
|
|
||||||
|
|
||||||
const sampleDatabaseAccount: DatabaseAccount = {
|
|
||||||
id: "id",
|
|
||||||
name: "name",
|
|
||||||
location: "location",
|
|
||||||
type: "type",
|
|
||||||
kind: "kind",
|
|
||||||
properties: {
|
|
||||||
documentEndpoint: "documentEndpoint",
|
|
||||||
gremlinEndpoint: "gremlinEndpoint",
|
|
||||||
tableEndpoint: "tableEndpoint",
|
|
||||||
cassandraEndpoint: "cassandraEndpoint",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const samplePinnedRepos: IPinnedRepo[] = [
|
const samplePinnedRepos: IPinnedRepo[] = [
|
||||||
{
|
{
|
||||||
@@ -130,279 +111,3 @@ describe("GitHub", () => {
|
|||||||
expect(fetchUrlParams.get("client_id")).toBeDefined();
|
expect(fetchUrlParams.get("client_id")).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Gallery", () => {
|
|
||||||
const junoClient = new JunoClient();
|
|
||||||
const originalSubscriptionId = userContext.subscriptionId;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
updateUserContext({
|
|
||||||
databaseAccount: {
|
|
||||||
name: "name",
|
|
||||||
} as DatabaseAccount,
|
|
||||||
subscriptionId: sampleSubscriptionId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
updateUserContext({ subscriptionId: originalSubscriptionId });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getSampleNotebooks", async () => {
|
|
||||||
window.fetch = jest.fn().mockReturnValue({
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
json: () => undefined as undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await junoClient.getSampleNotebooks();
|
|
||||||
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
|
||||||
`${JunoClient.getJunoEndpoint()}/api/notebooks/gallery/samples`,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getPublicNotebooks", async () => {
|
|
||||||
window.fetch = jest.fn().mockReturnValue({
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
json: () => undefined as undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await junoClient.getPublicNotebooks();
|
|
||||||
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
|
||||||
`${JunoClient.getJunoEndpoint()}/api/notebooks/gallery/public`,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getNotebook", async () => {
|
|
||||||
const id = "id";
|
|
||||||
window.fetch = jest.fn().mockReturnValue({
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
json: () => undefined as undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await junoClient.getNotebookInfo(id);
|
|
||||||
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith(`${JunoClient.getJunoEndpoint()}/api/notebooks/gallery/${id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getNotebookContent", async () => {
|
|
||||||
const id = "id";
|
|
||||||
window.fetch = jest.fn().mockReturnValue({
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
text: () => undefined as undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await junoClient.getNotebookContent(id);
|
|
||||||
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith(`${JunoClient.getJunoEndpoint()}/api/notebooks/gallery/${id}/content`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("increaseNotebookViews", async () => {
|
|
||||||
const id = "id";
|
|
||||||
window.fetch = jest.fn().mockReturnValue({
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
json: () => undefined as undefined,
|
|
||||||
});
|
|
||||||
const response = await junoClient.increaseNotebookViews(id);
|
|
||||||
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith(`${JunoClient.getJunoEndpoint()}/api/notebooks/gallery/${id}/views`, {
|
|
||||||
method: "PATCH",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("increaseNotebookDownloadCount", async () => {
|
|
||||||
const id = "id";
|
|
||||||
window.fetch = jest.fn().mockReturnValue({
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
json: () => undefined as undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await junoClient.increaseNotebookDownloadCount(id);
|
|
||||||
|
|
||||||
const authorizationHeader = getAuthorizationHeader();
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
|
||||||
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
|
|
||||||
sampleDatabaseAccount.name
|
|
||||||
}/gallery/${id}/downloads`,
|
|
||||||
{
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
[authorizationHeader.header]: authorizationHeader.token,
|
|
||||||
[HttpHeaders.contentType]: "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("favoriteNotebook", async () => {
|
|
||||||
const id = "id";
|
|
||||||
window.fetch = jest.fn().mockReturnValue({
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
json: () => undefined as undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await junoClient.favoriteNotebook(id);
|
|
||||||
|
|
||||||
const authorizationHeader = getAuthorizationHeader();
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
|
||||||
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
|
|
||||||
sampleDatabaseAccount.name
|
|
||||||
}/gallery/${id}/favorite`,
|
|
||||||
{
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
[authorizationHeader.header]: authorizationHeader.token,
|
|
||||||
[HttpHeaders.contentType]: "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("unfavoriteNotebook", async () => {
|
|
||||||
const id = "id";
|
|
||||||
window.fetch = jest.fn().mockReturnValue({
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
json: () => undefined as undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await junoClient.unfavoriteNotebook(id);
|
|
||||||
|
|
||||||
const authorizationHeader = getAuthorizationHeader();
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
|
||||||
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
|
|
||||||
sampleDatabaseAccount.name
|
|
||||||
}/gallery/${id}/unfavorite`,
|
|
||||||
{
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
[authorizationHeader.header]: authorizationHeader.token,
|
|
||||||
[HttpHeaders.contentType]: "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getFavoriteNotebooks", async () => {
|
|
||||||
window.fetch = jest.fn().mockReturnValue({
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
json: () => undefined as undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await junoClient.getFavoriteNotebooks();
|
|
||||||
|
|
||||||
const authorizationHeader = getAuthorizationHeader();
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
|
||||||
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
|
|
||||||
sampleDatabaseAccount.name
|
|
||||||
}/gallery/favorites`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
[authorizationHeader.header]: authorizationHeader.token,
|
|
||||||
[HttpHeaders.contentType]: "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getPublishedNotebooks", async () => {
|
|
||||||
window.fetch = jest.fn().mockReturnValue({
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
json: () => undefined as undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await junoClient.getPublishedNotebooks();
|
|
||||||
|
|
||||||
const authorizationHeader = getAuthorizationHeader();
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
|
||||||
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
|
|
||||||
sampleDatabaseAccount.name
|
|
||||||
}/gallery/published`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
[authorizationHeader.header]: authorizationHeader.token,
|
|
||||||
[HttpHeaders.contentType]: "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteNotebook", async () => {
|
|
||||||
const id = "id";
|
|
||||||
window.fetch = jest.fn().mockReturnValue({
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
json: () => undefined as undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await junoClient.deleteNotebook(id);
|
|
||||||
|
|
||||||
const authorizationHeader = getAuthorizationHeader();
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
|
||||||
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
|
|
||||||
sampleDatabaseAccount.name
|
|
||||||
}/gallery/${id}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
[authorizationHeader.header]: authorizationHeader.token,
|
|
||||||
[HttpHeaders.contentType]: "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("publishNotebook", async () => {
|
|
||||||
const name = "name";
|
|
||||||
const description = "description";
|
|
||||||
const tags = ["tag"];
|
|
||||||
const thumbnailUrl = "thumbnailUrl";
|
|
||||||
const content = `{ "key": "value" }`;
|
|
||||||
const addLinkToNotebookViewer = true;
|
|
||||||
window.fetch = jest.fn().mockReturnValue({
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
json: () => undefined as undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await junoClient.publishNotebook(name, description, tags, thumbnailUrl, content);
|
|
||||||
|
|
||||||
const authorizationHeader = getAuthorizationHeader();
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
|
||||||
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
|
|
||||||
sampleDatabaseAccount.name
|
|
||||||
}/gallery`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
[authorizationHeader.header]: authorizationHeader.token,
|
|
||||||
[HttpHeaders.contentType]: "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
tags,
|
|
||||||
thumbnailUrl,
|
|
||||||
content: JSON.parse(content),
|
|
||||||
addLinkToNotebookViewer,
|
|
||||||
} as IPublishNotebookRequest),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -44,30 +44,6 @@ export interface IGalleryItem {
|
|||||||
pendingScanJobIds: string[];
|
pendingScanJobIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPublicGalleryData {
|
|
||||||
metadata: IPublicGalleryMetaData;
|
|
||||||
notebooksData: IGalleryItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IPublicGalleryMetaData {
|
|
||||||
acceptedCodeOfConduct: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IUserGallery {
|
|
||||||
favorites: string[];
|
|
||||||
published: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only exported for unit test
|
|
||||||
export interface IPublishNotebookRequest {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
tags: string[];
|
|
||||||
thumbnailUrl: string;
|
|
||||||
content: unknown;
|
|
||||||
addLinkToNotebookViewer: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class JunoClient {
|
export class JunoClient {
|
||||||
private cachedPinnedRepos: ko.Observable<IPinnedRepo[]>;
|
private cachedPinnedRepos: ko.Observable<IPinnedRepo[]>;
|
||||||
|
|
||||||
@@ -176,90 +152,6 @@ export class JunoClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSampleNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
|
|
||||||
return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/samples`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getPublicNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
|
|
||||||
return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/public`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getPublicGalleryData(): Promise<IJunoResponse<IPublicGalleryData>> {
|
|
||||||
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/public`;
|
|
||||||
const response = await window.fetch(url, { headers: JunoClient.getHeaders() });
|
|
||||||
|
|
||||||
let data: IPublicGalleryData;
|
|
||||||
if (response.status === HttpStatusCodes.OK) {
|
|
||||||
data = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async acceptCodeOfConduct(): Promise<IJunoResponse<boolean>> {
|
|
||||||
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/acceptCodeOfConduct`;
|
|
||||||
const response = await window.fetch(url, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: JunoClient.getHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let data: boolean;
|
|
||||||
if (response.status === HttpStatusCodes.OK) {
|
|
||||||
data = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async isCodeOfConductAccepted(): Promise<IJunoResponse<boolean>> {
|
|
||||||
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/isCodeOfConductAccepted`;
|
|
||||||
const response = await window.fetch(url, { headers: JunoClient.getHeaders() });
|
|
||||||
|
|
||||||
let data: boolean;
|
|
||||||
if (response.status === HttpStatusCodes.OK) {
|
|
||||||
data = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getNotebookInfo(id: string): Promise<IJunoResponse<IGalleryItem>> {
|
|
||||||
const response = await window.fetch(this.getNotebookInfoUrl(id));
|
|
||||||
|
|
||||||
let data: IGalleryItem;
|
|
||||||
if (response.status === HttpStatusCodes.OK) {
|
|
||||||
data = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getNotebookContent(id: string): Promise<IJunoResponse<string>> {
|
|
||||||
const response = await window.fetch(this.getNotebookContentUrl(id));
|
|
||||||
|
|
||||||
let data: string;
|
|
||||||
if (response.status === HttpStatusCodes.OK) {
|
|
||||||
data = await response.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async increaseNotebookViews(id: string): Promise<IJunoResponse<IGalleryItem>> {
|
public async increaseNotebookViews(id: string): Promise<IJunoResponse<IGalleryItem>> {
|
||||||
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}/views`, {
|
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}/views`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -276,151 +168,6 @@ export class JunoClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async increaseNotebookDownloadCount(id: string): Promise<IJunoResponse<IGalleryItem>> {
|
|
||||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}/downloads`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: JunoClient.getHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let data: IGalleryItem;
|
|
||||||
if (response.status === HttpStatusCodes.OK) {
|
|
||||||
data = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async favoriteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
|
|
||||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}/favorite`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: JunoClient.getHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let data: IGalleryItem;
|
|
||||||
if (response.status === HttpStatusCodes.OK) {
|
|
||||||
data = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async unfavoriteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
|
|
||||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}/unfavorite`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: JunoClient.getHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let data: IGalleryItem;
|
|
||||||
if (response.status === HttpStatusCodes.OK) {
|
|
||||||
data = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getFavoriteNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
|
|
||||||
return await this.getNotebooks(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/favorites`, {
|
|
||||||
headers: JunoClient.getHeaders(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getPublishedNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
|
|
||||||
return await this.getNotebooks(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/published`, {
|
|
||||||
headers: JunoClient.getHeaders(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async deleteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
|
|
||||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: JunoClient.getHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let data: IGalleryItem;
|
|
||||||
if (response.status === HttpStatusCodes.OK) {
|
|
||||||
data = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async publishNotebook(
|
|
||||||
name: string,
|
|
||||||
description: string,
|
|
||||||
tags: string[],
|
|
||||||
thumbnailUrl: string,
|
|
||||||
content: string,
|
|
||||||
): Promise<IJunoResponse<IGalleryItem>> {
|
|
||||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: JunoClient.getHeaders(),
|
|
||||||
body: JSON.stringify({
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
tags,
|
|
||||||
thumbnailUrl,
|
|
||||||
content: JSON.parse(content),
|
|
||||||
addLinkToNotebookViewer: true,
|
|
||||||
} as IPublishNotebookRequest),
|
|
||||||
});
|
|
||||||
|
|
||||||
let data: IGalleryItem;
|
|
||||||
if (response.status === HttpStatusCodes.OK) {
|
|
||||||
data = await response.json();
|
|
||||||
} else {
|
|
||||||
throw new Error(`HTTP status ${response.status} thrown. ${(await response.json()).Message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public getNotebookContentUrl(id: string): string {
|
|
||||||
return `${this.getNotebooksUrl()}/gallery/${id}/content`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getNotebookInfoUrl(id: string): string {
|
|
||||||
return `${this.getNotebooksUrl()}/gallery/${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async reportAbuse(notebookId: string, abuseCategory: string, notes: string): Promise<IJunoResponse<boolean>> {
|
|
||||||
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/reportAbuse`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
notebookId,
|
|
||||||
abuseCategory,
|
|
||||||
notes,
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
[HttpHeaders.contentType]: "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let data: boolean;
|
|
||||||
if (response.status === HttpStatusCodes.OK) {
|
|
||||||
data = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async requestSchema(
|
public async requestSchema(
|
||||||
schemaRequest: DataModels.ISchemaRequest,
|
schemaRequest: DataModels.ISchemaRequest,
|
||||||
): Promise<IJunoResponse<DataModels.ISchemaRequest>> {
|
): Promise<IJunoResponse<DataModels.ISchemaRequest>> {
|
||||||
|
|||||||
@@ -316,6 +316,10 @@
|
|||||||
"deleteUdf": "Delete User Defined Function"
|
"deleteUdf": "Delete User Defined Function"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
"tabMenu": {
|
||||||
|
"duplicateTab": "Duplicate tab",
|
||||||
|
"closeTab": "Close tab"
|
||||||
|
},
|
||||||
"documents": {
|
"documents": {
|
||||||
"newItem": "New Item",
|
"newItem": "New Item",
|
||||||
"newDocument": "New Document",
|
"newDocument": "New Document",
|
||||||
@@ -760,7 +764,6 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"indexingPolicy": "Indexing Policy",
|
"indexingPolicy": "Indexing Policy",
|
||||||
"partitionKeys": "Partition Keys",
|
"partitionKeys": "Partition Keys",
|
||||||
"partitionKeysPreview": "Partition Keys (preview)",
|
|
||||||
"computedProperties": "Computed Properties",
|
"computedProperties": "Computed Properties",
|
||||||
"containerPolicies": "Container Policies",
|
"containerPolicies": "Container Policies",
|
||||||
"throughputBuckets": "Throughput Buckets",
|
"throughputBuckets": "Throughput Buckets",
|
||||||
@@ -895,6 +898,10 @@
|
|||||||
},
|
},
|
||||||
"partitionKeyEditor": {
|
"partitionKeyEditor": {
|
||||||
"changePartitionKey": "Change {{partitionKeyName}}",
|
"changePartitionKey": "Change {{partitionKeyName}}",
|
||||||
|
"confirmCancel1": "You are about to cancel the following copy job.",
|
||||||
|
"confirmCancel2": "Cancelling will stop the job immediately.",
|
||||||
|
"confirmComplete1": "You are about to complete the following copy job.",
|
||||||
|
"confrimComplete2": "Once completed, continuous data copy will stop after any pending documents are processed. To maintain data integrity, we recommend stopping updates to the source container before completing the job.",
|
||||||
"currentPartitionKey": "Current {{partitionKeyName}}",
|
"currentPartitionKey": "Current {{partitionKeyName}}",
|
||||||
"partitioning": "Partitioning",
|
"partitioning": "Partitioning",
|
||||||
"hierarchical": "Hierarchical",
|
"hierarchical": "Hierarchical",
|
||||||
@@ -1033,6 +1040,7 @@
|
|||||||
"description": "Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
"description": "Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"migrationTypeTitle": "Migration type",
|
||||||
"selectContainers": {
|
"selectContainers": {
|
||||||
"description": "Please select a source container and a destination container to copy to.",
|
"description": "Please select a source container and a destination container to copy to.",
|
||||||
"sourceContainerSubHeading": "Source container",
|
"sourceContainerSubHeading": "Source container",
|
||||||
@@ -1117,7 +1125,8 @@
|
|||||||
"buttonText": "Enable Online Copy",
|
"buttonText": "Enable Online Copy",
|
||||||
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Validating All versions and deletes change feed mode (preview)...",
|
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Validating All versions and deletes change feed mode (preview)...",
|
||||||
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Enabling All versions and deletes change feed mode (preview)...",
|
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Enabling All versions and deletes change feed mode (preview)...",
|
||||||
"enablingOnlineCopySpinnerLabel": "Enabling online copy on your \"{{accountName}}\" account ..."
|
"enablingOnlineCopySpinnerLabel": "Enabling online copy on your \"{{accountName}}\" account ...",
|
||||||
|
"onlineMigrationPrerequisitesMessage": "Online migration prerequisites must be enabled before proceeding."
|
||||||
},
|
},
|
||||||
"monitorJobs": {
|
"monitorJobs": {
|
||||||
"columns": {
|
"columns": {
|
||||||
@@ -1152,6 +1161,16 @@
|
|||||||
"confirmButtonText": "Confirm",
|
"confirmButtonText": "Confirm",
|
||||||
"cancelButtonText": "Cancel"
|
"cancelButtonText": "Cancel"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dataTransfers": {
|
||||||
|
"polling": {
|
||||||
|
"cancelConsoleMessage": "Data transfer job \"{{jobName}}\" cancelled",
|
||||||
|
"completedConsoleMessage": "Data transfer job \"{{jobName}}\" completed",
|
||||||
|
"defaultErrorMessage": "Operation could not be completed",
|
||||||
|
"errorConsoleMessage": "Data transfer job \"{{jobName}}\" failed: {{errorMessage}}",
|
||||||
|
"pauseConsoleMessage": "Data transfer job \"{{jobName}}\" paused",
|
||||||
|
"retryConsoleMessage": "Data transfer job \"{{jobName}}\" in progress, total count: {{totalCount}}, processed count: {{processedCount}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,6 @@ export type Features = {
|
|||||||
phoenixNotebooks?: boolean;
|
phoenixNotebooks?: boolean;
|
||||||
phoenixFeatures?: boolean;
|
phoenixFeatures?: boolean;
|
||||||
notebooksDownBanner: boolean;
|
notebooksDownBanner: boolean;
|
||||||
publicGallery?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function extractFeatures(given = new URLSearchParams(window.location.search)): Features {
|
export function extractFeatures(given = new URLSearchParams(window.location.search)): Features {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as msal from "@azure/msal-browser";
|
import * as msal from "@azure/msal-browser";
|
||||||
import { getEnvironmentScopeEndpoint } from "Common/EnvironmentUtility";
|
import { getEnvironmentScopeEndpoint } from "Common/EnvironmentUtility";
|
||||||
|
import { stringifyError } from "Common/stringifyError";
|
||||||
import { Action, ActionModifiers } from "Shared/Telemetry/TelemetryConstants";
|
import { Action, ActionModifiers } from "Shared/Telemetry/TelemetryConstants";
|
||||||
import { hasProxyServer, isDataplaneRbacSupported } from "Utils/APITypeUtils";
|
import { hasProxyServer, isDataplaneRbacSupported } from "Utils/APITypeUtils";
|
||||||
import { AuthType } from "../AuthType";
|
import { AuthType } from "../AuthType";
|
||||||
@@ -154,9 +155,9 @@ export async function acquireMsalTokenForAccount(
|
|||||||
traceFailure(Action.SignInAad, {
|
traceFailure(Action.SignInAad, {
|
||||||
request: JSON.stringify(loginRequest),
|
request: JSON.stringify(loginRequest),
|
||||||
acquireTokenType: silent ? "silent" : "interactive",
|
acquireTokenType: silent ? "silent" : "interactive",
|
||||||
errorMessage: JSON.stringify(error),
|
errorMessage: stringifyError(error),
|
||||||
});
|
});
|
||||||
traceFailure(Action.AcquireMsalToken, { error: JSON.stringify(error) }, msalStartKey);
|
traceFailure(Action.AcquireMsalToken, { error: stringifyError(error) }, msalStartKey);
|
||||||
// Mark expected failure for health metrics so timeout emits healthy
|
// Mark expected failure for health metrics so timeout emits healthy
|
||||||
if (isExpectedError(error)) {
|
if (isExpectedError(error)) {
|
||||||
scenarioMonitor.markExpectedFailure();
|
scenarioMonitor.markExpectedFailure();
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ export const allowedEmulatorEndpoints: ReadonlyArray<string> = ["https://localho
|
|||||||
|
|
||||||
export const allowedArcadiaEndpoints: ReadonlyArray<string> = ["https://workspaceartifacts.projectarcadia.net"];
|
export const allowedArcadiaEndpoints: ReadonlyArray<string> = ["https://workspaceartifacts.projectarcadia.net"];
|
||||||
|
|
||||||
export const allowedHostedExplorerEndpoints: ReadonlyArray<string> = ["https://cosmos.azure.com/"];
|
export const allowedHostedExplorerEndpoints: ReadonlyArray<string> = [
|
||||||
|
"https://cosmos.azure.com",
|
||||||
|
...(process.env.NODE_ENV === "development" ? ["https://localhost:12900"] : []),
|
||||||
|
];
|
||||||
|
|
||||||
export const allowedMsalRedirectEndpoints: ReadonlyArray<string> = ["https://dataexplorer-preview.azurewebsites.net/"];
|
export const allowedMsalRedirectEndpoints: ReadonlyArray<string> = ["https://dataexplorer-preview.azurewebsites.net/"];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { fetchWithTimeout, tryFetchWithTimeout } from "./FetchWithTimeout";
|
||||||
|
|
||||||
|
describe("fetchWithTimeout", () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards init and resolves with the fetch response", async () => {
|
||||||
|
const fakeResponse = { ok: true } as Response;
|
||||||
|
const fetchMock = jest.fn().mockResolvedValue(fakeResponse);
|
||||||
|
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||||
|
|
||||||
|
const result = await fetchWithTimeout("https://example.com", { method: "GET" }, 1000);
|
||||||
|
|
||||||
|
expect(result).toBe(fakeResponse);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe("https://example.com");
|
||||||
|
expect((init as RequestInit).method).toBe("GET");
|
||||||
|
expect((init as RequestInit).signal).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts the fetch when the internal timeout fires", async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
let receivedSignal: AbortSignal | undefined;
|
||||||
|
global.fetch = jest.fn().mockImplementation((_url: string, init: RequestInit) => {
|
||||||
|
receivedSignal = init.signal ?? undefined;
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
init.signal?.addEventListener("abort", () => {
|
||||||
|
const err = new Error("aborted");
|
||||||
|
err.name = "AbortError";
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}) as unknown as typeof global.fetch;
|
||||||
|
|
||||||
|
const pending = fetchWithTimeout("https://example.com", {}, 50);
|
||||||
|
jest.advanceTimersByTime(50);
|
||||||
|
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
expect(receivedSignal?.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws immediately when the external signal is already aborted", async () => {
|
||||||
|
const fetchMock = jest.fn();
|
||||||
|
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const reason = new Error("user-cancelled");
|
||||||
|
controller.abort(reason);
|
||||||
|
|
||||||
|
await expect(fetchWithTimeout("https://example.com", { signal: controller.signal }, 1000)).rejects.toBe(reason);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts mid-fetch when the external signal aborts and propagates the reason", async () => {
|
||||||
|
const reason = new Error("user-cancelled");
|
||||||
|
let internalSignal: AbortSignal | undefined;
|
||||||
|
global.fetch = jest.fn().mockImplementation((_url: string, init: RequestInit) => {
|
||||||
|
internalSignal = init.signal ?? undefined;
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
init.signal?.addEventListener("abort", () => reject(init.signal?.reason ?? new Error("aborted")));
|
||||||
|
});
|
||||||
|
}) as unknown as typeof global.fetch;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const pending = fetchWithTimeout("https://example.com", { signal: controller.signal }, 60000);
|
||||||
|
// Let the fetch wire up its abort listener before triggering.
|
||||||
|
await Promise.resolve();
|
||||||
|
controller.abort(reason);
|
||||||
|
|
||||||
|
await expect(pending).rejects.toBe(reason);
|
||||||
|
expect(internalSignal?.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the timer when timeoutMs is Infinity", async () => {
|
||||||
|
const setTimeoutSpy = jest.spyOn(global, "setTimeout");
|
||||||
|
global.fetch = jest.fn().mockResolvedValue({ ok: true } as Response) as unknown as typeof global.fetch;
|
||||||
|
|
||||||
|
await fetchWithTimeout("https://example.com", {}, Infinity);
|
||||||
|
|
||||||
|
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
||||||
|
setTimeoutSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up the timer and external listener after a successful fetch", async () => {
|
||||||
|
const clearTimeoutSpy = jest.spyOn(global, "clearTimeout");
|
||||||
|
global.fetch = jest.fn().mockResolvedValue({ ok: true } as Response) as unknown as typeof global.fetch;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const removeListenerSpy = jest.spyOn(controller.signal, "removeEventListener");
|
||||||
|
|
||||||
|
await fetchWithTimeout("https://example.com", { signal: controller.signal }, 1000);
|
||||||
|
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||||
|
expect(removeListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function));
|
||||||
|
clearTimeoutSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up the timer and listener when the fetch rejects", async () => {
|
||||||
|
const clearTimeoutSpy = jest.spyOn(global, "clearTimeout");
|
||||||
|
const networkError = new Error("network down");
|
||||||
|
global.fetch = jest.fn().mockRejectedValue(networkError) as unknown as typeof global.fetch;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const removeListenerSpy = jest.spyOn(controller.signal, "removeEventListener");
|
||||||
|
|
||||||
|
await expect(fetchWithTimeout("https://example.com", { signal: controller.signal }, 1000)).rejects.toBe(
|
||||||
|
networkError,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||||
|
expect(removeListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function));
|
||||||
|
clearTimeoutSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tryFetchWithTimeout", () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on fetch failure", async () => {
|
||||||
|
global.fetch = jest.fn().mockRejectedValue(new Error("boom")) as unknown as typeof global.fetch;
|
||||||
|
await expect(tryFetchWithTimeout("https://example.com")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the response on success", async () => {
|
||||||
|
const response = { ok: true } as Response;
|
||||||
|
global.fetch = jest.fn().mockResolvedValue(response) as unknown as typeof global.fetch;
|
||||||
|
await expect(tryFetchWithTimeout("https://example.com")).resolves.toBe(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
*
|
*
|
||||||
* Usage: await fetchWithTimeout(url, { method: 'GET', headers: {...} }, 10000);
|
* Usage: await fetchWithTimeout(url, { method: 'GET', headers: {...} }, 10000);
|
||||||
*
|
*
|
||||||
|
* If `init.signal` is provided, it is combined with the internal timeout: aborting
|
||||||
|
* the caller's signal aborts the fetch (propagating the caller's abort reason), and
|
||||||
|
* the timeout still applies. Pass `timeoutMs: Infinity` to disable the timeout entirely
|
||||||
|
* (useful for long-running operations that should rely solely on caller cancellation).
|
||||||
|
*
|
||||||
* A shared helper to remove duplicated inline implementations across the codebase.
|
* A shared helper to remove duplicated inline implementations across the codebase.
|
||||||
*/
|
*/
|
||||||
export async function fetchWithTimeout(
|
export async function fetchWithTimeout(
|
||||||
@@ -10,13 +15,30 @@ export async function fetchWithTimeout(
|
|||||||
init: RequestInit = {},
|
init: RequestInit = {},
|
||||||
timeoutMs: number = 5000,
|
timeoutMs: number = 5000,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
const externalSignal = init.signal;
|
||||||
|
if (externalSignal?.aborted) {
|
||||||
|
throw externalSignal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const id = setTimeout(() => controller.abort(), timeoutMs);
|
const hasTimeout = Number.isFinite(timeoutMs);
|
||||||
|
const timeoutId = hasTimeout ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
|
||||||
|
|
||||||
|
let onExternalAbort: (() => void) | undefined;
|
||||||
|
if (externalSignal) {
|
||||||
|
onExternalAbort = () => controller.abort(externalSignal.reason);
|
||||||
|
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { ...init, signal: controller.signal });
|
return await fetch(url, { ...init, signal: controller.signal });
|
||||||
return response;
|
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(id);
|
if (timeoutId !== undefined) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
if (externalSignal && onExternalAbort) {
|
||||||
|
externalSignal.removeEventListener("abort", onExternalAbort);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import { HttpStatusCodes } from "../Common/Constants";
|
|
||||||
import { useDialog } from "../Explorer/Controls/Dialog";
|
|
||||||
import { GalleryTab, SortBy } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
|
|
||||||
import Explorer from "../Explorer/Explorer";
|
|
||||||
import { useNotebook } from "../Explorer/Notebook/useNotebook";
|
|
||||||
import { IGalleryItem, JunoClient } from "../Juno/JunoClient";
|
|
||||||
import * as GalleryUtils from "./GalleryUtils";
|
|
||||||
|
|
||||||
const galleryItem: IGalleryItem = {
|
|
||||||
id: "id",
|
|
||||||
name: "name",
|
|
||||||
description: "description",
|
|
||||||
gitSha: "gitSha",
|
|
||||||
tags: ["tag1"],
|
|
||||||
author: "author",
|
|
||||||
thumbnailUrl: "thumbnailUrl",
|
|
||||||
created: "created",
|
|
||||||
isSample: false,
|
|
||||||
downloads: 0,
|
|
||||||
favorites: 0,
|
|
||||||
views: 0,
|
|
||||||
newCellId: undefined,
|
|
||||||
policyViolations: undefined,
|
|
||||||
pendingScanJobIds: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("GalleryUtils", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
jest.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("downloadItem shows dialog in data explorer", () => {
|
|
||||||
const container = new Explorer();
|
|
||||||
GalleryUtils.downloadItem(container, undefined, galleryItem, undefined);
|
|
||||||
|
|
||||||
expect(useDialog.getState().visible).toBe(true);
|
|
||||||
expect(useDialog.getState().dialogProps).toBeDefined();
|
|
||||||
expect(useDialog.getState().dialogProps.title).toBe(`Download to ${useNotebook.getState().notebookFolderName}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("favoriteItem favorites item", async () => {
|
|
||||||
const container = {} as Explorer;
|
|
||||||
const junoClient = new JunoClient();
|
|
||||||
junoClient.favoriteNotebook = jest
|
|
||||||
.fn()
|
|
||||||
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: galleryItem }));
|
|
||||||
const onComplete = jest.fn().mockImplementation();
|
|
||||||
|
|
||||||
await GalleryUtils.favoriteItem(container, junoClient, galleryItem, onComplete);
|
|
||||||
|
|
||||||
expect(junoClient.favoriteNotebook).toHaveBeenCalledWith(galleryItem.id);
|
|
||||||
expect(onComplete).toHaveBeenCalledWith(galleryItem);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("unfavoriteItem unfavorites item", async () => {
|
|
||||||
const container = {} as Explorer;
|
|
||||||
const junoClient = new JunoClient();
|
|
||||||
junoClient.unfavoriteNotebook = jest
|
|
||||||
.fn()
|
|
||||||
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: galleryItem }));
|
|
||||||
const onComplete = jest.fn().mockImplementation();
|
|
||||||
|
|
||||||
await GalleryUtils.unfavoriteItem(container, junoClient, galleryItem, onComplete);
|
|
||||||
|
|
||||||
expect(junoClient.unfavoriteNotebook).toHaveBeenCalledWith(galleryItem.id);
|
|
||||||
expect(onComplete).toHaveBeenCalledWith(galleryItem);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteItem shows dialog in data explorer", () => {
|
|
||||||
const container = {} as Explorer;
|
|
||||||
GalleryUtils.deleteItem(container, undefined, galleryItem, undefined);
|
|
||||||
|
|
||||||
expect(useDialog.getState().visible).toBe(true);
|
|
||||||
expect(useDialog.getState().dialogProps).toBeDefined();
|
|
||||||
expect(useDialog.getState().dialogProps.title).toBe("Remove published notebook");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getGalleryViewerProps gets gallery viewer props correctly", () => {
|
|
||||||
const selectedTab: GalleryTab = GalleryTab.OfficialSamples;
|
|
||||||
const sortBy: SortBy = SortBy.MostDownloaded;
|
|
||||||
const searchText = "my-complicated%20search%20query!!!";
|
|
||||||
|
|
||||||
const response = GalleryUtils.getGalleryViewerProps(
|
|
||||||
`?${GalleryUtils.GalleryViewerParams.SelectedTab}=${GalleryTab[selectedTab]}&${GalleryUtils.GalleryViewerParams.SortBy}=${SortBy[sortBy]}&${GalleryUtils.GalleryViewerParams.SearchText}=${searchText}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response).toEqual({
|
|
||||||
selectedTab,
|
|
||||||
sortBy,
|
|
||||||
searchText: decodeURIComponent(searchText),
|
|
||||||
} as GalleryUtils.GalleryViewerProps);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getNotebookViewerProps gets notebook viewer props correctly", () => {
|
|
||||||
const notebookUrl = "https%3A%2F%2Fnotebook.url";
|
|
||||||
const galleryItemId = "1234-abcd-efgh";
|
|
||||||
const hideInputs = "true";
|
|
||||||
|
|
||||||
const response = GalleryUtils.getNotebookViewerProps(
|
|
||||||
`?${GalleryUtils.NotebookViewerParams.NotebookUrl}=${notebookUrl}&${GalleryUtils.NotebookViewerParams.GalleryItemId}=${galleryItemId}&${GalleryUtils.NotebookViewerParams.HideInputs}=${hideInputs}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response).toEqual({
|
|
||||||
notebookUrl: decodeURIComponent(notebookUrl),
|
|
||||||
galleryItemId,
|
|
||||||
hideInputs: true,
|
|
||||||
} as GalleryUtils.NotebookViewerProps);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getTabTitle returns correct title for official samples", () => {
|
|
||||||
expect(GalleryUtils.getTabTitle(GalleryTab.OfficialSamples)).toBe("Official samples");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,528 +0,0 @@
|
|||||||
import { IChoiceGroupOption, IChoiceGroupProps, IProgressIndicatorProps } from "@fluentui/react";
|
|
||||||
import { Notebook } from "@nteract/commutable";
|
|
||||||
import { NotebookV4 } from "@nteract/commutable/lib/v4";
|
|
||||||
import { HttpStatusCodes } from "../Common/Constants";
|
|
||||||
import { getErrorMessage, getErrorStack, handleError } from "../Common/ErrorHandlingUtils";
|
|
||||||
import { TextFieldProps, useDialog } from "../Explorer/Controls/Dialog";
|
|
||||||
import {
|
|
||||||
GalleryTab,
|
|
||||||
GalleryViewerComponent,
|
|
||||||
SortBy,
|
|
||||||
} from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
|
|
||||||
import Explorer from "../Explorer/Explorer";
|
|
||||||
import { useNotebook } from "../Explorer/Notebook/useNotebook";
|
|
||||||
import { IGalleryItem, JunoClient } from "../Juno/JunoClient";
|
|
||||||
import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants";
|
|
||||||
import { trace, traceFailure, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor";
|
|
||||||
import { logConsoleInfo, logConsoleProgress } from "./NotificationConsoleUtils";
|
|
||||||
|
|
||||||
const defaultSelectedAbuseCategory = "Other";
|
|
||||||
const abuseCategories: IChoiceGroupOption[] = [
|
|
||||||
{
|
|
||||||
key: "ChildEndangermentExploitation",
|
|
||||||
text: "Child endangerment or exploitation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "ContentInfringement",
|
|
||||||
text: "Content infringement",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "OffensiveContent",
|
|
||||||
text: "Offensive content",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Terrorism",
|
|
||||||
text: "Terrorism",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "ThreatsCyberbullyingHarassment",
|
|
||||||
text: "Threats, cyber bullying or harassment",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "VirusSpywareMalware",
|
|
||||||
text: "Virus, spyware or malware",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Fraud",
|
|
||||||
text: "Fraud",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "HateSpeech",
|
|
||||||
text: "Hate speech",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "ImminentHarmToPersonsOrProperty",
|
|
||||||
text: "Imminent harm to persons or property",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Other",
|
|
||||||
text: "Other",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export enum NotebookViewerParams {
|
|
||||||
NotebookUrl = "notebookUrl",
|
|
||||||
GalleryItemId = "galleryItemId",
|
|
||||||
HideInputs = "hideInputs",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotebookViewerProps {
|
|
||||||
notebookUrl: string;
|
|
||||||
galleryItemId: string;
|
|
||||||
hideInputs: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum GalleryViewerParams {
|
|
||||||
SelectedTab = "tab",
|
|
||||||
SortBy = "sort",
|
|
||||||
SearchText = "q",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GalleryViewerProps {
|
|
||||||
selectedTab: GalleryTab;
|
|
||||||
sortBy: SortBy;
|
|
||||||
searchText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DialogHost {
|
|
||||||
showOkModalDialog(
|
|
||||||
title: string,
|
|
||||||
msg: string,
|
|
||||||
okLabel: string,
|
|
||||||
onOk: () => void,
|
|
||||||
progressIndicatorProps?: IProgressIndicatorProps,
|
|
||||||
): void;
|
|
||||||
|
|
||||||
showOkCancelModalDialog(
|
|
||||||
title: string,
|
|
||||||
msg: string,
|
|
||||||
okLabel: string,
|
|
||||||
onOk: () => void,
|
|
||||||
cancelLabel: string,
|
|
||||||
onCancel: () => void,
|
|
||||||
progressIndicatorProps?: IProgressIndicatorProps,
|
|
||||||
choiceGroupProps?: IChoiceGroupProps,
|
|
||||||
textFieldProps?: TextFieldProps,
|
|
||||||
primaryButtonDisabled?: boolean,
|
|
||||||
): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reportAbuse(
|
|
||||||
junoClient: JunoClient,
|
|
||||||
data: IGalleryItem,
|
|
||||||
dialogHost: DialogHost,
|
|
||||||
onComplete: (success: boolean) => void,
|
|
||||||
): void {
|
|
||||||
trace(Action.NotebooksGalleryClickReportAbuse, ActionModifiers.Mark, { notebookId: data.id });
|
|
||||||
|
|
||||||
const notebookId = data.id;
|
|
||||||
let abuseCategory = defaultSelectedAbuseCategory;
|
|
||||||
let additionalDetails: string;
|
|
||||||
|
|
||||||
dialogHost.showOkCancelModalDialog(
|
|
||||||
"Report Abuse",
|
|
||||||
undefined,
|
|
||||||
"Report Abuse",
|
|
||||||
async () => {
|
|
||||||
dialogHost.showOkCancelModalDialog(
|
|
||||||
"Report Abuse",
|
|
||||||
`Submitting your report on ${data.name} violating code of conduct`,
|
|
||||||
"Reporting...",
|
|
||||||
undefined,
|
|
||||||
"Cancel",
|
|
||||||
undefined,
|
|
||||||
{},
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
const startKey = traceStart(Action.NotebooksGalleryReportAbuse, { notebookId: data.id, abuseCategory });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await junoClient.reportAbuse(notebookId, abuseCategory, additionalDetails);
|
|
||||||
if (response.status !== HttpStatusCodes.Accepted) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when submitting report for ${data.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
dialogHost.showOkModalDialog(
|
|
||||||
"Report Abuse",
|
|
||||||
`Your report on ${data.name} has been submitted. Thank you for reporting the violation.`,
|
|
||||||
"OK",
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
percentComplete: 1,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
traceSuccess(Action.NotebooksGalleryReportAbuse, { notebookId: data.id, abuseCategory }, startKey);
|
|
||||||
|
|
||||||
onComplete(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
traceFailure(
|
|
||||||
Action.NotebooksGalleryReportAbuse,
|
|
||||||
{
|
|
||||||
notebookId: data.id,
|
|
||||||
abuseCategory,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
errorStack: getErrorStack(error),
|
|
||||||
},
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
handleError(
|
|
||||||
error,
|
|
||||||
"GalleryUtils/reportAbuse",
|
|
||||||
`Failed to submit report on ${data.name} violating code of conduct`,
|
|
||||||
);
|
|
||||||
|
|
||||||
dialogHost.showOkModalDialog(
|
|
||||||
"Report Abuse",
|
|
||||||
`Failed to submit report on ${data.name} violating code of conduct`,
|
|
||||||
"OK",
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
percentComplete: 1,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Cancel",
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
label: "How does this content violate the code of conduct?",
|
|
||||||
options: abuseCategories,
|
|
||||||
defaultSelectedKey: defaultSelectedAbuseCategory,
|
|
||||||
onChange: (_event?: React.FormEvent<HTMLElement | HTMLInputElement>, option?: IChoiceGroupOption) => {
|
|
||||||
abuseCategory = option?.key;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "You can also include additional relevant details on the offensive content",
|
|
||||||
multiline: true,
|
|
||||||
rows: 3,
|
|
||||||
autoAdjustHeight: false,
|
|
||||||
onChange: (_event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string) => {
|
|
||||||
additionalDetails = newValue;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function downloadItem(
|
|
||||||
container: Explorer,
|
|
||||||
junoClient: JunoClient,
|
|
||||||
data: IGalleryItem,
|
|
||||||
onComplete: (item: IGalleryItem) => void,
|
|
||||||
): void {
|
|
||||||
trace(Action.NotebooksGalleryClickDownload, ActionModifiers.Mark, {
|
|
||||||
notebookId: data.id,
|
|
||||||
downloadCount: data.downloads,
|
|
||||||
isSample: data.isSample,
|
|
||||||
});
|
|
||||||
|
|
||||||
const name = data.name;
|
|
||||||
useDialog.getState().showOkCancelModalDialog(
|
|
||||||
`Download to ${useNotebook.getState().notebookFolderName}`,
|
|
||||||
undefined,
|
|
||||||
"Download",
|
|
||||||
async () => {
|
|
||||||
if (useNotebook.getState().isPhoenixNotebooks) {
|
|
||||||
await container.allocateContainer();
|
|
||||||
}
|
|
||||||
const notebookServerInfo = useNotebook.getState().notebookServerInfo;
|
|
||||||
if (notebookServerInfo && notebookServerInfo.notebookServerEndpoint !== undefined) {
|
|
||||||
downloadNotebookItem(name, data, junoClient, container, onComplete);
|
|
||||||
} else {
|
|
||||||
useDialog
|
|
||||||
.getState()
|
|
||||||
.showOkModalDialog(
|
|
||||||
"Failed to connect",
|
|
||||||
"Failed to connect to temporary workspace. Please refresh the page and try again.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Cancel",
|
|
||||||
undefined,
|
|
||||||
container.getDownloadModalContent(name),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export async function downloadNotebookItem(
|
|
||||||
fileName: string,
|
|
||||||
data: IGalleryItem,
|
|
||||||
junoClient: JunoClient,
|
|
||||||
container: Explorer,
|
|
||||||
onComplete: (item: IGalleryItem) => void,
|
|
||||||
) {
|
|
||||||
const clearInProgressMessage = logConsoleProgress(
|
|
||||||
`Downloading ${fileName} to ${useNotebook.getState().notebookFolderName}`,
|
|
||||||
);
|
|
||||||
const startKey = traceStart(Action.NotebooksGalleryDownload, {
|
|
||||||
notebookId: data.id,
|
|
||||||
downloadCount: data.downloads,
|
|
||||||
isSample: data.isSample,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await junoClient.getNotebookContent(data.id);
|
|
||||||
if (!response.data) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when fetching ${data.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const notebook = JSON.parse(response.data) as Notebook;
|
|
||||||
removeNotebookViewerLink(notebook, data.newCellId);
|
|
||||||
|
|
||||||
if (!data.isSample) {
|
|
||||||
const metadata = notebook.metadata as { [name: string]: unknown };
|
|
||||||
metadata.untrusted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
await container.importAndOpenContent(data.name, JSON.stringify(notebook));
|
|
||||||
logConsoleInfo(`Successfully downloaded ${data.name} to ${useNotebook.getState().notebookFolderName}`);
|
|
||||||
|
|
||||||
const increaseDownloadResponse = await junoClient.increaseNotebookDownloadCount(data.id);
|
|
||||||
if (increaseDownloadResponse.data) {
|
|
||||||
traceSuccess(
|
|
||||||
Action.NotebooksGalleryDownload,
|
|
||||||
{ notebookId: data.id, downloadCount: increaseDownloadResponse.data.downloads, isSample: data.isSample },
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
onComplete(increaseDownloadResponse.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
traceFailure(
|
|
||||||
Action.NotebooksGalleryDownload,
|
|
||||||
{
|
|
||||||
notebookId: data.id,
|
|
||||||
downloadCount: data.downloads,
|
|
||||||
isSample: data.isSample,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
errorStack: getErrorStack(error),
|
|
||||||
},
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
handleError(error, "GalleryUtils/downloadItem", `Failed to download ${data.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearInProgressMessage();
|
|
||||||
}
|
|
||||||
export const removeNotebookViewerLink = (notebook: Notebook, newCellId: string): void => {
|
|
||||||
if (!newCellId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const notebookV4 = notebook as NotebookV4;
|
|
||||||
if (notebookV4?.cells[0]?.source[0]?.search(newCellId)) {
|
|
||||||
notebookV4.cells.splice(0, 1);
|
|
||||||
notebook = notebookV4;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function favoriteItem(
|
|
||||||
container: Explorer,
|
|
||||||
junoClient: JunoClient,
|
|
||||||
data: IGalleryItem,
|
|
||||||
onComplete: (item: IGalleryItem) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
if (container) {
|
|
||||||
const startKey = traceStart(Action.NotebooksGalleryFavorite, {
|
|
||||||
notebookId: data.id,
|
|
||||||
isSample: data.isSample,
|
|
||||||
favoriteCount: data.favorites,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await junoClient.favoriteNotebook(data.id);
|
|
||||||
if (!response.data) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when favoriting ${data.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
traceSuccess(
|
|
||||||
Action.NotebooksGalleryFavorite,
|
|
||||||
{ notebookId: data.id, isSample: data.isSample, favoriteCount: response.data.favorites },
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
onComplete(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
traceFailure(
|
|
||||||
Action.NotebooksGalleryFavorite,
|
|
||||||
{
|
|
||||||
notebookId: data.id,
|
|
||||||
isSample: data.isSample,
|
|
||||||
favoriteCount: data.favorites,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
errorStack: getErrorStack(error),
|
|
||||||
},
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
handleError(error, "GalleryUtils/favoriteItem", `Failed to favorite ${data.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unfavoriteItem(
|
|
||||||
container: Explorer,
|
|
||||||
junoClient: JunoClient,
|
|
||||||
data: IGalleryItem,
|
|
||||||
onComplete: (item: IGalleryItem) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
if (container) {
|
|
||||||
const startKey = traceStart(Action.NotebooksGalleryUnfavorite, {
|
|
||||||
notebookId: data.id,
|
|
||||||
isSample: data.isSample,
|
|
||||||
favoriteCount: data.favorites,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await junoClient.unfavoriteNotebook(data.id);
|
|
||||||
if (!response.data) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when unfavoriting ${data.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
traceSuccess(
|
|
||||||
Action.NotebooksGalleryUnfavorite,
|
|
||||||
{ notebookId: data.id, isSample: data.isSample, favoriteCount: response.data.favorites },
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
onComplete(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
traceFailure(
|
|
||||||
Action.NotebooksGalleryUnfavorite,
|
|
||||||
{
|
|
||||||
notebookId: data.id,
|
|
||||||
isSample: data.isSample,
|
|
||||||
favoriteCount: data.favorites,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
errorStack: getErrorStack(error),
|
|
||||||
},
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
handleError(error, "GalleryUtils/unfavoriteItem", `Failed to unfavorite ${data.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteItem(
|
|
||||||
container: Explorer,
|
|
||||||
junoClient: JunoClient,
|
|
||||||
data: IGalleryItem,
|
|
||||||
onComplete: (item: IGalleryItem) => void,
|
|
||||||
beforeDelete?: () => void,
|
|
||||||
afterDelete?: () => void,
|
|
||||||
): void {
|
|
||||||
if (container) {
|
|
||||||
trace(Action.NotebooksGalleryClickDelete, ActionModifiers.Mark, { notebookId: data.id });
|
|
||||||
|
|
||||||
useDialog.getState().showOkCancelModalDialog(
|
|
||||||
"Remove published notebook",
|
|
||||||
`Would you like to remove ${data.name} from the gallery?`,
|
|
||||||
"Remove",
|
|
||||||
async () => {
|
|
||||||
if (beforeDelete) {
|
|
||||||
beforeDelete();
|
|
||||||
}
|
|
||||||
const name = data.name;
|
|
||||||
const clearInProgressMessage = logConsoleProgress(`Removing ${name} from gallery`);
|
|
||||||
const startKey = traceStart(Action.NotebooksGalleryDelete, { notebookId: data.id });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await junoClient.deleteNotebook(data.id);
|
|
||||||
if (!response.data) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} while removing ${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
traceSuccess(Action.NotebooksGalleryDelete, { notebookId: data.id }, startKey);
|
|
||||||
|
|
||||||
logConsoleInfo(`Successfully removed ${name} from gallery`);
|
|
||||||
onComplete(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
traceFailure(
|
|
||||||
Action.NotebooksGalleryDelete,
|
|
||||||
{ notebookId: data.id, error: getErrorMessage(error), errorStack: getErrorStack(error) },
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
handleError(error, "GalleryUtils/deleteItem", `Failed to remove ${name} from gallery`);
|
|
||||||
} finally {
|
|
||||||
if (afterDelete) {
|
|
||||||
afterDelete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearInProgressMessage();
|
|
||||||
},
|
|
||||||
"Cancel",
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getGalleryViewerProps(search: string): GalleryViewerProps {
|
|
||||||
const params = new URLSearchParams(search);
|
|
||||||
let selectedTab: GalleryTab;
|
|
||||||
if (params.has(GalleryViewerParams.SelectedTab)) {
|
|
||||||
selectedTab = GalleryTab[params.get(GalleryViewerParams.SelectedTab) as keyof typeof GalleryTab];
|
|
||||||
}
|
|
||||||
|
|
||||||
let sortBy: SortBy;
|
|
||||||
if (params.has(GalleryViewerParams.SortBy)) {
|
|
||||||
sortBy = SortBy[params.get(GalleryViewerParams.SortBy) as keyof typeof SortBy];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedTab,
|
|
||||||
sortBy,
|
|
||||||
searchText: params.get(GalleryViewerParams.SearchText),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNotebookViewerProps(search: string): NotebookViewerProps {
|
|
||||||
const params = new URLSearchParams(search);
|
|
||||||
return {
|
|
||||||
notebookUrl: params.get(NotebookViewerParams.NotebookUrl),
|
|
||||||
galleryItemId: params.get(NotebookViewerParams.GalleryItemId),
|
|
||||||
hideInputs: JSON.parse(params.get(NotebookViewerParams.HideInputs)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTabTitle(tab: GalleryTab): string {
|
|
||||||
switch (tab) {
|
|
||||||
case GalleryTab.PublicGallery:
|
|
||||||
return GalleryViewerComponent.PublicGalleryTitle;
|
|
||||||
case GalleryTab.OfficialSamples:
|
|
||||||
return GalleryViewerComponent.OfficialSamplesTitle;
|
|
||||||
case GalleryTab.Favorites:
|
|
||||||
return GalleryViewerComponent.FavoritesTitle;
|
|
||||||
case GalleryTab.Published:
|
|
||||||
return GalleryViewerComponent.PublishedTitle;
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown tab ${tab}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filterPublishedNotebooks(items: IGalleryItem[]): {
|
|
||||||
published: IGalleryItem[];
|
|
||||||
underReview: IGalleryItem[];
|
|
||||||
removed: IGalleryItem[];
|
|
||||||
} {
|
|
||||||
const underReview: IGalleryItem[] = [];
|
|
||||||
const removed: IGalleryItem[] = [];
|
|
||||||
const published: IGalleryItem[] = [];
|
|
||||||
|
|
||||||
items?.forEach((item) => {
|
|
||||||
if (item.policyViolations?.length > 0) {
|
|
||||||
removed.push(item);
|
|
||||||
} else if (item.pendingScanJobIds?.length > 0) {
|
|
||||||
underReview.push(item);
|
|
||||||
} else {
|
|
||||||
published.push(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { published, underReview, removed };
|
|
||||||
}
|
|
||||||
@@ -6,9 +6,10 @@
|
|||||||
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
|
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { stringifyError } from "Common/stringifyError";
|
||||||
|
import { configContext } from "../../../../ConfigContext";
|
||||||
import { armRequest } from "../../request";
|
import { armRequest } from "../../request";
|
||||||
import * as Types from "./types";
|
import * as Types from "./types";
|
||||||
import { configContext } from "../../../../ConfigContext";
|
|
||||||
const apiVersion = "2025-11-01-preview";
|
const apiVersion = "2025-11-01-preview";
|
||||||
|
|
||||||
/* Lists the SQL databases under an existing Azure Cosmos DB database account. */
|
/* Lists the SQL databases under an existing Azure Cosmos DB database account. */
|
||||||
@@ -18,7 +19,14 @@ export async function listSqlDatabases(
|
|||||||
accountName: string,
|
accountName: string,
|
||||||
): Promise<Types.SqlDatabaseListResult> {
|
): Promise<Types.SqlDatabaseListResult> {
|
||||||
const path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/sqlDatabases`;
|
const path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/sqlDatabases`;
|
||||||
return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "GET", apiVersion });
|
console.log("{{cdbp}} in listSqlDatabases(): path: " + path);
|
||||||
|
try {
|
||||||
|
console.log("{{cdbp}} in listSqlDatabases(): calling armRequest");
|
||||||
|
return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "GET", apiVersion });
|
||||||
|
} catch (error) {
|
||||||
|
console.log("{{cdbp}} in listSqlDatabases(): ERROR: " + stringifyError(error));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gets the SQL database under an existing Azure Cosmos DB database account with the provided name. */
|
/* Gets the SQL database under an existing Azure Cosmos DB database account with the provided name. */
|
||||||
|
|||||||
@@ -75,4 +75,200 @@ describe("ARM request", () => {
|
|||||||
armRequest({ apiVersion: "2001-01-01", host: "https://foo.com", path: "foo", method: "GET" }),
|
armRequest({ apiVersion: "2001-01-01", host: "https://foo.com", path: "foo", method: "GET" }),
|
||||||
).rejects.toThrow("No authority token provided");
|
).rejects.toThrow("No authority token provided");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("timeout and retry behavior", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
updateUserContext({
|
||||||
|
authType: AuthType.AAD,
|
||||||
|
authorizationToken: "some-token",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeAbortError = () => Object.assign(new Error("aborted"), { name: "AbortError" });
|
||||||
|
const okResponse = () => ({
|
||||||
|
ok: true,
|
||||||
|
headers: new Headers(),
|
||||||
|
json: async () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards timeoutMs to the underlying fetch timer", async () => {
|
||||||
|
const setTimeoutSpy = jest.spyOn(global, "setTimeout");
|
||||||
|
window.fetch = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
await armRequest({
|
||||||
|
apiVersion: "2001-01-01",
|
||||||
|
host: "https://foo.com",
|
||||||
|
path: "foo",
|
||||||
|
method: "POST",
|
||||||
|
timeoutMs: 12345,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutValues = setTimeoutSpy.mock.calls.map((c) => c[1]);
|
||||||
|
expect(timeoutValues).toContain(12345);
|
||||||
|
setTimeoutSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default 5000ms timeout when timeoutMs is not provided", async () => {
|
||||||
|
const setTimeoutSpy = jest.spyOn(global, "setTimeout");
|
||||||
|
window.fetch = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
await armRequest({ apiVersion: "2001-01-01", host: "https://foo.com", path: "foo", method: "POST" });
|
||||||
|
|
||||||
|
const timeoutValues = setTimeoutSpy.mock.calls.map((c) => c[1]);
|
||||||
|
expect(timeoutValues).toContain(5000);
|
||||||
|
setTimeoutSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the timer when timeoutMs is Infinity", async () => {
|
||||||
|
const setTimeoutSpy = jest.spyOn(global, "setTimeout");
|
||||||
|
window.fetch = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
await armRequest({
|
||||||
|
apiVersion: "2001-01-01",
|
||||||
|
host: "https://foo.com",
|
||||||
|
path: "foo",
|
||||||
|
method: "POST",
|
||||||
|
timeoutMs: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// No timer should be created by fetchWithTimeout.
|
||||||
|
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
||||||
|
setTimeoutSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries GET on timeout with escalating timeouts and eventually succeeds", async () => {
|
||||||
|
const setTimeoutSpy = jest.spyOn(global, "setTimeout");
|
||||||
|
const fetchMock = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(makeAbortError())
|
||||||
|
.mockRejectedValueOnce(makeAbortError())
|
||||||
|
.mockResolvedValueOnce(okResponse());
|
||||||
|
window.fetch = fetchMock;
|
||||||
|
|
||||||
|
await armRequest({
|
||||||
|
apiVersion: "2001-01-01",
|
||||||
|
host: "https://foo.com",
|
||||||
|
path: "foo",
|
||||||
|
method: "GET",
|
||||||
|
timeoutMs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
const timeoutValues = setTimeoutSpy.mock.calls.map((c) => c[1]);
|
||||||
|
// Each attempt creates a setTimeout for its escalating timeout (1x, 2x, 4x).
|
||||||
|
expect(timeoutValues).toContain(1000);
|
||||||
|
expect(timeoutValues).toContain(2000);
|
||||||
|
expect(timeoutValues).toContain(4000);
|
||||||
|
setTimeoutSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up after exhausting retries and rejects with the last AbortError", async () => {
|
||||||
|
const fetchMock = jest.fn().mockRejectedValue(makeAbortError());
|
||||||
|
window.fetch = fetchMock;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
armRequest({
|
||||||
|
apiVersion: "2001-01-01",
|
||||||
|
host: "https://foo.com",
|
||||||
|
path: "foo",
|
||||||
|
method: "GET",
|
||||||
|
timeoutMs: 1000,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
|
||||||
|
// 3 attempts total (initial + 2 retries).
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry non-GET methods on timeout", async () => {
|
||||||
|
const fetchMock = jest.fn().mockRejectedValue(makeAbortError());
|
||||||
|
window.fetch = fetchMock;
|
||||||
|
|
||||||
|
for (const method of ["POST", "PUT", "PATCH", "DELETE", "HEAD"] as const) {
|
||||||
|
fetchMock.mockClear();
|
||||||
|
await expect(
|
||||||
|
armRequest({
|
||||||
|
apiVersion: "2001-01-01",
|
||||||
|
host: "https://foo.com",
|
||||||
|
path: "foo",
|
||||||
|
method,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
}),
|
||||||
|
).rejects.toBeTruthy();
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry GET on HTTP 500 (server error)", async () => {
|
||||||
|
const fetchMock = jest.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ code: "InternalServerError", message: "boom" }),
|
||||||
|
});
|
||||||
|
window.fetch = fetchMock;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
armRequest({
|
||||||
|
apiVersion: "2001-01-01",
|
||||||
|
host: "https://foo.com",
|
||||||
|
path: "foo",
|
||||||
|
method: "GET",
|
||||||
|
timeoutMs: 1000,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("boom");
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops retrying GET when the caller's signal is already aborted", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const reason = new Error("user-cancelled");
|
||||||
|
controller.abort(reason);
|
||||||
|
const fetchMock = jest.fn().mockRejectedValue(makeAbortError());
|
||||||
|
window.fetch = fetchMock;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
armRequest({
|
||||||
|
apiVersion: "2001-01-01",
|
||||||
|
host: "https://foo.com",
|
||||||
|
path: "foo",
|
||||||
|
method: "GET",
|
||||||
|
timeoutMs: 1000,
|
||||||
|
signal: controller.signal,
|
||||||
|
}),
|
||||||
|
).rejects.toBeTruthy();
|
||||||
|
|
||||||
|
// fetchWithTimeout throws synchronously on already-aborted signal, so no fetch call.
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines caller signal with timeout: aborting the signal cancels in-flight fetch", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const reason = new Error("user-cancelled");
|
||||||
|
|
||||||
|
let receivedSignal: AbortSignal | undefined;
|
||||||
|
window.fetch = jest.fn().mockImplementation((_url: string, init: RequestInit) => {
|
||||||
|
receivedSignal = init.signal ?? undefined;
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
init.signal?.addEventListener("abort", () => reject(init.signal?.reason ?? new Error("aborted")));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = armRequest({
|
||||||
|
apiVersion: "2001-01-01",
|
||||||
|
host: "https://foo.com",
|
||||||
|
path: "foo",
|
||||||
|
method: "POST",
|
||||||
|
timeoutMs: 60000,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow fetch to wire up its abort listener.
|
||||||
|
await Promise.resolve();
|
||||||
|
controller.abort(reason);
|
||||||
|
|
||||||
|
await expect(pending).rejects.toBe(reason);
|
||||||
|
expect(receivedSignal?.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Instead, generate ARM clients that consume this function with stricter typing.
|
|||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { stringifyError } from "Common/stringifyError";
|
||||||
import promiseRetry, { AbortError } from "p-retry";
|
import promiseRetry, { AbortError } from "p-retry";
|
||||||
import { HttpHeaders } from "../../Common/Constants";
|
import { HttpHeaders } from "../../Common/Constants";
|
||||||
import { configContext } from "../../ConfigContext";
|
import { configContext } from "../../ConfigContext";
|
||||||
@@ -50,8 +51,13 @@ interface Options {
|
|||||||
contentType?: string;
|
contentType?: string;
|
||||||
customHeaders?: Record<string, string>;
|
customHeaders?: Record<string, string>;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ARM_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === "AbortError";
|
||||||
|
|
||||||
export async function armRequestWithoutPolling<T>({
|
export async function armRequestWithoutPolling<T>({
|
||||||
host,
|
host,
|
||||||
path,
|
path,
|
||||||
@@ -62,6 +68,7 @@ export async function armRequestWithoutPolling<T>({
|
|||||||
contentType,
|
contentType,
|
||||||
customHeaders,
|
customHeaders,
|
||||||
signal,
|
signal,
|
||||||
|
timeoutMs,
|
||||||
}: Options): Promise<{ result: T; operationStatusUrl: string }> {
|
}: Options): Promise<{ result: T; operationStatusUrl: string }> {
|
||||||
const url = new URL(path, host);
|
const url = new URL(path, host);
|
||||||
url.searchParams.append("api-version", configContext.armAPIVersion || apiVersion);
|
url.searchParams.append("api-version", configContext.armAPIVersion || apiVersion);
|
||||||
@@ -71,6 +78,9 @@ export async function armRequestWithoutPolling<T>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!userContext?.authorizationToken && !customHeaders?.["Authorization"]) {
|
if (!userContext?.authorizationToken && !customHeaders?.["Authorization"]) {
|
||||||
|
console.log(
|
||||||
|
"{{cdbp}} in armRequestWithoutPolling(): condition '!userContext?.authorizationToken && !customHeaders?.['Authorization']' met, throwing 'No authority token provided' error",
|
||||||
|
);
|
||||||
throw new Error("No authority token provided");
|
throw new Error("No authority token provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,10 +94,14 @@ export async function armRequestWithoutPolling<T>({
|
|||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: requestBody ? JSON.stringify(requestBody) : undefined,
|
body: requestBody ? JSON.stringify(requestBody) : undefined,
|
||||||
...(signal ? { signal } : {}),
|
signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = signal ? await window.fetch(url.href, fetchInit) : await fetchWithTimeout(url.href, fetchInit);
|
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_ARM_TIMEOUT_MS;
|
||||||
|
console.log(
|
||||||
|
`{{cdbp}} in armRequestWithoutPolling(): calling fetchWithRetry (method=${method}, timeoutMs=${effectiveTimeoutMs}, hasSignal=${!!signal})`,
|
||||||
|
);
|
||||||
|
const response = await fetchWithRetry(url.href, fetchInit, method, effectiveTimeoutMs, signal);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let error: ARMError;
|
let error: ARMError;
|
||||||
@@ -101,9 +115,11 @@ export async function armRequestWithoutPolling<T>({
|
|||||||
error.code = errorResponse.code;
|
error.code = errorResponse.code;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log("{{cdbp}} in armRequestWithoutPolling(): ERROR: " + stringifyError(error));
|
||||||
throw new Error(await response.text());
|
throw new Error(await response.text());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("{{cdbp}} in armRequestWithoutPolling(): ERROR: " + stringifyError(error));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +139,7 @@ export async function armRequest<T>({
|
|||||||
contentType,
|
contentType,
|
||||||
customHeaders,
|
customHeaders,
|
||||||
signal,
|
signal,
|
||||||
|
timeoutMs,
|
||||||
}: Options): Promise<T> {
|
}: Options): Promise<T> {
|
||||||
const armRequestResult = await armRequestWithoutPolling<T>({
|
const armRequestResult = await armRequestWithoutPolling<T>({
|
||||||
host,
|
host,
|
||||||
@@ -134,6 +151,7 @@ export async function armRequest<T>({
|
|||||||
contentType,
|
contentType,
|
||||||
customHeaders,
|
customHeaders,
|
||||||
signal,
|
signal,
|
||||||
|
timeoutMs,
|
||||||
});
|
});
|
||||||
const operationStatusUrl = armRequestResult.operationStatusUrl;
|
const operationStatusUrl = armRequestResult.operationStatusUrl;
|
||||||
if (operationStatusUrl) {
|
if (operationStatusUrl) {
|
||||||
@@ -142,6 +160,45 @@ export async function armRequest<T>({
|
|||||||
return armRequestResult.result;
|
return armRequestResult.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls `fetchWithTimeout` once for non-idempotent methods. For idempotent GETs, retries
|
||||||
|
* up to {@link RETRY_TIMEOUT_MULTIPLIERS}.length attempts on timeout, escalating the timeout
|
||||||
|
* on each attempt. HTTP error responses (4xx/5xx) are NOT retried — they are surfaced by the
|
||||||
|
* caller's response.ok check. External `signal` cancellation aborts the retry loop immediately.
|
||||||
|
*/
|
||||||
|
async function fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
fetchInit: RequestInit,
|
||||||
|
method: Options["method"],
|
||||||
|
timeoutMs: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Response> {
|
||||||
|
if (method !== "GET") {
|
||||||
|
return fetchWithTimeout(url, fetchInit, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETRY_TIMEOUT_MULTIPLIERS = [1, 2, 4];
|
||||||
|
const RETRY_BACKOFF_MS = 100;
|
||||||
|
|
||||||
|
return promiseRetry(
|
||||||
|
(attemptNumber: number) => {
|
||||||
|
const attemptTimeoutMs =
|
||||||
|
timeoutMs * RETRY_TIMEOUT_MULTIPLIERS[Math.min(attemptNumber - 1, RETRY_TIMEOUT_MULTIPLIERS.length - 1)];
|
||||||
|
console.log(`{{cdbp}} in fetchWithRetry(): calling fetchWithTimeout: attempt=${attemptNumber} url=${url}`);
|
||||||
|
return fetchWithTimeout(url, fetchInit, attemptTimeoutMs);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retries: RETRY_TIMEOUT_MULTIPLIERS.length - 1,
|
||||||
|
factor: 1,
|
||||||
|
minTimeout: RETRY_BACKOFF_MS,
|
||||||
|
maxTimeout: RETRY_BACKOFF_MS,
|
||||||
|
signal,
|
||||||
|
// Only retry on timeout aborts. Caller cancellation (external signal aborted) stops retries.
|
||||||
|
shouldRetry: (error) => isAbortError(error) && !signal?.aborted,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function getOperationStatus(operationStatusUrl: string) {
|
async function getOperationStatus(operationStatusUrl: string) {
|
||||||
if (!userContext.authorizationToken) {
|
if (!userContext.authorizationToken) {
|
||||||
throw new Error("No authority token provided");
|
throw new Error("No authority token provided");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as msal from "@azure/msal-browser";
|
import * as msal from "@azure/msal-browser";
|
||||||
import { useBoolean } from "@fluentui/react-hooks";
|
import { useBoolean } from "@fluentui/react-hooks";
|
||||||
|
import { stringifyError } from "Common/stringifyError";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ConfigContext } from "../ConfigContext";
|
import { ConfigContext } from "../ConfigContext";
|
||||||
import {
|
import {
|
||||||
@@ -77,7 +78,7 @@ export function useAADAuth(config?: ConfigContext): ReturnType {
|
|||||||
localStorage.setItem("cachedTenantId", response.tenantId);
|
localStorage.setItem("cachedTenantId", response.tenantId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAuthFailure({
|
setAuthFailure({
|
||||||
failureMessage: `Login failed: ${JSON.stringify(error)}`,
|
failureMessage: `Login failed: ${stringifyError(error)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [msalInstance, config]);
|
}, [msalInstance, config]);
|
||||||
@@ -111,7 +112,7 @@ export function useAADAuth(config?: ConfigContext): ReturnType {
|
|||||||
localStorage.setItem("cachedTenantId", response.tenantId);
|
localStorage.setItem("cachedTenantId", response.tenantId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAuthFailure({
|
setAuthFailure({
|
||||||
failureMessage: `Tenant switch failed: ${JSON.stringify(error)}`,
|
failureMessage: `Tenant switch failed: ${stringifyError(error)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -144,7 +145,7 @@ export function useAADAuth(config?: ConfigContext): ReturnType {
|
|||||||
failureLinkAction: acquireTokens,
|
failureLinkAction: acquireTokens,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const errorJson = JSON.stringify(error);
|
const errorJson = stringifyError(error);
|
||||||
setAuthFailure({
|
setAuthFailure({
|
||||||
failureMessage: `We were unable to establish authorization for this account, due to the following error: \n${errorJson}`,
|
failureMessage: `We were unable to establish authorization for this account, due to the following error: \n${errorJson}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1008,9 +1008,6 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
|
|||||||
if (inputs.flights.indexOf(Flights.NotebooksDownBanner) !== -1) {
|
if (inputs.flights.indexOf(Flights.NotebooksDownBanner) !== -1) {
|
||||||
userContext.features.notebooksDownBanner = true;
|
userContext.features.notebooksDownBanner = true;
|
||||||
}
|
}
|
||||||
if (inputs.flights.indexOf(Flights.PublicGallery) !== -1) {
|
|
||||||
userContext.features.publicGallery = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle initial theme from portal
|
// Handle initial theme from portal
|
||||||
|
|||||||
+10
@@ -616,6 +616,16 @@ export class DataExplorer {
|
|||||||
await page.goto(url);
|
await page.goto(url);
|
||||||
return DataExplorer.waitForExplorer(page);
|
return DataExplorer.waitForExplorer(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the tab navigation <li> element for the given tab ID or React tab name (e.g. "tab0", "Home") */
|
||||||
|
tabNavHeader(tabId: string): Locator {
|
||||||
|
return this.frame.getByTestId(`TabNav:${tabId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a context menu item in the tab right-click menu by visible label */
|
||||||
|
tabContextMenuItem(label: string): Locator {
|
||||||
|
return this.frame.getByRole("menuitem", { name: label });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForApiResponse(
|
export async function waitForApiResponse(
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { DataExplorer, TestAccount } from "../fx";
|
||||||
|
import { createTestSQLContainer, TestContainerContext } from "../testData";
|
||||||
|
|
||||||
|
let context: TestContainerContext = null!;
|
||||||
|
let explorer: DataExplorer = null!;
|
||||||
|
|
||||||
|
test.beforeAll("Create Test Database", async () => {
|
||||||
|
context = await createTestSQLContainer({ includeTestData: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll("Delete Test Database", async () => {
|
||||||
|
await context?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach("Open Data Explorer", async ({ page }) => {
|
||||||
|
explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Duplicate Items tab opens a second Items tab", async () => {
|
||||||
|
// Open Items tab
|
||||||
|
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
|
||||||
|
await containerNode.expand();
|
||||||
|
const itemsNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id);
|
||||||
|
await itemsNode.element.click();
|
||||||
|
|
||||||
|
const documentsTab = explorer.documentsTab("tab0");
|
||||||
|
await documentsTab.documentsFilter.waitFor({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Right-click the tab nav header
|
||||||
|
await explorer.tabNavHeader("tab0").click({ button: "right" });
|
||||||
|
|
||||||
|
// "Duplicate tab" should be visible in the context menu
|
||||||
|
const duplicateMenuItem = explorer.tabContextMenuItem("Duplicate tab");
|
||||||
|
await expect(duplicateMenuItem).toBeVisible();
|
||||||
|
await duplicateMenuItem.click();
|
||||||
|
|
||||||
|
// A second tab should appear
|
||||||
|
const tab1 = explorer.tab("tab1");
|
||||||
|
await expect(tab1).toBeAttached({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// The duplicated tab should also show the Documents content
|
||||||
|
const duplicatedTab = explorer.documentsTab("tab1");
|
||||||
|
await duplicatedTab.documentsFilter.waitFor({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Duplicate Query tab preserves query text in new tab", async () => {
|
||||||
|
// Open a new SQL query tab via container context menu
|
||||||
|
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
|
||||||
|
await containerNode.openContextMenu();
|
||||||
|
await containerNode.contextMenuItem("New SQL Query").click();
|
||||||
|
|
||||||
|
const queryTab = explorer.queryTab("tab0");
|
||||||
|
const editor = queryTab.editor();
|
||||||
|
await editor.locator.waitFor({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Type a custom query
|
||||||
|
const customQuery = 'SELECT * FROM c WHERE c.id = "duplicate-query-test"';
|
||||||
|
await editor.setText(customQuery);
|
||||||
|
|
||||||
|
// Right-click the tab nav header
|
||||||
|
await explorer.tabNavHeader("tab0").click({ button: "right" });
|
||||||
|
|
||||||
|
const duplicateMenuItem = explorer.tabContextMenuItem("Duplicate tab");
|
||||||
|
await expect(duplicateMenuItem).toBeVisible();
|
||||||
|
await duplicateMenuItem.click();
|
||||||
|
|
||||||
|
// Second query tab should appear
|
||||||
|
const tab1 = explorer.tab("tab1");
|
||||||
|
await expect(tab1).toBeAttached({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// The duplicated tab should contain the same query text
|
||||||
|
const duplicatedQueryTab = explorer.queryTab("tab1");
|
||||||
|
await duplicatedQueryTab.editor().locator.waitFor({ timeout: 30_000 });
|
||||||
|
const editorText = await duplicatedQueryTab.editor().text();
|
||||||
|
expect(editorText).toContain("duplicate-query-test");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Right-click context menu does not appear for the Home tab", async () => {
|
||||||
|
// The Home tab (ReactTabKind) is never duplicable — no context menu should appear
|
||||||
|
await explorer.tabNavHeader("Home").click({ button: "right" });
|
||||||
|
|
||||||
|
// Neither menu item should be visible
|
||||||
|
await expect(explorer.tabContextMenuItem("Duplicate tab")).not.toBeVisible();
|
||||||
|
await expect(explorer.tabContextMenuItem("Close tab")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Close tab from right-click menu closes the tab", async () => {
|
||||||
|
// Open Items tab
|
||||||
|
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
|
||||||
|
await containerNode.expand();
|
||||||
|
const itemsNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id);
|
||||||
|
await itemsNode.element.click();
|
||||||
|
|
||||||
|
const documentsTab = explorer.documentsTab("tab0");
|
||||||
|
await documentsTab.documentsFilter.waitFor({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Right-click the tab nav header and close the tab
|
||||||
|
await explorer.tabNavHeader("tab0").click({ button: "right" });
|
||||||
|
await explorer.tabContextMenuItem("Close tab").click();
|
||||||
|
|
||||||
|
// The tab pane should be removed
|
||||||
|
await expect(explorer.tab("tab0")).not.toBeAttached({ timeout: 15_000 });
|
||||||
|
});
|
||||||
@@ -113,7 +113,6 @@ module.exports = function (_env = {}, argv = {}) {
|
|||||||
hostedExplorer: "./src/HostedExplorer.tsx",
|
hostedExplorer: "./src/HostedExplorer.tsx",
|
||||||
terminal: "./src/Terminal/index.ts",
|
terminal: "./src/Terminal/index.ts",
|
||||||
cellOutputViewer: "./src/CellOutputViewer/CellOutputViewer.tsx",
|
cellOutputViewer: "./src/CellOutputViewer/CellOutputViewer.tsx",
|
||||||
galleryViewer: "./src/GalleryViewer/GalleryViewer.tsx",
|
|
||||||
selfServe: "./src/SelfServe/SelfServe.tsx",
|
selfServe: "./src/SelfServe/SelfServe.tsx",
|
||||||
connectToGitHub: "./src/GitHub/GitHubConnector.ts",
|
connectToGitHub: "./src/GitHub/GitHubConnector.ts",
|
||||||
redirectBridge: "./src/redirectBridge.ts",
|
redirectBridge: "./src/redirectBridge.ts",
|
||||||
@@ -154,11 +153,6 @@ module.exports = function (_env = {}, argv = {}) {
|
|||||||
template: "src/CellOutputViewer/cellOutputViewer.html",
|
template: "src/CellOutputViewer/cellOutputViewer.html",
|
||||||
chunks: ["cellOutputViewer"],
|
chunks: ["cellOutputViewer"],
|
||||||
}),
|
}),
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
filename: "gallery.html",
|
|
||||||
template: "src/GalleryViewer/galleryViewer.html",
|
|
||||||
chunks: ["galleryViewer"],
|
|
||||||
}),
|
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
filename: "connectToGitHub.html",
|
filename: "connectToGitHub.html",
|
||||||
template: "src/connectToGitHub.html",
|
template: "src/connectToGitHub.html",
|
||||||
|
|||||||
Reference in New Issue
Block a user