diff --git a/.eslintrc.js b/.eslintrc.js index fb1ed3a73..d4675ee95 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -40,7 +40,7 @@ module.exports = { }, ], rules: { - "no-console": ["error", { allow: ["error", "warn", "dir"] }], + //CTODO uncomment when console debugging is reverted: "no-console": ["error", { allow: ["error", "warn", "dir"] }], curly: "error", "@typescript-eslint/switch-exhaustiveness-check": "error", "@typescript-eslint/no-unused-vars": "error", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21180bb36..a3ced85dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -314,6 +314,9 @@ jobs: needs: [playwright-tests] runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -338,3 +341,62 @@ jobs: name: html-report--attempt-${{ github.run_attempt }} path: playwright-report 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 diff --git a/images/GalleryIcon.svg b/images/GalleryIcon.svg deleted file mode 100644 index bf49a651d..000000000 --- a/images/GalleryIcon.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/package-lock.json b/package-lock.json index b3c3e7779..385c69c6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,7 +76,7 @@ "html2canvas": "1.0.0-rc.5", "i18next": "23.11.5", "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", "iframe-resizer-react": "1.1.0", "immer": "9.0.6", @@ -12724,17 +12724,19 @@ } }, "node_modules/cross-fetch": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", - "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", "dependencies": { - "node-fetch": "^2.6.12" + "node-fetch": "^2.7.0" } }, "node_modules/cross-fetch/node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -16917,11 +16919,12 @@ } }, "node_modules/i18next-http-backend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", - "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.5.tgz", + "integrity": "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ==", + "license": "MIT", "dependencies": { - "cross-fetch": "4.0.0" + "cross-fetch": "4.1.0" } }, "node_modules/i18next-resources-for-ts": { @@ -27107,9 +27110,10 @@ "license": "ISC" }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", "engines": { "node": ">=14.14" } diff --git a/package.json b/package.json index e8614111f..19efa3a9b 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "html2canvas": "1.0.0-rc.5", "i18next": "23.11.5", "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", "iframe-resizer-react": "1.1.0", "immer": "9.0.6", diff --git a/preview/package-lock.json b/preview/package-lock.json index fd98a4bfe..f2b68ac65 100644 --- a/preview/package-lock.json +++ b/preview/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "body-parser": "^2.2.2", "express": "^5.2.1", - "follow-redirects": "^1.15.6", + "follow-redirects": "^1.16.0", "http-proxy-middleware": "^3.0.5", "node": "^20.19.5", "node-fetch": "^2.6.1", @@ -280,15 +280,16 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, diff --git a/preview/package.json b/preview/package.json index c50f57a1e..5812e4659 100644 --- a/preview/package.json +++ b/preview/package.json @@ -13,7 +13,7 @@ "dependencies": { "body-parser": "^2.2.2", "express": "^5.2.1", - "follow-redirects": "^1.15.6", + "follow-redirects": "^1.16.0", "http-proxy-middleware": "^3.0.5", "node": "^20.19.5", "node-fetch": "^2.6.1", diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 4ee38c6a0..eb1cb44c8 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -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 { public static readonly cassandra = [ "AccountEndpoint=(.*).cassandra.cosmosdb.azure.com", @@ -118,7 +112,6 @@ export class Flights { public static readonly PhoenixNotebooks = "phoenixnotebooks"; public static readonly PhoenixFeatures = "phoenixfeatures"; public static readonly NotebooksDownBanner = "notebooksdownbanner"; - public static readonly PublicGallery = "publicgallery"; } export class AfecFeatures { diff --git a/src/Common/ErrorHandlingUtils.ts b/src/Common/ErrorHandlingUtils.ts index f96780065..0f205eebc 100644 --- a/src/Common/ErrorHandlingUtils.ts +++ b/src/Common/ErrorHandlingUtils.ts @@ -1,3 +1,4 @@ +import { stringifyError } from "Common/stringifyError"; import { MessageTypes } from "../Contracts/ExplorerContracts"; import { SubscriptionType } from "../Contracts/SubscriptionType"; import { isExpectedError } from "../Metrics/ErrorClassification"; @@ -20,6 +21,7 @@ export const handleError = ( consoleErrorPrefix?: string, options?: HandleErrorOptions, ): void => { + console.log("{{cdbp}} in handleError(): raw error: " + stringifyError(error)); //CTODO in case a stray error happens const errorMessage = getErrorMessage(error); const errorCode = error instanceof ARMError ? error.code : undefined; @@ -44,7 +46,7 @@ export const handleError = ( export const getErrorMessage = (error: string | Error = ""): string => { let errorMessage = typeof error === "string" ? error : error.message; if (!errorMessage) { - errorMessage = JSON.stringify(error); + errorMessage = stringifyError(error); } return replaceKnownError(errorMessage); }; diff --git a/src/Common/IteratorUtilities.ts b/src/Common/IteratorUtilities.ts index 827033fd7..29dc93d33 100644 --- a/src/Common/IteratorUtilities.ts +++ b/src/Common/IteratorUtilities.ts @@ -19,22 +19,38 @@ export interface MinimalQueryIterator { // Pick, "fetchNext">; export function nextPage(documentsIterator: MinimalQueryIterator, firstItemIndex: number): Promise { - TelemetryProcessor.traceStart(Action.ExecuteQuery); - return documentsIterator.fetchNext().then((response) => { - TelemetryProcessor.traceSuccess(Action.ExecuteQuery, { dataExplorerArea: Constants.Areas.Tab }); - const documents = response.resources; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const headers = (response as any).headers || {}; // TODO this is a private key. Remove any - const itemCount = (documents && documents.length) || 0; - return { - documents, - hasMoreResults: response.hasMoreResults, - itemCount, - firstItemIndex: Number(firstItemIndex) + 1, - lastItemIndex: Number(firstItemIndex) + Number(itemCount), - headers, - activityId: response.activityId, - requestCharge: response.requestCharge, - }; - }); + const startKey = TelemetryProcessor.traceStart(Action.ExecuteQuery); + return documentsIterator + .fetchNext() + .then((response) => { + const durationMs = Date.now() - startKey; + TelemetryProcessor.traceSuccess( + Action.ExecuteQuery, + { dataExplorerArea: Constants.Areas.Tab, durationMs }, + startKey, + ); + const documents = response.resources; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const headers = (response as any).headers || {}; // TODO this is a private key. Remove any + const itemCount = (documents && documents.length) || 0; + return { + 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; + }); } diff --git a/src/Common/MessageHandler.ts b/src/Common/MessageHandler.ts index 2a986f835..0b877eef1 100644 --- a/src/Common/MessageHandler.ts +++ b/src/Common/MessageHandler.ts @@ -1,8 +1,9 @@ +import { SeverityLevel } from "@microsoft/applicationinsights-web"; import { FabricMessageTypes } from "Contracts/FabricMessageTypes"; import Q from "q"; import * as _ from "underscore"; -import * as Logger from "../Common/Logger"; import { MessageTypes } from "../Contracts/ExplorerContracts"; +import { trackTrace } from "../Shared/appInsights"; import { getDataExplorerWindow } from "../Utils/WindowUtils"; import * as Constants from "./Constants"; @@ -97,18 +98,66 @@ const _sendMessage = (message: any): void => { const portalChildWindow = getDataExplorerWindow(window) || window; if (portalChildWindow === window) { // Current window is a child of portal, send message to portal window - if (portalChildWindow.document.referrer) { - portalChildWindow.parent.postMessage(message, portalChildWindow.document.referrer); + const portalTargetOrigin = _getPortalTargetOrigin(portalChildWindow); + if (portalTargetOrigin) { + portalChildWindow.parent.postMessage(message, portalTargetOrigin); } else { - Logger.logError("Iframe failed to send message to portal", "MessageHandler"); + _reportPostMessageFailure("Iframe failed to send message to portal: no target origin"); } } else { // Current window is not a child of portal, send message to the child window instead (which is data explorer) if (portalChildWindow.location.origin) { portalChildWindow.postMessage(message, portalChildWindow.location.origin); } 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 ""; +}; diff --git a/src/Common/dataAccess/dataTransfers.ts b/src/Common/dataAccess/dataTransfers.ts index 3e8829486..0f9d9fac3 100644 --- a/src/Common/dataAccess/dataTransfers.ts +++ b/src/Common/dataAccess/dataTransfers.ts @@ -1,11 +1,15 @@ import { configContext } from "ConfigContext"; +import { Keys, t } from "Localization"; import { ApiType, userContext } from "UserContext"; import * as NotificationConsoleUtils from "Utils/NotificationConsoleUtils"; import { cancel, + complete, create, get, listByDatabaseAccount, + pause, + resume, } from "Utils/arm/generatedClients/dataTransferService/dataTransferJobs"; import { CosmosCassandraDataTransferDataSourceSink, @@ -31,6 +35,7 @@ export interface DataTransferParams { sourceCollectionName: string; targetDatabaseName: string; targetCollectionName: string; + mode?: "Offline" | "Online"; } export const getDataTransferJobs = async ( @@ -80,6 +85,7 @@ export const initiateDataTransfer = async (params: DataTransferParams): Promise< sourceCollectionName, targetDatabaseName, targetCollectionName, + mode, } = params; const sourcePayload = createPayload(apiType, sourceDatabaseName, sourceCollectionName); const targetPayload = createPayload(apiType, targetDatabaseName, targetCollectionName); @@ -87,6 +93,7 @@ export const initiateDataTransfer = async (params: DataTransferParams): Promise< properties: { source: sourcePayload, destination: targetPayload, + ...(mode ? { mode } : {}), }, }; return create(subscriptionId, resourceGroupName, accountName, jobName, body); @@ -137,30 +144,52 @@ const pollDataTransferJobOperation = async ( if (status === "Cancelled") { removeFromPolling(jobName); clearMessage && clearMessage(); - const cancelMessage = `Data transfer job ${jobName} cancelled`; + const cancelMessage = t(Keys.containerCopy.dataTransfers.polling.cancelConsoleMessage, { jobName: jobName }); NotificationConsoleUtils.logConsoleError(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") { removeFromPolling(jobName); const errorMessage = body?.properties?.error ? JSON.stringify(body?.properties?.error) - : "Operation could not be completed"; + : t(Keys.containerCopy.dataTransfers.polling.defaultErrorMessage); const error = new Error(errorMessage); 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); } if (status === "Completed") { removeFromPolling(jobName); clearMessage && clearMessage(); - NotificationConsoleUtils.logConsoleInfo(`Data transfer job ${jobName} completed`); + NotificationConsoleUtils.logConsoleInfo( + t(Keys.containerCopy.dataTransfers.polling.completedConsoleMessage, { + jobName: jobName, + }), + ); return body; } const processedCount = body.properties.processedCount; const totalCount = body.properties.totalCount; - const retryMessage = `Data transfer job ${jobName} in progress, total count: ${totalCount}, processed count: ${processedCount}`; - throw new Error(retryMessage); + throw new Error( + t(Keys.containerCopy.dataTransfers.polling.retryConsoleMessage, { + jobName: jobName, + processedCount: processedCount, + totalCount: totalCount, + }), + ); }; export const cancelDataTransferJob = async ( @@ -174,6 +203,43 @@ export const cancelDataTransferJob = async ( removeFromPolling(cancelResult?.properties?.jobName); }; +export const pauseDataTransferJob = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + jobName: string, +): Promise => { + 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 => { + const resumeResult: DataTransferJobGetResults = await resume(subscriptionId, resourceGroupName, accountName, jobName); + updateDataTransferJob(resumeResult); +}; + +export const completeDataTransferJob = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + jobName: string, +): Promise => { + const completeResult: DataTransferJobGetResults = await complete( + subscriptionId, + resourceGroupName, + accountName, + jobName, + ); + updateDataTransferJob(completeResult); + removeFromPolling(completeResult?.properties?.jobName); +}; + const createPayload = ( apiType: ApiType, databaseName: string, diff --git a/src/Common/dataAccess/readDatabases.ts b/src/Common/dataAccess/readDatabases.ts index e9f43e65f..ace64c058 100644 --- a/src/Common/dataAccess/readDatabases.ts +++ b/src/Common/dataAccess/readDatabases.ts @@ -1,3 +1,4 @@ +import { stringifyError } from "Common/stringifyError"; import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract"; import { isFabric, isFabricMirroredKey, isFabricNative } from "Platform/Fabric/FabricUtil"; import { AuthType } from "../../AuthType"; @@ -26,6 +27,7 @@ export async function readDatabases(): Promise { (userContext.fabricContext?.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY]).resourceTokenInfo .resourceTokens ) { + console.log("{{cdbp}} in readDatabases(): isFabricMirroredKey && has resourceTokens"); //CTODO should not get here const tokensData = (userContext.fabricContext.artifactInfo as FabricArtifactInfo[CosmosDbArtifactType.MIRRORED_KEY]) .resourceTokenInfo; @@ -59,6 +61,7 @@ export async function readDatabases(): Promise { clearMessage(); return databases; } else if (isFabricNative() && userContext.fabricContext?.databaseName) { + console.log("{{cdbp}} in readDatabases(): isFabricNative"); //CTODO should not get here const databaseId = userContext.fabricContext.databaseName; databases = [ { @@ -81,9 +84,15 @@ export async function readDatabases(): Promise { userContext.apiType !== "Tables" && !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(); + console.log("{{cdbp}} in readDatabases(): done readDatabasesWithARM"); } else { + console.log("{{cdbp}} in readDatabases(): calling SDK"); const sdkResponse = await client().databases.readAll().fetchAll(); + console.log("{{cdbp}} in readDatabases(): done SDK"); databases = sdkResponse.resources as DataModels.Database[]; } } catch (error) { @@ -108,22 +117,30 @@ export async function readDatabasesWithARM(accountOverride?: { const accountName = accountOverride?.accountName ?? userContext?.databaseAccount?.name ?? ""; const apiType = accountOverride?.apiType ?? userContext.apiType; - switch (apiType) { - case "SQL": - rpResponse = await listSqlDatabases(subscriptionId, resourceGroup, accountName); - break; - case "Mongo": - rpResponse = await listMongoDBDatabases(subscriptionId, resourceGroup, accountName); - break; - case "Cassandra": - rpResponse = await listCassandraKeyspaces(subscriptionId, resourceGroup, accountName); - break; - case "Gremlin": - rpResponse = await listGremlinDatabases(subscriptionId, resourceGroup, accountName); - break; - default: - throw new Error(`Unsupported default experience type: ${apiType}`); - } + try { + switch (apiType) { + case "SQL": + console.log("{{cdbp}} in readDatabasesWithARM(): calling listSqlDatabases"); + rpResponse = await listSqlDatabases(subscriptionId, resourceGroup, accountName); + console.log("{{cdbp}} in readDatabasesWithARM(): done listSqlDatabases"); + break; + case "Mongo": + rpResponse = await listMongoDBDatabases(subscriptionId, resourceGroup, accountName); + break; + case "Cassandra": + rpResponse = await listCassandraKeyspaces(subscriptionId, resourceGroup, accountName); + break; + case "Gremlin": + 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; + } } diff --git a/src/Common/stringifyError.ts b/src/Common/stringifyError.ts new file mode 100644 index 000000000..e248a380f --- /dev/null +++ b/src/Common/stringifyError.ts @@ -0,0 +1,7 @@ +export const stringifyError = (error: unknown): string => { + const plainObject: Record = {}; + Object.getOwnPropertyNames(error as object).forEach((key) => { + plainObject[key] = (error as Record)[key]; + }); + return JSON.stringify(plainObject, null, "\r\n"); +}; diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index a84afe9c2..cfeaf1b95 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -393,7 +393,7 @@ export enum CollectionTabKind { Terminal = 14, NotebookV2 = 15, SparkMasterTab = 16 /* Deprecated */, - Gallery = 17, + Gallery = 17 /* Deprecated */, NotebookViewer = 18, Schema = 19, CollectionSettingsV2 = 20, diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx index 359243d10..4922c43f3 100644 --- a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx @@ -115,7 +115,7 @@ export const getCopyJobs = async (): Promise => { }); return formattedJobs; } 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")) { throw { message: "Previous copy job request was cancelled.", diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.ts index 8296e0add..5d8ba6c29 100644 --- a/src/Explorer/ContainerCopy/CommandBar/Utils.ts +++ b/src/Explorer/ContainerCopy/CommandBar/Utils.ts @@ -5,7 +5,7 @@ import RefreshIcon from "../../../../images/refresh-cosmos.svg"; import SunIcon from "../../../../images/SunIcon.svg"; import { configContext, Platform } from "../../../ConfigContext"; import { useThemeStore } from "../../../hooks/useTheme"; -import { Keys, t } from "../../../Localization"; +import { Keys, t } from "Localization"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import Explorer from "../../Explorer"; import * as Actions from "../Actions/CopyJobActions"; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx index 927840b86..c86183ac1 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx @@ -1,7 +1,7 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import React from "react"; import { Keys, t } from "Localization"; +import React from "react"; import { CopyJobContext } from "../../../Context/CopyJobContext"; import { CopyJobContextProviderType } from "../../../Types/CopyJobTypes"; import AddReadWritePermissionToDefaultIdentity from "./AddReadWritePermissionToDefaultIdentity"; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx index 4c35e306f..f0cd65cf3 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx @@ -1,7 +1,7 @@ import "@testing-library/jest-dom"; import { render, RenderResult } from "@testing-library/react"; -import React from "react"; import { Keys, t } from "Localization"; +import React from "react"; import { CopyJobContext } from "../../../Context/CopyJobContext"; import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes"; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx index 295c0d633..cd1bb48d5 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx @@ -1,7 +1,7 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; -import React from "react"; import { Keys, t } from "Localization"; +import React from "react"; import PopoverMessage from "./PopoverContainer"; jest.mock("../../../../../Common/LoadingOverlay", () => { diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx index 9d9aa07fe..0cd8c4292 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx @@ -1,11 +1,11 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Keys, t } from "Localization"; import React from "react"; import { configContext, Platform } from "../../../../../../ConfigContext"; import { DatabaseAccount } from "../../../../../../Contracts/DataModels"; import * as useDatabaseAccountsHook from "../../../../../../hooks/useDatabaseAccounts"; import { apiType, userContext } from "../../../../../../UserContext"; -import { Keys, t } from "Localization"; import { CopyJobContext } from "../../../../Context/CopyJobContext"; import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; import { CopyJobContextProviderType, CopyJobContextState } from "../../../../Types/CopyJobTypes"; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx index 03c07ee02..afc275709 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx @@ -1,7 +1,7 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; -import React from "react"; import { Keys, t } from "Localization"; +import React from "react"; import { useCopyJobContext } from "../../../../Context/CopyJobContext"; import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; import { MigrationType } from "./MigrationType"; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx index 5da34fd23..f84ab0edb 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx @@ -1,7 +1,7 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; -import React from "react"; import { Keys, t } from "Localization"; +import React from "react"; import { DatabaseContainerSectionProps, DropdownOptionType } from "../../../../Types/CopyJobTypes"; import { DatabaseContainerSection } from "./DatabaseContainerSection"; diff --git a/src/Explorer/Controls/Header/GalleryHeaderComponent.tsx b/src/Explorer/Controls/Header/GalleryHeaderComponent.tsx deleted file mode 100644 index 6d3143169..000000000 --- a/src/Explorer/Controls/Header/GalleryHeaderComponent.tsx +++ /dev/null @@ -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 ( - - {text} - - ); - }; - - public render(): JSX.Element { - return ( - - - {this.renderHeaderItem( - GalleryHeaderComponent.azureText, - GalleryHeaderComponent.openPortal, - GalleryHeaderComponent.mainHeaderTextProps, - )} - - - - - - {this.renderHeaderItem( - GalleryHeaderComponent.cosmosdbText, - GalleryHeaderComponent.openDataExplorer, - GalleryHeaderComponent.headerItemTextProps, - )} - - - - - - {this.renderHeaderItem( - GalleryHeaderComponent.galleryText, - () => "", - GalleryHeaderComponent.headerItemTextProps, - )} - - - <> - - - {this.renderHeaderItem( - GalleryHeaderComponent.loginText, - GalleryHeaderComponent.openDataExplorer, - GalleryHeaderComponent.headerItemTextProps, - )} - - - ); - } -} diff --git a/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.test.tsx b/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.test.tsx deleted file mode 100644 index d96fda04b..000000000 --- a/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.test.tsx +++ /dev/null @@ -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(); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx b/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx deleted file mode 100644 index fc3a70d91..000000000 --- a/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx +++ /dev/null @@ -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 = ({ - 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(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} - - ); - }; - - /* - * 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 ( - - handlerOnClick(event, activate)} - /> - - ); - }; - - const handlerOnClick = ( - event: - | React.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 ( - - {isDeletingPublishedNotebook && ( - - )} - {!isDeletingPublishedNotebook && ( - <> - - - - - {data.tags ? ( - data.tags.map((tag, index, array) => ( - - handlerOnClick(event, () => onTagClick(tag))}>{tag} - {index === array.length - 1 ? <> : ", "} - - )) - ) : ( -
- )} -
- - - - {data.views !== undefined && generateIconText("RedEye", data.views.toString())} - {data.downloads !== undefined && generateIconText("Download", data.downloads.toString())} - {data.favorites !== undefined && generateIconText("Heart", data.favorites.toString())} - -
- {cardButtonsVisible && ( - - - - - {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), - ), - )} - - - )} - - )} -
- ); -}; diff --git a/src/Explorer/Controls/NotebookGallery/Cards/__snapshots__/GalleryCardComponent.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/Cards/__snapshots__/GalleryCardComponent.test.tsx.snap deleted file mode 100644 index ed4aaa8f1..000000000 --- a/src/Explorer/Controls/NotebookGallery/Cards/__snapshots__/GalleryCardComponent.test.tsx.snap +++ /dev/null @@ -1,256 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`GalleryCardComponent renders 1`] = ` - - - - - - - - tag - - - - - - - - - - 0 - - - - - 0 - - - - - 0 - - - - - - - - - - - - - - - - - - -`; diff --git a/src/Explorer/Controls/NotebookGallery/CodeOfConduct/CodeOfConduct.test.tsx b/src/Explorer/Controls/NotebookGallery/CodeOfConduct/CodeOfConduct.test.tsx deleted file mode 100644 index 0dd05974d..000000000 --- a/src/Explorer/Controls/NotebookGallery/CodeOfConduct/CodeOfConduct.test.tsx +++ /dev/null @@ -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(); - expect(wrapper).toMatchSnapshot(); - }); - - it("onAcceptedCodeOfConductCalled", async () => { - const wrapper = shallow(); - wrapper.find(".genericPaneSubmitBtn").first().simulate("click"); - await Promise.resolve(); - expect(codeOfConductProps.onAcceptCodeOfConduct).toHaveBeenCalled(); - }); -}); diff --git a/src/Explorer/Controls/NotebookGallery/CodeOfConduct/CodeOfConduct.tsx b/src/Explorer/Controls/NotebookGallery/CodeOfConduct/CodeOfConduct.tsx deleted file mode 100644 index 3c6a8c939..000000000 --- a/src/Explorer/Controls/NotebookGallery/CodeOfConduct/CodeOfConduct.tsx +++ /dev/null @@ -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 = ({ - 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(false); - - const acceptCodeOfConduct = async (): Promise => { - 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 ( - - - {descriptionPara1} - - - - {descriptionPara2} - - - - - {descriptionPara3} - - {link1.label} - - - - - - - - - - await acceptCodeOfConduct()} - tabIndex={0} - className="genericPaneSubmitBtn" - text="Continue" - disabled={!readCodeOfConduct} - /> - - - ); -}; diff --git a/src/Explorer/Controls/NotebookGallery/CodeOfConduct/__snapshots__/CodeOfConduct.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/CodeOfConduct/__snapshots__/CodeOfConduct.test.tsx.snap deleted file mode 100644 index 871aac4cc..000000000 --- a/src/Explorer/Controls/NotebookGallery/CodeOfConduct/__snapshots__/CodeOfConduct.test.tsx.snap +++ /dev/null @@ -1,68 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CodeOfConduct renders 1`] = ` - - - - Azure Cosmos DB Notebook Gallery - Code of Conduct - - - - - The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB. - - - - - In order to view and publish your samples to the gallery, you must accept the - - code of conduct. - - - - - - - - - - -`; diff --git a/src/Explorer/Controls/NotebookGallery/GalleryAndNotebookViewerComponent.tsx b/src/Explorer/Controls/NotebookGallery/GalleryAndNotebookViewerComponent.tsx deleted file mode 100644 index 3e2b5f605..000000000 --- a/src/Explorer/Controls/NotebookGallery/GalleryAndNotebookViewerComponent.tsx +++ /dev/null @@ -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 ; - } - - 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 ; - } - - 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, - }); - }; -} diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.less b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.less deleted file mode 100644 index 53151cc28..000000000 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.less +++ /dev/null @@ -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%; -} diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.test.tsx b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.test.tsx deleted file mode 100644 index d5e2adecf..000000000 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.test.tsx +++ /dev/null @@ -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(); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx deleted file mode 100644 index a43fc6c3b..000000000 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx +++ /dev/null @@ -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 { - 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 ( - - {tab.content} - - ); - }); - - return ( -
- {pivotItems} -
- ); - } - - 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 ( - - - {line1} - {line2} - - ); - }; - - 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 ; - } - return this.createEmptyTabContent( - "ContactHeart", - <>You don't have any favorites yet, - <> - Favorite any notebook from the{" "} - this.setState({ selectedTab: GalleryTab.OfficialSamples })}>official samples or{" "} - this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery - , - ); - } - 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 ; - } - return this.createEmptyTabContent( - "Contact", - <> - You have not published anything to the{" "} - this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery 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 = ( - - {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), - )} - - ); - - return this.createSearchBarHeader(content); - }; - - private createPublishedNotebooksSectionContent = ( - title: string, - description: string, - content: JSX.Element, - ): JSX.Element => { - return ( - - {title && ( - {title} - )} - {description && {description}} - {content} - - ); - }; - - private createPublicGalleryTabContent(data: IGalleryItem[], acceptedCodeOfConduct: boolean): JSX.Element { - return ( -
- {this.createSearchBarHeader(this.createCardsTabContent(data))} - {acceptedCodeOfConduct === false && ( - -
- { - this.setState({ isCodeOfConductAccepted: result }); - }} - /> -
-
- )} -
- ); - } - - private createSearchBarHeader(content: JSX.Element): JSX.Element { - return ( - - - - - - - - - - - - - - - - {content} - - ); - } - - private createCardsTabContent(data: IGalleryItem[]): JSX.Element { - return data ? ( - - - - ) : ( - - ); - } - - private createPolicyViolationsListContent(data: IGalleryItem[]): JSX.Element { - return ( - - - - - - - {data.map((item) => ( - - - - - ))} - -
NamePolicy violations
{item.name}{item.policyViolations.join(", ")}
- ); - } - - 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 { - 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 { - if (!offline) { - try { - let response: IJunoResponse | IJunoResponse; - 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 { - 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 { - 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 ( -
- -
- ); - }; - - 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 => { - 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 => { - 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 => { - GalleryUtils.downloadItem(this.props.container, this.props.junoClient, data, (item) => - this.refreshSelectedTab(item), - ); - }; - - private deleteItem = async (data: IGalleryItem, beforeDelete: () => void, afterDelete: () => void): Promise => { - 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, 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, 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); - }; -} diff --git a/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.less b/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.less deleted file mode 100644 index 5ec067fad..000000000 --- a/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.less +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.test.tsx b/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.test.tsx deleted file mode 100644 index 07129e7c7..000000000 --- a/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.test.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { shallow } from "enzyme"; -import React from "react"; -import { InfoComponent } from "./InfoComponent"; - -describe("InfoComponent", () => { - it("renders", () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx b/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx deleted file mode 100644 index 94515d46a..000000000 --- a/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx +++ /dev/null @@ -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 { - private getInfoPanel = (iconName: string, labelText: string, url?: string, onClick?: () => void): JSX.Element => { - return ( - -
- - -
- - ); - }; - - private onHover = (): JSX.Element => { - return ( - - {this.getInfoPanel("Script", "Code of Conduct", CodeOfConductEndpoints.codeOfConduct)} - - {this.getInfoPanel("RedEye", "Privacy Statement", CodeOfConductEndpoints.privacyStatement)} - - - {this.getInfoPanel("KnowledgeArticle", "Microsoft Terms of Use", CodeOfConductEndpoints.termsOfUse)} - - {this.props.onReportAbuseClick && ( - - {this.getInfoPanel("ReportHacked", "Report Abuse", undefined, () => this.props.onReportAbuseClick())} - - )} - - ); - }; - - public render(): JSX.Element { - return ( - -
- - -
-
- ); - } -} diff --git a/src/Explorer/Controls/NotebookGallery/InfoComponent/__snapshots__/InfoComponent.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/InfoComponent/__snapshots__/InfoComponent.test.tsx.snap deleted file mode 100644 index 7311a01ca..000000000 --- a/src/Explorer/Controls/NotebookGallery/InfoComponent/__snapshots__/InfoComponent.test.tsx.snap +++ /dev/null @@ -1,35 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`InfoComponent renders 1`] = ` - -
- - - Help - -
-
-`; diff --git a/src/Explorer/Controls/NotebookGallery/__snapshots__/GalleryViewerComponent.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/__snapshots__/GalleryViewerComponent.test.tsx.snap deleted file mode 100644 index b7f1567b3..000000000 --- a/src/Explorer/Controls/NotebookGallery/__snapshots__/GalleryViewerComponent.test.tsx.snap +++ /dev/null @@ -1,98 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`GalleryViewerComponent renders 1`] = ` -
- - - - - - - - - - Sort by - - - - - - - - - - - - - - - -
-`; diff --git a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx index 000aef69e..5235b1d28 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx +++ b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx @@ -25,10 +25,6 @@ describe("NotebookMetadataComponent", () => { isFavorite: false, downloadButtonText: "Download", onTagClick: undefined, - onDownloadClick: undefined, - onFavoriteClick: undefined, - onUnfavoriteClick: undefined, - onReportAbuseClick: undefined, }; const wrapper = shallow(); @@ -57,10 +53,6 @@ describe("NotebookMetadataComponent", () => { isFavorite: true, downloadButtonText: "Download", onTagClick: undefined, - onDownloadClick: undefined, - onFavoriteClick: undefined, - onUnfavoriteClick: undefined, - onReportAbuseClick: undefined, }; const wrapper = shallow(); diff --git a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx index 2f9a812f8..248d8b550 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx +++ b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx @@ -1,46 +1,21 @@ /** * 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 { IGalleryItem } from "../../../Juno/JunoClient"; import * as FileSystemUtil from "../../Notebook/FileSystemUtil"; import "./NotebookViewerComponent.less"; import CosmosDBLogo from "../../../../images/CosmosDB-logo.svg"; -import { InfoComponent } from "../NotebookGallery/InfoComponent/InfoComponent"; export interface NotebookMetadataComponentProps { data: IGalleryItem; isFavorite: boolean; downloadButtonText?: string; onTagClick: (tag: string) => void; - onFavoriteClick: () => void; - onUnfavoriteClick: () => void; - onDownloadClick: () => void; - onReportAbuseClick: () => void; } export class NotebookMetadataComponent extends React.Component { - private renderFavouriteButton = (): JSX.Element => { - return ( - - {this.props.isFavorite !== undefined ? ( - <> - - {this.props.data.favorites} likes - - ) : ( - <> - {this.props.data.favorites} likes - - )} - - ); - }; - public render(): JSX.Element { const options: Intl.DateTimeFormatOptions = { year: "numeric", @@ -59,20 +34,10 @@ export class NotebookMetadataComponent extends React.Component - {this.renderFavouriteButton()} - - {this.props.downloadButtonText && ( - - - - )} - - - <> - - - + + {this.props.data.favorites} likes + diff --git a/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx b/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx index cc068e170..a6a9cd16d 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx @@ -1,7 +1,7 @@ /** * 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 { createContentRef } from "@nteract/core"; import * as React from "react"; @@ -11,14 +11,11 @@ import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient"; import { SessionStorageUtility } from "../../../Shared/StorageUtility"; import { Action } from "../../../Shared/Telemetry/TelemetryConstants"; import { traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor"; -import * as GalleryUtils from "../../../Utils/GalleryUtils"; -import { DialogHost } from "../../../Utils/GalleryUtils"; import Explorer from "../../Explorer"; import { NotebookClientV2 } from "../../Notebook/NotebookClientV2"; import { NotebookComponentBootstrapper } from "../../Notebook/NotebookComponent/NotebookComponentBootstrapper"; import NotebookReadOnlyRenderer from "../../Notebook/NotebookRenderer/NotebookReadOnlyRenderer"; import { useNotebook } from "../../Notebook/useNotebook"; -import { Dialog, TextFieldProps, useDialog } from "../Dialog"; import { NotebookMetadataComponent } from "./NotebookMetadataComponent"; import "./NotebookViewerComponent.less"; @@ -42,10 +39,10 @@ interface NotebookViewerComponentState { showProgressBar: boolean; } -export class NotebookViewerComponent - extends React.Component - implements DialogHost -{ +export class NotebookViewerComponent extends React.Component< + NotebookViewerComponentProps, + NotebookViewerComponentState +> { private clientManager: NotebookClientV2; private notebookComponentBootstrapper: NotebookComponentBootstrapper; @@ -102,7 +99,6 @@ export class NotebookViewerComponent ); const notebook: Notebook = await response.json(); - GalleryUtils.removeNotebookViewerLink(notebook, this.props.galleryItem?.newCellId); this.notebookComponentBootstrapper.setContent("json", notebook); this.setState({ content: notebook, showProgressBar: false }); @@ -150,10 +146,6 @@ export class NotebookViewerComponent isFavorite={this.state.isFavorite} downloadButtonText={this.props.container && `Download to ${useNotebook.getState().notebookFolderName}`} onTagClick={this.props.onTagClick} - onFavoriteClick={this.favoriteItem} - onUnfavoriteClick={this.unfavoriteItem} - onDownloadClick={this.downloadItem} - onReportAbuseClick={this.state.galleryItem.isSample ? undefined : this.reportAbuse} /> ) : ( @@ -166,7 +158,6 @@ export class NotebookViewerComponent hideInputs: this.props.hideInputs, hidePrompts: this.props.hidePrompts, })} - ); } @@ -191,81 +182,4 @@ export class NotebookViewerComponent 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 => { - GalleryUtils.favoriteItem(this.props.container, this.props.junoClient, this.state.galleryItem, (item) => - this.setState({ galleryItem: item, isFavorite: true }), - ); - }; - - private unfavoriteItem = async (): Promise => { - GalleryUtils.unfavoriteItem(this.props.container, this.props.junoClient, this.state.galleryItem, (item) => - this.setState({ galleryItem: item, isFavorite: false }), - ); - }; - - private downloadItem = async (): Promise => { - 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, () => {}); - }; } diff --git a/src/Explorer/Controls/NotebookViewer/__snapshots__/NotebookMetadataComponent.test.tsx.snap b/src/Explorer/Controls/NotebookViewer/__snapshots__/NotebookMetadataComponent.test.tsx.snap index 716390c36..a8b87926c 100644 --- a/src/Explorer/Controls/NotebookViewer/__snapshots__/NotebookMetadataComponent.test.tsx.snap +++ b/src/Explorer/Controls/NotebookViewer/__snapshots__/NotebookMetadataComponent.test.tsx.snap @@ -27,28 +27,14 @@ exports[`NotebookMetadataComponent renders liked notebook 1`] = ` - + 0 likes - - - - - - - - + 0 likes - - - - - - - ({ + 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", () => { - // Create a test setup function to get fresh instances for each test const setupTest = () => { - // Create an instance of the mocked Explorer const explorer = new Explorer(); - // Create minimal mock objects for database and collection // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockDatabase = {} as any as import("../../../../Contracts/ViewModels").Database; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mockCollection = {} as any as import("../../../../Contracts/ViewModels").Collection; + const mockCollection = { + 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 = { database: mockDatabase, collection: mockCollection, @@ -27,15 +89,53 @@ describe("PartitionKeyComponent", () => { return { explorer, props }; }; - it("renders default component and matches snapshot", () => { - const { props } = setupTest(); - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + beforeEach(() => { + jest.clearAllMocks(); + updateUserContext({ + 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 wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + render(); + expect(screen.getByText("/id")).toBeTruthy(); + }); + + it("renders read-only component without change button", () => { + const { props } = setupTest(); + const { container } = render(); + 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(); + // 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(); + await waitFor(() => { + expect(container.querySelector("[data-test='online-job-action-menu']")).toBeTruthy(); + }); }); }); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx index d276d6371..75053f91c 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx @@ -1,7 +1,10 @@ import { DefaultButton, + DirectionalHint, FontWeights, + IContextualMenuProps, IMessageBarStyles, + IconButton, Link, MessageBar, MessageBarType, @@ -14,8 +17,16 @@ import * as React from "react"; import * as ViewModels from "../../../../Contracts/ViewModels"; 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 { CopyJobActions, CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums"; +import { useDialog } from "Explorer/Controls/Dialog"; import Explorer from "Explorer/Explorer"; import { ChangePartitionKeyPane } from "Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane"; import { Keys, t } from "Localization"; @@ -94,11 +105,11 @@ export const PartitionKeyComponent: React.FC = ({ const textSubHeadingStyle1 = { root: { color: "var(--colorNeutralForeground1)" }, }; - const startPollingforUpdate = (currentJob: DataTransferJobGetResults) => { + const startPollingforUpdate = async (currentJob: DataTransferJobGetResults) => { if (isCurrentJobInProgress(currentJob)) { const jobName = currentJob?.properties?.jobName; try { - pollDataTransferJob( + await pollDataTransferJob( jobName, userContext.subscriptionId, userContext.resourceGroup, @@ -119,6 +130,124 @@ export const PartitionKeyComponent: React.FC = ({ ); }; + 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 ? ( + + + {t(Keys.controls.settings.partitionKeyEditor.confirmCancel1)} +
+ {jobName} +
+ {t(Keys.controls.settings.partitionKeyEditor.confirmCancel2)} +
+ ) : action === CopyJobActions.complete ? ( + + + {t(Keys.controls.settings.partitionKeyEditor.confirmComplete1)} +
+ {jobName} +
+ {t(Keys.controls.settings.partitionKeyEditor.confrimComplete2)} +
+ ) : 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 jobStatus = currentJob?.properties?.status; return ( @@ -269,12 +398,26 @@ export const PartitionKeyComponent: React.FC = ({ }, }} > - {isCurrentJobInProgress(portalDataTransferJob) && ( - cancelRunningDataTransferJob(portalDataTransferJob)} - /> - )} + {isCurrentJobInProgress(portalDataTransferJob) && + (isOnlineJob(portalDataTransferJob) ? ( + + ) : ( + cancelRunningDataTransferJob(portalDataTransferJob)} + /> + ))}
)} diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap deleted file mode 100644 index 3c75bf55f..000000000 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap +++ /dev/null @@ -1,271 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PartitionKeyComponent renders default component and matches snapshot 1`] = ` - - - - Change partition key - - - - - Current partition key - - - Partitioning - - - - - - Non-hierarchical - - - - - - To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the source container for the entire duration of the partition key change process. - - Learn more - - - - To change the partition key, a new destination container must be created or an existing destination container selected. Data will then be copied to the destination container. - - - -`; - -exports[`PartitionKeyComponent renders read-only component and matches snapshot 1`] = ` - - - - - - Current partition key - - - Partitioning - - - - - - Non-hierarchical - - - - - -`; diff --git a/src/Explorer/Controls/Settings/SettingsUtils.test.tsx b/src/Explorer/Controls/Settings/SettingsUtils.test.tsx index ea05388ae..716d90a99 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.test.tsx @@ -291,7 +291,7 @@ describe("SettingsUtils", () => { it("handles partition key tab title based on 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", () => { diff --git a/src/Explorer/Controls/Settings/SettingsUtils.tsx b/src/Explorer/Controls/Settings/SettingsUtils.tsx index 6e0990849..5d8e2766c 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.tsx @@ -2,7 +2,6 @@ import { Keys, t } from "Localization"; import * as Constants from "../../../Common/Constants"; import * as DataModels from "../../../Contracts/DataModels"; import * as ViewModels from "../../../Contracts/ViewModels"; -import { isFabricNative } from "../../../Platform/Fabric/FabricUtil"; import { userContext } from "../../../UserContext"; import { isCapabilityEnabled } from "../../../Utils/CapabilityUtils"; import { MongoIndex } from "../../../Utils/arm/generatedClients/cosmos/types"; @@ -185,9 +184,7 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => { case SettingsV2TabTypes.IndexingPolicyTab: return t(Keys.controls.settings.tabTitles.indexingPolicy); case SettingsV2TabTypes.PartitionKeyTab: - return isFabricNative() - ? t(Keys.controls.settings.tabTitles.partitionKeys) - : t(Keys.controls.settings.tabTitles.partitionKeysPreview); + return t(Keys.controls.settings.tabTitles.partitionKeys); case SettingsV2TabTypes.ComputedPropertiesTab: return t(Keys.controls.settings.tabTitles.computedProperties); case SettingsV2TabTypes.ContainerVectorPolicyTab: diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index 5913f938f..53ab9c658 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -429,7 +429,7 @@ exports[`SettingsComponent renders 1`] = ` "data-test": "settings-tab-header/PartitionKeyTab", } } - headerText="Partition Keys (preview)" + headerText="Partition Keys" itemKey="PartitionKeyTab" key="PartitionKeyTab" style={ diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index d7a03f380..03e5e72cd 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -2,10 +2,10 @@ import * as msal from "@azure/msal-browser"; import { Link } from "@fluentui/react/lib/Link"; import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility"; import { sendMessage } from "Common/MessageHandler"; +import { stringifyError } from "Common/stringifyError"; import { Platform, configContext } from "ConfigContext"; import { MessageTypes } from "Contracts/ExplorerContracts"; import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane"; -import { IGalleryItem } from "Juno/JunoClient"; import { isFabricMirrored, isFabricMirroredKey, @@ -52,13 +52,10 @@ import { useSidePanel } from "../hooks/useSidePanel"; import { ReactTabKind, useTabs } from "../hooks/useTabs"; import "./ComponentRegisterer"; import { DialogProps, useDialog } from "./Controls/Dialog"; -import { GalleryTab as GalleryTabKind } from "./Controls/NotebookGallery/GalleryViewerComponent"; import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter"; import * as FileSystemUtil from "./Notebook/FileSystemUtil"; -import { SnapshotRequest } from "./Notebook/NotebookComponent/types"; import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem"; import type NotebookManager from "./Notebook/NotebookManager"; -import { NotebookPaneContent } from "./Notebook/NotebookManager"; import { NotebookUtil } from "./Notebook/NotebookUtil"; import { useNotebook } from "./Notebook/useNotebook"; 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", ); } else { - const errorJson = JSON.stringify(error); + const errorJson = stringifyError(error); logConsoleError( `Failed to perform authorization for this account, due to the following error: \n${errorJson}`, ); @@ -405,19 +402,27 @@ export default class Explorer { }, startKey, ); + console.log("{{cdbp}} in refreshAllDatabases(): done readDatabases"); const currentDatabases = useDatabases.getState().databases; + console.log("{{cdbp}} in refreshAllDatabases(): currentDatabases: " + currentDatabases); const deltaDatabases = this.getDeltaDatabases(databases, currentDatabases); + console.log("{{cdbp}} in refreshAllDatabases(): deltaDatabases: " + deltaDatabases); let updatedDatabases = currentDatabases.filter( (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) => db1.id().localeCompare(db2.id()), ); + console.log("{{cdbp}} in refreshAllDatabases(): updatedDatabases after sort: " + updatedDatabases); useDatabases.setState({ databases: updatedDatabases, databasesFetchedSuccessfully: true }); scenarioMonitor.completePhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched); + console.log("{{cdbp}} in refreshAllDatabases(): calling refreshAndExpandNewDatabases"); await this.refreshAndExpandNewDatabases(deltaDatabases.toAdd, updatedDatabases); + console.log("{{cdbp}} in refreshAllDatabases(): done refreshAndExpandNewDatabases"); } catch (error) { + console.log("{{cdbp}} in refreshAllDatabases(): ERROR: " + stringifyError(error)); //CTODO this should be logged already but just in case const errorMessage = getErrorMessage(error); TelemetryProcessor.traceFailure( Action.LoadDatabases, @@ -607,6 +612,7 @@ export default class Explorer { ? databases : databases.filter((db) => db.isDatabaseExpanded() || db.id() === Constants.SavedQueries.DatabaseName); + console.log("{{cdbp}} in refreshAndExpandNewDatabases(): databasesToLoad: " + databasesToLoad); const startKey: number = TelemetryProcessor.traceStart(Action.LoadCollections, { dataExplorerArea: Constants.Areas.ResourceTree, }); @@ -615,6 +621,7 @@ export default class Explorer { try { await Promise.all( databasesToLoad.map(async (database: ViewModels.Database) => { + console.log("{{cdbp}} in refreshAndExpandNewDatabases(): loadCollections for database: " + database.id); await database.loadCollections(true); const isNewDatabase: boolean = _.some(newDatabases, (db: ViewModels.Database) => db.id() === database.id()); if (isNewDatabase) { @@ -634,6 +641,7 @@ export default class Explorer { // Start DatabaseTreeRendered โ€” React render cycle will complete it in ResourceTree scenarioMonitor.startPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabaseTreeRendered); } catch (error) { + console.log("{{cdbp}} in refreshAndExpandNewDatabases(): ERROR: " + stringifyError(error)); //CTODO this should be logged already but just in case TelemetryProcessor.traceFailure( Action.LoadCollections, { @@ -714,24 +722,6 @@ export default class Explorer { return Promise.resolve(false); } - public async publishNotebook( - name: string, - content: NotebookPaneContent, - notebookContentRef?: string, - onTakeSnapshot?: (request: SnapshotRequest) => void, - onClosePanel?: () => void, - ): Promise { - if (this.notebookManager) { - await this.notebookManager.openPublishNotebookPane( - name, - content, - notebookContentRef, - onTakeSnapshot, - onClosePanel, - ); - } - } - public copyNotebook(name: string, content: string): void { this.notebookManager?.openCopyNotebookPane(name, content); } @@ -1051,45 +1041,6 @@ export default class Explorer { useTabs.getState().activateNewTab(newTab); } - public async openGallery( - selectedTab?: GalleryTabKind, - notebookUrl?: string, - galleryItem?: IGalleryItem, - isFavorite?: boolean, - ): Promise { - 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( options: { databaseId?: string; diff --git a/src/Explorer/Graph/GraphExplorerComponent/GraphExplorer.tsx b/src/Explorer/Graph/GraphExplorerComponent/GraphExplorer.tsx index 5077bc189..071f6c943 100644 --- a/src/Explorer/Graph/GraphExplorerComponent/GraphExplorer.tsx +++ b/src/Explorer/Graph/GraphExplorerComponent/GraphExplorer.tsx @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-empty-function */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { FeedOptions, ItemDefinition, QueryIterator, Resource } from "@azure/cosmos"; +import { stringifyError } from "Common/stringifyError"; import * as Q from "q"; import * as React from "react"; import LoadGraphIcon from "../../../../images/LoadGraph.png"; @@ -1092,8 +1093,8 @@ export class GraphExplorer extends React.Component void) { let errorDataStr = ""; if (errorData && errorData.length > 0) { - console.error(msg, errorData); - errorDataStr = ": " + JSON.stringify(errorData); + console.error(msg + String(errorData)); + errorDataStr = ": " + stringifyError(errorData); } const consoleMessage = `${msg}${errorDataStr}`; diff --git a/src/Explorer/Notebook/NotebookComponent/store.ts b/src/Explorer/Notebook/NotebookComponent/store.ts index 3a971debf..47fa91c02 100644 --- a/src/Explorer/Notebook/NotebookComponent/store.ts +++ b/src/Explorer/Notebook/NotebookComponent/store.ts @@ -1,6 +1,7 @@ import { AppState, epics as coreEpics, IContentProvider, reducers } from "@nteract/core"; import { configuration } from "@nteract/mythic-configuration"; import { makeConfigureStore } from "@nteract/myths"; +import { stringifyError } from "Common/stringifyError"; import { AnyAction, compose, Dispatch, Middleware, MiddlewareAPI, Store } from "redux"; import { Epic } from "redux-observable"; import { Observable } from "rxjs"; @@ -44,7 +45,7 @@ export default function configureStore( const traceFailure = (title: string, error: any) => { if (error instanceof Error) { - onTraceFailure(title, `${error.message} ${JSON.stringify(error.stack)}`); + onTraceFailure(title, `${error.message} ${stringifyError(error.stack)}`); console.error(error); } else { onTraceFailure(title, error.message); diff --git a/src/Explorer/Notebook/NotebookManager.tsx b/src/Explorer/Notebook/NotebookManager.tsx index 3ccbefcaf..a58272e70 100644 --- a/src/Explorer/Notebook/NotebookManager.tsx +++ b/src/Explorer/Notebook/NotebookManager.tsx @@ -17,16 +17,13 @@ import { JunoClient } from "../../Juno/JunoClient"; import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import { userContext } from "../../UserContext"; -import { getFullName } from "../../Utils/UserUtils"; import { useDialog } from "../Controls/Dialog"; import Explorer from "../Explorer"; import { CopyNotebookPane } from "../Panes/CopyNotebookPane/CopyNotebookPane"; import { GitHubReposPanel } from "../Panes/GitHubReposPanel/GitHubReposPanel"; -import { PublishNotebookPane } from "../Panes/PublishNotebookPane/PublishNotebookPane"; import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter"; import { InMemoryContentProvider } from "./NotebookComponent/ContentProviders/InMemoryContentProvider"; import { NotebookContentProvider } from "./NotebookComponent/NotebookContentProvider"; -import { SnapshotRequest } from "./NotebookComponent/types"; import { NotebookContainerClient } from "./NotebookContainerClient"; import { NotebookContentClient } from "./NotebookContentClient"; 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 { - useSidePanel - .getState() - .openSidePanel( - "Publish Notebook", - , - "440px", - onClosePanel, - ); - } - public openCopyNotebookPane(name: string, content: string): void { const { container } = this.params; useSidePanel diff --git a/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.test.tsx b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.test.tsx new file mode 100644 index 000000000..67b9a2c0d --- /dev/null +++ b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.test.tsx @@ -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 = { + 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 ?
{label}
: 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( + , + ); + }; + + 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(); + }); +}); diff --git a/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx index 5a91c6755..80089092d 100644 --- a/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx +++ b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx @@ -7,16 +7,24 @@ import { IconButton, Link, MessageBar, + MessageBarType, + PrimaryButton, Stack, Text, TooltipHost, } from "@fluentui/react"; +import MarkdownRender from "@nteract/markdown"; import * as Constants from "Common/Constants"; +import { CapabilityNames } from "Common/Constants"; import { handleError } from "Common/ErrorHandlingUtils"; +import LoadingOverlay from "Common/LoadingOverlay"; +import { logError } from "Common/Logger"; import { createCollection } from "Common/dataAccess/createCollection"; import { DataTransferParams, initiateDataTransfer } from "Common/dataAccess/dataTransfers"; import * as DataModels from "Contracts/DataModels"; import * as ViewModels from "Contracts/ViewModels"; +import { buildResourceLink } from "Explorer/ContainerCopy/CopyJobUtils"; +import { BackupPolicyType } from "Explorer/ContainerCopy/Enums/CopyJobEnums"; import { getPartitionKeyName, getPartitionKeyPlaceHolder, @@ -30,6 +38,8 @@ import { Keys, t } from "Localization"; import { userContext } from "UserContext"; import { getCollectionName } from "Utils/APITypeUtils"; 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 * as React from "react"; @@ -40,6 +50,15 @@ export interface ChangePartitionKeyPaneProps { onClose: () => Promise; } +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 = ({ sourceDatabase, sourceCollection, @@ -52,6 +71,118 @@ export const ChangePartitionKeyPane: React.FC = ({ const [isExecuting, setIsExecuting] = React.useState(false); const [subPartitionKeys, setSubPartitionKeys] = React.useState([]); const [partitionKey, setPartitionKey] = React.useState(); + const [onlineMode, setOnlineMode] = React.useState(false); + + // Pane-local account state for tracking prerequisite enablement + const [localAccount, setLocalAccount] = React.useState(userContext.databaseAccount); + const [isEnablingPrerequisite, setIsEnablingPrerequisite] = React.useState(false); + const [prerequisiteLoaderMessage, setPrerequisiteLoaderMessage] = React.useState(""); + + 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(null); + const timeoutRef = React.useRef(null); + + React.useEffect(() => { + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const refreshAccount = async (): Promise => { + 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[] => { return sourceDatabase @@ -84,9 +215,17 @@ export const ChangePartitionKeyPane: React.FC = ({ setFormError("Choose an existing container"); return false; } + if (onlineMode && !onlinePrerequisitesMet) { + setFormError("Online migration prerequisites must be enabled before proceeding."); + return false; + } return true; }; + const getModeForApi = (): "Offline" | "Online" => { + return onlineMode ? "Online" : "Offline"; + }; + const createDataTransferJob = async () => { const jobName = `Portal_${targetCollectionId}_${Math.floor(Date.now() / 1000)}`; const dataTransferParams: DataTransferParams = { @@ -99,6 +238,7 @@ export const ChangePartitionKeyPane: React.FC = ({ sourceCollectionName: sourceCollection.id(), targetDatabaseName: sourceDatabase.id(), targetCollectionName: targetCollectionId, + mode: getModeForApi(), }; await initiateDataTransfer(dataTransferParams); }; @@ -133,12 +273,18 @@ export const ChangePartitionKeyPane: React.FC = ({ return !!selectedDatabase?.offer(); }; + const isSubmitDisabled = onlineMode && !onlinePrerequisitesMet; + + const migrationTypeLowercase = getModeForApi().toLowerCase() as keyof typeof Keys.containerCopy.migrationType; + const migrationTypeContent = Keys.containerCopy.migrationType[migrationTypeLowercase]; + return ( @@ -151,11 +297,58 @@ export const ChangePartitionKeyPane: React.FC = ({ {t(Keys.common.learnMore)} + + {/* Migration Type */} + + + {t(Keys.containerCopy.migrationTypeTitle)} + + +
+ setOnlineMode(false)} + /> + {t(Keys.containerCopy.migrationType.offline.title)} + + setOnlineMode(true)} + /> + {t(Keys.containerCopy.migrationType.online.title)} +
+
+ {migrationTypeContent && ( + + + + )} +
+ - Database id + {t(Keys.panes.addDatabase.databaseIdLabel)} = ({ /> )} + + {/* Online prerequisites section */} + {onlineMode && ( + + + + {t(Keys.containerCopy.assignPermissions.onlineConfiguration.title)} + + + {t(Keys.containerCopy.assignPermissions.onlineConfiguration.description, { accountName })} + + + {/* Point In Time Restore */} + + + + + {t(Keys.containerCopy.pointInTimeRestore.title)} + + + {!pitrEnabled && ( + + + {t(Keys.containerCopy.pointInTimeRestore.description, { accessName: accountName })} + + + + )} + + + {/* Online Copy Enabled */} + + + + + {t(Keys.containerCopy.onlineCopyEnabled.title)} + + + {!onlineCopyFeatureEnabled && ( + + + {t(Keys.containerCopy.onlineCopyEnabled.description, { accountName })}  + + {t(Keys.containerCopy.onlineCopyEnabled.hrefText)} + + + + + )} + + + {!onlinePrerequisitesMet && ( + + {t(Keys.containerCopy.onlineCopyEnabled.onlineMigrationPrerequisitesMessage)} + + )} + + )}
); diff --git a/src/Explorer/Panes/PublishNotebookPane/PublishNotebookPane.test.tsx b/src/Explorer/Panes/PublishNotebookPane/PublishNotebookPane.test.tsx deleted file mode 100644 index 3ce42e2e4..000000000 --- a/src/Explorer/Panes/PublishNotebookPane/PublishNotebookPane.test.tsx +++ /dev/null @@ -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(); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/src/Explorer/Panes/PublishNotebookPane/PublishNotebookPane.tsx b/src/Explorer/Panes/PublishNotebookPane/PublishNotebookPane.tsx deleted file mode 100644 index e11155f37..000000000 --- a/src/Explorer/Panes/PublishNotebookPane/PublishNotebookPane.tsx +++ /dev/null @@ -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 = ({ - explorer: container, - junoClient, - name, - author, - notebookContent, - notebookContentRef, - onTakeSnapshot, -}: PublishNotebookPaneAProps): JSX.Element => { - const closeSidePanel = useSidePanel((state) => state.closeSidePanel); - - const [isCodeOfConductAccepted, setIsCodeOfConductAccepted] = useState(false); - const [content, setContent] = useState(""); - const [formError, setFormError] = useState(""); - const [formErrorDetail, setFormErrorDetail] = useState(""); - const [isExecuting, setIsExecuting] = useState(); - - const [notebookName, setNotebookName] = useState(name); - const [notebookDescription, setNotebookDescription] = useState(""); - const [notebookTags, setNotebookTags] = useState(""); - const [imageSrc, setImageSrc] = useState(); - 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(); - 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 => { - 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 ( - - {!isCodeOfConductAccepted ? ( -
- { - setIsCodeOfConductAccepted(isAccepted); - }} - /> -
- ) : ( - - )} -
- ); -}; diff --git a/src/Explorer/Panes/PublishNotebookPane/PublishNotebookPaneComponent.tsx b/src/Explorer/Panes/PublishNotebookPane/PublishNotebookPaneComponent.tsx deleted file mode 100644 index ae5257008..000000000 --- a/src/Explorer/Panes/PublishNotebookPane/PublishNotebookPaneComponent.tsx +++ /dev/null @@ -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 = ({ - notebookName, - notebookTags, - notebookDescription, - notebookAuthor, - notebookCreatedDate, - notebookObject, - notebookContentRef, - imageSrc, - onError, - clearFormError, - setNotebookName, - setNotebookDescription, - setNotebookTags, - setImageSrc, - onTakeSnapshot, -}: PublishNotebookPaneProps) => { - const [type, setType] = useState(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 ; - case ImageTypes.CustomImage: - return ( - { - 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 ( -
- - - {descriptionPara1} - - - - {descriptionPara2} - - - - { - const notebookName = newValue + ".ipynb"; - setNotebookName(notebookName); - }} - /> - - - - { - setNotebookDescription(newValue); - }} - /> - - - - { - setNotebookTags(newValue); - }} - /> - - - - - - - {renderThumbnailSelectors(type)} - - - {t(Keys.panes.publishNotebook.preview)} - - - undefined} - onTagClick={undefined} - onFavoriteClick={undefined} - onUnfavoriteClick={undefined} - onDownloadClick={undefined} - onDeleteClick={undefined} - /> - - -
- ); -}; diff --git a/src/Explorer/Panes/PublishNotebookPane/__snapshots__/PublishNotebookPane.test.tsx.snap b/src/Explorer/Panes/PublishNotebookPane/__snapshots__/PublishNotebookPane.test.tsx.snap deleted file mode 100644 index 23af68f23..000000000 --- a/src/Explorer/Panes/PublishNotebookPane/__snapshots__/PublishNotebookPane.test.tsx.snap +++ /dev/null @@ -1,116 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PublishNotebookPaneComponent renders 1`] = ` -
- - - - 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. - - - - - Would you like to publish and share "SampleNotebook" to the gallery? - - - - - - - - - - - - - - - - - - - - Preview - - - - - - -
-`; diff --git a/src/Explorer/Panes/PublishNotebookPane/styled.less b/src/Explorer/Panes/PublishNotebookPane/styled.less deleted file mode 100644 index e1170b670..000000000 --- a/src/Explorer/Panes/PublishNotebookPane/styled.less +++ /dev/null @@ -1,6 +0,0 @@ -.publishNotebookPanelContent { - display: flex; - flex-direction: column; - flex: 1; - overflow-y: auto; -} \ No newline at end of file diff --git a/src/Explorer/Tables/DataTable/DataTableBindingManager.ts b/src/Explorer/Tables/DataTable/DataTableBindingManager.ts index 41a1d49e4..f786e9ee4 100644 --- a/src/Explorer/Tables/DataTable/DataTableBindingManager.ts +++ b/src/Explorer/Tables/DataTable/DataTableBindingManager.ts @@ -93,7 +93,7 @@ function createDataTable( for (var i = 0; i < tableEntityListViewModel.headers.length; i++) { jsonColTable.push({ - sTitle: tableEntityListViewModel.headers[i], + sTitle: Utilities.htmlEncode(tableEntityListViewModel.headers[i]), data: tableEntityListViewModel.headers[i], aTargets: [i], mRender: bindColumn, diff --git a/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts b/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts index 1c69e0efc..dc041ec91 100644 --- a/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts +++ b/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts @@ -1,3 +1,4 @@ +import { stringifyError } from "Common/stringifyError"; import * as DataTables from "datatables.net"; import * as ko from "knockout"; import Q from "q"; @@ -37,7 +38,7 @@ function parseError(err: any): ErrorDataModel[] { try { return _parse(err); } catch (e) { - return [{ message: JSON.stringify(err) }]; + return [{ message: stringifyError(err) }]; } } diff --git a/src/Explorer/Tables/TableDataClient.ts b/src/Explorer/Tables/TableDataClient.ts index 631e19e88..ed9992af5 100644 --- a/src/Explorer/Tables/TableDataClient.ts +++ b/src/Explorer/Tables/TableDataClient.ts @@ -1,4 +1,5 @@ import { FeedOptions } from "@azure/cosmos"; +import { stringifyError } from "Common/stringifyError"; import * as ko from "knockout"; import Q from "q"; import { AuthType } from "../../AuthType"; @@ -172,7 +173,7 @@ export class CassandraAPIDataClient extends TableDataClient { deferred.resolve(entity); }, (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()}`); deferred.reject(errorText); }, @@ -361,7 +362,7 @@ export class CassandraAPIDataClient extends TableDataClient { deferred.resolve(); }, (error) => { - const errorText = error.responseJSON?.message ?? JSON.stringify(error); + const errorText = error.responseJSON?.message ?? stringifyError(error); handleError( errorText, "CreateKeyspaceCassandra", @@ -400,7 +401,7 @@ export class CassandraAPIDataClient extends TableDataClient { deferred.resolve(); }, (error) => { - const errorText = error.responseJSON?.message ?? JSON.stringify(error); + const errorText = error.responseJSON?.message ?? stringifyError(error); handleError( errorText, "CreateTableCassandra", @@ -450,7 +451,7 @@ export class CassandraAPIDataClient extends TableDataClient { deferred.resolve(data); }, (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()}`); deferred.reject(errorText); }, @@ -492,7 +493,7 @@ export class CassandraAPIDataClient extends TableDataClient { deferred.resolve(data.columns); }, (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()}`); deferred.reject(errorText); }, diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.duplicateTab.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.duplicateTab.test.tsx new file mode 100644 index 000000000..673d6e828 --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.duplicateTab.test.tsx @@ -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("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"); + }); +}); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index 8f2c252d0..5d9f2bb37 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -49,6 +49,8 @@ import { userContext } from "UserContext"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import { Allotment } from "allotment"; 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 { format } from "react-string-format"; 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([]), + 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 { return ( ; - } - - public getContainer(): Explorer { - return this.props.container; - } -} diff --git a/src/Explorer/Tabs/MongoQueryTab/MongoQueryTab.tsx b/src/Explorer/Tabs/MongoQueryTab/MongoQueryTab.tsx index 22e50e1c8..80ce4c4f8 100644 --- a/src/Explorer/Tabs/MongoQueryTab/MongoQueryTab.tsx +++ b/src/Explorer/Tabs/MongoQueryTab/MongoQueryTab.tsx @@ -2,6 +2,7 @@ import { ActionType, TabKind } from "Contracts/ActionContracts"; import React from "react"; import MongoUtility from "../../../Common/MongoUtility"; import * as ViewModels from "../../../Contracts/ViewModels"; +import { useTabs } from "../../../hooks/useTabs"; import Explorer from "../../Explorer"; import { NewQueryTab } from "../QueryTab/QueryTab"; import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../QueryTab/QueryTabComponent"; @@ -67,6 +68,30 @@ export class NewMongoQueryTab extends NewQueryTab { 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 { return ; } diff --git a/src/Explorer/Tabs/NotebookV2Tab.ts b/src/Explorer/Tabs/NotebookV2Tab.ts index 92e0e6958..b54efc00e 100644 --- a/src/Explorer/Tabs/NotebookV2Tab.ts +++ b/src/Explorer/Tabs/NotebookV2Tab.ts @@ -1,7 +1,6 @@ import { stringifyNotebook, toJS } from "@nteract/commutable"; import * as ko from "knockout"; import * as Q from "q"; -import { userContext } from "UserContext"; import ClearAllOutputsIcon from "../../../images/notebook/Notebook-clear-all-outputs.svg"; import CopyIcon from "../../../images/notebook/Notebook-copy.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 { default as InterruptKernelIcon, default as KillKernelIcon } from "../../../images/notebook/Notebook-stop.svg"; import SaveIcon from "../../../images/save-cosmos.svg"; -import { useNotebookSnapshotStore } from "../../hooks/useNotebookSnapshotStore"; -import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants"; +import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import * as NotebookConfigurationUtils from "../../Utils/NotebookConfigurationUtils"; import { logConsoleInfo } from "../../Utils/NotificationConsoleUtils"; @@ -21,9 +19,7 @@ import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandBu import { useDialog } from "../Controls/Dialog"; import * as CommandBarComponentButtonFactory from "../Menus/CommandBar/CommandBarComponentButtonFactory"; import { KernelSpecsDisplay } from "../Notebook/NotebookClientV2"; -import * as CdbActions from "../Notebook/NotebookComponent/actions"; import { NotebookComponentAdapter } from "../Notebook/NotebookComponent/NotebookComponentAdapter"; -import { CdbAppState, SnapshotRequest } from "../Notebook/NotebookComponent/types"; import { NotebookContentItem } from "../Notebook/NotebookContentItem"; import { NotebookUtil } from "../Notebook/NotebookUtil"; import { useNotebook } from "../Notebook/useNotebook"; @@ -97,7 +93,6 @@ export default class NotebookTabV2 extends NotebookTabBase { const saveLabel = "Save"; const copyToLabel = "Copy to ..."; - const publishLabel = "Publish to gallery"; const kernelLabel = "No Kernel"; const runLabel = "Run"; 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[] = [ { 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 = () => { const notebookContent = this.notebookComponentAdapter.getContent(); let content: string; diff --git a/src/Explorer/Tabs/QueryTab/QueryTab.duplicateTab.test.tsx b/src/Explorer/Tabs/QueryTab/QueryTab.duplicateTab.test.tsx new file mode 100644 index 000000000..59b712f45 --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/QueryTab.duplicateTab.test.tsx @@ -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("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"); + }); +}); diff --git a/src/Explorer/Tabs/QueryTab/QueryTab.tsx b/src/Explorer/Tabs/QueryTab/QueryTab.tsx index e504d601e..61013a113 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTab.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTab.tsx @@ -4,7 +4,8 @@ import { MessageTypes } from "Contracts/MessageTypes"; import React from "react"; import * as DataModels from "../../../Contracts/DataModels"; 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 { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../../Tabs/QueryTab/QueryTabComponent"; 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 { return ; } diff --git a/src/Explorer/Tabs/Tabs.tsx b/src/Explorer/Tabs/Tabs.tsx index e0fbccd76..54fefedd1 100644 --- a/src/Explorer/Tabs/Tabs.tsx +++ b/src/Explorer/Tabs/Tabs.tsx @@ -1,4 +1,5 @@ import { Spinner, SpinnerSize, TooltipHost } from "@fluentui/react"; +import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from "@fluentui/react-components"; import { CollectionTabKind } from "Contracts/ViewModels"; import Explorer from "Explorer/Explorer"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; @@ -10,6 +11,7 @@ import { QuickstartTab } from "Explorer/Tabs/QuickstartTab"; import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab"; import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab"; import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; +import { Keys, t } from "Localization"; import { isFabricNative } from "Platform/Fabric/FabricUtil"; import { userContext } from "UserContext"; import { useTeachingBubble } from "hooks/useTeachingBubble"; @@ -85,8 +87,9 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind? focusTab.current.focus(); } }, [active]); - return ( + const liElement = (
  • setHovering(true)} onMouseLeave={() => setHovering(false)} className={active ? "active tabList" : "tabList"} @@ -155,6 +158,22 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
  • ); + + if (!tab?.canDuplicate()) { + return liElement; + } + + return ( + + {liElement} + + + tab.duplicateTab()}>{t(Keys.tabs.tabMenu.duplicateTab)} + tab.onCloseTabButtonClick()}>{t(Keys.tabs.tabMenu.closeTab)} + + + + ); } const onKeyPressReactTabClose = (e: KeyboardEvent, tabKind: ReactTabKind): void => { diff --git a/src/Explorer/Tabs/TabsBase.ts b/src/Explorer/Tabs/TabsBase.ts index 2602b672d..40e79b531 100644 --- a/src/Explorer/Tabs/TabsBase.ts +++ b/src/Explorer/Tabs/TabsBase.ts @@ -64,6 +64,14 @@ export default class TabsBase extends WaitsForTemplateViewModel { public getPersistedState = (): OpenTab | null => this.persistedState; public triggerPersistState: () => void = undefined; + public canDuplicate(): boolean { + return false; + } + + public duplicateTab(): void { + // Subclasses override this to support tab duplication + } + public onCloseTabButtonClick(): void { useTabs.getState().closeTab(this); TelemetryProcessor.trace(Action.Tab, ActionModifiers.Close, { diff --git a/src/Explorer/Tree/ResourceTreeAdapter.tsx b/src/Explorer/Tree/ResourceTreeAdapter.tsx index 819c73d6c..2d19bc8e9 100644 --- a/src/Explorer/Tree/ResourceTreeAdapter.tsx +++ b/src/Explorer/Tree/ResourceTreeAdapter.tsx @@ -10,7 +10,6 @@ import CopyIcon from "../../../images/notebook/Notebook-copy.svg"; import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg"; import NotebookIcon from "../../../images/notebook/Notebook-resource.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 CollectionIcon from "../../../images/tree-collection.svg"; import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; @@ -18,7 +17,7 @@ import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtili import * as DataModels from "../../Contracts/DataModels"; import * as ViewModels from "../../Contracts/ViewModels"; 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 { userContext } from "../../UserContext"; import { isServerlessAccount } from "../../Utils/CapabilityUtils"; @@ -49,7 +48,6 @@ export class ResourceTreeAdapter implements ReactAdapter { public parameters: ko.Observable; - public galleryContentRoot: NotebookContentItem; public myNotebooksContentRoot: NotebookContentItem; public gitHubNotebooksContentRoot: NotebookContentItem; @@ -102,11 +100,6 @@ export class ResourceTreeAdapter implements ReactAdapter { public async initialize(): Promise { const refreshTasks: Promise[] = []; - this.galleryContentRoot = { - name: "Gallery", - path: "Gallery", - type: NotebookContentItemType.File, - }; this.myNotebooksContentRoot = { name: useNotebook.getState().notebookFolderName, path: useNotebook.getState().notebookBasePath, @@ -538,20 +531,7 @@ export class ResourceTreeAdapter implements ReactAdapter { ]; if (item.type === NotebookContentItemType.Notebook) { - items.push({ - 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); - } - }, - }); + // Additional notebook-specific context menu items can be added here } // "Copy to ..." isn't needed if github locations are not available diff --git a/src/GalleryViewer/GalleryViewer.less b/src/GalleryViewer/GalleryViewer.less deleted file mode 100644 index 2eab230e9..000000000 --- a/src/GalleryViewer/GalleryViewer.less +++ /dev/null @@ -1,5 +0,0 @@ -@import "../../less/Common/Constants"; - -.standalone-gallery-root { - background: @GalleryBackgroundColor; -} \ No newline at end of file diff --git a/src/GalleryViewer/GalleryViewer.tsx b/src/GalleryViewer/GalleryViewer.tsx deleted file mode 100644 index 2026415af..000000000 --- a/src/GalleryViewer/GalleryViewer.tsx +++ /dev/null @@ -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 = ( -
    -
    - -
    -
    -
    - - 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. - - - If {`you'd`} like to run or edit the notebook in your own Azure Cosmos DB account,{" "} - sign in and select an account with{" "} - notebooks enabled. From there, you can download the sample to your - account. If you {`don't`} have an account yet, you can{" "} - create one from the Azure portal. - -
    - - -
    -
    - ); - - ReactDOM.render(element, document.getElementById("galleryContent")); -}; - -// Entry point -window.addEventListener("load", onInit); diff --git a/src/GalleryViewer/galleryViewer.html b/src/GalleryViewer/galleryViewer.html deleted file mode 100644 index 41ca38498..000000000 --- a/src/GalleryViewer/galleryViewer.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Gallery Viewer - - - - -
    - - diff --git a/src/HostedExplorer.test.tsx b/src/HostedExplorer.test.tsx new file mode 100644 index 000000000..083700e0e --- /dev/null +++ b/src/HostedExplorer.test.tsx @@ -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; + +(ConnectExplorer as jest.Mock).mockImplementation(() =>
    ); + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await act(async () => { + dispatchPostMessage({ type: "tryCosmosDBConnectionString" }, "https://cosmos.azure.com"); + }); + + expect(mockFetchEncryptedToken).not.toHaveBeenCalled(); + }); + + it("ignores messages with an unrelated type", async () => { + render(); + + 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(); + + 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()).not.toThrow(); + + Object.defineProperty(window, "opener", { + value: originalOpener, + writable: true, + configurable: true, + }); + }); +}); diff --git a/src/HostedExplorer.tsx b/src/HostedExplorer.tsx index d5c27c2df..41863fb7d 100644 --- a/src/HostedExplorer.tsx +++ b/src/HostedExplorer.tsx @@ -6,18 +6,22 @@ import { render } from "react-dom"; import ChevronRight from "../images/chevron-right.svg"; import "../less/hostedexplorer.less"; import { AuthType } from "./AuthType"; +import { logError } from "./Common/Logger"; import { DatabaseAccount } from "./Contracts/DataModels"; import "./Explorer/Menus/NavBar/MeControlComponent.less"; import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame"; 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 { FeedbackCommandButton } from "./Platform/Hosted/Components/FeedbackCommandButton"; import { MeControl } from "./Platform/Hosted/Components/MeControl"; import { SignInButton } from "./Platform/Hosted/Components/SignInButton"; 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 "./Shared/appInsights"; +import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils"; import { useAADAuth } from "./hooks/useAADAuth"; import { useConfig } from "./hooks/useConfig"; import { useTokenMetadata } from "./hooks/usePortalAccessToken"; @@ -42,20 +46,71 @@ const App: React.FunctionComponent = () => { const ref = React.useRef(); + 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(() => { // If ref.current is undefined no iframe has been rendered if (ref.current) { // 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 const frameWindow = ref.current.contentWindow as HostedExplorerChildFrame; - // AAD authenticated uses ALWAYS using AAD authType - if (isLoggedIn) { - frameWindow.hostedConfig = { - authType: AuthType.AAD, - databaseAccount, - authorizationToken: armToken, - }; - } else if (authType === AuthType.EncryptedToken) { + + if (authType === AuthType.EncryptedToken) { frameWindow.hostedConfig = { authType: AuthType.EncryptedToken, encryptedToken, @@ -73,12 +128,18 @@ const App: React.FunctionComponent = () => { authType: AuthType.ResourceToken, resourceToken: connectionString, }; + } else if (isLoggedIn && !connectionString) { + frameWindow.hostedConfig = { + authType: AuthType.AAD, + databaseAccount, + authorizationToken: armToken, + }; } } }); const showExplorer = - (config && isLoggedIn && databaseAccount) || + (config && isLoggedIn && databaseAccount && !connectionString) || (encryptedTokenMetadata && encryptedTokenMetadata) || (authType === AuthType.ResourceToken && connectionString); @@ -99,12 +160,12 @@ const App: React.FunctionComponent = () => { {(isLoggedIn || encryptedTokenMetadata?.accountName) && ( account separator )} - {isLoggedIn && ( + {isLoggedIn && !connectionString && ( )} - {!isLoggedIn && encryptedTokenMetadata?.accountName && ( + {(!isLoggedIn || connectionString) && encryptedTokenMetadata?.accountName && ( {encryptedTokenMetadata?.accountName} @@ -127,7 +188,9 @@ const App: React.FunctionComponent = () => { // It's possible this can be changed once all knockout code has been removed.