Merge branch 'master' into locfiles/486ca1c2-3f14-4217-8aae-5aae878d249c

This commit is contained in:
sunghyunkang1111
2026-06-04 10:29:13 -05:00
committed by GitHub
96 changed files with 2314 additions and 4649 deletions
+1 -1
View File
@@ -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",
+62
View File
@@ -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
-10
View File
@@ -1,10 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path d="M4.75 0.999903C4.98438 0.999903 5.1849 1.02334 5.35156 1.07022C5.51823 1.11709 5.66927 1.17959 5.80469 1.25772C5.9401 1.33584 6.0599 1.41657 6.16406 1.4999C6.26823 1.58324 6.3724 1.66397 6.47656 1.74209C6.58073 1.82022 6.69531 1.88011 6.82031 1.92178C6.94531 1.96344 7.08854 1.98949 7.25 1.9999H15C15.1406 1.9999 15.2708 2.02594 15.3906 2.07803C15.5104 2.13011 15.6146 2.20042 15.7031 2.28897C15.7917 2.37751 15.8646 2.48428 15.9219 2.60928C15.9792 2.73428 16.0052 2.86449 16 2.9999V6.52334L15 5.52334V2.9999H7.25C7.08854 2.9999 6.94792 3.02334 6.82812 3.07022C6.70833 3.11709 6.59375 3.17959 6.48438 3.25772C6.375 3.33584 6.26823 3.41657 6.16406 3.4999C6.0599 3.58324 5.94271 3.66397 5.8125 3.74209C5.68229 3.82022 5.53125 3.88011 5.35938 3.92178C5.1875 3.96344 4.98438 3.98949 4.75 3.9999H1V12.9999H6V13.9999H0V1.9999C0 1.85928 0.0260417 1.72907 0.078125 1.60928C0.130208 1.48949 0.200521 1.38532 0.289062 1.29678C0.377604 1.20824 0.484375 1.13532 0.609375 1.07803C0.734375 1.02074 0.864583 0.994695 1 0.999903H4.75ZM4.75 2.9999C4.875 2.9999 4.98438 2.98949 5.07812 2.96865C5.17188 2.94782 5.25781 2.91397 5.33594 2.86709C5.41406 2.82022 5.48958 2.76813 5.5625 2.71084C5.63542 2.65355 5.71875 2.58324 5.8125 2.4999C5.72396 2.42178 5.64323 2.35407 5.57031 2.29678C5.4974 2.23949 5.41927 2.1874 5.33594 2.14053C5.2526 2.09365 5.16667 2.0598 5.07812 2.03897C4.98958 2.01813 4.88021 2.00511 4.75 1.9999H1V2.9999H4.75ZM12.7109 4.9999L16 8.28897V15.9999H7V4.9999H12.7109ZM13 7.9999H14.2891L13 6.71084V7.9999ZM15 8.9999H12V5.9999H8V14.9999H15V8.9999ZM9 12.9999H13V13.9999H9V12.9999ZM11 11.9999H9V10.9999H11V11.9999ZM11 9.9999H9V8.9999H11V9.9999ZM11 7.9999H9V6.9999H11V7.9999Z" fill="#0078D4"/>
</g>
<defs>
<clipPath id="clip0">
<rect y="-9.15527e-05" width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

+16 -12
View File
@@ -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"
}
+1 -1
View File
@@ -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",
+5 -4
View File
@@ -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"
},
+1 -1
View File
@@ -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",
-7
View File
@@ -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 {
+3 -1
View File
@@ -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);
};
+34 -18
View File
@@ -19,22 +19,38 @@ export interface MinimalQueryIterator {
// Pick<QueryIterator<any>, "fetchNext">;
export function nextPage(documentsIterator: MinimalQueryIterator, firstItemIndex: number): Promise<QueryResults> {
TelemetryProcessor.traceStart(Action.ExecuteQuery);
return documentsIterator.fetchNext().then((response) => {
TelemetryProcessor.traceSuccess(Action.ExecuteQuery, { dataExplorerArea: Constants.Areas.Tab });
const documents = response.resources;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const headers = (response as any).headers || {}; // TODO this is a private key. Remove any
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;
});
}
+54 -5
View File
@@ -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 "";
};
+72 -6
View File
@@ -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<void> => {
const pauseResult: DataTransferJobGetResults = await pause(subscriptionId, resourceGroupName, accountName, jobName);
updateDataTransferJob(pauseResult);
removeFromPolling(pauseResult?.properties?.jobName);
};
export const resumeDataTransferJob = async (
subscriptionId: string,
resourceGroupName: string,
accountName: string,
jobName: string,
): Promise<void> => {
const resumeResult: DataTransferJobGetResults = await resume(subscriptionId, resourceGroupName, accountName, jobName);
updateDataTransferJob(resumeResult);
};
export const completeDataTransferJob = async (
subscriptionId: string,
resourceGroupName: string,
accountName: string,
jobName: string,
): Promise<void> => {
const completeResult: DataTransferJobGetResults = await complete(
subscriptionId,
resourceGroupName,
accountName,
jobName,
);
updateDataTransferJob(completeResult);
removeFromPolling(completeResult?.properties?.jobName);
};
const createPayload = (
apiType: ApiType,
databaseName: string,
+34 -17
View File
@@ -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<DataModels.Database[]> {
(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<DataModels.Database[]> {
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<DataModels.Database[]> {
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;
}
}
+7
View File
@@ -0,0 +1,7 @@
export const stringifyError = (error: unknown): string => {
const plainObject: Record<string, unknown> = {};
Object.getOwnPropertyNames(error as object).forEach((key) => {
plainObject[key] = (error as Record<string, unknown>)[key];
});
return JSON.stringify(plainObject, null, "\r\n");
};
+1 -1
View File
@@ -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,
@@ -115,7 +115,7 @@ export const getCopyJobs = async (): Promise<CopyJobType[]> => {
});
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.",
@@ -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";
@@ -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";
@@ -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";
@@ -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", () => {
@@ -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";
@@ -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";
@@ -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";
@@ -1,81 +0,0 @@
import { CommandButton, FontIcon, FontWeights, ITextProps, Separator, Stack, Text } from "@fluentui/react";
import * as React from "react";
export class GalleryHeaderComponent extends React.Component {
private static readonly azureText = "Microsoft Azure";
private static readonly cosmosdbText = "Cosmos DB";
private static readonly galleryText = "Gallery";
private static readonly loginText = "Sign In";
private static readonly openPortal = () => window.open("https://portal.azure.com", "_blank");
private static readonly openDataExplorer = () => (window.location.href = new URL("./", window.location.href).href);
private static readonly headerItemStyle: React.CSSProperties = {
color: "white",
};
private static readonly mainHeaderTextProps: ITextProps = {
style: GalleryHeaderComponent.headerItemStyle,
variant: "mediumPlus",
styles: {
root: {
fontWeight: FontWeights.semibold,
},
},
};
private static readonly headerItemTextProps: ITextProps = { style: GalleryHeaderComponent.headerItemStyle };
private renderHeaderItem = (text: string, onClick: () => void, textProps: ITextProps): JSX.Element => {
return (
<CommandButton onClick={onClick} ariaLabel={text}>
<Text {...textProps}>{text}</Text>
</CommandButton>
);
};
public render(): JSX.Element {
return (
<Stack
tokens={{ childrenGap: 10 }}
horizontal
styles={{ root: { background: "#0078d4", paddingLeft: 20, paddingRight: 20 } }}
verticalAlign="center"
>
<Stack.Item>
{this.renderHeaderItem(
GalleryHeaderComponent.azureText,
GalleryHeaderComponent.openPortal,
GalleryHeaderComponent.mainHeaderTextProps,
)}
</Stack.Item>
<Stack.Item>
<Separator vertical />
</Stack.Item>
<Stack.Item>
{this.renderHeaderItem(
GalleryHeaderComponent.cosmosdbText,
GalleryHeaderComponent.openDataExplorer,
GalleryHeaderComponent.headerItemTextProps,
)}
</Stack.Item>
<Stack.Item>
<FontIcon style={GalleryHeaderComponent.headerItemStyle} iconName="ChevronRight" />
</Stack.Item>
<Stack.Item>
{this.renderHeaderItem(
GalleryHeaderComponent.galleryText,
() => "",
GalleryHeaderComponent.headerItemTextProps,
)}
</Stack.Item>
<Stack.Item grow>
<></>
</Stack.Item>
<Stack.Item>
{this.renderHeaderItem(
GalleryHeaderComponent.loginText,
GalleryHeaderComponent.openDataExplorer,
GalleryHeaderComponent.headerItemTextProps,
)}
</Stack.Item>
</Stack>
);
}
}
@@ -1,39 +0,0 @@
import { shallow } from "enzyme";
import React from "react";
import { GalleryCardComponent, GalleryCardComponentProps } from "./GalleryCardComponent";
describe("GalleryCardComponent", () => {
it("renders", () => {
const props: GalleryCardComponentProps = {
data: {
id: "id",
name: "name",
description: "description",
author: "author",
thumbnailUrl: "thumbnailUrl",
created: "created",
gitSha: "gitSha",
tags: ["tag"],
isSample: false,
downloads: 0,
favorites: 0,
views: 0,
newCellId: undefined,
policyViolations: undefined,
pendingScanJobIds: undefined,
},
isFavorite: false,
showDownload: true,
showDelete: true,
onClick: undefined,
onTagClick: undefined,
onFavoriteClick: undefined,
onUnfavoriteClick: undefined,
onDownloadClick: undefined,
onDeleteClick: undefined,
};
const wrapper = shallow(<GalleryCardComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
@@ -1,205 +0,0 @@
import {
BaseButton,
Button,
DocumentCard,
DocumentCardActivity,
DocumentCardDetails,
DocumentCardPreview,
DocumentCardTitle,
Icon,
IconButton,
IDocumentCardPreviewProps,
IDocumentCardStyles,
ImageFit,
Link,
Separator,
Spinner,
SpinnerSize,
Text,
TooltipHost,
} from "@fluentui/react";
import React, { FunctionComponent, useState } from "react";
import CosmosDBLogo from "../../../../../images/CosmosDB-logo.svg";
import { IGalleryItem } from "../../../../Juno/JunoClient";
import * as FileSystemUtil from "../../../Notebook/FileSystemUtil";
export interface GalleryCardComponentProps {
data: IGalleryItem;
isFavorite: boolean;
showDownload: boolean;
showDelete: boolean;
onClick: () => void;
onTagClick: (tag: string) => void;
onFavoriteClick: () => void;
onUnfavoriteClick: () => void;
onDownloadClick: () => void;
onDeleteClick: (beforeDelete: () => void, afterDelete: () => void) => void;
}
export const GalleryCardComponent: FunctionComponent<GalleryCardComponentProps> = ({
data,
isFavorite,
showDownload,
showDelete,
onClick,
onTagClick,
onFavoriteClick,
onUnfavoriteClick,
onDownloadClick,
onDeleteClick,
}: GalleryCardComponentProps) => {
const CARD_WIDTH = 256;
const cardImageHeight = 144;
const cardDescriptionMaxChars = 80;
const cardItemGapSmall = 8;
const cardDeleteSpinnerHeight = 360;
const smallTextLineHeight = 18;
const [isDeletingPublishedNotebook, setIsDeletingPublishedNotebook] = useState<boolean>(false);
const cardButtonsVisible = isFavorite !== undefined || showDownload || showDelete;
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
};
const dateString = new Date(data.created).toLocaleString("default", options);
const cardTitle = FileSystemUtil.stripExtension(data.name, "ipynb");
const renderTruncated = (text: string, totalLength: number): string => {
let truncatedDescription = text.substr(0, totalLength);
if (text.length > totalLength) {
truncatedDescription = `${truncatedDescription} ...`;
}
return truncatedDescription;
};
const generateIconText = (iconName: string, text: string): JSX.Element => {
return (
<Text variant="tiny" styles={{ root: { color: "#605E5C", paddingRight: cardItemGapSmall } }}>
<Icon iconName={iconName} styles={{ root: { verticalAlign: "middle" } }} /> {text}
</Text>
);
};
/*
* Fluent UI doesn't support tooltips on IconButtons out of the box. In the meantime the recommendation is
* to do the following (from https://developer.microsoft.com/en-us/fluentui#/controls/web/button)
*/
const generateIconButtonWithTooltip = (
iconName: string,
title: string,
horizontalAlign: "right" | "left",
activate: () => void,
): JSX.Element => {
return (
<TooltipHost
content={title}
id={`TooltipHost-IconButton-${iconName}`}
calloutProps={{ gapSpace: 0 }}
styles={{ root: { display: "inline-block", float: horizontalAlign } }}
>
<IconButton
iconProps={{ iconName }}
title={title}
ariaLabel={title}
onClick={(event) => handlerOnClick(event, activate)}
/>
</TooltipHost>
);
};
const handlerOnClick = (
event:
| React.MouseEvent<HTMLElement | HTMLAnchorElement | HTMLButtonElement | MouseEvent>
| React.MouseEvent<
HTMLAnchorElement | HTMLButtonElement | HTMLDivElement | BaseButton | Button | HTMLSpanElement,
MouseEvent
>,
activate: () => void,
): void => {
event.stopPropagation();
event.preventDefault();
activate();
};
const DocumentCardActivityPeople = [{ name: data.author, profileImageSrc: data.isSample && CosmosDBLogo }];
const previewProps: IDocumentCardPreviewProps = {
previewImages: [
{
previewImageSrc: data.thumbnailUrl,
imageFit: ImageFit.cover,
width: CARD_WIDTH,
height: cardImageHeight,
},
],
};
const cardStyles: IDocumentCardStyles = {
root: { display: "inline-block", marginRight: 20, width: CARD_WIDTH },
};
return (
<DocumentCard aria-label={cardTitle} styles={cardStyles} onClick={onClick}>
{isDeletingPublishedNotebook && (
<Spinner
size={SpinnerSize.large}
label={`Deleting '${cardTitle}'`}
styles={{ root: { height: cardDeleteSpinnerHeight } }}
/>
)}
{!isDeletingPublishedNotebook && (
<>
<DocumentCardActivity activity={dateString} people={DocumentCardActivityPeople} />
<DocumentCardPreview {...previewProps} />
<DocumentCardDetails>
<Text variant="small" nowrap styles={{ root: { height: smallTextLineHeight, padding: "2px 16px" } }}>
{data.tags ? (
data.tags.map((tag, index, array) => (
<span key={tag}>
<Link onClick={(event) => handlerOnClick(event, () => onTagClick(tag))}>{tag}</Link>
{index === array.length - 1 ? <></> : ", "}
</span>
))
) : (
<br />
)}
</Text>
<DocumentCardTitle title={renderTruncated(cardTitle, 20)} shouldTruncate />
<DocumentCardTitle
title={renderTruncated(data.description, cardDescriptionMaxChars)}
showAsSecondaryTitle
/>
<span style={{ padding: "8px 16px" }}>
{data.views !== undefined && generateIconText("RedEye", data.views.toString())}
{data.downloads !== undefined && generateIconText("Download", data.downloads.toString())}
{data.favorites !== undefined && generateIconText("Heart", data.favorites.toString())}
</span>
</DocumentCardDetails>
{cardButtonsVisible && (
<DocumentCardDetails>
<Separator styles={{ root: { padding: 0, height: 1 } }} />
<span style={{ padding: "0px 16px" }}>
{isFavorite !== undefined &&
generateIconButtonWithTooltip(
isFavorite ? "HeartFill" : "Heart",
isFavorite ? "Unfavorite" : "Favorite",
"left",
isFavorite ? onUnfavoriteClick : onFavoriteClick,
)}
{showDownload && generateIconButtonWithTooltip("Download", "Download", "left", onDownloadClick)}
{showDelete &&
generateIconButtonWithTooltip("Delete", "Remove", "right", () =>
onDeleteClick(
() => setIsDeletingPublishedNotebook(true),
() => setIsDeletingPublishedNotebook(false),
),
)}
</span>
</DocumentCardDetails>
)}
</>
)}
</DocumentCard>
);
};
@@ -1,256 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GalleryCardComponent renders 1`] = `
<StyledDocumentCardBase
aria-label="name"
styles={
{
"root": {
"display": "inline-block",
"marginRight": 20,
"width": 256,
},
}
}
>
<StyledDocumentCardActivityBase
activity="Invalid Date"
people={
[
{
"name": "author",
"profileImageSrc": false,
},
]
}
/>
<StyledDocumentCardPreviewBase
previewImages={
[
{
"height": 144,
"imageFit": 2,
"previewImageSrc": "thumbnailUrl",
"width": 256,
},
]
}
/>
<StyledDocumentCardDetailsBase>
<Text
nowrap={true}
styles={
{
"root": {
"height": 18,
"padding": "2px 16px",
},
}
}
variant="small"
>
<span
key="tag"
>
<StyledLinkBase
onClick={[Function]}
>
tag
</StyledLinkBase>
</span>
</Text>
<StyledDocumentCardTitleBase
shouldTruncate={true}
title="name"
/>
<StyledDocumentCardTitleBase
showAsSecondaryTitle={true}
title="description"
/>
<span
style={
{
"padding": "8px 16px",
}
}
>
<Text
styles={
{
"root": {
"color": "#605E5C",
"paddingRight": 8,
},
}
}
variant="tiny"
>
<Icon
iconName="RedEye"
styles={
{
"root": {
"verticalAlign": "middle",
},
}
}
/>
0
</Text>
<Text
styles={
{
"root": {
"color": "#605E5C",
"paddingRight": 8,
},
}
}
variant="tiny"
>
<Icon
iconName="Download"
styles={
{
"root": {
"verticalAlign": "middle",
},
}
}
/>
0
</Text>
<Text
styles={
{
"root": {
"color": "#605E5C",
"paddingRight": 8,
},
}
}
variant="tiny"
>
<Icon
iconName="Heart"
styles={
{
"root": {
"verticalAlign": "middle",
},
}
}
/>
0
</Text>
</span>
</StyledDocumentCardDetailsBase>
<StyledDocumentCardDetailsBase>
<Separator
styles={
{
"root": {
"height": 1,
"padding": 0,
},
}
}
/>
<span
style={
{
"padding": "0px 16px",
}
}
>
<StyledTooltipHostBase
calloutProps={
{
"gapSpace": 0,
}
}
content="Favorite"
id="TooltipHost-IconButton-Heart"
styles={
{
"root": {
"display": "inline-block",
"float": "left",
},
}
}
>
<CustomizedIconButton
ariaLabel="Favorite"
iconProps={
{
"iconName": "Heart",
}
}
onClick={[Function]}
title="Favorite"
/>
</StyledTooltipHostBase>
<StyledTooltipHostBase
calloutProps={
{
"gapSpace": 0,
}
}
content="Download"
id="TooltipHost-IconButton-Download"
styles={
{
"root": {
"display": "inline-block",
"float": "left",
},
}
}
>
<CustomizedIconButton
ariaLabel="Download"
iconProps={
{
"iconName": "Download",
}
}
onClick={[Function]}
title="Download"
/>
</StyledTooltipHostBase>
<StyledTooltipHostBase
calloutProps={
{
"gapSpace": 0,
}
}
content="Remove"
id="TooltipHost-IconButton-Delete"
styles={
{
"root": {
"display": "inline-block",
"float": "right",
},
}
}
>
<CustomizedIconButton
ariaLabel="Remove"
iconProps={
{
"iconName": "Delete",
}
}
onClick={[Function]}
title="Remove"
/>
</StyledTooltipHostBase>
</span>
</StyledDocumentCardDetailsBase>
</StyledDocumentCardBase>
`;
@@ -1,34 +0,0 @@
jest.mock("../../../../Juno/JunoClient");
import { shallow } from "enzyme";
import React from "react";
import { HttpStatusCodes } from "../../../../Common/Constants";
import { JunoClient } from "../../../../Juno/JunoClient";
import { CodeOfConduct, CodeOfConductProps } from "./CodeOfConduct";
describe("CodeOfConduct", () => {
let codeOfConductProps: CodeOfConductProps;
beforeEach(() => {
const junoClient = new JunoClient();
junoClient.acceptCodeOfConduct = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
data: true,
});
codeOfConductProps = {
junoClient: junoClient,
onAcceptCodeOfConduct: jest.fn(),
};
});
it("renders", () => {
const wrapper = shallow(<CodeOfConduct {...codeOfConductProps} />);
expect(wrapper).toMatchSnapshot();
});
it("onAcceptedCodeOfConductCalled", async () => {
const wrapper = shallow(<CodeOfConduct {...codeOfConductProps} />);
wrapper.find(".genericPaneSubmitBtn").first().simulate("click");
await Promise.resolve();
expect(codeOfConductProps.onAcceptCodeOfConduct).toHaveBeenCalled();
});
});
@@ -1,110 +0,0 @@
import { Checkbox, Link, PrimaryButton, Stack, Text } from "@fluentui/react";
import React, { FunctionComponent, useEffect, useState } from "react";
import { CodeOfConductEndpoints, HttpStatusCodes } from "../../../../Common/Constants";
import { getErrorMessage, getErrorStack, handleError } from "../../../../Common/ErrorHandlingUtils";
import { JunoClient } from "../../../../Juno/JunoClient";
import { Action } from "../../../../Shared/Telemetry/TelemetryConstants";
import { trace, traceFailure, traceStart, traceSuccess } from "../../../../Shared/Telemetry/TelemetryProcessor";
export interface CodeOfConductProps {
junoClient: JunoClient;
onAcceptCodeOfConduct: (result: boolean) => void;
}
export const CodeOfConduct: FunctionComponent<CodeOfConductProps> = ({
junoClient,
onAcceptCodeOfConduct,
}: CodeOfConductProps) => {
const descriptionPara1 = "Azure Cosmos DB Notebook Gallery - Code of Conduct";
const descriptionPara2 = "The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB.";
const descriptionPara3 = "In order to view and publish your samples to the gallery, you must accept the ";
const link1: { label: string; url: string } = {
label: "code of conduct.",
url: CodeOfConductEndpoints.codeOfConduct,
};
const [readCodeOfConduct, setReadCodeOfConduct] = useState<boolean>(false);
const acceptCodeOfConduct = async (): Promise<void> => {
const startKey = traceStart(Action.NotebooksGalleryAcceptCodeOfConduct);
try {
const response = await junoClient.acceptCodeOfConduct();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
}
traceSuccess(Action.NotebooksGalleryAcceptCodeOfConduct, {}, startKey);
onAcceptCodeOfConduct(response.data);
} catch (error) {
traceFailure(
Action.NotebooksGalleryAcceptCodeOfConduct,
{
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey,
);
handleError(error, "CodeOfConduct/acceptCodeOfConduct", "Failed to accept code of conduct");
}
};
const onChangeCheckbox = (): void => {
setReadCodeOfConduct(!readCodeOfConduct);
};
useEffect(() => {
trace(Action.NotebooksGalleryViewCodeOfConduct);
}, []);
return (
<Stack tokens={{ childrenGap: 20 }}>
<Stack.Item>
<Text style={{ fontWeight: 500, fontSize: "20px" }}>{descriptionPara1}</Text>
</Stack.Item>
<Stack.Item>
<Text>{descriptionPara2}</Text>
</Stack.Item>
<Stack.Item>
<Text>
{descriptionPara3}
<Link href={link1.url} target="_blank">
{link1.label}
</Link>
</Text>
</Stack.Item>
<Stack.Item>
<Checkbox
styles={{
label: {
margin: 0,
padding: "2 0 2 0",
},
text: {
fontSize: 12,
},
}}
label="I have read and accept the code of conduct."
onChange={onChangeCheckbox}
/>
</Stack.Item>
<Stack.Item>
<PrimaryButton
ariaLabel="Continue"
title="Continue"
onClick={async () => await acceptCodeOfConduct()}
tabIndex={0}
className="genericPaneSubmitBtn"
text="Continue"
disabled={!readCodeOfConduct}
/>
</Stack.Item>
</Stack>
);
};
@@ -1,68 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CodeOfConduct renders 1`] = `
<Stack
tokens={
{
"childrenGap": 20,
}
}
>
<StackItem>
<Text
style={
{
"fontSize": "20px",
"fontWeight": 500,
}
}
>
Azure Cosmos DB Notebook Gallery - Code of Conduct
</Text>
</StackItem>
<StackItem>
<Text>
The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB.
</Text>
</StackItem>
<StackItem>
<Text>
In order to view and publish your samples to the gallery, you must accept the
<StyledLinkBase
href="https://aka.ms/cosmos-code-of-conduct"
target="_blank"
>
code of conduct.
</StyledLinkBase>
</Text>
</StackItem>
<StackItem>
<StyledCheckboxBase
label="I have read and accept the code of conduct."
onChange={[Function]}
styles={
{
"label": {
"margin": 0,
"padding": "2 0 2 0",
},
"text": {
"fontSize": 12,
},
}
}
/>
</StackItem>
<StackItem>
<CustomizedPrimaryButton
ariaLabel="Continue"
className="genericPaneSubmitBtn"
disabled={true}
onClick={[Function]}
tabIndex={0}
text="Continue"
title="Continue"
/>
</StackItem>
</Stack>
`;
@@ -1,114 +0,0 @@
import * as React from "react";
import { JunoClient, IGalleryItem } from "../../../Juno/JunoClient";
import { GalleryTab, SortBy, GalleryViewerComponentProps, GalleryViewerComponent } from "./GalleryViewerComponent";
import { NotebookViewerComponentProps, NotebookViewerComponent } from "../NotebookViewer/NotebookViewerComponent";
import * as GalleryUtils from "../../../Utils/GalleryUtils";
import Explorer from "../../Explorer";
export interface GalleryAndNotebookViewerComponentProps {
container?: Explorer;
junoClient: JunoClient;
notebookUrl?: string;
galleryItem?: IGalleryItem;
isFavorite?: boolean;
selectedTab: GalleryTab;
sortBy: SortBy;
searchText: string;
}
interface GalleryAndNotebookViewerComponentState {
notebookUrl: string;
galleryItem: IGalleryItem;
isFavorite: boolean;
selectedTab: GalleryTab;
sortBy: SortBy;
searchText: string;
}
export class GalleryAndNotebookViewerComponent extends React.Component<
GalleryAndNotebookViewerComponentProps,
GalleryAndNotebookViewerComponentState
> {
constructor(props: GalleryAndNotebookViewerComponentProps) {
super(props);
this.state = {
notebookUrl: props.notebookUrl,
galleryItem: props.galleryItem,
isFavorite: props.isFavorite,
selectedTab: props.selectedTab,
sortBy: props.sortBy,
searchText: props.searchText,
};
}
public render(): JSX.Element {
if (this.state.notebookUrl) {
const props: NotebookViewerComponentProps = {
container: this.props.container,
junoClient: this.props.junoClient,
notebookUrl: this.state.notebookUrl,
galleryItem: this.state.galleryItem,
isFavorite: this.state.isFavorite,
backNavigationText: GalleryUtils.getTabTitle(this.state.selectedTab),
onBackClick: this.onBackClick,
onTagClick: this.loadTaggedItems,
};
return <NotebookViewerComponent {...props} />;
}
const props: GalleryViewerComponentProps = {
container: this.props.container,
junoClient: this.props.junoClient,
selectedTab: this.state.selectedTab,
sortBy: this.state.sortBy,
searchText: this.state.searchText,
openNotebook: this.openNotebook,
onSelectedTabChange: this.onSelectedTabChange,
onSortByChange: this.onSortByChange,
onSearchTextChange: this.onSearchTextChange,
};
return <GalleryViewerComponent {...props} />;
}
private onBackClick = (): void => {
this.setState({
notebookUrl: undefined,
});
};
private loadTaggedItems = (tag: string): void => {
this.setState({
notebookUrl: undefined,
searchText: tag,
});
};
private openNotebook = (data: IGalleryItem, isFavorite: boolean): void => {
this.setState({
notebookUrl: this.props.junoClient.getNotebookContentUrl(data.id),
galleryItem: data,
isFavorite,
});
};
private onSelectedTabChange = (selectedTab: GalleryTab): void => {
this.setState({
selectedTab,
});
};
private onSortByChange = (sortBy: SortBy): void => {
this.setState({
sortBy,
});
};
private onSearchTextChange = (searchText: string): void => {
this.setState({
searchText,
});
};
}
@@ -1,21 +0,0 @@
@import "../../../../less/Common/Constants";
.galleryContainer {
padding: @LargeSpace @LargeSpace 30px @LargeSpace;
height: 100%;
overflow-y: auto;
width: 100%;
font-family: @DataExplorerFont;
background: @GalleryBackgroundColor;
}
.publicGalleryTabContainer {
position: relative;
min-height: 100vh;
}
.publicGalleryTabOverlayContent {
background: white;
padding: 20px;
margin: 10%;
}
@@ -1,21 +0,0 @@
import { shallow } from "enzyme";
import React from "react";
import { GalleryViewerComponent, GalleryViewerComponentProps, GalleryTab, SortBy } from "./GalleryViewerComponent";
describe("GalleryViewerComponent", () => {
it("renders", () => {
const props: GalleryViewerComponentProps = {
junoClient: undefined,
selectedTab: GalleryTab.OfficialSamples,
sortBy: SortBy.MostViewed,
searchText: undefined,
openNotebook: undefined,
onSelectedTabChange: undefined,
onSortByChange: undefined,
onSearchTextChange: undefined,
};
const wrapper = shallow(<GalleryViewerComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
@@ -1,760 +0,0 @@
import {
Dropdown,
FocusZone,
FontIcon,
FontWeights,
IDropdownOption,
IPageSpecification,
IPivotItemProps,
IPivotProps,
IRectangle,
Label,
Link,
List,
Overlay,
Pivot,
PivotItem,
SearchBox,
Spinner,
SpinnerSize,
Stack,
Text,
} from "@fluentui/react";
import * as React from "react";
import { userContext } from "UserContext";
import { HttpStatusCodes } from "../../../Common/Constants";
import { handleError } from "../../../Common/ErrorHandlingUtils";
import { IGalleryItem, IJunoResponse, IPublicGalleryData, JunoClient } from "../../../Juno/JunoClient";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import { trace } from "../../../Shared/Telemetry/TelemetryProcessor";
import * as GalleryUtils from "../../../Utils/GalleryUtils";
import Explorer from "../../Explorer";
import { GalleryCardComponent, GalleryCardComponentProps } from "./Cards/GalleryCardComponent";
import { CodeOfConduct } from "./CodeOfConduct/CodeOfConduct";
import "./GalleryViewerComponent.less";
import { InfoComponent } from "./InfoComponent/InfoComponent";
export interface GalleryViewerComponentProps {
container?: Explorer;
junoClient: JunoClient;
selectedTab: GalleryTab;
sortBy: SortBy;
searchText: string;
openNotebook: (data: IGalleryItem, isFavorite: boolean) => void;
onSelectedTabChange: (newTab: GalleryTab) => void;
onSortByChange: (sortBy: SortBy) => void;
onSearchTextChange: (searchText: string) => void;
}
export enum GalleryTab {
PublicGallery,
OfficialSamples,
Favorites,
Published,
}
export enum SortBy {
MostViewed,
MostDownloaded,
MostFavorited,
MostRecent,
}
interface GalleryViewerComponentState {
sampleNotebooks: IGalleryItem[];
publicNotebooks: IGalleryItem[];
favoriteNotebooks: IGalleryItem[];
publishedNotebooks: IGalleryItem[];
selectedTab: GalleryTab;
sortBy: SortBy;
searchText: string;
isCodeOfConductAccepted: boolean;
isFetchingPublishedNotebooks: boolean;
isFetchingFavouriteNotebooks: boolean;
}
interface GalleryTabInfo {
tab: GalleryTab;
content: JSX.Element;
}
export class GalleryViewerComponent extends React.Component<GalleryViewerComponentProps, GalleryViewerComponentState> {
public static readonly OfficialSamplesTitle = "Official samples";
public static readonly PublicGalleryTitle = "Public gallery";
public static readonly FavoritesTitle = "My favorites";
public static readonly PublishedTitle = "My published work";
private static readonly rowsPerPage = 5;
private static readonly CARD_WIDTH = 256;
private static readonly mostViewedText = "Most viewed";
private static readonly mostDownloadedText = "Most downloaded";
private static readonly mostFavoritedText = "Most favorited";
private static readonly mostRecentText = "Most recent";
private readonly sortingOptions: IDropdownOption[];
private viewGalleryTraced: boolean;
private viewOfficialSamplesTraced: boolean;
private viewPublicGalleryTraced: boolean;
private viewFavoritesTraced: boolean;
private viewPublishedNotebooksTraced: boolean;
private sampleNotebooks: IGalleryItem[];
private publicNotebooks: IGalleryItem[];
private favoriteNotebooks: IGalleryItem[];
private publishedNotebooks: IGalleryItem[];
private isCodeOfConductAccepted: boolean;
private columnCount: number;
private rowCount: number;
constructor(props: GalleryViewerComponentProps) {
super(props);
this.state = {
sampleNotebooks: undefined,
publicNotebooks: undefined,
favoriteNotebooks: undefined,
publishedNotebooks: undefined,
selectedTab: props.selectedTab,
sortBy: props.sortBy,
searchText: props.searchText,
isCodeOfConductAccepted: undefined,
isFetchingFavouriteNotebooks: true,
isFetchingPublishedNotebooks: true,
};
this.sortingOptions = [
{
key: SortBy.MostViewed,
text: GalleryViewerComponent.mostViewedText,
},
{
key: SortBy.MostDownloaded,
text: GalleryViewerComponent.mostDownloadedText,
},
{
key: SortBy.MostRecent,
text: GalleryViewerComponent.mostRecentText,
},
{
key: SortBy.MostFavorited,
text: GalleryViewerComponent.mostFavoritedText,
},
];
this.loadTabContent(this.state.selectedTab, this.state.searchText, this.state.sortBy, false);
this.loadFavoriteNotebooks(this.state.searchText, this.state.sortBy, false); // Need this to show correct favorite button state
}
public render(): JSX.Element {
this.traceViewGallery();
const tabs: GalleryTabInfo[] = [];
if (userContext.features.publicGallery) {
tabs.push(
this.createPublicGalleryTab(
GalleryTab.PublicGallery,
this.state.publicNotebooks,
this.state.isCodeOfConductAccepted,
),
);
}
tabs.push(this.createSamplesTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks));
if (this.props.container) {
tabs.push(this.createFavoritesTab(GalleryTab.Favorites, this.state.favoriteNotebooks));
if (userContext.features.publicGallery) {
tabs.push(this.createPublishedNotebooksTab(GalleryTab.Published, this.state.publishedNotebooks));
}
}
const pivotProps: IPivotProps = {
onLinkClick: this.onPivotChange,
selectedKey: GalleryTab[this.state.selectedTab],
};
const pivotItems = tabs.map((tab) => {
const pivotItemProps: IPivotItemProps = {
itemKey: GalleryTab[tab.tab],
style: { marginTop: 20 },
headerText: GalleryUtils.getTabTitle(tab.tab),
};
return (
<PivotItem key={pivotItemProps.itemKey} {...pivotItemProps}>
{tab.content}
</PivotItem>
);
});
return (
<div className="galleryContainer">
<Pivot {...pivotProps}>{pivotItems}</Pivot>
</div>
);
}
private traceViewGallery = (): void => {
if (!this.viewGalleryTraced) {
this.viewGalleryTraced = true;
trace(Action.NotebooksGalleryViewGallery);
}
switch (this.state.selectedTab) {
case GalleryTab.PublicGallery:
if (!this.viewPublicGalleryTraced) {
this.resetViewGalleryTabTracedFlags();
this.viewPublicGalleryTraced = true;
trace(Action.NotebooksGalleryViewPublicGallery);
}
break;
case GalleryTab.OfficialSamples:
if (!this.viewOfficialSamplesTraced) {
this.resetViewGalleryTabTracedFlags();
this.viewOfficialSamplesTraced = true;
trace(Action.NotebooksGalleryViewOfficialSamples);
}
break;
case GalleryTab.Favorites:
if (!this.viewFavoritesTraced) {
this.resetViewGalleryTabTracedFlags();
this.viewFavoritesTraced = true;
trace(Action.NotebooksGalleryViewFavorites);
}
break;
case GalleryTab.Published:
if (!this.viewPublishedNotebooksTraced) {
this.resetViewGalleryTabTracedFlags();
this.viewPublishedNotebooksTraced = true;
trace(Action.NotebooksGalleryViewPublishedNotebooks);
}
break;
default:
throw new Error(`Unknown selected tab ${this.state.selectedTab}`);
}
};
private resetViewGalleryTabTracedFlags = (): void => {
this.viewOfficialSamplesTraced = false;
this.viewPublicGalleryTraced = false;
this.viewFavoritesTraced = false;
this.viewPublishedNotebooksTraced = false;
};
private isEmptyData = (data: IGalleryItem[]): boolean => {
return !data || data.length === 0;
};
private createEmptyTabContent = (iconName: string, line1: JSX.Element, line2: JSX.Element): JSX.Element => {
return (
<Stack horizontalAlign="center" tokens={{ childrenGap: 10 }}>
<FontIcon iconName={iconName} style={{ fontSize: 100, color: "lightgray", marginTop: 20 }} />
<Text styles={{ root: { fontWeight: FontWeights.semibold } }}>{line1}</Text>
<Text>{line2}</Text>
</Stack>
);
};
private createSamplesTab = (tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo => {
return {
tab,
content: this.createSearchBarHeader(this.createCardsTabContent(data)),
};
};
private createPublicGalleryTab(
tab: GalleryTab,
data: IGalleryItem[],
acceptedCodeOfConduct: boolean,
): GalleryTabInfo {
return {
tab,
content: this.createPublicGalleryTabContent(data, acceptedCodeOfConduct),
};
}
private getFavouriteNotebooksTabContent = (data: IGalleryItem[]) => {
if (this.isEmptyData(data)) {
if (this.state.isFetchingFavouriteNotebooks) {
return <Spinner size={SpinnerSize.large} />;
}
return this.createEmptyTabContent(
"ContactHeart",
<>You don&apos;t have any favorites yet</>,
<>
Favorite any notebook from the{" "}
<Link onClick={() => this.setState({ selectedTab: GalleryTab.OfficialSamples })}>official samples</Link> or{" "}
<Link onClick={() => this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery</Link>
</>,
);
}
return this.createSearchBarHeader(this.createCardsTabContent(data));
};
private createFavoritesTab(tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo {
return {
tab,
content: this.getFavouriteNotebooksTabContent(data),
};
}
private getPublishedNotebooksTabContent = (data: IGalleryItem[]) => {
if (this.isEmptyData(data)) {
if (this.state.isFetchingPublishedNotebooks) {
return <Spinner size={SpinnerSize.large} />;
}
return this.createEmptyTabContent(
"Contact",
<>
You have not published anything to the{" "}
<Link onClick={() => this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery</Link> yet
</>,
<>Publish your notebooks to share your work with other users</>,
);
}
return this.createPublishedNotebooksTabContent(data);
};
private createPublishedNotebooksTab = (tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo => {
return {
tab,
content: this.getPublishedNotebooksTabContent(data),
};
};
private createPublishedNotebooksTabContent = (data: IGalleryItem[]): JSX.Element => {
const { published, underReview, removed } = GalleryUtils.filterPublishedNotebooks(data);
const content = (
<Stack tokens={{ childrenGap: 20 }}>
{published?.length > 0 &&
this.createPublishedNotebooksSectionContent(
undefined,
"You have successfully published and shared the following notebook(s) to the public gallery.",
this.createCardsTabContent(published),
)}
{underReview?.length > 0 &&
this.createPublishedNotebooksSectionContent(
"Under Review",
"Content of a notebook you published is currently being scanned for illegal content. It will not be available to public gallery until the review is completed (may take a few days)",
this.createCardsTabContent(underReview),
)}
{removed?.length > 0 &&
this.createPublishedNotebooksSectionContent(
"Removed",
"These notebooks were found to contain illegal content and has been taken down.",
this.createPolicyViolationsListContent(removed),
)}
</Stack>
);
return this.createSearchBarHeader(content);
};
private createPublishedNotebooksSectionContent = (
title: string,
description: string,
content: JSX.Element,
): JSX.Element => {
return (
<Stack tokens={{ childrenGap: 10 }}>
{title && (
<Text styles={{ root: { fontWeight: FontWeights.semibold, marginLeft: 10, marginRight: 10 } }}>{title}</Text>
)}
{description && <Text styles={{ root: { marginLeft: 10, marginRight: 10 } }}>{description}</Text>}
{content}
</Stack>
);
};
private createPublicGalleryTabContent(data: IGalleryItem[], acceptedCodeOfConduct: boolean): JSX.Element {
return (
<div className="publicGalleryTabContainer">
{this.createSearchBarHeader(this.createCardsTabContent(data))}
{acceptedCodeOfConduct === false && (
<Overlay isDarkThemed>
<div className="publicGalleryTabOverlayContent">
<CodeOfConduct
junoClient={this.props.junoClient}
onAcceptCodeOfConduct={(result: boolean) => {
this.setState({ isCodeOfConductAccepted: result });
}}
/>
</div>
</Overlay>
)}
</div>
);
}
private createSearchBarHeader(content: JSX.Element): JSX.Element {
return (
<Stack tokens={{ childrenGap: 10 }}>
<Stack horizontal wrap tokens={{ childrenGap: 20, padding: 10 }}>
<Stack.Item grow>
<SearchBox value={this.state.searchText} placeholder="Search" onChange={this.onSearchBoxChange} />
</Stack.Item>
<Stack.Item>
<Label>Sort by</Label>
</Stack.Item>
<Stack.Item styles={{ root: { minWidth: 200 } }}>
<Dropdown options={this.sortingOptions} selectedKey={this.state.sortBy} onChange={this.onDropdownChange} />
</Stack.Item>
<Stack.Item>
<InfoComponent />
</Stack.Item>
</Stack>
<Stack.Item>{content}</Stack.Item>
</Stack>
);
}
private createCardsTabContent(data: IGalleryItem[]): JSX.Element {
return data ? (
<FocusZone>
<List
items={data}
getPageSpecification={this.getPageSpecification}
renderedWindowsAhead={3}
onRenderCell={this.onRenderCell}
/>
</FocusZone>
) : (
<Spinner size={SpinnerSize.large} />
);
}
private createPolicyViolationsListContent(data: IGalleryItem[]): JSX.Element {
return (
<table style={{ margin: 10 }}>
<tbody>
<tr>
<th>Name</th>
<th>Policy violations</th>
</tr>
{data.map((item) => (
<tr key={`policy-violations-tr-${item.id}`}>
<td>{item.name}</td>
<td>{item.policyViolations.join(", ")}</td>
</tr>
))}
</tbody>
</table>
);
}
private loadTabContent(tab: GalleryTab, searchText: string, sortBy: SortBy, offline: boolean): void {
switch (tab) {
case GalleryTab.PublicGallery:
this.loadPublicNotebooks(searchText, sortBy, offline);
break;
case GalleryTab.OfficialSamples:
this.loadSampleNotebooks(searchText, sortBy, offline);
break;
case GalleryTab.Favorites:
this.loadFavoriteNotebooks(searchText, sortBy, offline);
break;
case GalleryTab.Published:
this.loadPublishedNotebooks(searchText, sortBy, offline);
break;
default:
throw new Error(`Unknown tab ${tab}`);
}
}
private async loadSampleNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
if (!offline) {
try {
const response = await this.props.junoClient.getSampleNotebooks();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when loading sample notebooks`);
}
this.sampleNotebooks = response.data;
trace(Action.NotebooksGalleryOfficialSamplesCount, ActionModifiers.Mark, {
count: this.sampleNotebooks?.length,
});
} catch (error) {
handleError(error, "GalleryViewerComponent/loadSampleNotebooks", "Failed to load sample notebooks");
}
}
this.setState({
sampleNotebooks: this.sampleNotebooks && [...this.sort(sortBy, this.search(searchText, this.sampleNotebooks))],
});
}
private async loadPublicNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
if (!offline) {
try {
let response: IJunoResponse<IGalleryItem[]> | IJunoResponse<IPublicGalleryData>;
if (this.props.container) {
response = await this.props.junoClient.getPublicGalleryData();
this.isCodeOfConductAccepted = response.data?.metadata.acceptedCodeOfConduct;
this.publicNotebooks = response.data?.notebooksData;
} else {
response = await this.props.junoClient.getPublicNotebooks();
this.publicNotebooks = response.data;
}
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when loading public notebooks`);
}
trace(Action.NotebooksGalleryPublicGalleryCount, ActionModifiers.Mark, { count: this.publicNotebooks?.length });
} catch (error) {
handleError(error, "GalleryViewerComponent/loadPublicNotebooks", "Failed to load public notebooks");
}
}
this.setState({
publicNotebooks: this.publicNotebooks && [...this.sort(sortBy, this.search(searchText, this.publicNotebooks))],
isCodeOfConductAccepted: this.isCodeOfConductAccepted,
});
}
private async loadFavoriteNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
if (!offline) {
try {
this.setState({ isFetchingFavouriteNotebooks: true });
const response = await this.props.junoClient.getFavoriteNotebooks();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when loading favorite notebooks`);
}
this.favoriteNotebooks = response.data;
trace(Action.NotebooksGalleryFavoritesCount, ActionModifiers.Mark, { count: this.favoriteNotebooks?.length });
} catch (error) {
handleError(error, "GalleryViewerComponent/loadFavoriteNotebooks", "Failed to load favorite notebooks");
} finally {
this.setState({ isFetchingFavouriteNotebooks: false });
}
}
this.setState({
favoriteNotebooks: this.favoriteNotebooks && [
...this.sort(sortBy, this.search(searchText, this.favoriteNotebooks)),
],
});
// Refresh favorite button state
if (this.state.selectedTab !== GalleryTab.Favorites) {
this.refreshSelectedTab();
}
}
private async loadPublishedNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
if (!offline) {
try {
this.setState({ isFetchingPublishedNotebooks: true });
const response = await this.props.junoClient.getPublishedNotebooks();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when loading published notebooks`);
}
this.publishedNotebooks = response.data;
const { published, underReview, removed } = GalleryUtils.filterPublishedNotebooks(this.publishedNotebooks);
trace(Action.NotebooksGalleryPublishedCount, ActionModifiers.Mark, {
count: this.publishedNotebooks?.length,
publishedCount: published.length,
underReviewCount: underReview.length,
removedCount: removed.length,
});
} catch (error) {
handleError(error, "GalleryViewerComponent/loadPublishedNotebooks", "Failed to load published notebooks");
} finally {
this.setState({ isFetchingPublishedNotebooks: false });
}
}
this.setState({
publishedNotebooks: this.publishedNotebooks && [
...this.sort(sortBy, this.search(searchText, this.publishedNotebooks)),
],
});
}
private search(searchText: string, data: IGalleryItem[]): IGalleryItem[] {
if (searchText) {
return data?.filter((item) => this.isGalleryItemPresent(searchText, item));
}
return data;
}
private isGalleryItemPresent(searchText: string, item: IGalleryItem): boolean {
const toSearch = searchText.trim().toUpperCase();
const searchData: string[] = [item.author.toUpperCase(), item.description.toUpperCase(), item.name.toUpperCase()];
if (item.tags) {
searchData.push(...item.tags.map((tag) => tag.toUpperCase()));
}
for (const data of searchData) {
if (data?.indexOf(toSearch) !== -1) {
return true;
}
}
return false;
}
private sort(sortBy: SortBy, data: IGalleryItem[]): IGalleryItem[] {
return data?.sort((a, b) => {
switch (sortBy) {
case SortBy.MostViewed:
return b.views - a.views;
case SortBy.MostDownloaded:
return b.downloads - a.downloads;
case SortBy.MostFavorited:
return b.favorites - a.favorites;
case SortBy.MostRecent:
return Date.parse(b.created) - Date.parse(a.created);
default:
throw new Error(`Unknown sorting condition ${sortBy}`);
}
});
}
private refreshSelectedTab(item?: IGalleryItem): void {
if (item) {
this.updateGalleryItem(item);
}
this.loadTabContent(this.state.selectedTab, this.state.searchText, this.state.sortBy, true);
}
private updateGalleryItem(updatedItem: IGalleryItem): void {
this.replaceGalleryItem(updatedItem, this.sampleNotebooks);
this.replaceGalleryItem(updatedItem, this.publicNotebooks);
this.replaceGalleryItem(updatedItem, this.favoriteNotebooks);
this.replaceGalleryItem(updatedItem, this.publishedNotebooks);
}
private replaceGalleryItem(item: IGalleryItem, items?: IGalleryItem[]): void {
const index = items?.findIndex((value) => value.id === item.id);
if (index !== -1) {
items?.splice(index, 1, item);
}
}
private getPageSpecification = (itemIndex?: number, visibleRect?: IRectangle): IPageSpecification => {
if (itemIndex === 0) {
this.columnCount = Math.floor(visibleRect.width / GalleryViewerComponent.CARD_WIDTH) || this.columnCount;
this.rowCount = GalleryViewerComponent.rowsPerPage;
}
return {
height: visibleRect.height,
itemCount: this.columnCount * this.rowCount,
};
};
private onRenderCell = (data?: IGalleryItem): JSX.Element => {
const isFavorite =
this.props.container && this.favoriteNotebooks?.find((item) => item.id === data.id) !== undefined;
const props: GalleryCardComponentProps = {
data,
isFavorite,
showDownload: !!this.props.container,
showDelete: this.state.selectedTab === GalleryTab.Published,
onClick: () => this.props.openNotebook(data, isFavorite),
onTagClick: this.loadTaggedItems,
onFavoriteClick: () => this.favoriteItem(data),
onUnfavoriteClick: () => this.unfavoriteItem(data),
onDownloadClick: () => this.downloadItem(data),
onDeleteClick: (beforeDelete: () => void, afterDelete: () => void) =>
this.deleteItem(data, beforeDelete, afterDelete),
};
return (
<div style={{ float: "left", padding: 5 }}>
<GalleryCardComponent {...props} />
</div>
);
};
private loadTaggedItems = (tag: string): void => {
const searchText = tag;
this.setState({
searchText,
});
this.loadTabContent(this.state.selectedTab, searchText, this.state.sortBy, true);
this.props.onSearchTextChange && this.props.onSearchTextChange(searchText);
};
private favoriteItem = async (data: IGalleryItem): Promise<void> => {
GalleryUtils.favoriteItem(this.props.container, this.props.junoClient, data, (item: IGalleryItem) => {
if (this.favoriteNotebooks) {
this.favoriteNotebooks.push(item);
} else {
this.favoriteNotebooks = [item];
}
this.refreshSelectedTab(item);
});
};
private unfavoriteItem = async (data: IGalleryItem): Promise<void> => {
GalleryUtils.unfavoriteItem(this.props.container, this.props.junoClient, data, (item: IGalleryItem) => {
this.favoriteNotebooks = this.favoriteNotebooks?.filter((value) => value.id !== item.id);
this.refreshSelectedTab(item);
});
};
private downloadItem = async (data: IGalleryItem): Promise<void> => {
GalleryUtils.downloadItem(this.props.container, this.props.junoClient, data, (item) =>
this.refreshSelectedTab(item),
);
};
private deleteItem = async (data: IGalleryItem, beforeDelete: () => void, afterDelete: () => void): Promise<void> => {
GalleryUtils.deleteItem(
this.props.container,
this.props.junoClient,
data,
(item) => {
this.publishedNotebooks = this.publishedNotebooks?.filter((notebook) => item.id !== notebook.id);
this.refreshSelectedTab(item);
},
beforeDelete,
afterDelete,
);
};
private onPivotChange = (item: PivotItem): void => {
const selectedTab = GalleryTab[item.props.itemKey as keyof typeof GalleryTab];
const searchText: string = undefined;
this.setState({
selectedTab,
searchText,
});
this.loadTabContent(selectedTab, searchText, this.state.sortBy, false);
this.props.onSelectedTabChange && this.props.onSelectedTabChange(selectedTab);
};
private onSearchBoxChange = (event?: React.ChangeEvent<HTMLInputElement>, newValue?: string): void => {
const searchText = newValue;
this.setState({
searchText,
});
this.loadTabContent(this.state.selectedTab, searchText, this.state.sortBy, true);
this.props.onSearchTextChange && this.props.onSearchTextChange(searchText);
};
private onDropdownChange = (event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption): void => {
const sortBy = option.key as SortBy;
this.setState({
sortBy,
});
this.loadTabContent(this.state.selectedTab, this.state.searchText, sortBy, true);
this.props.onSortByChange && this.props.onSortByChange(sortBy);
};
}
@@ -1,26 +0,0 @@
@import "../../../../../less/Common/Constants.less";
.infoPanel, .infoPanelMain {
display: flex;
align-items: center;
}
.infoPanel {
padding-left: 5px;
padding-right: 5px;
}
.infoLabel, .infoLabelMain {
padding-left: 5px
}
.infoLabel {
font-weight: 400
}
.infoIconMain {
color: @AccentMedium
}
.infoIconMain:hover {
color: @BaseMedium
}
@@ -1,10 +0,0 @@
import { shallow } from "enzyme";
import React from "react";
import { InfoComponent } from "./InfoComponent";
describe("InfoComponent", () => {
it("renders", () => {
const wrapper = shallow(<InfoComponent />);
expect(wrapper).toMatchSnapshot();
});
});
@@ -1,51 +0,0 @@
import { HoverCard, HoverCardType, Icon, Label, Link, Stack } from "@fluentui/react";
import * as React from "react";
import { CodeOfConductEndpoints } from "../../../../Common/Constants";
import "./InfoComponent.less";
export interface InfoComponentProps {
onReportAbuseClick?: () => void;
}
export class InfoComponent extends React.Component<InfoComponentProps> {
private getInfoPanel = (iconName: string, labelText: string, url?: string, onClick?: () => void): JSX.Element => {
return (
<Link href={url} target={url && "_blank"} onClick={onClick}>
<div className="infoPanel">
<Icon iconName={iconName} styles={{ root: { verticalAlign: "middle" } }} />
<Label className="infoLabel">{labelText}</Label>
</div>
</Link>
);
};
private onHover = (): JSX.Element => {
return (
<Stack tokens={{ childrenGap: 5, padding: 5 }}>
<Stack.Item>{this.getInfoPanel("Script", "Code of Conduct", CodeOfConductEndpoints.codeOfConduct)}</Stack.Item>
<Stack.Item>
{this.getInfoPanel("RedEye", "Privacy Statement", CodeOfConductEndpoints.privacyStatement)}
</Stack.Item>
<Stack.Item>
{this.getInfoPanel("KnowledgeArticle", "Microsoft Terms of Use", CodeOfConductEndpoints.termsOfUse)}
</Stack.Item>
{this.props.onReportAbuseClick && (
<Stack.Item>
{this.getInfoPanel("ReportHacked", "Report Abuse", undefined, () => this.props.onReportAbuseClick())}
</Stack.Item>
)}
</Stack>
);
};
public render(): JSX.Element {
return (
<HoverCard plainCardProps={{ onRenderPlainCard: this.onHover }} instantOpenOnClick type={HoverCardType.plain}>
<div className="infoPanelMain" tabIndex={0}>
<Icon className="infoIconMain" iconName="Help" styles={{ root: { verticalAlign: "middle" } }} />
<Label className="infoLabelMain">Help</Label>
</div>
</HoverCard>
);
}
}
@@ -1,35 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InfoComponent renders 1`] = `
<StyledHoverCardBase
instantOpenOnClick={true}
plainCardProps={
{
"onRenderPlainCard": [Function],
}
}
type="PlainCard"
>
<div
className="infoPanelMain"
tabIndex={0}
>
<Icon
className="infoIconMain"
iconName="Help"
styles={
{
"root": {
"verticalAlign": "middle",
},
}
}
/>
<StyledLabelBase
className="infoLabelMain"
>
Help
</StyledLabelBase>
</div>
</StyledHoverCardBase>
`;
@@ -1,98 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GalleryViewerComponent renders 1`] = `
<div
className="galleryContainer"
>
<StyledPivot
onLinkClick={[Function]}
selectedKey="OfficialSamples"
>
<PivotItem
headerText="Official samples"
itemKey="OfficialSamples"
key="OfficialSamples"
style={
{
"marginTop": 20,
}
}
>
<Stack
tokens={
{
"childrenGap": 10,
}
}
>
<Stack
horizontal={true}
tokens={
{
"childrenGap": 20,
"padding": 10,
}
}
wrap={true}
>
<StackItem
grow={true}
>
<StyledSearchBox
onChange={[Function]}
placeholder="Search"
/>
</StackItem>
<StackItem>
<StyledLabelBase>
Sort by
</StyledLabelBase>
</StackItem>
<StackItem
styles={
{
"root": {
"minWidth": 200,
},
}
}
>
<Dropdown
onChange={[Function]}
options={
[
{
"key": 0,
"text": "Most viewed",
},
{
"key": 1,
"text": "Most downloaded",
},
{
"key": 3,
"text": "Most recent",
},
{
"key": 2,
"text": "Most favorited",
},
]
}
selectedKey={0}
/>
</StackItem>
<StackItem>
<InfoComponent />
</StackItem>
</Stack>
<StackItem>
<StyledSpinnerBase
size={3}
/>
</StackItem>
</Stack>
</PivotItem>
</StyledPivot>
</div>
`;
@@ -25,10 +25,6 @@ describe("NotebookMetadataComponent", () => {
isFavorite: false,
downloadButtonText: "Download",
onTagClick: undefined,
onDownloadClick: undefined,
onFavoriteClick: undefined,
onUnfavoriteClick: undefined,
onReportAbuseClick: undefined,
};
const wrapper = shallow(<NotebookMetadataComponent {...props} />);
@@ -57,10 +53,6 @@ describe("NotebookMetadataComponent", () => {
isFavorite: true,
downloadButtonText: "Download",
onTagClick: undefined,
onDownloadClick: undefined,
onFavoriteClick: undefined,
onUnfavoriteClick: undefined,
onReportAbuseClick: undefined,
};
const wrapper = shallow(<NotebookMetadataComponent {...props} />);
@@ -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<NotebookMetadataComponentProps> {
private renderFavouriteButton = (): JSX.Element => {
return (
<Text>
{this.props.isFavorite !== undefined ? (
<>
<IconButton
iconProps={{ iconName: this.props.isFavorite ? "HeartFill" : "Heart" }}
onClick={this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick}
/>
{this.props.data.favorites} likes
</>
) : (
<>
<Icon iconName="Heart" /> {this.props.data.favorites} likes
</>
)}
</Text>
);
};
public render(): JSX.Element {
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
@@ -59,20 +34,10 @@ export class NotebookMetadataComponent extends React.Component<NotebookMetadataC
</Text>
</Stack.Item>
<Stack.Item>{this.renderFavouriteButton()}</Stack.Item>
{this.props.downloadButtonText && (
<Stack.Item>
<PrimaryButton text={this.props.downloadButtonText} onClick={this.props.onDownloadClick} />
</Stack.Item>
)}
<Stack.Item grow>
<></>
</Stack.Item>
<Stack.Item>
<InfoComponent onReportAbuseClick={this.props.onReportAbuseClick} />
<Text>
<Icon iconName="Heart" /> {this.props.data.favorites} likes
</Text>
</Stack.Item>
</Stack>
@@ -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<NotebookViewerComponentProps, NotebookViewerComponentState>
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}
/>
</div>
) : (
@@ -166,7 +158,6 @@ export class NotebookViewerComponent
hideInputs: this.props.hideInputs,
hidePrompts: this.props.hidePrompts,
})}
<Dialog />
</div>
);
}
@@ -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<void> => {
GalleryUtils.favoriteItem(this.props.container, this.props.junoClient, this.state.galleryItem, (item) =>
this.setState({ galleryItem: item, isFavorite: true }),
);
};
private unfavoriteItem = async (): Promise<void> => {
GalleryUtils.unfavoriteItem(this.props.container, this.props.junoClient, this.state.galleryItem, (item) =>
this.setState({ galleryItem: item, isFavorite: false }),
);
};
private downloadItem = async (): Promise<void> => {
GalleryUtils.downloadItem(this.props.container, this.props.junoClient, this.state.galleryItem, (item) =>
this.setState({ galleryItem: item }),
);
};
private reportAbuse = (): void => {
GalleryUtils.reportAbuse(this.props.junoClient, this.state.galleryItem, this, () => {});
};
}
@@ -27,28 +27,14 @@ exports[`NotebookMetadataComponent renders liked notebook 1`] = `
</StackItem>
<StackItem>
<Text>
<CustomizedIconButton
iconProps={
{
"iconName": "HeartFill",
}
}
<Icon
iconName="Heart"
/>
0
likes
</Text>
</StackItem>
<StackItem>
<CustomizedPrimaryButton
text="Download"
/>
</StackItem>
<StackItem
grow={true}
/>
<StackItem>
<InfoComponent />
</StackItem>
</Stack>
<Stack
horizontal={true}
@@ -139,28 +125,14 @@ exports[`NotebookMetadataComponent renders un-liked notebook 1`] = `
</StackItem>
<StackItem>
<Text>
<CustomizedIconButton
iconProps={
{
"iconName": "Heart",
}
}
<Icon
iconName="Heart"
/>
0
likes
</Text>
</StackItem>
<StackItem>
<CustomizedPrimaryButton
text="Download"
/>
</StackItem>
<StackItem
grow={true}
/>
<StackItem>
<InfoComponent />
</StackItem>
</Stack>
<Stack
horizontal={true}
@@ -1,23 +1,85 @@
import { shallow } from "enzyme";
import { render, screen, waitFor } from "@testing-library/react";
import { DatabaseAccount } from "Contracts/DataModels";
import {
PartitionKeyComponent,
PartitionKeyComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent";
import Explorer from "Explorer/Explorer";
import { useDataTransferJobs } from "hooks/useDataTransferJobs";
import React from "react";
import { updateUserContext } from "UserContext";
import { DataTransferJobGetResults } from "Utils/arm/generatedClients/dataTransferService/types";
import Explorer from "../../../Explorer";
jest.mock("Common/dataAccess/dataTransfers", () => ({
cancelDataTransferJob: jest.fn().mockResolvedValue(undefined),
pauseDataTransferJob: jest.fn().mockResolvedValue(undefined),
resumeDataTransferJob: jest.fn().mockResolvedValue(undefined),
completeDataTransferJob: jest.fn().mockResolvedValue(undefined),
pollDataTransferJob: jest.fn().mockResolvedValue(undefined),
}));
jest.mock("hooks/useDataTransferJobs", () => ({
useDataTransferJobs: jest.fn(() => ({ dataTransferJobs: [] })),
refreshDataTransferJobs: jest.fn().mockResolvedValue(undefined),
}));
jest.mock("hooks/useSidePanel", () => ({
useSidePanel: {
getState: () => ({
openSidePanel: jest.fn(),
}),
},
}));
jest.mock("ConfigContext", () => ({
configContext: { platform: "Portal" },
Platform: { Emulator: "Emulator", Portal: "Portal" },
}));
jest.mock("Explorer/Explorer", () => {
return jest.fn().mockImplementation(() => ({
refreshAllDatabases: jest.fn(),
refreshExplorer: jest.fn(),
}));
});
const mockOfflineJob = {
properties: {
jobName: "Portal_test_123",
source: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "testCol" },
destination: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "newCol" },
status: "InProgress",
processedCount: 50,
totalCount: 100,
mode: "Offline" as const,
},
} as DataTransferJobGetResults;
const mockOnlineJob = {
properties: {
jobName: "Portal_test_456",
source: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "testCol" },
destination: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "newCol" },
status: "InProgress",
processedCount: 50,
totalCount: 100,
mode: "Online" as const,
},
} as DataTransferJobGetResults;
describe("PartitionKeyComponent", () => {
// 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(<PartitionKeyComponent {...props} />);
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(<PartitionKeyComponent {...props} isReadOnly={true} />);
expect(wrapper).toMatchSnapshot();
render(<PartitionKeyComponent {...props} />);
expect(screen.getByText("/id")).toBeTruthy();
});
it("renders read-only component without change button", () => {
const { props } = setupTest();
const { container } = render(<PartitionKeyComponent {...props} isReadOnly={true} />);
expect(container.querySelector("[data-test='change-partition-key-button']")).toBeNull();
});
it("shows cancel button for offline job in progress", () => {
(useDataTransferJobs as unknown as jest.Mock).mockReturnValue({
dataTransferJobs: [mockOfflineJob],
});
const { props } = setupTest();
const { container } = render(<PartitionKeyComponent {...props} />);
// For offline jobs, the online action menu should not be present
expect(container.querySelector("[data-test='online-job-action-menu']")).toBeNull();
});
it("shows ellipsis action menu for online job in progress", async () => {
(useDataTransferJobs as unknown as jest.Mock).mockReturnValue({
dataTransferJobs: [mockOnlineJob],
});
const { props } = setupTest();
const { container } = render(<PartitionKeyComponent {...props} />);
await waitFor(() => {
expect(container.querySelector("[data-test='online-job-action-menu']")).toBeTruthy();
});
});
});
@@ -1,7 +1,10 @@
import {
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<PartitionKeyComponentProps> = ({
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<PartitionKeyComponentProps> = ({
);
};
const pauseRunningDataTransferJob = async (currentJob: DataTransferJobGetResults) => {
await pauseDataTransferJob(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
currentJob?.properties?.jobName,
);
};
const resumePausedDataTransferJob = async (currentJob: DataTransferJobGetResults) => {
await resumeDataTransferJob(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
currentJob?.properties?.jobName,
);
startPollingforUpdate(currentJob);
};
const completeOnlineDataTransferJob = async (currentJob: DataTransferJobGetResults) => {
await completeDataTransferJob(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
currentJob?.properties?.jobName,
);
};
const isOnlineJob = (currentJob: DataTransferJobGetResults): boolean => {
const mode = (currentJob?.properties?.mode ?? "").toLowerCase();
return mode === CopyJobMigrationType.Online;
};
const showActionConfirmationDialog = (
currentJob: DataTransferJobGetResults,
action: CopyJobActions,
onConfirm: () => void,
): void => {
const jobName = currentJob?.properties?.jobName;
const dialogBody =
action === CopyJobActions.cancel ? (
<Stack tokens={{ childrenGap: 10 }}>
<Stack.Item>
{t(Keys.controls.settings.partitionKeyEditor.confirmCancel1)}
<br />
<b>{jobName}</b>
</Stack.Item>
<Stack.Item>{t(Keys.controls.settings.partitionKeyEditor.confirmCancel2)}</Stack.Item>
</Stack>
) : action === CopyJobActions.complete ? (
<Stack tokens={{ childrenGap: 10 }}>
<Stack.Item>
{t(Keys.controls.settings.partitionKeyEditor.confirmComplete1)}
<br />
<b>{jobName}</b>
</Stack.Item>
<Stack.Item>{t(Keys.controls.settings.partitionKeyEditor.confrimComplete2)}</Stack.Item>
</Stack>
) : null;
useDialog
.getState()
.showOkCancelModalDialog("", null, t(Keys.common.confirm), onConfirm, t(Keys.common.cancel), null, dialogBody);
};
const getOnlineJobMenuProps = (currentJob: DataTransferJobGetResults): IContextualMenuProps => {
const jobStatus = currentJob?.properties?.status;
const isPaused = jobStatus === "Paused";
const items: IContextualMenuProps["items"] = [];
if (!isPaused) {
items.push({
key: CopyJobActions.pause,
text: t(Keys.containerCopy.monitorJobs.actions.pause),
iconProps: { iconName: "Pause" },
onClick: () => {
pauseRunningDataTransferJob(currentJob);
},
});
}
if (isPaused) {
items.push({
key: CopyJobActions.resume,
text: t(Keys.containerCopy.monitorJobs.actions.resume),
iconProps: { iconName: "Play" },
onClick: () => {
resumePausedDataTransferJob(currentJob);
},
});
}
items.push({
key: CopyJobActions.cancel,
text: t(Keys.common.cancel),
iconProps: { iconName: "Cancel" },
onClick: () =>
showActionConfirmationDialog(currentJob, CopyJobActions.cancel, () => cancelRunningDataTransferJob(currentJob)),
});
items.push({
key: CopyJobActions.complete,
text: t(Keys.containerCopy.monitorJobs.actions.complete),
iconProps: { iconName: "CheckMark" },
onClick: () =>
showActionConfirmationDialog(currentJob, CopyJobActions.complete, () =>
completeOnlineDataTransferJob(currentJob),
),
});
return {
items,
directionalHint: DirectionalHint.leftTopEdge,
directionalHintFixed: false,
};
};
const isCurrentJobInProgress = (currentJob: DataTransferJobGetResults) => {
const jobStatus = currentJob?.properties?.status;
return (
@@ -269,12 +398,26 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({
},
}}
></ProgressIndicator>
{isCurrentJobInProgress(portalDataTransferJob) && (
<DefaultButton
text={t(Keys.controls.settings.partitionKeyEditor.cancelButton)}
onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)}
/>
)}
{isCurrentJobInProgress(portalDataTransferJob) &&
(isOnlineJob(portalDataTransferJob) ? (
<IconButton
data-test="online-job-action-menu"
role="button"
iconProps={{
iconName: "More",
styles: { root: { fontSize: "20px", fontWeight: "bold" } },
}}
menuProps={getOnlineJobMenuProps(portalDataTransferJob)}
menuIconProps={{ iconName: "", className: "hidden" }}
ariaLabel={t(Keys.containerCopy.monitorJobs.columns.actions)}
title={t(Keys.containerCopy.monitorJobs.columns.actions)}
/>
) : (
<DefaultButton
text={t(Keys.controls.settings.partitionKeyEditor.cancelButton)}
onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)}
/>
))}
</Stack>
</Stack>
)}
@@ -1,271 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PartitionKeyComponent renders default component and matches snapshot 1`] = `
<Stack
styles={
{
"root": {
"maxWidth": 600,
},
}
}
tokens={
{
"childrenGap": 20,
}
}
>
<Stack
tokens={
{
"childrenGap": 10,
}
}
>
<Text
styles={
{
"root": {
"color": "var(--colorNeutralForeground1)",
"fontSize": 16,
"fontWeight": 600,
},
}
}
>
Change partition key
</Text>
<Stack
horizontal={true}
tokens={
{
"childrenGap": 20,
}
}
>
<Stack
tokens={
{
"childrenGap": 5,
}
}
>
<Text
styles={
{
"root": {
"color": "var(--colorNeutralForeground1)",
"fontWeight": 600,
},
}
}
>
Current partition key
</Text>
<Text
styles={
{
"root": {
"color": "var(--colorNeutralForeground1)",
"fontWeight": 600,
},
}
}
>
Partitioning
</Text>
</Stack>
<Stack
data-test="partition-key-values"
tokens={
{
"childrenGap": 5,
}
}
>
<Text
styles={
{
"root": {
"color": "var(--colorNeutralForeground1)",
},
}
}
/>
<Text
styles={
{
"root": {
"color": "var(--colorNeutralForeground1)",
},
}
}
>
Non-hierarchical
</Text>
</Stack>
</Stack>
</Stack>
<StyledMessageBar
data-test="partition-key-warning"
messageBarIconProps={
{
"className": "messageBarWarningIcon",
"iconName": "WarningSolid",
}
}
messageBarType={5}
styles={
{
"root": {
"selectors": {
"&.ms-MessageBar--warning": {
"backgroundColor": "var(--colorStatusWarningBackground1)",
"border": "1px solid var(--colorStatusWarningBorder1)",
},
".ms-MessageBar-icon": {
"color": "var(--colorNeutralForeground1)",
},
".ms-MessageBar-text": {
"color": "var(--colorNeutralForeground1)",
},
},
},
}
}
>
To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the source container for the entire duration of the partition key change process.
<StyledLinkBase
href="https://learn.microsoft.com/azure/cosmos-db/container-copy#how-does-container-copy-work"
style={
{
"color": "var(--colorBrandForeground1)",
}
}
target="_blank"
underline={true}
>
Learn more
</StyledLinkBase>
</StyledMessageBar>
<Text
styles={
{
"root": {
"color": "var(--colorNeutralForeground1)",
},
}
}
>
To change the partition key, a new destination container must be created or an existing destination container selected. Data will then be copied to the destination container.
</Text>
<CustomizedPrimaryButton
data-test="change-partition-key-button"
onClick={[Function]}
styles={
{
"root": {
"width": "fit-content",
},
}
}
text="Change"
/>
</Stack>
`;
exports[`PartitionKeyComponent renders read-only component and matches snapshot 1`] = `
<Stack
styles={
{
"root": {
"maxWidth": 600,
},
}
}
tokens={
{
"childrenGap": 20,
}
}
>
<Stack
tokens={
{
"childrenGap": 10,
}
}
>
<Stack
horizontal={true}
tokens={
{
"childrenGap": 20,
}
}
>
<Stack
tokens={
{
"childrenGap": 5,
}
}
>
<Text
styles={
{
"root": {
"color": "var(--colorNeutralForeground1)",
"fontWeight": 600,
},
}
}
>
Current partition key
</Text>
<Text
styles={
{
"root": {
"color": "var(--colorNeutralForeground1)",
"fontWeight": 600,
},
}
}
>
Partitioning
</Text>
</Stack>
<Stack
data-test="partition-key-values"
tokens={
{
"childrenGap": 5,
}
}
>
<Text
styles={
{
"root": {
"color": "var(--colorNeutralForeground1)",
},
}
}
/>
<Text
styles={
{
"root": {
"color": "var(--colorNeutralForeground1)",
},
}
}
>
Non-hierarchical
</Text>
</Stack>
</Stack>
</Stack>
</Stack>
`;
@@ -291,7 +291,7 @@ describe("SettingsUtils", () => {
it("handles partition key tab title based on fabric native", () => {
// 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", () => {
@@ -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:
@@ -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={
+13 -62
View File
@@ -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<void> {
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<void> {
const title = "Gallery";
const GalleryTab = await (await import(/* webpackChunkName: "GalleryTab" */ "./Tabs/GalleryTab")).default;
const galleryTab = useTabs
.getState()
.getTabs(ViewModels.CollectionTabKind.Gallery)
.find((tab) => tab.tabTitle() === title);
if (galleryTab instanceof GalleryTab) {
useTabs.getState().activateTab(galleryTab);
} else {
useTabs.getState().activateNewTab(
new GalleryTab(
{
tabKind: ViewModels.CollectionTabKind.Gallery,
title,
tabPath: title,
onLoadStartKey: undefined,
isTabsContentExpanded: ko.observable(true),
},
{
account: userContext.databaseAccount,
container: this,
junoClient: this.notebookManager?.junoClient,
selectedTab: selectedTab || GalleryTabKind.OfficialSamples,
notebookUrl,
galleryItem,
isFavorite,
},
),
);
}
}
public async onNewCollectionClicked(
options: {
databaseId?: string;
@@ -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<GraphExplorerProps, GraphExpl
public static reportToConsole(type: ConsoleDataType, msg: string, ...errorData: any[]): void | (() => 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}`;
@@ -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);
-28
View File
@@ -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<void> {
useSidePanel
.getState()
.openSidePanel(
"Publish Notebook",
<PublishNotebookPane
explorer={this.params.container}
junoClient={this.junoClient}
name={name}
author={getFullName()}
notebookContent={content}
notebookContentRef={notebookContentRef}
onTakeSnapshot={onTakeSnapshot}
/>,
"440px",
onClosePanel,
);
}
public openCopyNotebookPane(name: string, content: string): void {
const { container } = this.params;
useSidePanel
@@ -0,0 +1,218 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { initiateDataTransfer } from "Common/dataAccess/dataTransfers";
import { DatabaseAccount } from "Contracts/DataModels";
import * as ViewModels from "Contracts/ViewModels";
import Explorer from "Explorer/Explorer";
import * as React from "react";
import { updateUserContext } from "UserContext";
import { ChangePartitionKeyPane } from "./ChangePartitionKeyPane";
jest.mock("Common/ErrorHandlingUtils", () => ({
handleError: jest.fn(),
getErrorMessage: jest.fn().mockReturnValue("error"),
getErrorStack: jest.fn().mockReturnValue(""),
}));
jest.mock("Common/dataAccess/createCollection", () => ({
createCollection: jest.fn().mockResolvedValue({}),
}));
jest.mock("Common/dataAccess/readDatabases", () => ({
readDatabases: jest.fn().mockResolvedValue([]),
}));
jest.mock("Common/dataAccess/dataTransfers", () => ({
initiateDataTransfer: jest.fn().mockResolvedValue({}),
}));
jest.mock("Utils/arm/databaseAccountUtils", () => ({
fetchDatabaseAccount: jest.fn().mockResolvedValue(null),
}));
jest.mock("Utils/arm/generatedClients/cosmos/databaseAccounts", () => ({
update: jest.fn().mockResolvedValue({}),
}));
jest.mock("hooks/useSidePanel", () => ({
useSidePanel: {
getState: () => ({
closeSidePanel: jest.fn(),
openSidePanel: jest.fn(),
}),
},
}));
jest.mock("Explorer/useDatabases", () => {
const state: Record<string, unknown> = {
databases: [],
resourceTokenCollection: undefined,
resourceTokenDatabase: undefined,
sampleDataResourceTokenCollection: undefined,
};
const mockStore = Object.assign(
jest.fn(() => state),
{
getState: () => state,
setState: jest.fn(),
subscribe: jest.fn(),
destroy: jest.fn(),
},
);
return { useDatabases: mockStore };
});
jest.mock("Common/LoadingOverlay", () => {
return {
__esModule: true,
default: ({ isLoading, label }: { isLoading: boolean; label: string }) =>
isLoading ? <div data-testid="loading-overlay">{label}</div> : null,
};
});
const createMockCollection = (id: string): ViewModels.Collection => {
const mockCollection = {
id: jest.fn().mockReturnValue(id),
offer: jest.fn().mockReturnValue(undefined),
partitionKey: { kind: "Hash", paths: ["/id"], version: 2 },
partitionKeyProperties: ["id"],
databaseId: "testDb",
} as unknown as ViewModels.Collection;
return mockCollection;
};
const createMockDatabase = (id: string, collections: ViewModels.Collection[] = []): ViewModels.Database => {
return {
id: jest.fn().mockReturnValue(id),
collections: jest.fn().mockReturnValue(collections),
} as unknown as ViewModels.Database;
};
describe("ChangePartitionKeyPane", () => {
const mockExplorer = new Explorer();
const mockOnClose = jest.fn().mockResolvedValue(undefined);
const mockCollection = createMockCollection("testCollection");
const mockDatabase = createMockDatabase("testDb", [mockCollection]);
beforeEach(() => {
jest.clearAllMocks();
updateUserContext({
databaseAccount: {
name: "testAccount",
id: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/testAccount",
properties: {
documentEndpoint: "https://test.documents.azure.com",
capabilities: [],
backupPolicy: { type: "Periodic" },
},
} as unknown as DatabaseAccount,
subscriptionId: "sub1",
resourceGroup: "rg1",
apiType: "SQL",
});
});
const renderPane = () => {
return render(
<ChangePartitionKeyPane
sourceDatabase={mockDatabase}
sourceCollection={mockCollection}
explorer={mockExplorer}
onClose={mockOnClose}
/>,
);
};
it("renders migration type choice group", () => {
renderPane();
expect(screen.getByText("Migration type")).toBeTruthy();
expect(screen.getByText("Offline mode")).toBeTruthy();
expect(screen.getByText("Online mode")).toBeTruthy();
});
it("defaults to offline migration type", () => {
renderPane();
const offlineRadio = screen.getByRole("radio", { name: "Offline mode" }) as HTMLInputElement;
expect(offlineRadio.checked).toBe(true);
});
it("does not show online prerequisites section when offline is selected", () => {
const { container } = renderPane();
expect(container.querySelector("[data-test='online-prerequisites-section']")).toBeNull();
});
it("shows online prerequisites section when online is selected", () => {
renderPane();
const onlineRadio = screen.getByRole("radio", { name: "Online mode" });
fireEvent.click(onlineRadio);
expect(screen.getByText("Online container copy")).toBeTruthy();
expect(screen.getByText("Point In Time Restore enabled")).toBeTruthy();
expect(screen.getByText("Online copy enabled")).toBeTruthy();
});
it("shows prerequisite sections when online prerequisites are not met", () => {
renderPane();
const onlineRadio = screen.getByRole("radio", { name: "Online mode" });
fireEvent.click(onlineRadio);
// When prerequisites aren't met, the enable buttons should be visible
expect(screen.getByText("Enable Point In Time Restore")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Enable Online Copy" }).length).toBeGreaterThan(0);
});
it("shows enable PITR button when PITR is not enabled", () => {
renderPane();
const onlineRadio = screen.getByRole("radio", { name: "Online mode" });
fireEvent.click(onlineRadio);
expect(screen.getByText("Enable Point In Time Restore")).toBeTruthy();
});
it("does not show PITR enable button when PITR is already enabled", () => {
updateUserContext({
databaseAccount: {
name: "testAccount",
id: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/testAccount",
properties: {
documentEndpoint: "https://test.documents.azure.com",
capabilities: [],
backupPolicy: { type: "Continuous" },
},
} as unknown as DatabaseAccount,
});
renderPane();
const onlineRadio = screen.getByRole("radio", { name: "Online mode" });
fireEvent.click(onlineRadio);
expect(screen.queryByText("Enable Point In Time Restore")).toBeNull();
});
it("disables online copy button when PITR is not enabled", () => {
renderPane();
const onlineRadio = screen.getByRole("radio", { name: "Online mode" });
fireEvent.click(onlineRadio);
const enableOnlineCopyBtns = screen.getAllByRole("button", { name: "Enable Online Copy" });
expect(enableOnlineCopyBtns.length).toBeGreaterThan(0);
expect((enableOnlineCopyBtns[0] as HTMLButtonElement).disabled).toBe(true);
});
it("passes mode to initiateDataTransfer when submitting", async () => {
const mockInitiateDataTransfer = jest.mocked(initiateDataTransfer);
// Mock refreshAllDatabases on the prototype to catch all calls
const refreshSpy = jest.spyOn(Explorer.prototype, "refreshAllDatabases").mockResolvedValue();
const { container } = renderPane();
// Fill in partition key (required for createContainer — state starts undefined)
const partitionKeyInput = container.querySelector("#addCollection-partitionKeyValue") as HTMLInputElement;
expect(partitionKeyInput).not.toBeNull();
fireEvent.change(partitionKeyInput, { target: { value: "/myKey" } });
const form = container.querySelector("form");
expect(form).not.toBeNull();
await act(async () => {
fireEvent.submit(form!);
});
expect(mockInitiateDataTransfer).toHaveBeenCalled();
expect(mockInitiateDataTransfer.mock.calls[0][0].mode).toBe("Offline");
refreshSpy.mockRestore();
});
});
@@ -7,16 +7,24 @@ import {
IconButton,
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<void>;
}
const checkPitrEnabled = (account: DataModels.DatabaseAccount): boolean => {
return account?.properties?.backupPolicy?.type === BackupPolicyType.Continuous;
};
const checkOnlineCopyEnabled = (account: DataModels.DatabaseAccount): boolean => {
const capabilities = account?.properties?.capabilities ?? [];
return capabilities.some((cap) => cap.name === CapabilityNames.EnableOnlineCopyFeature);
};
export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
sourceDatabase,
sourceCollection,
@@ -52,6 +71,118 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
const [isExecuting, setIsExecuting] = React.useState<boolean>(false);
const [subPartitionKeys, setSubPartitionKeys] = React.useState<string[]>([]);
const [partitionKey, setPartitionKey] = React.useState<string>();
const [onlineMode, setOnlineMode] = React.useState<boolean>(false);
// Pane-local account state for tracking prerequisite enablement
const [localAccount, setLocalAccount] = React.useState<DataModels.DatabaseAccount>(userContext.databaseAccount);
const [isEnablingPrerequisite, setIsEnablingPrerequisite] = React.useState<boolean>(false);
const [prerequisiteLoaderMessage, setPrerequisiteLoaderMessage] = React.useState<string>("");
const pitrEnabled = checkPitrEnabled(localAccount);
const onlineCopyFeatureEnabled = checkOnlineCopyEnabled(localAccount);
const onlinePrerequisitesMet = pitrEnabled && onlineCopyFeatureEnabled;
const accountName = localAccount?.name ?? "";
const subscriptionId = userContext.subscriptionId;
const resourceGroup = userContext.resourceGroup;
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const refreshAccount = async (): Promise<DataModels.DatabaseAccount | null> => {
try {
const account = await fetchDatabaseAccount(subscriptionId, resourceGroup, accountName);
if (account) {
setLocalAccount(account);
}
return account;
} catch (error) {
logError(
error.message || "Error fetching account after enabling prerequisite.",
"ChangePartitionKey/refreshAccount",
);
return null;
}
};
const clearPollingTimers = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
const startPollingForAccountUpdate = () => {
intervalRef.current = setInterval(() => {
refreshAccount();
}, 30 * 1000);
timeoutRef.current = setTimeout(
() => {
clearPollingTimers();
setIsEnablingPrerequisite(false);
},
10 * 60 * 1000,
);
};
const handleEnablePitr = () => {
const sourceAccountLink = buildResourceLink(localAccount);
const featureUrl = `${sourceAccountLink}/backupRestore`;
setIsEnablingPrerequisite(true);
setPrerequisiteLoaderMessage(t(Keys.containerCopy.popoverOverlaySpinnerLabel));
window.open(featureUrl, "_blank");
startPollingForAccountUpdate();
};
const handleEnableOnlineCopy = async () => {
setIsEnablingPrerequisite(true);
try {
setPrerequisiteLoaderMessage(
t(Keys.containerCopy.onlineCopyEnabled.validateAllVersionsAndDeletesChangeFeedSpinnerLabel),
);
const currentAccount = await fetchDatabaseAccount(subscriptionId, resourceGroup, accountName);
if (!currentAccount?.properties?.enableAllVersionsAndDeletesChangeFeed) {
setPrerequisiteLoaderMessage(
t(Keys.containerCopy.onlineCopyEnabled.enablingAllVersionsAndDeletesChangeFeedSpinnerLabel),
);
await updateDatabaseAccount(subscriptionId, resourceGroup, accountName, {
properties: {
enableAllVersionsAndDeletesChangeFeed: true,
},
});
}
const capabilities = currentAccount?.properties?.capabilities ?? [];
setPrerequisiteLoaderMessage(
t(Keys.containerCopy.onlineCopyEnabled.enablingOnlineCopySpinnerLabel, { accountName }),
);
await updateDatabaseAccount(subscriptionId, resourceGroup, accountName, {
properties: {
capabilities: [...capabilities, { name: CapabilityNames.EnableOnlineCopyFeature }],
},
});
startPollingForAccountUpdate();
} catch (error) {
logError(error.message || "Failed to enable online copy feature.", "ChangePartitionKey/handleEnableOnlineCopy");
setFormError("Failed to enable online copy feature. Please try again.");
setIsEnablingPrerequisite(false);
}
};
const getCollectionOptions = (): IDropdownOption[] => {
return sourceDatabase
@@ -84,9 +215,17 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
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<ChangePartitionKeyPaneProps> = ({
sourceCollectionName: sourceCollection.id(),
targetDatabaseName: sourceDatabase.id(),
targetCollectionName: targetCollectionId,
mode: getModeForApi(),
};
await initiateDataTransfer(dataTransferParams);
};
@@ -133,12 +273,18 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
return !!selectedDatabase?.offer();
};
const isSubmitDisabled = onlineMode && !onlinePrerequisitesMet;
const migrationTypeLowercase = getModeForApi().toLowerCase() as keyof typeof Keys.containerCopy.migrationType;
const migrationTypeContent = Keys.containerCopy.migrationType[migrationTypeLowercase];
return (
<RightPaneForm
formError={formError}
isExecuting={isExecuting}
onSubmit={submit}
submitButtonText={t(Keys.common.ok)}
isSubmitButtonDisabled={isSubmitDisabled}
>
<Stack tokens={{ childrenGap: 10 }} className="panelMainContent">
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
@@ -151,11 +297,58 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
{t(Keys.common.learnMore)}
</Link>
</Text>
{/* Migration Type */}
<Stack data-test="migration-type-section">
<Text className="panelTextBold" variant="small" style={{ marginBottom: 4 }}>
{t(Keys.containerCopy.migrationTypeTitle)}
</Text>
<Stack className="panelGroupSpacing" horizontal verticalAlign="center">
<div role="radiogroup">
<input
className="panelRadioBtn"
checked={!onlineMode}
aria-label="Offline mode"
aria-checked={!onlineMode}
name="migrationType"
type="radio"
role="radio"
id="migrationTypeOffline"
tabIndex={0}
onChange={() => setOnlineMode(false)}
/>
<span className="panelRadioBtnLabel">{t(Keys.containerCopy.migrationType.offline.title)}</span>
<input
className="panelRadioBtn"
checked={onlineMode}
aria-label="Online mode"
aria-checked={onlineMode}
name="migrationType"
type="radio"
role="radio"
tabIndex={0}
onChange={() => setOnlineMode(true)}
/>
<span className="panelRadioBtnLabel">{t(Keys.containerCopy.migrationType.online.title)}</span>
</div>
</Stack>
{migrationTypeContent && (
<Text
variant="small"
style={{ color: "var(--colorNeutralForeground1)", marginTop: 4 }}
data-test={`migration-type-description-${migrationTypeLowercase}`}
>
<MarkdownRender source={t(migrationTypeContent.description)} linkTarget="_blank" />
</Text>
)}
</Stack>
<Stack>
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small">
Database id
{t(Keys.panes.addDatabase.databaseIdLabel)}
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
@@ -420,6 +613,89 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
/>
</Stack>
)}
{/* Online prerequisites section */}
{onlineMode && (
<Stack data-test="online-prerequisites-section" tokens={{ childrenGap: 10 }}>
<LoadingOverlay isLoading={isEnablingPrerequisite} label={prerequisiteLoaderMessage} />
<Text className="panelTextBold" variant="small">
{t(Keys.containerCopy.assignPermissions.onlineConfiguration.title)}
</Text>
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
{t(Keys.containerCopy.assignPermissions.onlineConfiguration.description, { accountName })}
</Text>
{/* Point In Time Restore */}
<Stack tokens={{ childrenGap: 5 }}>
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 5 }}>
<Icon
iconName={pitrEnabled ? "SkypeCircleCheck" : "StatusCircleRing"}
styles={{
root: { color: pitrEnabled ? "green" : "var(--colorNeutralForeground1)", fontSize: 16 },
}}
/>
<Text variant="small" style={{ fontWeight: 600, color: "var(--colorNeutralForeground1)" }}>
{t(Keys.containerCopy.pointInTimeRestore.title)}
</Text>
</Stack>
{!pitrEnabled && (
<Stack tokens={{ childrenGap: 10, padding: "0 0 0 20px" }}>
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
{t(Keys.containerCopy.pointInTimeRestore.description, { accessName: accountName })}
</Text>
<PrimaryButton
data-test="enable-pitr-button"
text={t(Keys.containerCopy.pointInTimeRestore.buttonText)}
disabled={isEnablingPrerequisite}
onClick={handleEnablePitr}
styles={{ root: { width: "fit-content" } }}
/>
</Stack>
)}
</Stack>
{/* Online Copy Enabled */}
<Stack tokens={{ childrenGap: 5 }}>
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 5 }}>
<Icon
iconName={onlineCopyFeatureEnabled ? "SkypeCircleCheck" : "StatusCircleRing"}
styles={{
root: {
color: onlineCopyFeatureEnabled ? "green" : "var(--colorNeutralForeground1)",
fontSize: 16,
},
}}
/>
<Text variant="small" style={{ fontWeight: 600, color: "var(--colorNeutralForeground1)" }}>
{t(Keys.containerCopy.onlineCopyEnabled.title)}
</Text>
</Stack>
{!onlineCopyFeatureEnabled && (
<Stack tokens={{ childrenGap: 10, padding: "0 0 0 20px" }}>
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
{t(Keys.containerCopy.onlineCopyEnabled.description, { accountName })}&ensp;
<Link href={t(Keys.containerCopy.onlineCopyEnabled.href)} target="_blank" rel="noopener noreferrer">
{t(Keys.containerCopy.onlineCopyEnabled.hrefText)}
</Link>
</Text>
<PrimaryButton
data-test="enable-online-copy-button"
text={t(Keys.containerCopy.onlineCopyEnabled.buttonText)}
disabled={isEnablingPrerequisite || !pitrEnabled}
onClick={handleEnableOnlineCopy}
styles={{ root: { width: "fit-content" } }}
/>
</Stack>
)}
</Stack>
{!onlinePrerequisitesMet && (
<MessageBar messageBarType={MessageBarType.warning} data-test="online-prerequisites-warning">
{t(Keys.containerCopy.onlineCopyEnabled.onlineMigrationPrerequisitesMessage)}
</MessageBar>
)}
</Stack>
)}
</Stack>
</RightPaneForm>
);
@@ -1,28 +0,0 @@
import { shallow } from "enzyme";
import React from "react";
import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent";
describe("PublishNotebookPaneComponent", () => {
it("renders", () => {
const props: PublishNotebookPaneProps = {
notebookName: "SampleNotebook.ipynb",
notebookDescription: "sample description",
notebookTags: "tag1, tag2",
imageSrc: "https://i.ytimg.com/vi/E_lByLdKeKY/maxresdefault.jpg",
notebookAuthor: "CosmosDB",
notebookCreatedDate: "2020-07-17T00:00:00Z",
notebookObject: undefined,
notebookContentRef: undefined,
setNotebookName: undefined,
setNotebookDescription: undefined,
setNotebookTags: undefined,
setImageSrc: undefined,
onError: undefined,
clearFormError: undefined,
onTakeSnapshot: undefined,
};
const wrapper = shallow(<PublishNotebookPaneComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
@@ -1,214 +0,0 @@
import { ImmutableNotebook, toJS } from "@nteract/commutable";
import React, { FunctionComponent, useEffect, useState } from "react";
import { HttpStatusCodes } from "../../../Common/Constants";
import { getErrorMessage, getErrorStack, handleError } from "../../../Common/ErrorHandlingUtils";
import { useNotebookSnapshotStore } from "../../../hooks/useNotebookSnapshotStore";
import { useSidePanel } from "../../../hooks/useSidePanel";
import { JunoClient } from "../../../Juno/JunoClient";
import { Keys, t } from "Localization";
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor";
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
import { CodeOfConduct } from "../../Controls/NotebookGallery/CodeOfConduct/CodeOfConduct";
import { GalleryTab } from "../../Controls/NotebookGallery/GalleryViewerComponent";
import Explorer from "../../Explorer";
import * as FileSystemUtil from "../../Notebook/FileSystemUtil";
import { SnapshotRequest } from "../../Notebook/NotebookComponent/types";
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent";
export interface PublishNotebookPaneAProps {
explorer: Explorer;
junoClient: JunoClient;
name: string;
author: string;
notebookContent: string | ImmutableNotebook;
notebookContentRef: string;
onTakeSnapshot: (request: SnapshotRequest) => void;
}
export const PublishNotebookPane: FunctionComponent<PublishNotebookPaneAProps> = ({
explorer: container,
junoClient,
name,
author,
notebookContent,
notebookContentRef,
onTakeSnapshot,
}: PublishNotebookPaneAProps): JSX.Element => {
const closeSidePanel = useSidePanel((state) => state.closeSidePanel);
const [isCodeOfConductAccepted, setIsCodeOfConductAccepted] = useState<boolean>(false);
const [content, setContent] = useState<string>("");
const [formError, setFormError] = useState<string>("");
const [formErrorDetail, setFormErrorDetail] = useState<string>("");
const [isExecuting, setIsExecuting] = useState<boolean>();
const [notebookName, setNotebookName] = useState<string>(name);
const [notebookDescription, setNotebookDescription] = useState<string>("");
const [notebookTags, setNotebookTags] = useState<string>("");
const [imageSrc, setImageSrc] = useState<string>();
const { snapshot: notebookSnapshot, error: notebookSnapshotError } = useNotebookSnapshotStore();
const CodeOfConductAccepted = async () => {
try {
const response = await junoClient.isCodeOfConductAccepted();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
}
setIsCodeOfConductAccepted(response.data);
} catch (error) {
handleError(
error,
"PublishNotebookPaneAdapter/isCodeOfConductAccepted",
"Failed to check if code of conduct was accepted",
);
}
};
const [notebookObject, setNotebookObject] = useState<ImmutableNotebook>();
useEffect(() => {
CodeOfConductAccepted();
let newContent;
if (typeof notebookContent === "string") {
newContent = notebookContent as string;
} else {
newContent = JSON.stringify(toJS(notebookContent));
setNotebookObject(notebookContent);
}
setContent(newContent);
}, []);
useEffect(() => {
setImageSrc(notebookSnapshot);
}, [notebookSnapshot]);
useEffect(() => {
setFormError(notebookSnapshotError);
}, [notebookSnapshotError]);
const submit = async (): Promise<void> => {
const clearPublishingMessage = NotificationConsoleUtils.logConsoleProgress(`Publishing ${name} to gallery`);
setIsExecuting(true);
let startKey: number;
if (!notebookName || !notebookDescription || !author || !imageSrc) {
setFormError(t(Keys.panes.publishNotebook.publishFailedError, { notebookName }));
setFormErrorDetail("Name, description, author and cover image are required");
createFormError(formError, formErrorDetail, "PublishNotebookPaneAdapter/submit");
setIsExecuting(false);
return;
}
try {
startKey = traceStart(Action.NotebooksGalleryPublish, {});
const response = await junoClient.publishNotebook(
notebookName,
notebookDescription,
notebookTags?.split(","),
imageSrc,
content,
);
const data = response.data;
if (data) {
let isPublishPending = false;
if (data.pendingScanJobIds?.length > 0) {
isPublishPending = true;
NotificationConsoleUtils.logConsoleInfo(
`Content of ${name} is currently being scanned for illegal content. It will not be available in the public gallery until the review is complete (may take a few days).`,
);
} else {
NotificationConsoleUtils.logConsoleInfo(`Published ${notebookName} to gallery`);
container.openGallery(GalleryTab.Published);
}
traceSuccess(
Action.NotebooksGalleryPublish,
{
notebookId: data.id,
isPublishPending,
},
startKey,
);
}
} catch (error) {
traceFailure(
Action.NotebooksGalleryPublish,
{
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey,
);
const errorMessage = getErrorMessage(error);
setFormError(
t(Keys.panes.publishNotebook.publishFailedError, {
notebookName: FileSystemUtil.stripExtension(notebookName, "ipynb"),
}),
);
setFormErrorDetail(`${errorMessage}`);
handleError(errorMessage, "PublishNotebookPaneAdapter/submit", formError);
return;
} finally {
clearPublishingMessage();
setIsExecuting(false);
}
closeSidePanel();
};
const createFormError = (formError: string, formErrorDetail: string, area: string): void => {
setFormError(formError);
setFormErrorDetail(formErrorDetail);
handleError(formErrorDetail, area, formError);
};
const clearFormError = (): void => {
setFormError("");
setFormErrorDetail("");
};
const props: RightPaneFormProps = {
formError: formError,
isExecuting: isExecuting,
submitButtonText: "Publish",
onSubmit: () => submit(),
isSubmitButtonHidden: !isCodeOfConductAccepted,
};
const publishNotebookPaneProps: PublishNotebookPaneProps = {
notebookDescription,
notebookTags,
imageSrc,
notebookName,
notebookAuthor: author,
notebookCreatedDate: new Date().toISOString(),
notebookObject: notebookObject,
notebookContentRef,
onError: createFormError,
clearFormError: clearFormError,
setNotebookName,
setNotebookDescription,
setNotebookTags,
setImageSrc,
onTakeSnapshot,
};
return (
<RightPaneForm {...props}>
{!isCodeOfConductAccepted ? (
<div style={{ padding: "25px", marginTop: "10px" }}>
<CodeOfConduct
junoClient={junoClient}
onAcceptCodeOfConduct={(isAccepted) => {
setIsCodeOfConductAccepted(isAccepted);
}}
/>
</div>
) : (
<PublishNotebookPaneComponent {...publishNotebookPaneProps} />
)}
</RightPaneForm>
);
};
@@ -1,264 +0,0 @@
import { Dropdown, IDropdownProps, ITextFieldProps, Stack, Text, TextField } from "@fluentui/react";
import { ImmutableNotebook } from "@nteract/commutable";
import React, { FunctionComponent, useState } from "react";
import { Keys, t } from "Localization";
import { GalleryCardComponent } from "../../Controls/NotebookGallery/Cards/GalleryCardComponent";
import * as FileSystemUtil from "../../Notebook/FileSystemUtil";
import { SnapshotRequest } from "../../Notebook/NotebookComponent/types";
import { NotebookUtil } from "../../Notebook/NotebookUtil";
import "./styled.less";
export interface PublishNotebookPaneProps {
notebookName: string;
notebookAuthor: string;
notebookTags: string;
notebookDescription: string;
notebookCreatedDate: string;
notebookObject: ImmutableNotebook;
notebookContentRef: string;
imageSrc: string;
onError: (formError: string, formErrorDetail: string, area: string) => void;
clearFormError: () => void;
setNotebookName: (newValue: string) => void;
setNotebookDescription: (newValue: string) => void;
setNotebookTags: (newValue: string) => void;
setImageSrc: (newValue: string) => void;
onTakeSnapshot: (request: SnapshotRequest) => void;
}
enum ImageTypes {
Url = "URL",
CustomImage = "Custom Image",
TakeScreenshot = "Take Screenshot",
UseFirstDisplayOutput = "Use First Display Output",
}
export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPaneProps> = ({
notebookName,
notebookTags,
notebookDescription,
notebookAuthor,
notebookCreatedDate,
notebookObject,
notebookContentRef,
imageSrc,
onError,
clearFormError,
setNotebookName,
setNotebookDescription,
setNotebookTags,
setImageSrc,
onTakeSnapshot,
}: PublishNotebookPaneProps) => {
const [type, setType] = useState<string>(ImageTypes.CustomImage);
const CARD_WIDTH = 256;
const cardImageHeight = 144;
const cardHeightToWidthRatio = cardImageHeight / CARD_WIDTH;
const maxImageSizeInMib = 1.5;
const descriptionPara1 = t(Keys.panes.publishNotebook.publishDescription);
const descriptionPara2 = t(Keys.panes.publishNotebook.publishPrompt, {
name: FileSystemUtil.stripExtension(notebookName, "ipynb"),
});
const options: ImageTypes[] = [ImageTypes.CustomImage, ImageTypes.Url];
if (onTakeSnapshot) {
options.push(ImageTypes.TakeScreenshot);
if (notebookObject) {
options.push(ImageTypes.UseFirstDisplayOutput);
}
}
const thumbnailSelectorProps: IDropdownProps = {
label: t(Keys.panes.publishNotebook.coverImage),
selectedKey: type,
ariaLabel: t(Keys.panes.publishNotebook.coverImage),
options: options.map((value: string) => ({ text: value, key: value })),
onChange: async (event, options) => {
setImageSrc("");
clearFormError();
if (options.text === ImageTypes.TakeScreenshot) {
onTakeSnapshot({
aspectRatio: cardHeightToWidthRatio,
requestId: new Date().getTime().toString(),
type: "notebook",
notebookContentRef,
});
} else if (options.text === ImageTypes.UseFirstDisplayOutput) {
const cellIds = NotebookUtil.findCodeCellWithDisplay(notebookObject);
if (cellIds.length > 0) {
onTakeSnapshot({
aspectRatio: cardHeightToWidthRatio,
requestId: new Date().getTime().toString(),
type: "celloutput",
cellId: cellIds[0],
notebookContentRef,
});
} else {
firstOutputErrorHandler(new Error(t(Keys.panes.publishNotebook.outputDoesNotExist)));
}
}
setType(options.text);
},
};
const thumbnailUrlProps: ITextFieldProps = {
label: t(Keys.panes.publishNotebook.coverImageUrl),
ariaLabel: t(Keys.panes.publishNotebook.coverImageUrl),
required: true,
onChange: (event, newValue) => {
setImageSrc(newValue);
},
};
const firstOutputErrorHandler = (error: Error) => {
const formError = t(Keys.panes.publishNotebook.failedToCaptureOutput);
const formErrorDetail = `${error}`;
const area = "PublishNotebookPaneComponent/UseFirstOutput";
onError(formError, formErrorDetail, area);
};
const imageToBase64 = (file: File, updateImageSrc: (result: string) => void) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
updateImageSrc(reader.result.toString());
};
reader.onerror = (error) => {
const formError = t(Keys.panes.publishNotebook.failedToConvertError, { fileName: file.name });
const formErrorDetail = `${error}`;
const area = "PublishNotebookPaneComponent/selectImageFile";
onError(formError, formErrorDetail, area);
};
};
const renderThumbnailSelectors = (type: string) => {
switch (type) {
case ImageTypes.Url:
return <TextField {...thumbnailUrlProps} />;
case ImageTypes.CustomImage:
return (
<input
id="selectImageFile"
type="file"
accept="image/*"
onChange={(event) => {
const file = event.target.files[0];
if (file.size / 1024 ** 2 > maxImageSizeInMib) {
event.target.value = "";
const formError = t(Keys.panes.publishNotebook.failedToUploadError, { fileName: file.name });
const formErrorDetail = `Image is larger than ${maxImageSizeInMib} MiB. Please Choose a different image.`;
const area = "PublishNotebookPaneComponent/selectImageFile";
onError(formError, formErrorDetail, area);
setImageSrc("");
return;
} else {
clearFormError();
}
imageToBase64(file, (result: string) => {
setImageSrc(result);
});
}}
/>
);
default:
return <></>;
}
};
return (
<div className="publishNotebookPanelContent">
<Stack className="panelMainContent" tokens={{ childrenGap: 20 }}>
<Stack.Item>
<Text>{descriptionPara1}</Text>
</Stack.Item>
<Stack.Item>
<Text>{descriptionPara2}</Text>
</Stack.Item>
<Stack.Item>
<TextField
label={t(Keys.panes.publishNotebook.name)}
ariaLabel={t(Keys.panes.publishNotebook.name)}
defaultValue={FileSystemUtil.stripExtension(notebookName, "ipynb")}
required
onChange={(event, newValue) => {
const notebookName = newValue + ".ipynb";
setNotebookName(notebookName);
}}
/>
</Stack.Item>
<Stack.Item>
<TextField
label={t(Keys.panes.publishNotebook.description)}
ariaLabel={t(Keys.panes.publishNotebook.description)}
multiline
rows={3}
required
onChange={(event, newValue) => {
setNotebookDescription(newValue);
}}
/>
</Stack.Item>
<Stack.Item>
<TextField
label={t(Keys.panes.publishNotebook.tags)}
ariaLabel={t(Keys.panes.publishNotebook.tags)}
placeholder={t(Keys.panes.publishNotebook.tagsPlaceholder)}
onChange={(event, newValue) => {
setNotebookTags(newValue);
}}
/>
</Stack.Item>
<Stack.Item>
<Dropdown {...thumbnailSelectorProps} />
</Stack.Item>
<Stack.Item>{renderThumbnailSelectors(type)}</Stack.Item>
<Stack.Item>
<Text>{t(Keys.panes.publishNotebook.preview)}</Text>
</Stack.Item>
<Stack.Item>
<GalleryCardComponent
data={{
id: undefined,
name: notebookName,
description: notebookDescription,
gitSha: undefined,
tags: notebookTags.split(","),
author: notebookAuthor,
thumbnailUrl: imageSrc,
created: notebookCreatedDate,
isSample: false,
downloads: undefined,
favorites: undefined,
views: undefined,
newCellId: undefined,
policyViolations: undefined,
pendingScanJobIds: undefined,
}}
isFavorite={undefined}
showDownload={false}
showDelete={false}
onClick={() => undefined}
onTagClick={undefined}
onFavoriteClick={undefined}
onUnfavoriteClick={undefined}
onDownloadClick={undefined}
onDeleteClick={undefined}
/>
</Stack.Item>
</Stack>
</div>
);
};
@@ -1,116 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PublishNotebookPaneComponent renders 1`] = `
<div
className="publishNotebookPanelContent"
>
<Stack
className="panelMainContent"
tokens={
{
"childrenGap": 20,
}
}
>
<StackItem>
<Text>
When published, this notebook will appear in the Azure Cosmos DB notebooks public gallery. Make sure you have removed any sensitive data or output before publishing.
</Text>
</StackItem>
<StackItem>
<Text>
Would you like to publish and share "SampleNotebook" to the gallery?
</Text>
</StackItem>
<StackItem>
<StyledTextFieldBase
ariaLabel="Name"
defaultValue="SampleNotebook"
label="Name"
onChange={[Function]}
required={true}
/>
</StackItem>
<StackItem>
<StyledTextFieldBase
ariaLabel="Description"
label="Description"
multiline={true}
onChange={[Function]}
required={true}
rows={3}
/>
</StackItem>
<StackItem>
<StyledTextFieldBase
ariaLabel="Tags"
label="Tags"
onChange={[Function]}
placeholder="Optional tag 1, Optional tag 2"
/>
</StackItem>
<StackItem>
<Dropdown
ariaLabel="Cover image"
label="Cover image"
onChange={[Function]}
options={
[
{
"key": "Custom Image",
"text": "Custom Image",
},
{
"key": "URL",
"text": "URL",
},
]
}
selectedKey="Custom Image"
/>
</StackItem>
<StackItem>
<input
accept="image/*"
id="selectImageFile"
onChange={[Function]}
type="file"
/>
</StackItem>
<StackItem>
<Text>
Preview
</Text>
</StackItem>
<StackItem>
<GalleryCardComponent
data={
{
"author": "CosmosDB",
"created": "2020-07-17T00:00:00Z",
"description": "sample description",
"downloads": undefined,
"favorites": undefined,
"gitSha": undefined,
"id": undefined,
"isSample": false,
"name": "SampleNotebook.ipynb",
"newCellId": undefined,
"pendingScanJobIds": undefined,
"policyViolations": undefined,
"tags": [
"tag1",
" tag2",
],
"thumbnailUrl": "https://i.ytimg.com/vi/E_lByLdKeKY/maxresdefault.jpg",
"views": undefined,
}
}
onClick={[Function]}
showDelete={false}
showDownload={false}
/>
</StackItem>
</Stack>
</div>
`;
@@ -1,6 +0,0 @@
.publishNotebookPanelContent {
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
}
@@ -93,7 +93,7 @@ function createDataTable(
for (var i = 0; i < tableEntityListViewModel.headers.length; i++) {
jsonColTable.push({
sTitle: tableEntityListViewModel.headers[i],
sTitle: Utilities.htmlEncode(tableEntityListViewModel.headers[i]),
data: tableEntityListViewModel.headers[i],
aTargets: [i],
mRender: bindColumn,
@@ -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 [<ErrorDataModel>{ message: JSON.stringify(err) }];
return [<ErrorDataModel>{ message: stringifyError(err) }];
}
}
+6 -5
View File
@@ -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);
},
@@ -0,0 +1,96 @@
import { useTabs } from "hooks/useTabs";
import * as ko from "knockout";
import * as ViewModels from "../../../Contracts/ViewModels";
import { DocumentsTabV2 } from "./DocumentsTabV2";
jest.mock("hooks/useTabs", () => ({
useTabs: {
getState: jest.fn(),
},
}));
jest.mock("UserContext", () => ({
userContext: { apiType: "SQL" },
}));
jest.mock("Explorer/Menus/CommandBar/CommandBarComponentAdapter", () => ({
useCommandBar: { getState: jest.fn(() => ({ setContextButtons: jest.fn() })) },
}));
jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
EditorReact: () => null,
}));
const mockCollection = {
id: ko.observable<string>("testContainer"),
databaseId: "testDb",
partitionKey: { paths: ["/pk"], kind: "Hash", version: 2 },
selectedSubnodeKind: jest.fn(),
container: {},
} as unknown as ViewModels.Collection;
const buildTab = () =>
new DocumentsTabV2({
partitionKey: mockCollection.partitionKey,
documentIds: ko.observableArray([]),
tabKind: ViewModels.CollectionTabKind.Documents,
title: "Items",
collection: mockCollection,
node: mockCollection,
tabPath: "testDb>testContainer>Documents",
});
describe("DocumentsTabV2.duplicateTab", () => {
let activateNewTab: jest.Mock;
beforeEach(() => {
activateNewTab = jest.fn();
(useTabs.getState as jest.Mock).mockReturnValue({ activateNewTab });
});
afterEach(() => jest.clearAllMocks());
it("calls activateNewTab with a new DocumentsTabV2 instance", () => {
const tab = buildTab();
tab.duplicateTab();
expect(activateNewTab).toHaveBeenCalledTimes(1);
const newTab = activateNewTab.mock.calls[0][0];
expect(newTab).toBeInstanceOf(DocumentsTabV2);
});
it("creates a duplicate with the same collection", () => {
const tab = buildTab();
tab.duplicateTab();
const newTab = activateNewTab.mock.calls[0][0] as DocumentsTabV2;
expect(newTab.collection).toBe(mockCollection);
});
it("creates a duplicate with the same partitionKey", () => {
const tab = buildTab();
tab.duplicateTab();
const newTab = activateNewTab.mock.calls[0][0] as DocumentsTabV2;
expect(newTab.partitionKey).toEqual(mockCollection.partitionKey);
});
it("creates a distinct tab instance", () => {
const tab = buildTab();
tab.duplicateTab();
const newTab = activateNewTab.mock.calls[0][0];
expect(newTab).not.toBe(tab);
});
it("preserves the raw title (not the display title) to avoid double-prefixing", () => {
const tab = buildTab();
tab.duplicateTab();
const newTab = activateNewTab.mock.calls[0][0] as DocumentsTabV2;
// The original tabTitle() is "testC…Items" (collection "testContainer" is > 8 chars so it's truncated).
// If duplicateTab() incorrectly used tabTitle() as the new title, the duplicate's tabTitle()
// would double-prefix to "testC…testC…Items". Using the raw title "Items" keeps it "testC…Items".
expect(newTab.tabTitle()).toBe("testC\u2026Items");
});
});
@@ -49,6 +49,8 @@ import { userContext } from "UserContext";
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
import { 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<DocumentId>([]),
tabKind: ViewModels.CollectionTabKind.Documents,
title: this.title,
collection: this.collection,
node: this.collection,
tabPath: `${this.collection.databaseId}>${this.collection.id()}>Documents`,
isPreferredApiMongoDB: userContext.apiType === "Mongo",
resourceTokenPartitionKey: this.resourceTokenPartitionKey,
});
useTabs.getState().activateNewTab(newTab);
}
public render(): JSX.Element {
return (
<DocumentsTabComponent
-36
View File
@@ -1,36 +0,0 @@
import React from "react";
import type { DatabaseAccount } from "../../Contracts/DataModels";
import type { TabOptions } from "../../Contracts/ViewModels";
import type { IGalleryItem, JunoClient } from "../../Juno/JunoClient";
import { GalleryAndNotebookViewerComponent as GalleryViewer } from "../Controls/NotebookGallery/GalleryAndNotebookViewerComponent";
import type { GalleryTab as GalleryViewerTab } from "../Controls/NotebookGallery/GalleryViewerComponent";
import { SortBy } from "../Controls/NotebookGallery/GalleryViewerComponent";
import type Explorer from "../Explorer";
import TabsBase from "./TabsBase";
interface Props {
account: DatabaseAccount;
container: Explorer;
junoClient: JunoClient;
selectedTab: GalleryViewerTab;
notebookUrl?: string;
galleryItem?: IGalleryItem;
isFavorite?: boolean;
}
export default class GalleryTab extends TabsBase {
constructor(
options: TabOptions,
private props: Props,
) {
super(options);
}
public render() {
return <GalleryViewer {...this.props} sortBy={SortBy.MostRecent} searchText={undefined} />;
}
public getContainer(): Explorer {
return this.props.container;
}
}
@@ -2,6 +2,7 @@ import { ActionType, TabKind } from "Contracts/ActionContracts";
import React from "react";
import 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 <QueryTabComponent {...this.iMongoQueryTabComponentProps} />;
}
+1 -51
View File
@@ -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;
@@ -0,0 +1,104 @@
import { useTabs } from "hooks/useTabs";
import * as ko from "knockout";
import * as ViewModels from "../../../Contracts/ViewModels";
import { IQueryTabProps, NewQueryTab } from "./QueryTab";
jest.mock("hooks/useTabs", () => ({
useTabs: {
getState: jest.fn(),
},
}));
jest.mock("Explorer/Menus/CommandBar/CommandBarComponentAdapter", () => ({
useCommandBar: { getState: jest.fn(() => ({ setContextButtons: jest.fn() })) },
}));
jest.mock("Shared/AppStatePersistenceUtility", () => ({
loadState: jest.fn(),
AppStateComponentNames: {},
readSubComponentState: jest.fn(),
}));
jest.mock("Common/MessageHandler", () => ({
sendMessage: jest.fn(),
}));
const mockCollection = {
id: ko.observable<string>("testContainer"),
databaseId: "testDb",
partitionKey: { paths: ["/pk"], kind: "Hash", version: 2 },
selectedSubnodeKind: jest.fn(),
container: {},
} as unknown as ViewModels.Collection;
const mockProps = { container: {} as IQueryTabProps["container"] };
const buildTab = (queryText = "SELECT * FROM c") =>
new NewQueryTab(
{
tabKind: ViewModels.CollectionTabKind.Query,
title: "Query 1",
tabPath: "",
collection: mockCollection,
node: mockCollection,
queryText,
partitionKey: mockCollection.partitionKey,
},
mockProps,
);
describe("NewQueryTab.duplicateTab", () => {
let activateNewTab: jest.Mock;
let getTabs: jest.Mock;
beforeEach(() => {
activateNewTab = jest.fn();
getTabs = jest.fn().mockReturnValue([]);
(useTabs.getState as jest.Mock).mockReturnValue({ activateNewTab, getTabs });
});
afterEach(() => jest.clearAllMocks());
it("calls activateNewTab with a new NewQueryTab instance", () => {
const tab = buildTab();
tab.duplicateTab();
expect(activateNewTab).toHaveBeenCalledTimes(1);
const newTab = activateNewTab.mock.calls[0][0];
expect(newTab).toBeInstanceOf(NewQueryTab);
});
it("preserves the current query text in the duplicate", () => {
const queryText = "SELECT * FROM c WHERE c.id = '123'";
const tab = buildTab(queryText);
tab.duplicateTab();
const newTab = activateNewTab.mock.calls[0][0] as NewQueryTab;
expect(newTab.iQueryTabComponentProps.queryText).toBe(queryText);
});
it("creates a duplicate with the same collection", () => {
const tab = buildTab();
tab.duplicateTab();
const newTab = activateNewTab.mock.calls[0][0] as NewQueryTab;
expect(newTab.collection).toBe(mockCollection);
});
it("creates a distinct tab instance", () => {
const tab = buildTab();
tab.duplicateTab();
const newTab = activateNewTab.mock.calls[0][0];
expect(newTab).not.toBe(tab);
});
it("assigns an auto-incremented title based on existing query tabs", () => {
getTabs.mockReturnValue([{}, {}]); // 2 existing tabs → new title = "Query 3"
const tab = buildTab();
tab.duplicateTab();
const newTab = activateNewTab.mock.calls[0][0] as NewQueryTab;
expect(newTab.tabTitle()).toContain("Query 3");
});
});
+26 -1
View File
@@ -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 <QueryTabComponent {...this.iQueryTabComponentProps} />;
}
+20 -1
View File
@@ -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 = (
<li
data-test={`TabNav:${tab !== undefined ? tab.tabId : ReactTabKind[tabKind!]}`}
onMouseOver={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
className={active ? "active tabList" : "tabList"}
@@ -155,6 +158,22 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
</span>
</li>
);
if (!tab?.canDuplicate()) {
return liElement;
}
return (
<Menu openOnContext>
<MenuTrigger disableButtonEnhancement>{liElement}</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem onClick={() => tab.duplicateTab()}>{t(Keys.tabs.tabMenu.duplicateTab)}</MenuItem>
<MenuItem onClick={() => tab.onCloseTabButtonClick()}>{t(Keys.tabs.tabMenu.closeTab)}</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
);
}
const onKeyPressReactTabClose = (e: KeyboardEvent, tabKind: ReactTabKind): void => {
+8
View File
@@ -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, {
+2 -22
View File
@@ -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<number>;
public galleryContentRoot: NotebookContentItem;
public myNotebooksContentRoot: NotebookContentItem;
public gitHubNotebooksContentRoot: NotebookContentItem;
@@ -102,11 +100,6 @@ export class ResourceTreeAdapter implements ReactAdapter {
public async initialize(): Promise<void[]> {
const refreshTasks: Promise<void>[] = [];
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
-5
View File
@@ -1,5 +0,0 @@
@import "../../less/Common/Constants";
.standalone-gallery-root {
background: @GalleryBackgroundColor;
}
-65
View File
@@ -1,65 +0,0 @@
import { initializeIcons, Link, Text } from "@fluentui/react";
import "bootstrap/dist/css/bootstrap.css";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { userContext } from "UserContext";
import { initializeConfiguration } from "../ConfigContext";
import { GalleryHeaderComponent } from "../Explorer/Controls/Header/GalleryHeaderComponent";
import {
GalleryAndNotebookViewerComponent,
GalleryAndNotebookViewerComponentProps,
} from "../Explorer/Controls/NotebookGallery/GalleryAndNotebookViewerComponent";
import { GalleryTab, SortBy } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
import { JunoClient } from "../Juno/JunoClient";
import * as GalleryUtils from "../Utils/GalleryUtils";
import "./GalleryViewer.less";
const enableNotebooksUrl = "https://aka.ms/cosmos-enable-notebooks";
const createAccountUrl = "https://aka.ms/cosmos-create-account-portal";
const onInit = async () => {
const dataExplorerUrl = new URL("./", window.location.href).href;
initializeIcons();
await initializeConfiguration();
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search);
const props: GalleryAndNotebookViewerComponentProps = {
junoClient: new JunoClient(),
selectedTab:
galleryViewerProps.selectedTab ||
(userContext.features.publicGallery ? GalleryTab.PublicGallery : GalleryTab.OfficialSamples),
sortBy: galleryViewerProps.sortBy || SortBy.MostRecent,
searchText: galleryViewerProps.searchText,
};
const element = (
<div className="standalone-gallery-root">
<header>
<GalleryHeaderComponent />
</header>
<div style={{ margin: "auto", width: "85%" }}>
<div style={{ paddingLeft: 26, paddingRight: 26, paddingTop: 20 }}>
<Text block>
Welcome to the Azure Cosmos DB notebooks gallery! View the sample notebooks to learn about use cases, best
practices, and how to get started with Azure Cosmos DB.
</Text>
<Text styles={{ root: { marginTop: 10 } }} block>
If {`you'd`} like to run or edit the notebook in your own Azure Cosmos DB account,{" "}
<Link href={dataExplorerUrl}>sign in</Link> and select an account with{" "}
<Link href={enableNotebooksUrl}>notebooks enabled</Link>. From there, you can download the sample to your
account. If you {`don't`} have an account yet, you can{" "}
<Link href={createAccountUrl}>create one from the Azure portal</Link>.
</Text>
</div>
<GalleryAndNotebookViewerComponent {...props} />
</div>
</div>
);
ReactDOM.render(element, document.getElementById("galleryContent"));
};
// Entry point
window.addEventListener("load", onInit);
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="height=device-height, width=device-width, initial-scale=1.0" />
<title>Gallery Viewer</title>
<link rel="shortcut icon" href="../../images/CosmosDB_rgb_ui_lighttheme.ico" type="image/x-icon" />
</head>
<body style="overflow-y: scroll">
<div class="galleryContent" id="galleryContent"></div>
</body>
</html>
+246
View File
@@ -0,0 +1,246 @@
jest.mock("./hooks/useAADAuth");
jest.mock("./hooks/useConfig");
jest.mock("./hooks/usePortalAccessToken");
jest.mock("./Platform/Hosted/Components/ConnectExplorer");
jest.mock("./Shared/appInsights");
jest.mock("./Platform/Hosted/Components/AccountSwitcher", () => ({
AccountSwitcher: () => null,
}));
jest.mock("./Platform/Hosted/Components/DirectoryPickerPanel", () => ({
DirectoryPickerPanel: () => null,
}));
jest.mock("./Platform/Hosted/Components/FeedbackCommandButton", () => ({
FeedbackCommandButton: () => null,
}));
jest.mock("./Platform/Hosted/Components/MeControl", () => ({
MeControl: () => null,
}));
jest.mock("./Platform/Hosted/Components/SignInButton", () => ({
SignInButton: () => null,
}));
jest.mock("./Platform/Hosted/Components/AadAuthorizationFailure", () => ({
AadAuthorizationFailure: () => null,
}));
import "@testing-library/jest-dom";
import { act, render } from "@testing-library/react";
import React from "react";
import { useAADAuth } from "./hooks/useAADAuth";
import { useConfig } from "./hooks/useConfig";
import { useTokenMetadata } from "./hooks/usePortalAccessToken";
import { App } from "./HostedExplorer";
import { ConnectExplorer, fetchEncryptedToken } from "./Platform/Hosted/Components/ConnectExplorer";
const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction<typeof fetchEncryptedToken>;
(ConnectExplorer as jest.Mock).mockImplementation(() => <div data-testid="connect-explorer" />);
import type { AccountInfo } from "@azure/msal-browser";
import type { AadAuthFailure } from "./hooks/useAADAuth";
const defaultAADAuth = {
isLoggedIn: false,
armToken: "",
graphToken: "",
account: undefined as AccountInfo | null | undefined,
tenantId: "",
logout: jest.fn(),
login: jest.fn(),
switchTenant: jest.fn(),
authFailure: undefined as AadAuthFailure | null | undefined,
};
beforeEach(() => {
jest.clearAllMocks();
(useAADAuth as jest.Mock).mockReturnValue(defaultAADAuth);
(useConfig as jest.Mock).mockReturnValue({});
(useTokenMetadata as jest.Mock).mockReturnValue(undefined);
mockFetchEncryptedToken.mockResolvedValue("encrypted-token");
});
const dispatchPostMessage = (data: unknown, origin: string) => {
const event = new MessageEvent("message", { data, origin });
window.dispatchEvent(event);
};
describe("HostedExplorer tryCosmosDB postMessage handler", () => {
it("accepts a valid SQL connection string from an allowed origin", async () => {
render(<App />);
const validConnStr = "AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=dGVzdGtleQ==;";
await act(async () => {
dispatchPostMessage(
{ type: "tryCosmosDBConnectionString", connectionString: validConnStr },
"https://cosmos.azure.com",
);
await Promise.resolve();
});
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(validConnStr);
});
it("accepts a valid Mongo connection string from an allowed origin", async () => {
render(<App />);
const mongoConnStr = "mongodb://myaccount:dGVzdGtleQ==@myaccount.documents.azure.com:10255";
await act(async () => {
dispatchPostMessage(
{ type: "tryCosmosDBConnectionString", connectionString: mongoConnStr },
"https://cosmos.azure.com",
);
await Promise.resolve();
});
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(mongoConnStr);
});
it("accepts a valid Cassandra connection string from an allowed origin", async () => {
render(<App />);
const cassandraConnStr =
"AccountEndpoint=https://myaccount.cassandra.cosmosdb.azure.com:443/;AccountKey=dGVzdGtleQ==;";
await act(async () => {
dispatchPostMessage(
{ type: "tryCosmosDBConnectionString", connectionString: cassandraConnStr },
"https://cosmos.azure.com",
);
await Promise.resolve();
});
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(cassandraConnStr);
});
it("accepts a valid Table connection string from an allowed origin", async () => {
render(<App />);
const tableConnStr =
"DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=dGVzdGtleQ==;TableEndpoint=https://myaccount.table.cosmosdb.azure.com:443/;";
await act(async () => {
dispatchPostMessage(
{ type: "tryCosmosDBConnectionString", connectionString: tableConnStr },
"https://cosmos.azure.com",
);
await Promise.resolve();
});
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(tableConnStr);
});
it("accepts a valid Gremlin connection string from an allowed origin", async () => {
render(<App />);
const gremlinConnStr =
"AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=dGVzdGtleQ==;ApiKind=Gremlin;";
await act(async () => {
dispatchPostMessage(
{ type: "tryCosmosDBConnectionString", connectionString: gremlinConnStr },
"https://cosmos.azure.com",
);
await Promise.resolve();
});
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(gremlinConnStr);
});
it("rejects messages from a disallowed origin", async () => {
render(<App />);
const validConnStr = "AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=dGVzdGtleQ==;";
await act(async () => {
dispatchPostMessage(
{ type: "tryCosmosDBConnectionString", connectionString: validConnStr },
"https://evil.example.com",
);
});
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
});
it("rejects messages with an invalid connection string format", async () => {
render(<App />);
await act(async () => {
dispatchPostMessage(
{ type: "tryCosmosDBConnectionString", connectionString: "not-a-real-connection-string" },
"https://cosmos.azure.com",
);
});
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
});
it("rejects messages with a non-string connection string value", async () => {
render(<App />);
await act(async () => {
dispatchPostMessage({ type: "tryCosmosDBConnectionString", connectionString: 12345 }, "https://cosmos.azure.com");
});
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
});
it("rejects messages with a missing connection string", async () => {
render(<App />);
await act(async () => {
dispatchPostMessage({ type: "tryCosmosDBConnectionString" }, "https://cosmos.azure.com");
});
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
});
it("ignores messages with an unrelated type", async () => {
render(<App />);
const validConnStr = "AccountEndpoint=https://myaccount.documents.azure.com:443/;AccountKey=dGVzdGtleQ==;";
await act(async () => {
dispatchPostMessage({ type: "someOtherMessage", connectionString: validConnStr }, "https://cosmos.azure.com");
});
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
});
it("sends tryCosmosDBReady to opener when present", () => {
const mockPostMessage = jest.fn();
const originalOpener = window.opener;
Object.defineProperty(window, "opener", {
value: { postMessage: mockPostMessage },
writable: true,
configurable: true,
});
render(<App />);
expect(mockPostMessage).toHaveBeenCalledWith({ type: "tryCosmosDBReady" }, "https://cosmos.azure.com");
Object.defineProperty(window, "opener", {
value: originalOpener,
writable: true,
configurable: true,
});
});
it("does not crash when there is no opener", () => {
const originalOpener = window.opener;
Object.defineProperty(window, "opener", {
value: null,
writable: true,
configurable: true,
});
expect(() => render(<App />)).not.toThrow();
Object.defineProperty(window, "opener", {
value: originalOpener,
writable: true,
configurable: true,
});
});
});
+82 -14
View File
@@ -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<HTMLIFrameElement>();
const connectWithConnectionString = React.useCallback(
(connStr: string) => {
if (!connStr || authType) {
return;
}
setConnectionString(connStr);
if (isResourceTokenConnectionString(connStr)) {
setAuthType(AuthType.ResourceToken);
} else {
fetchEncryptedToken(connStr)
.then((token) => {
setEncryptedToken(token);
setAuthType(AuthType.ConnectionString);
})
.catch((error) => {
logError(
`Failed to connect with connection string: ${error}`,
"HostedExplorer/connectWithConnectionString",
);
});
}
},
[authType],
);
// Listen for connection string sent via postMessage (from TryCosmosDB)
React.useEffect(() => {
const MSG_READY = "tryCosmosDBReady";
const MSG_CONNECTION_STRING = "tryCosmosDBConnectionString";
// Signal to the opener that we are ready to receive a connection string
if (window.opener) {
try {
for (const origin of allowedHostedExplorerEndpoints) {
window.opener.postMessage({ type: MSG_READY }, origin);
}
} catch {
// opener may be cross-origin, ignore
}
}
const handler = (event: MessageEvent) => {
if (!allowedHostedExplorerEndpoints.includes(event.origin)) {
return;
}
if (event.data?.type === MSG_CONNECTION_STRING) {
const connStr: string = event.data.connectionString;
if (parseConnectionString(connStr)) {
connectWithConnectionString(connStr);
}
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [connectWithConnectionString]);
React.useEffect(() => {
// 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) && (
<img className="chevronRight" src={ChevronRight} alt="account separator" />
)}
{isLoggedIn && (
{isLoggedIn && !connectionString && (
<span className="accountSwitchComponentContainer">
<AccountSwitcher armToken={armToken} setDatabaseAccount={setDatabaseAccount} />
</span>
)}
{!isLoggedIn && encryptedTokenMetadata?.accountName && (
{(!isLoggedIn || connectionString) && encryptedTokenMetadata?.accountName && (
<span className="accountSwitchComponentContainer">
<span className="accountNameHeader">{encryptedTokenMetadata?.accountName}</span>
</span>
@@ -127,7 +188,9 @@ const App: React.FunctionComponent = () => {
// It's possible this can be changed once all knockout code has been removed.
<iframe
// Setting key is needed so React will re-render this element on any account change
key={databaseAccount?.id || encryptedTokenMetadata?.accountName || authType}
key={
authType ? `${authType}-${encryptedTokenMetadata?.accountName || connectionString}` : databaseAccount?.id
}
ref={ref}
data-test="DataExplorerFrame"
id="explorerMenu"
@@ -148,4 +211,9 @@ const App: React.FunctionComponent = () => {
);
};
render(<App />, document.getElementById("App"));
export { App };
const appElement = document.getElementById("App");
if (appElement) {
render(<App />, appElement);
}
+2 -297
View File
@@ -1,24 +1,5 @@
import { HttpHeaders, HttpStatusCodes } from "../Common/Constants";
import { DatabaseAccount } from "../Contracts/DataModels";
import { updateUserContext, userContext } from "../UserContext";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
import { IPinnedRepo, IPublishNotebookRequest, JunoClient } from "./JunoClient";
const sampleSubscriptionId = "subscriptionId";
const sampleDatabaseAccount: DatabaseAccount = {
id: "id",
name: "name",
location: "location",
type: "type",
kind: "kind",
properties: {
documentEndpoint: "documentEndpoint",
gremlinEndpoint: "gremlinEndpoint",
tableEndpoint: "tableEndpoint",
cassandraEndpoint: "cassandraEndpoint",
},
};
import { HttpStatusCodes } from "../Common/Constants";
import { IPinnedRepo, JunoClient } from "./JunoClient";
const samplePinnedRepos: IPinnedRepo[] = [
{
@@ -130,279 +111,3 @@ describe("GitHub", () => {
expect(fetchUrlParams.get("client_id")).toBeDefined();
});
});
describe("Gallery", () => {
const junoClient = new JunoClient();
const originalSubscriptionId = userContext.subscriptionId;
beforeAll(() => {
updateUserContext({
databaseAccount: {
name: "name",
} as DatabaseAccount,
subscriptionId: sampleSubscriptionId,
});
});
afterEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
updateUserContext({ subscriptionId: originalSubscriptionId });
});
it("getSampleNotebooks", async () => {
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as undefined,
});
const response = await junoClient.getSampleNotebooks();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toHaveBeenCalledWith(
`${JunoClient.getJunoEndpoint()}/api/notebooks/gallery/samples`,
undefined,
);
});
it("getPublicNotebooks", async () => {
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as undefined,
});
const response = await junoClient.getPublicNotebooks();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toHaveBeenCalledWith(
`${JunoClient.getJunoEndpoint()}/api/notebooks/gallery/public`,
undefined,
);
});
it("getNotebook", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as undefined,
});
const response = await junoClient.getNotebookInfo(id);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toHaveBeenCalledWith(`${JunoClient.getJunoEndpoint()}/api/notebooks/gallery/${id}`);
});
it("getNotebookContent", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
text: () => undefined as undefined,
});
const response = await junoClient.getNotebookContent(id);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toHaveBeenCalledWith(`${JunoClient.getJunoEndpoint()}/api/notebooks/gallery/${id}/content`);
});
it("increaseNotebookViews", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as undefined,
});
const response = await junoClient.increaseNotebookViews(id);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toHaveBeenCalledWith(`${JunoClient.getJunoEndpoint()}/api/notebooks/gallery/${id}/views`, {
method: "PATCH",
});
});
it("increaseNotebookDownloadCount", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as undefined,
});
const response = await junoClient.increaseNotebookDownloadCount(id);
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toHaveBeenCalledWith(
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
sampleDatabaseAccount.name
}/gallery/${id}/downloads`,
{
method: "PATCH",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
[HttpHeaders.contentType]: "application/json",
},
},
);
});
it("favoriteNotebook", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as undefined,
});
const response = await junoClient.favoriteNotebook(id);
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toHaveBeenCalledWith(
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
sampleDatabaseAccount.name
}/gallery/${id}/favorite`,
{
method: "PATCH",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
[HttpHeaders.contentType]: "application/json",
},
},
);
});
it("unfavoriteNotebook", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as undefined,
});
const response = await junoClient.unfavoriteNotebook(id);
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toHaveBeenCalledWith(
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
sampleDatabaseAccount.name
}/gallery/${id}/unfavorite`,
{
method: "PATCH",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
[HttpHeaders.contentType]: "application/json",
},
},
);
});
it("getFavoriteNotebooks", async () => {
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as undefined,
});
const response = await junoClient.getFavoriteNotebooks();
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toHaveBeenCalledWith(
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
sampleDatabaseAccount.name
}/gallery/favorites`,
{
headers: {
[authorizationHeader.header]: authorizationHeader.token,
[HttpHeaders.contentType]: "application/json",
},
},
);
});
it("getPublishedNotebooks", async () => {
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as undefined,
});
const response = await junoClient.getPublishedNotebooks();
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toHaveBeenCalledWith(
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
sampleDatabaseAccount.name
}/gallery/published`,
{
headers: {
[authorizationHeader.header]: authorizationHeader.token,
[HttpHeaders.contentType]: "application/json",
},
},
);
});
it("deleteNotebook", async () => {
const id = "id";
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as undefined,
});
const response = await junoClient.deleteNotebook(id);
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toHaveBeenCalledWith(
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
sampleDatabaseAccount.name
}/gallery/${id}`,
{
method: "DELETE",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
[HttpHeaders.contentType]: "application/json",
},
},
);
});
it("publishNotebook", async () => {
const name = "name";
const description = "description";
const tags = ["tag"];
const thumbnailUrl = "thumbnailUrl";
const content = `{ "key": "value" }`;
const addLinkToNotebookViewer = true;
window.fetch = jest.fn().mockReturnValue({
status: HttpStatusCodes.OK,
json: () => undefined as undefined,
});
const response = await junoClient.publishNotebook(name, description, tags, thumbnailUrl, content);
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toHaveBeenCalledWith(
`${JunoClient.getJunoEndpoint()}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${
sampleDatabaseAccount.name
}/gallery`,
{
method: "PUT",
headers: {
[authorizationHeader.header]: authorizationHeader.token,
[HttpHeaders.contentType]: "application/json",
},
body: JSON.stringify({
name,
description,
tags,
thumbnailUrl,
content: JSON.parse(content),
addLinkToNotebookViewer,
} as IPublishNotebookRequest),
},
);
});
});
-253
View File
@@ -44,30 +44,6 @@ export interface IGalleryItem {
pendingScanJobIds: string[];
}
export interface IPublicGalleryData {
metadata: IPublicGalleryMetaData;
notebooksData: IGalleryItem[];
}
export interface IPublicGalleryMetaData {
acceptedCodeOfConduct: boolean;
}
export interface IUserGallery {
favorites: string[];
published: string[];
}
// Only exported for unit test
export interface IPublishNotebookRequest {
name: string;
description: string;
tags: string[];
thumbnailUrl: string;
content: unknown;
addLinkToNotebookViewer: boolean;
}
export class JunoClient {
private cachedPinnedRepos: ko.Observable<IPinnedRepo[]>;
@@ -176,90 +152,6 @@ export class JunoClient {
};
}
public async getSampleNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/samples`);
}
public async getPublicNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/public`);
}
public async getPublicGalleryData(): Promise<IJunoResponse<IPublicGalleryData>> {
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/public`;
const response = await window.fetch(url, { headers: JunoClient.getHeaders() });
let data: IPublicGalleryData;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data,
};
}
public async acceptCodeOfConduct(): Promise<IJunoResponse<boolean>> {
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/acceptCodeOfConduct`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders(),
});
let data: boolean;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data,
};
}
public async isCodeOfConductAccepted(): Promise<IJunoResponse<boolean>> {
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/isCodeOfConductAccepted`;
const response = await window.fetch(url, { headers: JunoClient.getHeaders() });
let data: boolean;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data,
};
}
public async getNotebookInfo(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(this.getNotebookInfoUrl(id));
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data,
};
}
public async getNotebookContent(id: string): Promise<IJunoResponse<string>> {
const response = await window.fetch(this.getNotebookContentUrl(id));
let data: string;
if (response.status === HttpStatusCodes.OK) {
data = await response.text();
}
return {
status: response.status,
data,
};
}
public async increaseNotebookViews(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}/views`, {
method: "PATCH",
@@ -276,151 +168,6 @@ export class JunoClient {
};
}
public async increaseNotebookDownloadCount(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}/downloads`, {
method: "PATCH",
headers: JunoClient.getHeaders(),
});
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data,
};
}
public async favoriteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}/favorite`, {
method: "PATCH",
headers: JunoClient.getHeaders(),
});
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data,
};
}
public async unfavoriteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}/unfavorite`, {
method: "PATCH",
headers: JunoClient.getHeaders(),
});
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data,
};
}
public async getFavoriteNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
return await this.getNotebooks(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/favorites`, {
headers: JunoClient.getHeaders(),
});
}
public async getPublishedNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
return await this.getNotebooks(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/published`, {
headers: JunoClient.getHeaders(),
});
}
public async deleteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}`, {
method: "DELETE",
headers: JunoClient.getHeaders(),
});
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data,
};
}
public async publishNotebook(
name: string,
description: string,
tags: string[],
thumbnailUrl: string,
content: string,
): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery`, {
method: "PUT",
headers: JunoClient.getHeaders(),
body: JSON.stringify({
name,
description,
tags,
thumbnailUrl,
content: JSON.parse(content),
addLinkToNotebookViewer: true,
} as IPublishNotebookRequest),
});
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
} else {
throw new Error(`HTTP status ${response.status} thrown. ${(await response.json()).Message}`);
}
return {
status: response.status,
data,
};
}
public getNotebookContentUrl(id: string): string {
return `${this.getNotebooksUrl()}/gallery/${id}/content`;
}
public getNotebookInfoUrl(id: string): string {
return `${this.getNotebooksUrl()}/gallery/${id}`;
}
public async reportAbuse(notebookId: string, abuseCategory: string, notes: string): Promise<IJunoResponse<boolean>> {
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/reportAbuse`, {
method: "POST",
body: JSON.stringify({
notebookId,
abuseCategory,
notes,
}),
headers: {
[HttpHeaders.contentType]: "application/json",
},
});
let data: boolean;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data,
};
}
public async requestSchema(
schemaRequest: DataModels.ISchemaRequest,
): Promise<IJunoResponse<DataModels.ISchemaRequest>> {
+21 -2
View File
@@ -316,6 +316,10 @@
"deleteUdf": "Delete User Defined Function"
},
"tabs": {
"tabMenu": {
"duplicateTab": "Duplicate tab",
"closeTab": "Close tab"
},
"documents": {
"newItem": "New Item",
"newDocument": "New Document",
@@ -760,7 +764,6 @@
"settings": "Settings",
"indexingPolicy": "Indexing Policy",
"partitionKeys": "Partition Keys",
"partitionKeysPreview": "Partition Keys (preview)",
"computedProperties": "Computed Properties",
"containerPolicies": "Container Policies",
"throughputBuckets": "Throughput Buckets",
@@ -895,6 +898,10 @@
},
"partitionKeyEditor": {
"changePartitionKey": "Change {{partitionKeyName}}",
"confirmCancel1": "You are about to cancel the following copy job.",
"confirmCancel2": "Cancelling will stop the job immediately.",
"confirmComplete1": "You are about to complete the following copy job.",
"confrimComplete2": "Once completed, continuous data copy will stop after any pending documents are processed. To maintain data integrity, we recommend stopping updates to the source container before completing the job.",
"currentPartitionKey": "Current {{partitionKeyName}}",
"partitioning": "Partitioning",
"hierarchical": "Hierarchical",
@@ -1033,6 +1040,7 @@
"description": "Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started)."
}
},
"migrationTypeTitle": "Migration type",
"selectContainers": {
"description": "Please select a source container and a destination container to copy to.",
"sourceContainerSubHeading": "Source container",
@@ -1117,7 +1125,8 @@
"buttonText": "Enable Online Copy",
"validateAllVersionsAndDeletesChangeFeedSpinnerLabel": "Validating All versions and deletes change feed mode (preview)...",
"enablingAllVersionsAndDeletesChangeFeedSpinnerLabel": "Enabling All versions and deletes change feed mode (preview)...",
"enablingOnlineCopySpinnerLabel": "Enabling online copy on your \"{{accountName}}\" account ..."
"enablingOnlineCopySpinnerLabel": "Enabling online copy on your \"{{accountName}}\" account ...",
"onlineMigrationPrerequisitesMessage": "Online migration prerequisites must be enabled before proceeding."
},
"monitorJobs": {
"columns": {
@@ -1152,6 +1161,16 @@
"confirmButtonText": "Confirm",
"cancelButtonText": "Cancel"
}
},
"dataTransfers": {
"polling": {
"cancelConsoleMessage": "Data transfer job \"{{jobName}}\" cancelled",
"completedConsoleMessage": "Data transfer job \"{{jobName}}\" completed",
"defaultErrorMessage": "Operation could not be completed",
"errorConsoleMessage": "Data transfer job \"{{jobName}}\" failed: {{errorMessage}}",
"pauseConsoleMessage": "Data transfer job \"{{jobName}}\" paused",
"retryConsoleMessage": "Data transfer job \"{{jobName}}\" in progress, total count: {{totalCount}}, processed count: {{processedCount}}"
}
}
}
}
-1
View File
@@ -44,7 +44,6 @@ export type Features = {
phoenixNotebooks?: boolean;
phoenixFeatures?: boolean;
notebooksDownBanner: boolean;
publicGallery?: boolean;
};
export function extractFeatures(given = new URLSearchParams(window.location.search)): Features {
+3 -2
View File
@@ -1,5 +1,6 @@
import * as msal from "@azure/msal-browser";
import { getEnvironmentScopeEndpoint } from "Common/EnvironmentUtility";
import { stringifyError } from "Common/stringifyError";
import { Action, ActionModifiers } from "Shared/Telemetry/TelemetryConstants";
import { hasProxyServer, isDataplaneRbacSupported } from "Utils/APITypeUtils";
import { AuthType } from "../AuthType";
@@ -154,9 +155,9 @@ export async function acquireMsalTokenForAccount(
traceFailure(Action.SignInAad, {
request: JSON.stringify(loginRequest),
acquireTokenType: silent ? "silent" : "interactive",
errorMessage: JSON.stringify(error),
errorMessage: stringifyError(error),
});
traceFailure(Action.AcquireMsalToken, { error: JSON.stringify(error) }, msalStartKey);
traceFailure(Action.AcquireMsalToken, { error: stringifyError(error) }, msalStartKey);
// Mark expected failure for health metrics so timeout emits healthy
if (isExpectedError(error)) {
scenarioMonitor.markExpectedFailure();
+4 -1
View File
@@ -83,7 +83,10 @@ export const allowedEmulatorEndpoints: ReadonlyArray<string> = ["https://localho
export const allowedArcadiaEndpoints: ReadonlyArray<string> = ["https://workspaceartifacts.projectarcadia.net"];
export const allowedHostedExplorerEndpoints: ReadonlyArray<string> = ["https://cosmos.azure.com/"];
export const allowedHostedExplorerEndpoints: ReadonlyArray<string> = [
"https://cosmos.azure.com",
...(process.env.NODE_ENV === "development" ? ["https://localhost:12900"] : []),
];
export const allowedMsalRedirectEndpoints: ReadonlyArray<string> = ["https://dataexplorer-preview.azurewebsites.net/"];
+136
View File
@@ -0,0 +1,136 @@
import { fetchWithTimeout, tryFetchWithTimeout } from "./FetchWithTimeout";
describe("fetchWithTimeout", () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
jest.useRealTimers();
});
it("forwards init and resolves with the fetch response", async () => {
const fakeResponse = { ok: true } as Response;
const fetchMock = jest.fn().mockResolvedValue(fakeResponse);
global.fetch = fetchMock as unknown as typeof global.fetch;
const result = await fetchWithTimeout("https://example.com", { method: "GET" }, 1000);
expect(result).toBe(fakeResponse);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://example.com");
expect((init as RequestInit).method).toBe("GET");
expect((init as RequestInit).signal).toBeDefined();
});
it("aborts the fetch when the internal timeout fires", async () => {
jest.useFakeTimers();
let receivedSignal: AbortSignal | undefined;
global.fetch = jest.fn().mockImplementation((_url: string, init: RequestInit) => {
receivedSignal = init.signal ?? undefined;
return new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener("abort", () => {
const err = new Error("aborted");
err.name = "AbortError";
reject(err);
});
});
}) as unknown as typeof global.fetch;
const pending = fetchWithTimeout("https://example.com", {}, 50);
jest.advanceTimersByTime(50);
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(receivedSignal?.aborted).toBe(true);
});
it("throws immediately when the external signal is already aborted", async () => {
const fetchMock = jest.fn();
global.fetch = fetchMock as unknown as typeof global.fetch;
const controller = new AbortController();
const reason = new Error("user-cancelled");
controller.abort(reason);
await expect(fetchWithTimeout("https://example.com", { signal: controller.signal }, 1000)).rejects.toBe(reason);
expect(fetchMock).not.toHaveBeenCalled();
});
it("aborts mid-fetch when the external signal aborts and propagates the reason", async () => {
const reason = new Error("user-cancelled");
let internalSignal: AbortSignal | undefined;
global.fetch = jest.fn().mockImplementation((_url: string, init: RequestInit) => {
internalSignal = init.signal ?? undefined;
return new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener("abort", () => reject(init.signal?.reason ?? new Error("aborted")));
});
}) as unknown as typeof global.fetch;
const controller = new AbortController();
const pending = fetchWithTimeout("https://example.com", { signal: controller.signal }, 60000);
// Let the fetch wire up its abort listener before triggering.
await Promise.resolve();
controller.abort(reason);
await expect(pending).rejects.toBe(reason);
expect(internalSignal?.aborted).toBe(true);
});
it("skips the timer when timeoutMs is Infinity", async () => {
const setTimeoutSpy = jest.spyOn(global, "setTimeout");
global.fetch = jest.fn().mockResolvedValue({ ok: true } as Response) as unknown as typeof global.fetch;
await fetchWithTimeout("https://example.com", {}, Infinity);
expect(setTimeoutSpy).not.toHaveBeenCalled();
setTimeoutSpy.mockRestore();
});
it("cleans up the timer and external listener after a successful fetch", async () => {
const clearTimeoutSpy = jest.spyOn(global, "clearTimeout");
global.fetch = jest.fn().mockResolvedValue({ ok: true } as Response) as unknown as typeof global.fetch;
const controller = new AbortController();
const removeListenerSpy = jest.spyOn(controller.signal, "removeEventListener");
await fetchWithTimeout("https://example.com", { signal: controller.signal }, 1000);
expect(clearTimeoutSpy).toHaveBeenCalled();
expect(removeListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function));
clearTimeoutSpy.mockRestore();
});
it("cleans up the timer and listener when the fetch rejects", async () => {
const clearTimeoutSpy = jest.spyOn(global, "clearTimeout");
const networkError = new Error("network down");
global.fetch = jest.fn().mockRejectedValue(networkError) as unknown as typeof global.fetch;
const controller = new AbortController();
const removeListenerSpy = jest.spyOn(controller.signal, "removeEventListener");
await expect(fetchWithTimeout("https://example.com", { signal: controller.signal }, 1000)).rejects.toBe(
networkError,
);
expect(clearTimeoutSpy).toHaveBeenCalled();
expect(removeListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function));
clearTimeoutSpy.mockRestore();
});
});
describe("tryFetchWithTimeout", () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
it("returns null on fetch failure", async () => {
global.fetch = jest.fn().mockRejectedValue(new Error("boom")) as unknown as typeof global.fetch;
await expect(tryFetchWithTimeout("https://example.com")).resolves.toBeNull();
});
it("returns the response on success", async () => {
const response = { ok: true } as Response;
global.fetch = jest.fn().mockResolvedValue(response) as unknown as typeof global.fetch;
await expect(tryFetchWithTimeout("https://example.com")).resolves.toBe(response);
});
});
+26 -4
View File
@@ -3,6 +3,11 @@
*
* Usage: await fetchWithTimeout(url, { method: 'GET', headers: {...} }, 10000);
*
* If `init.signal` is provided, it is combined with the internal timeout: aborting
* the caller's signal aborts the fetch (propagating the caller's abort reason), and
* the timeout still applies. Pass `timeoutMs: Infinity` to disable the timeout entirely
* (useful for long-running operations that should rely solely on caller cancellation).
*
* A shared helper to remove duplicated inline implementations across the codebase.
*/
export async function fetchWithTimeout(
@@ -10,13 +15,30 @@ export async function fetchWithTimeout(
init: RequestInit = {},
timeoutMs: number = 5000,
): Promise<Response> {
const externalSignal = init.signal;
if (externalSignal?.aborted) {
throw externalSignal.reason ?? new DOMException("The operation was aborted.", "AbortError");
}
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
const hasTimeout = Number.isFinite(timeoutMs);
const timeoutId = hasTimeout ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
let onExternalAbort: (() => void) | undefined;
if (externalSignal) {
onExternalAbort = () => controller.abort(externalSignal.reason);
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
}
try {
const response = await fetch(url, { ...init, signal: controller.signal });
return response;
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(id);
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
if (externalSignal && onExternalAbort) {
externalSignal.removeEventListener("abort", onExternalAbort);
}
}
}
-113
View File
@@ -1,113 +0,0 @@
import { HttpStatusCodes } from "../Common/Constants";
import { useDialog } from "../Explorer/Controls/Dialog";
import { GalleryTab, SortBy } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
import Explorer from "../Explorer/Explorer";
import { useNotebook } from "../Explorer/Notebook/useNotebook";
import { IGalleryItem, JunoClient } from "../Juno/JunoClient";
import * as GalleryUtils from "./GalleryUtils";
const galleryItem: IGalleryItem = {
id: "id",
name: "name",
description: "description",
gitSha: "gitSha",
tags: ["tag1"],
author: "author",
thumbnailUrl: "thumbnailUrl",
created: "created",
isSample: false,
downloads: 0,
favorites: 0,
views: 0,
newCellId: undefined,
policyViolations: undefined,
pendingScanJobIds: undefined,
};
describe("GalleryUtils", () => {
afterEach(() => {
jest.resetAllMocks();
});
it("downloadItem shows dialog in data explorer", () => {
const container = new Explorer();
GalleryUtils.downloadItem(container, undefined, galleryItem, undefined);
expect(useDialog.getState().visible).toBe(true);
expect(useDialog.getState().dialogProps).toBeDefined();
expect(useDialog.getState().dialogProps.title).toBe(`Download to ${useNotebook.getState().notebookFolderName}`);
});
it("favoriteItem favorites item", async () => {
const container = {} as Explorer;
const junoClient = new JunoClient();
junoClient.favoriteNotebook = jest
.fn()
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: galleryItem }));
const onComplete = jest.fn().mockImplementation();
await GalleryUtils.favoriteItem(container, junoClient, galleryItem, onComplete);
expect(junoClient.favoriteNotebook).toHaveBeenCalledWith(galleryItem.id);
expect(onComplete).toHaveBeenCalledWith(galleryItem);
});
it("unfavoriteItem unfavorites item", async () => {
const container = {} as Explorer;
const junoClient = new JunoClient();
junoClient.unfavoriteNotebook = jest
.fn()
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: galleryItem }));
const onComplete = jest.fn().mockImplementation();
await GalleryUtils.unfavoriteItem(container, junoClient, galleryItem, onComplete);
expect(junoClient.unfavoriteNotebook).toHaveBeenCalledWith(galleryItem.id);
expect(onComplete).toHaveBeenCalledWith(galleryItem);
});
it("deleteItem shows dialog in data explorer", () => {
const container = {} as Explorer;
GalleryUtils.deleteItem(container, undefined, galleryItem, undefined);
expect(useDialog.getState().visible).toBe(true);
expect(useDialog.getState().dialogProps).toBeDefined();
expect(useDialog.getState().dialogProps.title).toBe("Remove published notebook");
});
it("getGalleryViewerProps gets gallery viewer props correctly", () => {
const selectedTab: GalleryTab = GalleryTab.OfficialSamples;
const sortBy: SortBy = SortBy.MostDownloaded;
const searchText = "my-complicated%20search%20query!!!";
const response = GalleryUtils.getGalleryViewerProps(
`?${GalleryUtils.GalleryViewerParams.SelectedTab}=${GalleryTab[selectedTab]}&${GalleryUtils.GalleryViewerParams.SortBy}=${SortBy[sortBy]}&${GalleryUtils.GalleryViewerParams.SearchText}=${searchText}`,
);
expect(response).toEqual({
selectedTab,
sortBy,
searchText: decodeURIComponent(searchText),
} as GalleryUtils.GalleryViewerProps);
});
it("getNotebookViewerProps gets notebook viewer props correctly", () => {
const notebookUrl = "https%3A%2F%2Fnotebook.url";
const galleryItemId = "1234-abcd-efgh";
const hideInputs = "true";
const response = GalleryUtils.getNotebookViewerProps(
`?${GalleryUtils.NotebookViewerParams.NotebookUrl}=${notebookUrl}&${GalleryUtils.NotebookViewerParams.GalleryItemId}=${galleryItemId}&${GalleryUtils.NotebookViewerParams.HideInputs}=${hideInputs}`,
);
expect(response).toEqual({
notebookUrl: decodeURIComponent(notebookUrl),
galleryItemId,
hideInputs: true,
} as GalleryUtils.NotebookViewerProps);
});
it("getTabTitle returns correct title for official samples", () => {
expect(GalleryUtils.getTabTitle(GalleryTab.OfficialSamples)).toBe("Official samples");
});
});
-528
View File
@@ -1,528 +0,0 @@
import { IChoiceGroupOption, IChoiceGroupProps, IProgressIndicatorProps } from "@fluentui/react";
import { Notebook } from "@nteract/commutable";
import { NotebookV4 } from "@nteract/commutable/lib/v4";
import { HttpStatusCodes } from "../Common/Constants";
import { getErrorMessage, getErrorStack, handleError } from "../Common/ErrorHandlingUtils";
import { TextFieldProps, useDialog } from "../Explorer/Controls/Dialog";
import {
GalleryTab,
GalleryViewerComponent,
SortBy,
} from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
import Explorer from "../Explorer/Explorer";
import { useNotebook } from "../Explorer/Notebook/useNotebook";
import { IGalleryItem, JunoClient } from "../Juno/JunoClient";
import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants";
import { trace, traceFailure, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor";
import { logConsoleInfo, logConsoleProgress } from "./NotificationConsoleUtils";
const defaultSelectedAbuseCategory = "Other";
const abuseCategories: IChoiceGroupOption[] = [
{
key: "ChildEndangermentExploitation",
text: "Child endangerment or exploitation",
},
{
key: "ContentInfringement",
text: "Content infringement",
},
{
key: "OffensiveContent",
text: "Offensive content",
},
{
key: "Terrorism",
text: "Terrorism",
},
{
key: "ThreatsCyberbullyingHarassment",
text: "Threats, cyber bullying or harassment",
},
{
key: "VirusSpywareMalware",
text: "Virus, spyware or malware",
},
{
key: "Fraud",
text: "Fraud",
},
{
key: "HateSpeech",
text: "Hate speech",
},
{
key: "ImminentHarmToPersonsOrProperty",
text: "Imminent harm to persons or property",
},
{
key: "Other",
text: "Other",
},
];
export enum NotebookViewerParams {
NotebookUrl = "notebookUrl",
GalleryItemId = "galleryItemId",
HideInputs = "hideInputs",
}
export interface NotebookViewerProps {
notebookUrl: string;
galleryItemId: string;
hideInputs: boolean;
}
export enum GalleryViewerParams {
SelectedTab = "tab",
SortBy = "sort",
SearchText = "q",
}
export interface GalleryViewerProps {
selectedTab: GalleryTab;
sortBy: SortBy;
searchText: string;
}
export interface DialogHost {
showOkModalDialog(
title: string,
msg: string,
okLabel: string,
onOk: () => void,
progressIndicatorProps?: IProgressIndicatorProps,
): void;
showOkCancelModalDialog(
title: string,
msg: string,
okLabel: string,
onOk: () => void,
cancelLabel: string,
onCancel: () => void,
progressIndicatorProps?: IProgressIndicatorProps,
choiceGroupProps?: IChoiceGroupProps,
textFieldProps?: TextFieldProps,
primaryButtonDisabled?: boolean,
): void;
}
export function reportAbuse(
junoClient: JunoClient,
data: IGalleryItem,
dialogHost: DialogHost,
onComplete: (success: boolean) => void,
): void {
trace(Action.NotebooksGalleryClickReportAbuse, ActionModifiers.Mark, { notebookId: data.id });
const notebookId = data.id;
let abuseCategory = defaultSelectedAbuseCategory;
let additionalDetails: string;
dialogHost.showOkCancelModalDialog(
"Report Abuse",
undefined,
"Report Abuse",
async () => {
dialogHost.showOkCancelModalDialog(
"Report Abuse",
`Submitting your report on ${data.name} violating code of conduct`,
"Reporting...",
undefined,
"Cancel",
undefined,
{},
undefined,
undefined,
true,
);
const startKey = traceStart(Action.NotebooksGalleryReportAbuse, { notebookId: data.id, abuseCategory });
try {
const response = await junoClient.reportAbuse(notebookId, abuseCategory, additionalDetails);
if (response.status !== HttpStatusCodes.Accepted) {
throw new Error(`Received HTTP ${response.status} when submitting report for ${data.name}`);
}
dialogHost.showOkModalDialog(
"Report Abuse",
`Your report on ${data.name} has been submitted. Thank you for reporting the violation.`,
"OK",
undefined,
{
percentComplete: 1,
},
);
traceSuccess(Action.NotebooksGalleryReportAbuse, { notebookId: data.id, abuseCategory }, startKey);
onComplete(response.data);
} catch (error) {
traceFailure(
Action.NotebooksGalleryReportAbuse,
{
notebookId: data.id,
abuseCategory,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey,
);
handleError(
error,
"GalleryUtils/reportAbuse",
`Failed to submit report on ${data.name} violating code of conduct`,
);
dialogHost.showOkModalDialog(
"Report Abuse",
`Failed to submit report on ${data.name} violating code of conduct`,
"OK",
undefined,
{
percentComplete: 1,
},
);
}
},
"Cancel",
undefined,
undefined,
{
label: "How does this content violate the code of conduct?",
options: abuseCategories,
defaultSelectedKey: defaultSelectedAbuseCategory,
onChange: (_event?: React.FormEvent<HTMLElement | HTMLInputElement>, option?: IChoiceGroupOption) => {
abuseCategory = option?.key;
},
},
{
label: "You can also include additional relevant details on the offensive content",
multiline: true,
rows: 3,
autoAdjustHeight: false,
onChange: (_event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string) => {
additionalDetails = newValue;
},
},
);
}
export function downloadItem(
container: Explorer,
junoClient: JunoClient,
data: IGalleryItem,
onComplete: (item: IGalleryItem) => void,
): void {
trace(Action.NotebooksGalleryClickDownload, ActionModifiers.Mark, {
notebookId: data.id,
downloadCount: data.downloads,
isSample: data.isSample,
});
const name = data.name;
useDialog.getState().showOkCancelModalDialog(
`Download to ${useNotebook.getState().notebookFolderName}`,
undefined,
"Download",
async () => {
if (useNotebook.getState().isPhoenixNotebooks) {
await container.allocateContainer();
}
const notebookServerInfo = useNotebook.getState().notebookServerInfo;
if (notebookServerInfo && notebookServerInfo.notebookServerEndpoint !== undefined) {
downloadNotebookItem(name, data, junoClient, container, onComplete);
} else {
useDialog
.getState()
.showOkModalDialog(
"Failed to connect",
"Failed to connect to temporary workspace. Please refresh the page and try again.",
);
}
},
"Cancel",
undefined,
container.getDownloadModalContent(name),
);
}
export async function downloadNotebookItem(
fileName: string,
data: IGalleryItem,
junoClient: JunoClient,
container: Explorer,
onComplete: (item: IGalleryItem) => void,
) {
const clearInProgressMessage = logConsoleProgress(
`Downloading ${fileName} to ${useNotebook.getState().notebookFolderName}`,
);
const startKey = traceStart(Action.NotebooksGalleryDownload, {
notebookId: data.id,
downloadCount: data.downloads,
isSample: data.isSample,
});
try {
const response = await junoClient.getNotebookContent(data.id);
if (!response.data) {
throw new Error(`Received HTTP ${response.status} when fetching ${data.name}`);
}
const notebook = JSON.parse(response.data) as Notebook;
removeNotebookViewerLink(notebook, data.newCellId);
if (!data.isSample) {
const metadata = notebook.metadata as { [name: string]: unknown };
metadata.untrusted = true;
}
await container.importAndOpenContent(data.name, JSON.stringify(notebook));
logConsoleInfo(`Successfully downloaded ${data.name} to ${useNotebook.getState().notebookFolderName}`);
const increaseDownloadResponse = await junoClient.increaseNotebookDownloadCount(data.id);
if (increaseDownloadResponse.data) {
traceSuccess(
Action.NotebooksGalleryDownload,
{ notebookId: data.id, downloadCount: increaseDownloadResponse.data.downloads, isSample: data.isSample },
startKey,
);
onComplete(increaseDownloadResponse.data);
}
} catch (error) {
traceFailure(
Action.NotebooksGalleryDownload,
{
notebookId: data.id,
downloadCount: data.downloads,
isSample: data.isSample,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey,
);
handleError(error, "GalleryUtils/downloadItem", `Failed to download ${data.name}`);
}
clearInProgressMessage();
}
export const removeNotebookViewerLink = (notebook: Notebook, newCellId: string): void => {
if (!newCellId) {
return;
}
const notebookV4 = notebook as NotebookV4;
if (notebookV4?.cells[0]?.source[0]?.search(newCellId)) {
notebookV4.cells.splice(0, 1);
notebook = notebookV4;
}
};
export async function favoriteItem(
container: Explorer,
junoClient: JunoClient,
data: IGalleryItem,
onComplete: (item: IGalleryItem) => void,
): Promise<void> {
if (container) {
const startKey = traceStart(Action.NotebooksGalleryFavorite, {
notebookId: data.id,
isSample: data.isSample,
favoriteCount: data.favorites,
});
try {
const response = await junoClient.favoriteNotebook(data.id);
if (!response.data) {
throw new Error(`Received HTTP ${response.status} when favoriting ${data.name}`);
}
traceSuccess(
Action.NotebooksGalleryFavorite,
{ notebookId: data.id, isSample: data.isSample, favoriteCount: response.data.favorites },
startKey,
);
onComplete(response.data);
} catch (error) {
traceFailure(
Action.NotebooksGalleryFavorite,
{
notebookId: data.id,
isSample: data.isSample,
favoriteCount: data.favorites,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey,
);
handleError(error, "GalleryUtils/favoriteItem", `Failed to favorite ${data.name}`);
}
}
}
export async function unfavoriteItem(
container: Explorer,
junoClient: JunoClient,
data: IGalleryItem,
onComplete: (item: IGalleryItem) => void,
): Promise<void> {
if (container) {
const startKey = traceStart(Action.NotebooksGalleryUnfavorite, {
notebookId: data.id,
isSample: data.isSample,
favoriteCount: data.favorites,
});
try {
const response = await junoClient.unfavoriteNotebook(data.id);
if (!response.data) {
throw new Error(`Received HTTP ${response.status} when unfavoriting ${data.name}`);
}
traceSuccess(
Action.NotebooksGalleryUnfavorite,
{ notebookId: data.id, isSample: data.isSample, favoriteCount: response.data.favorites },
startKey,
);
onComplete(response.data);
} catch (error) {
traceFailure(
Action.NotebooksGalleryUnfavorite,
{
notebookId: data.id,
isSample: data.isSample,
favoriteCount: data.favorites,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey,
);
handleError(error, "GalleryUtils/unfavoriteItem", `Failed to unfavorite ${data.name}`);
}
}
}
export function deleteItem(
container: Explorer,
junoClient: JunoClient,
data: IGalleryItem,
onComplete: (item: IGalleryItem) => void,
beforeDelete?: () => void,
afterDelete?: () => void,
): void {
if (container) {
trace(Action.NotebooksGalleryClickDelete, ActionModifiers.Mark, { notebookId: data.id });
useDialog.getState().showOkCancelModalDialog(
"Remove published notebook",
`Would you like to remove ${data.name} from the gallery?`,
"Remove",
async () => {
if (beforeDelete) {
beforeDelete();
}
const name = data.name;
const clearInProgressMessage = logConsoleProgress(`Removing ${name} from gallery`);
const startKey = traceStart(Action.NotebooksGalleryDelete, { notebookId: data.id });
try {
const response = await junoClient.deleteNotebook(data.id);
if (!response.data) {
throw new Error(`Received HTTP ${response.status} while removing ${name}`);
}
traceSuccess(Action.NotebooksGalleryDelete, { notebookId: data.id }, startKey);
logConsoleInfo(`Successfully removed ${name} from gallery`);
onComplete(response.data);
} catch (error) {
traceFailure(
Action.NotebooksGalleryDelete,
{ notebookId: data.id, error: getErrorMessage(error), errorStack: getErrorStack(error) },
startKey,
);
handleError(error, "GalleryUtils/deleteItem", `Failed to remove ${name} from gallery`);
} finally {
if (afterDelete) {
afterDelete();
}
}
clearInProgressMessage();
},
"Cancel",
undefined,
);
}
}
export function getGalleryViewerProps(search: string): GalleryViewerProps {
const params = new URLSearchParams(search);
let selectedTab: GalleryTab;
if (params.has(GalleryViewerParams.SelectedTab)) {
selectedTab = GalleryTab[params.get(GalleryViewerParams.SelectedTab) as keyof typeof GalleryTab];
}
let sortBy: SortBy;
if (params.has(GalleryViewerParams.SortBy)) {
sortBy = SortBy[params.get(GalleryViewerParams.SortBy) as keyof typeof SortBy];
}
return {
selectedTab,
sortBy,
searchText: params.get(GalleryViewerParams.SearchText),
};
}
export function getNotebookViewerProps(search: string): NotebookViewerProps {
const params = new URLSearchParams(search);
return {
notebookUrl: params.get(NotebookViewerParams.NotebookUrl),
galleryItemId: params.get(NotebookViewerParams.GalleryItemId),
hideInputs: JSON.parse(params.get(NotebookViewerParams.HideInputs)),
};
}
export function getTabTitle(tab: GalleryTab): string {
switch (tab) {
case GalleryTab.PublicGallery:
return GalleryViewerComponent.PublicGalleryTitle;
case GalleryTab.OfficialSamples:
return GalleryViewerComponent.OfficialSamplesTitle;
case GalleryTab.Favorites:
return GalleryViewerComponent.FavoritesTitle;
case GalleryTab.Published:
return GalleryViewerComponent.PublishedTitle;
default:
throw new Error(`Unknown tab ${tab}`);
}
}
export function filterPublishedNotebooks(items: IGalleryItem[]): {
published: IGalleryItem[];
underReview: IGalleryItem[];
removed: IGalleryItem[];
} {
const underReview: IGalleryItem[] = [];
const removed: IGalleryItem[] = [];
const published: IGalleryItem[] = [];
items?.forEach((item) => {
if (item.policyViolations?.length > 0) {
removed.push(item);
} else if (item.pendingScanJobIds?.length > 0) {
underReview.push(item);
} else {
published.push(item);
}
});
return { published, underReview, removed };
}
@@ -6,9 +6,10 @@
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
*/
import { stringifyError } from "Common/stringifyError";
import { configContext } from "../../../../ConfigContext";
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
/* Lists the SQL databases under an existing Azure Cosmos DB database account. */
@@ -18,7 +19,14 @@ export async function listSqlDatabases(
accountName: string,
): Promise<Types.SqlDatabaseListResult> {
const path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/sqlDatabases`;
return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "GET", apiVersion });
console.log("{{cdbp}} in listSqlDatabases(): path: " + path);
try {
console.log("{{cdbp}} in listSqlDatabases(): calling armRequest");
return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "GET", apiVersion });
} catch (error) {
console.log("{{cdbp}} in listSqlDatabases(): ERROR: " + stringifyError(error));
throw error;
}
}
/* Gets the SQL database under an existing Azure Cosmos DB database account with the provided name. */
+196
View File
@@ -75,4 +75,200 @@ describe("ARM request", () => {
armRequest({ apiVersion: "2001-01-01", host: "https://foo.com", path: "foo", method: "GET" }),
).rejects.toThrow("No authority token provided");
});
describe("timeout and retry behavior", () => {
beforeEach(() => {
updateUserContext({
authType: AuthType.AAD,
authorizationToken: "some-token",
});
});
const makeAbortError = () => Object.assign(new Error("aborted"), { name: "AbortError" });
const okResponse = () => ({
ok: true,
headers: new Headers(),
json: async () => ({}),
});
it("forwards timeoutMs to the underlying fetch timer", async () => {
const setTimeoutSpy = jest.spyOn(global, "setTimeout");
window.fetch = jest.fn().mockResolvedValue(okResponse());
await armRequest({
apiVersion: "2001-01-01",
host: "https://foo.com",
path: "foo",
method: "POST",
timeoutMs: 12345,
});
const timeoutValues = setTimeoutSpy.mock.calls.map((c) => c[1]);
expect(timeoutValues).toContain(12345);
setTimeoutSpy.mockRestore();
});
it("uses the default 5000ms timeout when timeoutMs is not provided", async () => {
const setTimeoutSpy = jest.spyOn(global, "setTimeout");
window.fetch = jest.fn().mockResolvedValue(okResponse());
await armRequest({ apiVersion: "2001-01-01", host: "https://foo.com", path: "foo", method: "POST" });
const timeoutValues = setTimeoutSpy.mock.calls.map((c) => c[1]);
expect(timeoutValues).toContain(5000);
setTimeoutSpy.mockRestore();
});
it("skips the timer when timeoutMs is Infinity", async () => {
const setTimeoutSpy = jest.spyOn(global, "setTimeout");
window.fetch = jest.fn().mockResolvedValue(okResponse());
await armRequest({
apiVersion: "2001-01-01",
host: "https://foo.com",
path: "foo",
method: "POST",
timeoutMs: Infinity,
});
// No timer should be created by fetchWithTimeout.
expect(setTimeoutSpy).not.toHaveBeenCalled();
setTimeoutSpy.mockRestore();
});
it("retries GET on timeout with escalating timeouts and eventually succeeds", async () => {
const setTimeoutSpy = jest.spyOn(global, "setTimeout");
const fetchMock = jest
.fn()
.mockRejectedValueOnce(makeAbortError())
.mockRejectedValueOnce(makeAbortError())
.mockResolvedValueOnce(okResponse());
window.fetch = fetchMock;
await armRequest({
apiVersion: "2001-01-01",
host: "https://foo.com",
path: "foo",
method: "GET",
timeoutMs: 1000,
});
expect(fetchMock).toHaveBeenCalledTimes(3);
const timeoutValues = setTimeoutSpy.mock.calls.map((c) => c[1]);
// Each attempt creates a setTimeout for its escalating timeout (1x, 2x, 4x).
expect(timeoutValues).toContain(1000);
expect(timeoutValues).toContain(2000);
expect(timeoutValues).toContain(4000);
setTimeoutSpy.mockRestore();
});
it("gives up after exhausting retries and rejects with the last AbortError", async () => {
const fetchMock = jest.fn().mockRejectedValue(makeAbortError());
window.fetch = fetchMock;
await expect(
armRequest({
apiVersion: "2001-01-01",
host: "https://foo.com",
path: "foo",
method: "GET",
timeoutMs: 1000,
}),
).rejects.toMatchObject({ name: "AbortError" });
// 3 attempts total (initial + 2 retries).
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("does not retry non-GET methods on timeout", async () => {
const fetchMock = jest.fn().mockRejectedValue(makeAbortError());
window.fetch = fetchMock;
for (const method of ["POST", "PUT", "PATCH", "DELETE", "HEAD"] as const) {
fetchMock.mockClear();
await expect(
armRequest({
apiVersion: "2001-01-01",
host: "https://foo.com",
path: "foo",
method,
timeoutMs: 1000,
}),
).rejects.toBeTruthy();
expect(fetchMock).toHaveBeenCalledTimes(1);
}
});
it("does not retry GET on HTTP 500 (server error)", async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ code: "InternalServerError", message: "boom" }),
});
window.fetch = fetchMock;
await expect(
armRequest({
apiVersion: "2001-01-01",
host: "https://foo.com",
path: "foo",
method: "GET",
timeoutMs: 1000,
}),
).rejects.toThrow("boom");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("stops retrying GET when the caller's signal is already aborted", async () => {
const controller = new AbortController();
const reason = new Error("user-cancelled");
controller.abort(reason);
const fetchMock = jest.fn().mockRejectedValue(makeAbortError());
window.fetch = fetchMock;
await expect(
armRequest({
apiVersion: "2001-01-01",
host: "https://foo.com",
path: "foo",
method: "GET",
timeoutMs: 1000,
signal: controller.signal,
}),
).rejects.toBeTruthy();
// fetchWithTimeout throws synchronously on already-aborted signal, so no fetch call.
expect(fetchMock).not.toHaveBeenCalled();
});
it("combines caller signal with timeout: aborting the signal cancels in-flight fetch", async () => {
const controller = new AbortController();
const reason = new Error("user-cancelled");
let receivedSignal: AbortSignal | undefined;
window.fetch = jest.fn().mockImplementation((_url: string, init: RequestInit) => {
receivedSignal = init.signal ?? undefined;
return new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener("abort", () => reject(init.signal?.reason ?? new Error("aborted")));
});
});
const pending = armRequest({
apiVersion: "2001-01-01",
host: "https://foo.com",
path: "foo",
method: "POST",
timeoutMs: 60000,
signal: controller.signal,
});
// Allow fetch to wire up its abort listener.
await Promise.resolve();
controller.abort(reason);
await expect(pending).rejects.toBe(reason);
expect(receivedSignal?.aborted).toBe(true);
});
});
});
+59 -2
View File
@@ -5,6 +5,7 @@ Instead, generate ARM clients that consume this function with stricter typing.
*/
import { stringifyError } from "Common/stringifyError";
import promiseRetry, { AbortError } from "p-retry";
import { HttpHeaders } from "../../Common/Constants";
import { configContext } from "../../ConfigContext";
@@ -50,8 +51,13 @@ interface Options {
contentType?: string;
customHeaders?: Record<string, string>;
signal?: AbortSignal;
timeoutMs?: number;
}
const DEFAULT_ARM_TIMEOUT_MS = 5000;
const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === "AbortError";
export async function armRequestWithoutPolling<T>({
host,
path,
@@ -62,6 +68,7 @@ export async function armRequestWithoutPolling<T>({
contentType,
customHeaders,
signal,
timeoutMs,
}: Options): Promise<{ result: T; operationStatusUrl: string }> {
const url = new URL(path, host);
url.searchParams.append("api-version", configContext.armAPIVersion || apiVersion);
@@ -71,6 +78,9 @@ export async function armRequestWithoutPolling<T>({
}
if (!userContext?.authorizationToken && !customHeaders?.["Authorization"]) {
console.log(
"{{cdbp}} in armRequestWithoutPolling(): condition '!userContext?.authorizationToken && !customHeaders?.['Authorization']' met, throwing 'No authority token provided' error",
);
throw new Error("No authority token provided");
}
@@ -84,10 +94,14 @@ export async function armRequestWithoutPolling<T>({
method,
headers,
body: requestBody ? JSON.stringify(requestBody) : undefined,
...(signal ? { signal } : {}),
signal,
};
const response = signal ? await window.fetch(url.href, fetchInit) : await fetchWithTimeout(url.href, fetchInit);
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_ARM_TIMEOUT_MS;
console.log(
`{{cdbp}} in armRequestWithoutPolling(): calling fetchWithRetry (method=${method}, timeoutMs=${effectiveTimeoutMs}, hasSignal=${!!signal})`,
);
const response = await fetchWithRetry(url.href, fetchInit, method, effectiveTimeoutMs, signal);
if (!response.ok) {
let error: ARMError;
@@ -101,9 +115,11 @@ export async function armRequestWithoutPolling<T>({
error.code = errorResponse.code;
}
} catch (error) {
console.log("{{cdbp}} in armRequestWithoutPolling(): ERROR: " + stringifyError(error));
throw new Error(await response.text());
}
console.log("{{cdbp}} in armRequestWithoutPolling(): ERROR: " + stringifyError(error));
throw error;
}
@@ -123,6 +139,7 @@ export async function armRequest<T>({
contentType,
customHeaders,
signal,
timeoutMs,
}: Options): Promise<T> {
const armRequestResult = await armRequestWithoutPolling<T>({
host,
@@ -134,6 +151,7 @@ export async function armRequest<T>({
contentType,
customHeaders,
signal,
timeoutMs,
});
const operationStatusUrl = armRequestResult.operationStatusUrl;
if (operationStatusUrl) {
@@ -142,6 +160,45 @@ export async function armRequest<T>({
return armRequestResult.result;
}
/**
* Calls `fetchWithTimeout` once for non-idempotent methods. For idempotent GETs, retries
* up to {@link RETRY_TIMEOUT_MULTIPLIERS}.length attempts on timeout, escalating the timeout
* on each attempt. HTTP error responses (4xx/5xx) are NOT retried they are surfaced by the
* caller's response.ok check. External `signal` cancellation aborts the retry loop immediately.
*/
async function fetchWithRetry(
url: string,
fetchInit: RequestInit,
method: Options["method"],
timeoutMs: number,
signal?: AbortSignal,
): Promise<Response> {
if (method !== "GET") {
return fetchWithTimeout(url, fetchInit, timeoutMs);
}
const RETRY_TIMEOUT_MULTIPLIERS = [1, 2, 4];
const RETRY_BACKOFF_MS = 100;
return promiseRetry(
(attemptNumber: number) => {
const attemptTimeoutMs =
timeoutMs * RETRY_TIMEOUT_MULTIPLIERS[Math.min(attemptNumber - 1, RETRY_TIMEOUT_MULTIPLIERS.length - 1)];
console.log(`{{cdbp}} in fetchWithRetry(): calling fetchWithTimeout: attempt=${attemptNumber} url=${url}`);
return fetchWithTimeout(url, fetchInit, attemptTimeoutMs);
},
{
retries: RETRY_TIMEOUT_MULTIPLIERS.length - 1,
factor: 1,
minTimeout: RETRY_BACKOFF_MS,
maxTimeout: RETRY_BACKOFF_MS,
signal,
// Only retry on timeout aborts. Caller cancellation (external signal aborted) stops retries.
shouldRetry: (error) => isAbortError(error) && !signal?.aborted,
},
);
}
async function getOperationStatus(operationStatusUrl: string) {
if (!userContext.authorizationToken) {
throw new Error("No authority token provided");
+4 -3
View File
@@ -1,5 +1,6 @@
import * as msal from "@azure/msal-browser";
import { useBoolean } from "@fluentui/react-hooks";
import { stringifyError } from "Common/stringifyError";
import * as React from "react";
import { ConfigContext } from "../ConfigContext";
import {
@@ -77,7 +78,7 @@ export function useAADAuth(config?: ConfigContext): ReturnType {
localStorage.setItem("cachedTenantId", response.tenantId);
} catch (error) {
setAuthFailure({
failureMessage: `Login failed: ${JSON.stringify(error)}`,
failureMessage: `Login failed: ${stringifyError(error)}`,
});
}
}, [msalInstance, config]);
@@ -111,7 +112,7 @@ export function useAADAuth(config?: ConfigContext): ReturnType {
localStorage.setItem("cachedTenantId", response.tenantId);
} catch (error) {
setAuthFailure({
failureMessage: `Tenant switch failed: ${JSON.stringify(error)}`,
failureMessage: `Tenant switch failed: ${stringifyError(error)}`,
});
}
},
@@ -144,7 +145,7 @@ export function useAADAuth(config?: ConfigContext): ReturnType {
failureLinkAction: acquireTokens,
});
} else {
const errorJson = JSON.stringify(error);
const errorJson = stringifyError(error);
setAuthFailure({
failureMessage: `We were unable to establish authorization for this account, due to the following error: \n${errorJson}`,
});
-3
View File
@@ -1008,9 +1008,6 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
if (inputs.flights.indexOf(Flights.NotebooksDownBanner) !== -1) {
userContext.features.notebooksDownBanner = true;
}
if (inputs.flights.indexOf(Flights.PublicGallery) !== -1) {
userContext.features.publicGallery = true;
}
}
// Handle initial theme from portal
+10
View File
@@ -616,6 +616,16 @@ export class DataExplorer {
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
/** Returns the tab navigation <li> element for the given tab ID or React tab name (e.g. "tab0", "Home") */
tabNavHeader(tabId: string): Locator {
return this.frame.getByTestId(`TabNav:${tabId}`);
}
/** Returns a context menu item in the tab right-click menu by visible label */
tabContextMenuItem(label: string): Locator {
return this.frame.getByRole("menuitem", { name: label });
}
}
export async function waitForApiResponse(
+105
View File
@@ -0,0 +1,105 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount } from "../fx";
import { createTestSQLContainer, TestContainerContext } from "../testData";
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer({ includeTestData: false });
});
test.afterAll("Delete Test Database", async () => {
await context?.dispose();
});
test.beforeEach("Open Data Explorer", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
});
test("Duplicate Items tab opens a second Items tab", async () => {
// Open Items tab
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.expand();
const itemsNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id);
await itemsNode.element.click();
const documentsTab = explorer.documentsTab("tab0");
await documentsTab.documentsFilter.waitFor({ timeout: 30_000 });
// Right-click the tab nav header
await explorer.tabNavHeader("tab0").click({ button: "right" });
// "Duplicate tab" should be visible in the context menu
const duplicateMenuItem = explorer.tabContextMenuItem("Duplicate tab");
await expect(duplicateMenuItem).toBeVisible();
await duplicateMenuItem.click();
// A second tab should appear
const tab1 = explorer.tab("tab1");
await expect(tab1).toBeAttached({ timeout: 30_000 });
// The duplicated tab should also show the Documents content
const duplicatedTab = explorer.documentsTab("tab1");
await duplicatedTab.documentsFilter.waitFor({ timeout: 30_000 });
});
test("Duplicate Query tab preserves query text in new tab", async () => {
// Open a new SQL query tab via container context menu
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.openContextMenu();
await containerNode.contextMenuItem("New SQL Query").click();
const queryTab = explorer.queryTab("tab0");
const editor = queryTab.editor();
await editor.locator.waitFor({ timeout: 30_000 });
// Type a custom query
const customQuery = 'SELECT * FROM c WHERE c.id = "duplicate-query-test"';
await editor.setText(customQuery);
// Right-click the tab nav header
await explorer.tabNavHeader("tab0").click({ button: "right" });
const duplicateMenuItem = explorer.tabContextMenuItem("Duplicate tab");
await expect(duplicateMenuItem).toBeVisible();
await duplicateMenuItem.click();
// Second query tab should appear
const tab1 = explorer.tab("tab1");
await expect(tab1).toBeAttached({ timeout: 30_000 });
// The duplicated tab should contain the same query text
const duplicatedQueryTab = explorer.queryTab("tab1");
await duplicatedQueryTab.editor().locator.waitFor({ timeout: 30_000 });
const editorText = await duplicatedQueryTab.editor().text();
expect(editorText).toContain("duplicate-query-test");
});
test("Right-click context menu does not appear for the Home tab", async () => {
// The Home tab (ReactTabKind) is never duplicable — no context menu should appear
await explorer.tabNavHeader("Home").click({ button: "right" });
// Neither menu item should be visible
await expect(explorer.tabContextMenuItem("Duplicate tab")).not.toBeVisible();
await expect(explorer.tabContextMenuItem("Close tab")).not.toBeVisible();
});
test("Close tab from right-click menu closes the tab", async () => {
// Open Items tab
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.expand();
const itemsNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id);
await itemsNode.element.click();
const documentsTab = explorer.documentsTab("tab0");
await documentsTab.documentsFilter.waitFor({ timeout: 30_000 });
// Right-click the tab nav header and close the tab
await explorer.tabNavHeader("tab0").click({ button: "right" });
await explorer.tabContextMenuItem("Close tab").click();
// The tab pane should be removed
await expect(explorer.tab("tab0")).not.toBeAttached({ timeout: 15_000 });
});
-6
View File
@@ -113,7 +113,6 @@ module.exports = function (_env = {}, argv = {}) {
hostedExplorer: "./src/HostedExplorer.tsx",
terminal: "./src/Terminal/index.ts",
cellOutputViewer: "./src/CellOutputViewer/CellOutputViewer.tsx",
galleryViewer: "./src/GalleryViewer/GalleryViewer.tsx",
selfServe: "./src/SelfServe/SelfServe.tsx",
connectToGitHub: "./src/GitHub/GitHubConnector.ts",
redirectBridge: "./src/redirectBridge.ts",
@@ -154,11 +153,6 @@ module.exports = function (_env = {}, argv = {}) {
template: "src/CellOutputViewer/cellOutputViewer.html",
chunks: ["cellOutputViewer"],
}),
new HtmlWebpackPlugin({
filename: "gallery.html",
template: "src/GalleryViewer/galleryViewer.html",
chunks: ["galleryViewer"],
}),
new HtmlWebpackPlugin({
filename: "connectToGitHub.html",
template: "src/connectToGitHub.html",