Compare commits

...

18 Commits

Author SHA1 Message Date
Asier Isayas
fd6b6f43e1 walmart block query restricted account 2023-10-25 12:55:50 -04:00
JustinKol
3a703b0bd0 Explorer null handling (#1658)
* Cassandra null value handling

* prettier run

* Added boolean and date

* Reverted null handling switch statement and added editing warning

* ran prettier

* putting new line back in

* prettier run

* changed warning message

* updated snapshot

* fixed undefined condition

* removed toString from undefined
2023-10-25 10:12:42 -04:00
jawelton74
70c031d04b Check for accounts that are restricted from connection string login. (#1673)
* Call Portal Backend to check for accounts that are restricted from
connection string login.

* Remove debug logging and revert to actual backend endpoint.

* Use React hooks for displaying error message.

* Move accountrestrictions route under guest.

* Check restrictions before resource token check.

* Revert test changes.
2023-10-23 12:04:19 -07:00
MokireddySampath
b949f60c2a Defect2271490 (#1474)
* arialabel has been added to close button of invitational youtube video

* heading role has been addedd and tag has been changed to h1

* outline has been restored to choose columns link in entities page

* header text color changed to meet luminosity ratio requirement

* capacity calculator link has been added with underline on focus

* add property is readout twice while using screenreader

* arialabel added to the add property button

* screenreader content changed to announce the entire alert

* alert message is partially readout on error

* Update fulldatatables.less

* Update queryBuilder.less

* Update TableEntity.tsx

* Update ThroughputInput.less

* Update ThroughputInput.tsx

* Update ThroughputInput.test.tsx.snap

* Update NewVertexComponent.tsx

* Update PanelInfoErrorComponent.tsx

* Update AddTableEntityPanel.tsx

* Update AddTableEntityPanel.test.tsx.snap

* Update SplashScreen.tsx

* Update QuickstartCarousel.tsx

* Update RightPaneForm.tsx

* Update fulldatatables.less
2023-10-23 18:04:59 +05:30
vchske
deb0ebcf92 Updating vcore mongo quickstart text based on feedback (#1669)
* Updating quickstart text based on feedback

* Escaping apostrophe
2023-10-20 12:32:22 -07:00
Laurent Nguyen
2d3048eafe Fabric: handle resource tokens (#1667)
* Update contracts for new all resource messages

* Add timestamp to token message signature

* Reconstruct resource tree with databases and collections parsed from token dictionary keys

* Create FabricDatabase and FabricCollection to turn off interaction

* Remove unnecessary FabricCollection derived class

* Handle resource tokens

* Bug fix

* Fix linting issues

* Fix update document

* Fix partitition keys

* Remove special case for FabricDatabase tree node

* Modify readCollections to follow normal flow with Fabric

* Move fabric databases refresh to data access and remove special case in Explorer

* Revert Explorer.tsx changes

* Disable database context menu and delete container context menu

* Remove create database/container button for Fabric

* Fix format

* Renew token logic

* Parallelize read collections calls to speed up

* Disable readDatabaseOffer, because it is too slow for now

* Reduce TOKEN_VALIDITY_MS a bit to make sure renewal happens before expiration. Receving new tokens new refreshes databases

* Add container element for Main app in HTML

* Do not handle "openTab" message anymore

* Fix style of main div

* Simplify conditional load of the fabric .css

* Fix format

* Fix tsc can't find dynamic less import

---------

Co-authored-by: Armando Trejo Oliver <artrejo@microsoft.com>
2023-10-19 23:12:52 +02:00
Asier Isayas
8075ef2847 Upgrade Cosmos SDK to 4.0.0 (#1664)
* upgrade cosmos sdk to 4.0.0

* added explicit any test

* fixed package-lock.json

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2023-10-19 15:22:12 -04:00
Asier Isayas
94158504a8 Cancel query timeout (#1651)
* cancel query option

* query timeout

* run prettier

* removed comments

* fixed npm run compile errors

* fixed tests

* fixed unit test  errors

* fixed unit test  errors

* fixed unit test  errors

* fixed unit test  errors

* fixed unit test  errors

* increased min timeout

* added automatican cancel query option

* added react string format

* npm run format

* added unless automatic cancellation has been enabled

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2023-10-19 13:21:39 -04:00
Laurent Nguyen
9b032ecae4 Turn off "New Database" for Fabric (#1663)
* Turn off "New Database" for Fabric

* Fix format
2023-10-18 08:30:45 +00:00
jawelton74
14d7677056 Add feature for disabling connection string login and enable this for aad redirect. (#1660)
* Add redirect for /aad to /?feature.enableAadDataPlane=true

* Add feature to hide the connection string login link. Enable this new
feature for the aad redirect.
2023-10-16 13:18:40 -07:00
sunghyunkang1111
d376a7463c P1, P2 bug fixes for private preview (#1657)
* P1 bug fix for private preview

* Add updated snapshot files

* Fix failing unit test

* Fix unit tests and update snapshot
2023-10-12 21:54:01 -05:00
Laurent Nguyen
9669301d14 Remove obsolete unit test (#1655) 2023-10-12 08:00:08 +02:00
Laurent Nguyen
dcd8d1637b Implement retrieval of authorization token for Fabric via iframe rpc (#1647)
* For Fabric, send message to get Authorization token from iframe parent

* tokenProvider: set date header and return token

* Expect account endpoint on initialize message from Fabric

* Fix format

---------

Co-authored-by: artrejo <artrejo@microsoft.com>
2023-10-10 12:25:58 -07:00
Laurent Nguyen
f36fccd3ef Auto-select first item in DocumentsTab. Fix selection highlighting (#1645)
* Auto-select first document in documentsTab

* Fix style row selection by making selector more specific
2023-10-10 07:19:35 +02:00
jawelton74
8ff9a84004 Add redirect for /aad to /?feature.enableAadDataPlane=true (#1649) 2023-10-09 11:31:36 -07:00
sunghyunkang1111
e42e24b175 Redesign copilot (#1650)
* Add copilot toggle button

* take out toggle button when closing tab

* Update snapshots

* Fix the prettier issue

* fix prettier
2023-10-09 12:42:25 -05:00
Laurent Nguyen
44d6f29edd Revert "For Fabric, send message to get Authorization token from iframe parent"
This reverts commit 9db06af552.
2023-10-06 14:35:30 +00:00
Laurent Nguyen
9db06af552 For Fabric, send message to get Authorization token from iframe parent 2023-10-06 14:33:46 +00:00
65 changed files with 3070 additions and 2281 deletions

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3626 11.9771C13.1573 11.9837 13.019 12.0288 12.9215 12.0836C12.817 12.1424 12.7301 12.229 12.6508 12.3542C12.4802 12.6239 12.3837 12.9957 12.252 13.5033L12.2347 13.5699C12.1079 14.0581 11.9424 14.6764 11.5775 15.1594C11.1788 15.687 10.5903 16 9.74835 16H4.63681C3.55423 16 2.75741 15.2435 2.18373 14.3271C1.60629 13.4047 1.17786 12.2045 0.845481 11.0542L0.845003 11.0525C0.464365 9.73512 -0.017355 8.06787 0.000479698 6.72244C0.00951195 6.04103 0.145994 5.34953 0.569528 4.825C1.00712 4.28305 1.66518 4.02294 2.50365 4.02294H2.63739C2.84267 4.01631 2.98102 3.97123 3.07848 3.9164C3.18305 3.85757 3.26995 3.77102 3.34916 3.64584C3.51985 3.37613 3.61631 3.00432 3.74801 2.49671L3.76529 2.43012C3.89213 1.94191 4.05757 1.32365 4.42251 0.840633C4.82119 0.312966 5.40972 0 6.25165 0H11.3632C12.4458 0 13.2426 0.756543 13.8163 1.67295C14.3937 2.59534 14.8221 3.79546 15.1545 4.94582L15.155 4.94748C15.5356 6.26488 16.0174 7.93213 15.9995 9.27756C15.9905 9.95897 15.854 10.6505 15.4305 11.175C14.9929 11.717 14.3348 11.9771 13.4963 11.9771H13.3626ZM14.1354 5.28381C13.8114 4.16251 13.4194 3.09096 12.9303 2.3096C12.4374 1.52225 11.9215 1.14296 11.3632 1.14296H7.95613C8.10274 1.42082 8.22537 1.731 8.33548 2.05199C8.48008 2.47355 8.61427 2.94842 8.75233 3.43699L8.78302 3.54557C9.32758 5.47082 10.008 7.94794 10.4498 9.5646C10.6507 10.2998 11.2693 10.8098 11.9798 10.8333H13.4156C13.4253 10.8333 13.4351 10.8335 13.4447 10.8341H13.4963C14.1298 10.8341 14.4484 10.6443 14.6237 10.4272C14.813 10.1928 14.9255 9.8162 14.9328 9.26133C14.9477 8.13479 14.5323 6.65749 14.1354 5.28381ZM3.06972 13.6904C3.56261 14.4777 4.0785 14.857 4.63681 14.857H8.04387C7.89726 14.5792 7.77463 14.269 7.66452 13.948C7.51992 13.5265 7.38573 13.0516 7.24767 12.563L7.21698 12.4544C6.67242 10.5292 5.99197 8.05206 5.55019 6.4354C5.34929 5.70024 4.73069 5.19015 4.02018 5.16674H2.58445C2.57465 5.16674 2.56492 5.16646 2.55526 5.1659H2.50365C1.87025 5.1659 1.5516 5.35572 1.37634 5.57277C1.18703 5.80723 1.07454 6.1838 1.06719 6.73867C1.05225 7.86521 1.46769 9.34251 1.86459 10.7162C2.18857 11.8375 2.58057 12.909 3.06972 13.6904ZM9.10298 10.8333H9.87304C9.67506 10.5554 9.5217 10.236 9.42598 9.88575C9.233 9.17956 8.99466 8.30985 8.74215 7.39421L8.47331 6.42622C8.26579 5.67902 7.62465 5.16674 6.89702 5.16674H6.12695C6.32494 5.44463 6.4783 5.76395 6.57402 6.11425C6.767 6.82044 7.00534 7.69015 7.25785 8.60579L7.52669 9.57378C7.73421 10.321 8.37535 10.8333 9.10298 10.8333ZM6.89702 4.02378C7.23212 4.02378 7.55609 4.08968 7.85646 4.21152C7.82459 4.0984 7.7931 3.98682 7.76205 3.87706L7.73417 3.77844C7.59304 3.27914 7.46754 2.83514 7.33414 2.44626C7.19129 2.02979 7.05211 1.71602 6.90214 1.4942C6.77014 1.29897 6.56087 1.14296 6.25165 1.14296C5.69009 1.14296 5.42306 1.33297 5.2517 1.55976C5.04661 1.83121 4.92755 2.21889 4.79304 2.73662C4.78284 2.77588 4.77249 2.81622 4.76191 2.85744C4.66966 3.21703 4.55997 3.64464 4.37733 4.02378H6.89702ZM9.10298 11.9762C8.76788 11.9762 8.44391 11.9103 8.14354 11.7885C8.1754 11.9016 8.2069 12.0132 8.23795 12.1229L8.26583 12.2216C8.40696 12.7208 8.53246 13.1649 8.66585 13.5537C8.80871 13.9702 8.94789 14.284 9.09786 14.5058C9.22986 14.701 9.43913 14.857 9.74835 14.857C10.3099 14.857 10.5769 14.667 10.7483 14.4402C10.9534 14.1688 11.0725 13.7811 11.207 13.2634C11.2172 13.2241 11.2275 13.1838 11.2381 13.1426C11.3303 12.783 11.44 12.3554 11.6227 11.9762H9.10298Z" fill="#0078D4"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -1179,16 +1179,16 @@ menuQuickStart {
}
}
.gridRowSelected {
#tbodycontent tr.gridRowSelected {
.active();
}
.gridRowSelected:hover {
#tbodycontent tr.gridRowSelected:hover {
cursor: default;
.hover();
}
.gridRowHighlighted {
#tbodycontent tr.gridRowHighlighted {
border-style: dotted;
border-width: 2px;
}
@@ -2576,9 +2576,10 @@ a:link {
.querydropdown.placeholderVisible {
font-style: italic;
}
.querydropdown.placeholderVisible::placeholder { /* Chrome, Firefox, Opera, Safari 10.1+ */
.querydropdown.placeholderVisible::placeholder {
/* Chrome, Firefox, Opera, Safari 10.1+ */
color: #767474;
opacity: 1;
opacity: 1;
}
.querydropdown:hover {
@@ -2648,7 +2649,7 @@ a:link {
.nav-tabs > li.active > .tabNavContentContainer > .tab_Content > .tabNavText {
font-weight: bolder;
border-bottom: 2px solid rgba(0,120,212,1);
border-bottom: 2px solid rgba(0, 120, 212, 1);
}
.nav-tabs > li.active:focus > .tabNavContentContainer {
@@ -3096,4 +3097,3 @@ a:link {
background: white;
height: 100%;
}

13
package-lock.json generated
View File

@@ -179,10 +179,11 @@
}
},
"@azure/cosmos": {
"version": "3.16.2",
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-3.16.2.tgz",
"integrity": "sha512-sceY5LWj0BHGj8PSyaVCfDRQLVZyoCfIY78kyIROJVEw0k+p9XFs8fhpykN8JklkCftL0WlaVY+X25SQwnhZsw==",
"version": "4.0.0",
"resolved": "https://msazure.pkgs.visualstudio.com/_packaging/AzurePortal/npm/registry/@azure/cosmos/-/cosmos-4.0.0.tgz",
"integrity": "sha1-X9qLNctiu82lIVm5bEw5gahD1bk=",
"requires": {
"@azure/abort-controller": "^1.0.0",
"@azure/core-auth": "^1.3.0",
"@azure/core-rest-pipeline": "^1.2.0",
"debug": "^4.1.1",
@@ -22133,6 +22134,12 @@
"resolved": "https://registry.npmjs.org/react-splitter-layout/-/react-splitter-layout-4.0.0.tgz",
"integrity": "sha512-SLqOjBOxRuizWUa83w6q5/u9cDWa9/yj9Iko9V9JFN8x+cqIXiDlUFWSx+icz3IIgvsN/oRIw3za5/32RjIwrA=="
},
"react-string-format": {
"version": "1.0.1",
"resolved": "https://msazure.pkgs.visualstudio.com/_packaging/AzurePortal/npm/registry/react-string-format/-/react-string-format-1.0.1.tgz",
"integrity": "sha1-JyQaRZHqURInBBx64HC3FJBh3AA=",
"license": "MIT"
},
"react-syntax-highlighter": {
"version": "12.2.1",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-12.2.1.tgz",

View File

@@ -5,7 +5,7 @@
"main": "index.js",
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "3.16.2",
"@azure/cosmos": "4.0.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "1.2.1",
"@azure/ms-rest-nodeauth": "3.0.7",
@@ -92,6 +92,7 @@
"react-notification-system": "0.2.17",
"react-redux": "7.1.3",
"react-splitter-layout": "4.0.0",
"react-string-format": "1.0.1",
"react-youtube": "9.0.1",
"redux": "4.0.4",
"reflect-metadata": "0.1.13",
@@ -234,4 +235,4 @@
"printWidth": 120,
"endOfLine": "auto"
}
}
}

View File

@@ -177,6 +177,7 @@ export class HttpHeaders {
public static activityId: string = "x-ms-activity-id";
public static apiType: string = "x-ms-cosmos-apitype";
public static authorization: string = "authorization";
public static graphAuthorization: string = "graph-authorization";
public static collectionIndexTransformationProgress: string =
"x-ms-documentdb-collection-index-transformation-progress";
public static continuation: string = "x-ms-continuation";

View File

@@ -125,7 +125,7 @@ describe("requestPlugin", () => {
const headers = {};
const endpoint = "https://docs.azure.com";
const path = "/dbs/foo";
requestPlugin({ endpoint, headers, path } as any, next as any);
requestPlugin({ endpoint, headers, path } as any, undefined, next as any);
expect(next.mock.calls[0][0]).toMatchSnapshot();
});
});
@@ -137,7 +137,7 @@ describe("requestPlugin", () => {
const headers = {};
const endpoint = "";
const path = "/dbs/foo";
requestPlugin({ endpoint, headers, path } as any, next as any);
requestPlugin({ endpoint, headers, path } as any, undefined, next as any);
expect(next.mock.calls[0][0]).toMatchSnapshot();
});
});

View File

@@ -1,13 +1,16 @@
import * as Cosmos from "@azure/cosmos";
import { configContext, Platform } from "../ConfigContext";
import { sendCachedDataMessage } from "Common/MessageHandler";
import { getAuthorizationTokenUsingResourceTokens } from "Common/getAuthorizationTokenUsingResourceTokens";
import { AuthorizationToken, MessageTypes } from "Contracts/MessageTypes";
import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil";
import { AuthType } from "../AuthType";
import { PriorityLevel } from "../Common/Constants";
import { Platform, configContext } from "../ConfigContext";
import { userContext } from "../UserContext";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
import { getErrorMessage } from "./ErrorHandlingUtils";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { PriorityLevel } from "../Common/Constants";
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
import { AuthType } from "../AuthType";
const _global = typeof self === "undefined" ? window : self;
@@ -26,6 +29,36 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
return decodeURIComponent(headers.authorization);
}
if (configContext.platform === Platform.Fabric) {
switch (requestInfo.resourceType) {
case Cosmos.ResourceType.conflicts:
case Cosmos.ResourceType.container:
case Cosmos.ResourceType.sproc:
case Cosmos.ResourceType.udf:
case Cosmos.ResourceType.trigger:
case Cosmos.ResourceType.item:
case Cosmos.ResourceType.pkranges:
// User resource tokens
headers[HttpHeaders.msDate] = new Date().toUTCString();
const resourceTokens = userContext.fabricDatabaseConnectionInfo.resourceTokens;
checkDatabaseResourceTokensValidity(userContext.fabricDatabaseConnectionInfo.resourceTokensTimestamp);
return getAuthorizationTokenUsingResourceTokens(resourceTokens, requestInfo.path, requestInfo.resourceId);
case Cosmos.ResourceType.none:
case Cosmos.ResourceType.database:
case Cosmos.ResourceType.offer:
case Cosmos.ResourceType.user:
case Cosmos.ResourceType.permission:
// User master tokens
const authorizationToken = await sendCachedDataMessage<AuthorizationToken>(MessageTypes.GetAuthorizationToken, [
requestInfo,
]);
console.log("Response from Fabric: ", authorizationToken);
headers[HttpHeaders.msDate] = authorizationToken.XDate;
return decodeURIComponent(authorizationToken.PrimaryReadWriteToken);
}
}
if (userContext.masterKey) {
// TODO This SDK method mutates the headers object. Find a better one or fix the SDK.
await Cosmos.setAuthorizationTokenHeaderUsingMasterKey(verb, resourceId, resourceType, headers, EmulatorMasterKey);
@@ -41,7 +74,7 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
return decodeURIComponent(result.PrimaryReadWriteToken);
};
export const requestPlugin: Cosmos.Plugin<any> = async (requestContext, next) => {
export const requestPlugin: Cosmos.Plugin<any> = async (requestContext, diagnosticNode, next) => {
requestContext.endpoint = new URL(configContext.PROXY_PATH, window.location.href).href;
requestContext.headers["x-ms-proxy-target"] = endpoint();
return next(requestContext);
@@ -56,7 +89,11 @@ export const endpoint = () => {
return userContext.endpoint || userContext?.databaseAccount?.properties?.documentEndpoint;
};
export async function getTokenFromAuthService(verb: string, resourceType: string, resourceId?: string): Promise<any> {
export async function getTokenFromAuthService(
verb: string,
resourceType: string,
resourceId?: string,
): Promise<AuthorizationToken> {
try {
const host = configContext.BACKEND_ENDPOINT;
const response = await _global.fetch(host + "/api/guest/runtimeproxy/authorizationTokens", {

View File

@@ -22,7 +22,7 @@ export function handleCachedDataMessage(message: any): void {
if (messageContent.error != null) {
cachedDataPromise.deferred.reject(messageContent.error);
} else {
cachedDataPromise.deferred.resolve(JSON.parse(messageContent.data));
cachedDataPromise.deferred.resolve(messageContent.data);
}
runGarbageCollector();
}

View File

@@ -1,18 +1,50 @@
import { ContainerResponse } from "@azure/cosmos";
import { Queries } from "Common/Constants";
import { Platform, configContext } from "ConfigContext";
import { AuthType } from "../../AuthType";
import * as DataModels from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { listCassandraTables } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
import { listGremlinGraphs } from "../../Utils/arm/generatedClients/cosmos/gremlinResources";
import { listMongoDBCollections } from "../../Utils/arm/generatedClients/cosmos/mongoDBResources";
import { listSqlContainers } from "../../Utils/arm/generatedClients/cosmos/sqlResources";
import { listTables } from "../../Utils/arm/generatedClients/cosmos/tableResources";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
export async function readCollections(databaseId: string): Promise<DataModels.Collection[]> {
const clearMessage = logConsoleProgress(`Querying containers for database ${databaseId}`);
if (
configContext.platform === Platform.Fabric &&
userContext.fabricDatabaseConnectionInfo &&
userContext.fabricDatabaseConnectionInfo.databaseId === databaseId
) {
const collections: DataModels.Collection[] = [];
const promises: Promise<ContainerResponse>[] = [];
for (const collectionResourceId in userContext.fabricDatabaseConnectionInfo.resourceTokens) {
// Dictionary key looks like this: dbs/SampleDB/colls/Container
const resourceIdObj = collectionResourceId.split("/");
const tokenDatabaseId = resourceIdObj[1];
const tokenCollectionId = resourceIdObj[3];
if (tokenDatabaseId === databaseId) {
promises.push(client().database(databaseId).container(tokenCollectionId).read());
}
}
const responses = await Promise.all(promises);
responses.forEach((response) => {
collections.push(response.resource as DataModels.Collection);
});
// Sort collections by id before returning
collections.sort((a, b) => a.id.localeCompare(b.id));
return collections;
}
try {
if (
userContext.authType === AuthType.AAD &&

View File

@@ -1,15 +1,22 @@
import { Platform, configContext } from "ConfigContext";
import { AuthType } from "../../AuthType";
import { Offer, ReadDatabaseOfferParams } from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { getCassandraKeyspaceThroughput } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
import { getGremlinDatabaseThroughput } from "../../Utils/arm/generatedClients/cosmos/gremlinResources";
import { getMongoDBDatabaseThroughput } from "../../Utils/arm/generatedClients/cosmos/mongoDBResources";
import { getSqlDatabaseThroughput } from "../../Utils/arm/generatedClients/cosmos/sqlResources";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { handleError } from "../ErrorHandlingUtils";
import { readOfferWithSDK } from "./readOfferWithSDK";
export const readDatabaseOffer = async (params: ReadDatabaseOfferParams): Promise<Offer> => {
if (configContext.platform === Platform.Fabric) {
// TODO This works, but is very slow, because it requests the token, so we skip for now
console.error("Skiping readDatabaseOffer for Fabric");
return undefined;
}
const clearMessage = logConsoleProgress(`Querying offer for database ${params.databaseId}`);
try {

View File

@@ -1,17 +1,45 @@
import { Platform, configContext } from "ConfigContext";
import { AuthType } from "../../AuthType";
import * as DataModels from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { listCassandraKeyspaces } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
import { listGremlinDatabases } from "../../Utils/arm/generatedClients/cosmos/gremlinResources";
import { listMongoDBDatabases } from "../../Utils/arm/generatedClients/cosmos/mongoDBResources";
import { listSqlDatabases } from "../../Utils/arm/generatedClients/cosmos/sqlResources";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
export async function readDatabases(): Promise<DataModels.Database[]> {
let databases: DataModels.Database[];
const clearMessage = logConsoleProgress(`Querying databases`);
if (configContext.platform === Platform.Fabric && userContext.fabricDatabaseConnectionInfo?.resourceTokens) {
const tokensData = userContext.fabricDatabaseConnectionInfo;
const databaseIdsSet = new Set<string>(); // databaseId
for (const collectionResourceId in tokensData.resourceTokens) {
// Dictionary key looks like this: dbs/SampleDB/colls/Container
const resourceIdObj = collectionResourceId.split("/");
const databaseId = resourceIdObj[1];
databaseIdsSet.add(databaseId);
}
const databases: DataModels.Database[] = Array.from(databaseIdsSet.values())
.sort((a, b) => a.localeCompare(b))
.map((databaseId) => ({
_rid: "",
_self: "",
_etag: "",
_ts: 0,
id: databaseId,
collections: [],
}));
return databases;
}
try {
if (
userContext.authType === AuthType.AAD &&

View File

@@ -0,0 +1,66 @@
export function getAuthorizationTokenUsingResourceTokens(
resourceTokens: { [resourceId: string]: string },
path: string,
resourceId: string,
): string {
// console.log(`getting token for path: "${path}" and resourceId: "${resourceId}"`);
if (resourceTokens && Object.keys(resourceTokens).length > 0) {
// For database account access(through getDatabaseAccount API), path and resourceId are "",
// so in this case we return the first token to be used for creating the auth header as the
// service will accept any token in this case
if (!path && !resourceId) {
return resourceTokens[Object.keys(resourceTokens)[0]];
}
// If we have exact resource token for the path use it
if (resourceId && resourceTokens[resourceId]) {
return resourceTokens[resourceId];
}
// minimum valid path /dbs
if (!path || path.length < 4) {
console.error(
`Unable to get authotization token for Path:"${path}" and resourcerId:"${resourceId}". Invalid path.`,
);
return null;
}
path = trimSlashFromLeftAndRight(path);
const pathSegments = (path && path.split("/")) || [];
// Item path
if (pathSegments.length === 6) {
// Look for a container token matching the item path
const containerPath = pathSegments.slice(0, 4).map(decodeURIComponent).join("/");
if (resourceTokens[containerPath]) {
return resourceTokens[containerPath];
}
}
// This is legacy behavior that lets someone use a resource token pointing ONLY at an ID
// It was used when _rid was exposed by the SDK, but now that we are using user provided ids it is not needed
// However removing it now would be a breaking change
// if it's an incomplete path like /dbs/db1/colls/, start from the parent resource
let index = pathSegments.length % 2 === 0 ? pathSegments.length - 1 : pathSegments.length - 2;
for (; index > 0; index -= 2) {
const id = decodeURI(pathSegments[index]);
if (resourceTokens[id]) {
return resourceTokens[id];
}
}
}
console.error(`Unable to get authotization token for Path:"${path}" and resourcerId:"${resourceId}"`);
return null;
}
const trimLeftSlashes = new RegExp("^[/]+");
const trimRightSlashes = new RegExp("[/]+$");
function trimSlashFromLeftAndRight(inputString: string): string {
if (typeof inputString !== "string") {
throw new Error("invalid input: input is not string");
}
return inputString.replace(trimLeftSlashes, "").replace(trimRightSlashes, "");
}

View File

@@ -64,6 +64,7 @@ let configContext: Readonly<ConfigContext> = {
`^https:\\/\\/.*\\.fabric\\.microsoft\\.com$`,
`^https:\\/\\/.*\\.powerbi\\.com$`,
`^https:\\/\\/.*\\.analysis-df\\.net$`,
`^https:\\/\\/.*\\.analysis-df\\.windows\\.net$`,
], // Webpack injects this at build time
gitSha: process.env.GIT_SHA,
hostedExplorerURL: "https://cosmos.azure.com/",

View File

@@ -1,46 +1,6 @@
import { MessageTypes } from "Contracts/MessageTypes";
import * as ActionContracts from "./ActionContracts";
import * as Diagnostics from "./Diagnostics";
import * as Versions from "./Versions";
/**
* Messaging types used with Data Explorer <-> Portal communication
* and Hosted <-> Explorer communication
*/
export enum MessageTypes {
TelemetryInfo,
LogInfo,
RefreshResources,
AllDatabases,
CollectionsForDatabase,
RefreshOffers,
AllOffers,
UpdateLocationHash,
SingleOffer,
RefreshOffer,
UpdateAccountName,
ForbiddenError,
AadSignIn,
GetAccessAadRequest,
GetAccessAadResponse,
UpdateAccountSwitch,
UpdateDirectoryControl,
SwitchAccount,
SendNotification,
ClearNotification,
ExplorerClickEvent,
LoadingStatus,
GetArcadiaToken,
CreateWorkspace,
CreateSparkPool,
RefreshDatabaseAccount,
CloseTab,
OpenQuickstartBlade,
OpenPostgreSQLPasswordReset,
OpenPostgresNetworkingBlade,
OpenCosmosDBNetworkingBlade,
DisplayNPSSurvey,
OpenVCoreMongoNetworkingBlade,
OpenVCoreMongoConnectionStringsBlade,
}
export { ActionContracts, Diagnostics, Versions };
export { ActionContracts, Diagnostics, MessageTypes, Versions };

View File

@@ -1,3 +1,5 @@
import { AuthorizationToken, MessageTypes } from "./MessageTypes";
export type FabricMessage =
| {
type: "newContainer";
@@ -5,21 +7,71 @@ export type FabricMessage =
}
| {
type: "initialize";
connectionString: string | undefined;
message: {
endpoint: string | undefined;
databaseId: string | undefined;
resourceTokens: unknown | undefined;
resourceTokensTimestamp: number | undefined;
error: string | undefined;
};
}
| {
type: "openTab";
databaseName: string;
collectionName: string | undefined;
type: "authorizationToken";
message: {
id: string;
error: string | undefined;
data: AuthorizationToken | undefined;
};
}
| {
type: "allResourceTokens";
message: {
endpoint: string | undefined;
databaseId: string | undefined;
resourceTokens: unknown | undefined;
resourceTokensTimestamp: number | undefined;
};
};
export type DataExploreMessage =
| "ready"
| {
type: number;
type: MessageTypes.TelemetryInfo;
data: {
action: "LoadDatabases";
actionModifier: "success" | "start";
defaultExperience: "SQL";
};
}
| {
type: MessageTypes.GetAuthorizationToken;
id: string;
params: GetCosmosTokenMessageOptions[];
}
| {
type: MessageTypes.GetAllResourceTokens;
};
export type GetCosmosTokenMessageOptions = {
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
resourceType: "" | "dbs" | "colls" | "docs" | "sprocs" | "pkranges";
resourceId: string;
};
export type CosmosDBTokenResponse = {
token: string;
date: string;
};
export type CosmosDBConnectionInfoResponse = {
endpoint: string;
databaseId: string;
resourceTokens: unknown;
};
export interface FabricDatabaseConnectionInfo {
endpoint: string;
databaseId: string;
resourceTokens: { [resourceId: string]: string };
resourceTokensTimestamp: number;
}

View File

@@ -0,0 +1,49 @@
/**
* Messaging types used with Data Explorer <-> Portal communication,
* Hosted <-> Explorer communication and Data Explorer -> Fabric communication.
*/
export enum MessageTypes {
TelemetryInfo,
LogInfo,
RefreshResources,
AllDatabases,
CollectionsForDatabase,
RefreshOffers,
AllOffers,
UpdateLocationHash,
SingleOffer,
RefreshOffer,
UpdateAccountName,
ForbiddenError,
AadSignIn,
GetAccessAadRequest,
GetAccessAadResponse,
UpdateAccountSwitch,
UpdateDirectoryControl,
SwitchAccount,
SendNotification,
ClearNotification,
ExplorerClickEvent,
LoadingStatus,
GetArcadiaToken,
CreateWorkspace,
CreateSparkPool,
RefreshDatabaseAccount,
CloseTab,
OpenQuickstartBlade,
OpenPostgreSQLPasswordReset,
OpenPostgresNetworkingBlade,
OpenCosmosDBNetworkingBlade,
DisplayNPSSurvey,
OpenVCoreMongoNetworkingBlade,
OpenVCoreMongoConnectionStringsBlade,
// Data Explorer -> Fabric communication
GetAuthorizationToken,
GetAllResourceTokens,
}
export interface AuthorizationToken {
XDate: string;
PrimaryReadWriteToken: string;
}

4
src/Definitions/less.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "*.less" {
const value: string;
export default value;
}

View File

@@ -129,20 +129,22 @@ export const createCollectionContextMenuButton = (
});
}
items.push({
iconSrc: DeleteCollectionIcon,
onClick: () => {
useSelectedNode.getState().setSelectedNode(selectedCollection);
useSidePanel
.getState()
.openSidePanel(
"Delete " + getCollectionName(),
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
);
},
label: `Delete ${getCollectionName()}`,
styleClass: "deleteCollectionMenuItem",
});
if (configContext.platform !== Platform.Fabric) {
items.push({
iconSrc: DeleteCollectionIcon,
onClick: () => {
useSelectedNode.getState().setSelectedNode(selectedCollection);
useSidePanel
.getState()
.openSidePanel(
"Delete " + getCollectionName(),
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
);
},
label: `Delete ${getCollectionName()}`,
styleClass: "deleteCollectionMenuItem",
});
}
return items;
};

View File

@@ -123,19 +123,6 @@ describe("ContainerSampleGenerator", () => {
await generator.createSampleContainerAsync();
});
it("should not create any sample for Mongo API account", async () => {
const experience = "Sample generation not supported for this API Mongo";
updateUserContext({
databaseAccount: {
properties: {
capabilities: [{ name: "EnableMongo" }],
},
} as DatabaseAccount,
});
expect(ContainerSampleGenerator.createSampleGeneratorAsync(explorerStub)).rejects.toMatch(experience);
});
it("should not create any sample for Table API account", async () => {
const experience = "Sample generation not supported for this API Tables";
updateUserContext({

View File

@@ -4,6 +4,7 @@ import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext } from "ConfigContext";
import { MessageTypes } from "Contracts/ExplorerContracts";
import { IGalleryItem } from "Juno/JunoClient";
import { requestDatabaseResourceTokens } from "Platform/Fabric/FabricUtil";
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointValidation";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import * as ko from "knockout";
@@ -379,6 +380,13 @@ export default class Explorer {
};
public onRefreshResourcesClick = (): void => {
if (configContext.platform === Platform.Fabric) {
// Requesting the tokens will trigger a refresh of the databases
// TODO: Once the id is returned from Fabric, we can await this call and then refresh the databases here
requestDatabaseResourceTokens();
return;
}
userContext.authType === AuthType.ResourceToken
? this.refreshDatabaseForResourceToken()
: this.refreshAllDatabases();

View File

@@ -50,31 +50,36 @@ export function createStaticCommandBarButtons(
return createStaticCommandBarButtonsForResourceToken(container, selectedNodeState);
}
const newCollectionBtn = createNewCollectionGroup(container);
const buttons: CommandButtonComponentProps[] = [];
buttons.push(newCollectionBtn);
if (
configContext.platform !== Platform.Fabric &&
userContext.apiType !== "Tables" &&
userContext.apiType !== "Cassandra"
) {
const addSynapseLink = createOpenSynapseLinkDialogButton(container);
if (addSynapseLink) {
// Avoid starting with a divider
const addDivider = () => {
if (buttons.length > 0) {
buttons.push(createDivider());
buttons.push(addSynapseLink);
}
};
if (configContext.platform !== Platform.Fabric) {
const newCollectionBtn = createNewCollectionGroup(container);
buttons.push(newCollectionBtn);
if (userContext.apiType !== "Tables" && userContext.apiType !== "Cassandra") {
const addSynapseLink = createOpenSynapseLinkDialogButton(container);
if (addSynapseLink) {
addDivider();
buttons.push(addSynapseLink);
}
}
if (userContext.apiType !== "Tables") {
newCollectionBtn.children = [createNewCollectionGroup(container)];
const newDatabaseBtn = createNewDatabase(container);
newCollectionBtn.children.push(newDatabaseBtn);
}
}
if (userContext.apiType !== "Tables") {
newCollectionBtn.children = [createNewCollectionGroup(container)];
const newDatabaseBtn = createNewDatabase(container);
newCollectionBtn.children.push(newDatabaseBtn);
}
if (useNotebook.getState().isNotebookEnabled) {
buttons.push(createDivider());
addDivider();
const notebookButtons: CommandButtonComponentProps[] = [];
const newNotebookButton = createNewNotebookButton(container);
@@ -128,7 +133,7 @@ export function createStaticCommandBarButtons(
const isQuerySupported = userContext.apiType === "SQL" || userContext.apiType === "Gremlin";
if (isQuerySupported) {
buttons.push(createDivider());
addDivider();
const newSqlQueryBtn = createNewSQLQueryButton(selectedNodeState);
buttons.push(newSqlQueryBtn);
}

View File

@@ -114,7 +114,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
super(props);
this.state = {
createNewDatabase: userContext.apiType !== "Tables" && !this.props.databaseId,
createNewDatabase:
userContext.apiType !== "Tables" && configContext.platform !== Platform.Fabric && !this.props.databaseId,
newDatabaseId: props.isQuickstart ? this.getSampleDBName() : "",
isSharedThroughputChecked: this.getSharedThroughputDefault(),
selectedDatabaseId:
@@ -274,36 +275,38 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</TooltipHost>
</Stack>
<Stack horizontal verticalAlign="center">
<div role="radiogroup">
<input
className="panelRadioBtn"
checked={this.state.createNewDatabase}
aria-label="Create new database"
aria-checked={this.state.createNewDatabase}
name="databaseType"
type="radio"
role="radio"
id="databaseCreateNew"
tabIndex={0}
onChange={this.onCreateNewDatabaseRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">Create new</span>
{configContext.platform !== Platform.Fabric && (
<Stack horizontal verticalAlign="center">
<div role="radiogroup">
<input
className="panelRadioBtn"
checked={this.state.createNewDatabase}
aria-label="Create new database"
aria-checked={this.state.createNewDatabase}
name="databaseType"
type="radio"
role="radio"
id="databaseCreateNew"
tabIndex={0}
onChange={this.onCreateNewDatabaseRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">Create new</span>
<input
className="panelRadioBtn"
checked={!this.state.createNewDatabase}
aria-label="Use existing database"
aria-checked={!this.state.createNewDatabase}
name="databaseType"
type="radio"
role="radio"
tabIndex={0}
onChange={this.onUseExistingDatabaseRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">Use existing</span>
</div>
</Stack>
<input
className="panelRadioBtn"
checked={!this.state.createNewDatabase}
aria-label="Use existing database"
aria-checked={!this.state.createNewDatabase}
name="databaseType"
type="radio"
role="radio"
tabIndex={0}
onChange={this.onUseExistingDatabaseRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">Use existing</span>
</div>
</Stack>
)}
{this.state.createNewDatabase && (
<Stack className="panelGroupSpacing">

View File

@@ -32,14 +32,8 @@ export const PanelInfoErrorComponent: React.FunctionComponent<PanelInfoErrorProp
return (
<Stack className="panelInfoErrorContainer" horizontal verticalAlign="center">
{icon}
<span className="panelWarningErrorDetailsLinkContainer">
<Text
role="alert"
aria-live="assertive"
aria-label={message}
className="panelWarningErrorMessage"
variant="small"
>
<span className="panelWarningErrorDetailsLinkContainer" role="alert" aria-live="assertive">
<Text aria-label={message} className="panelWarningErrorMessage" variant="small">
{message}
{link && linkText && (
<Link target="_blank" href={link}>

View File

@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { mount, ReactWrapper } from "enzyme";
import { ReactWrapper, mount } from "enzyme";
import React from "react";
import { RightPaneForm } from "./RightPaneForm";
@@ -34,6 +34,6 @@ describe("Right Pane Form", () => {
it("should render error in header", () => {
render(<RightPaneForm {...props} formError="file already Exist" />);
expect(screen.getByLabelText("error")).toBeDefined();
expect(screen.getByRole("alert").innerHTML).toEqual("file already Exist");
expect(screen.getByRole("alert").innerHTML).toContain("file already Exist");
});
});

View File

@@ -1,4 +1,13 @@
import { Checkbox, ChoiceGroup, IChoiceGroupOption, SpinButton } from "@fluentui/react";
import {
Checkbox,
ChoiceGroup,
IChoiceGroupOption,
ISpinButtonStyles,
IToggleStyles,
Position,
SpinButton,
Toggle,
} from "@fluentui/react";
import * as Constants from "Common/Constants";
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
import { configContext } from "ConfigContext";
@@ -6,10 +15,10 @@ import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import * as StringUtility from "Shared/StringUtility";
import { userContext } from "UserContext";
import { logConsoleInfo } from "Utils/NotificationConsoleUtils";
import { useSidePanel } from "hooks/useSidePanel";
import React, { FunctionComponent, MouseEvent, useState } from "react";
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
import { useSidePanel } from "hooks/useSidePanel";
import React, { FunctionComponent, useState } from "react";
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
export const SettingsPane: FunctionComponent = () => {
const closeSidePanel = useSidePanel((state) => state.closeSidePanel);
@@ -19,6 +28,13 @@ export const SettingsPane: FunctionComponent = () => {
? Constants.Queries.UnlimitedPageOption
: Constants.Queries.CustomPageOption,
);
const [queryTimeoutEnabled, setQueryTimeoutEnabled] = useState<boolean>(
LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled),
);
const [queryTimeout, setQueryTimeout] = useState<number>(LocalStorageUtility.getEntryNumber(StorageKey.QueryTimeout));
const [automaticallyCancelQueryAfterTimeout, setAutomaticallyCancelQueryAfterTimeout] = useState<boolean>(
LocalStorageUtility.getEntryBoolean(StorageKey.AutomaticallyCancelQueryAfterTimeout),
);
const [customItemPerPage, setCustomItemPerPage] = useState<number>(
LocalStorageUtility.getEntryNumber(StorageKey.CustomItemPerPage) || 0,
);
@@ -53,7 +69,7 @@ export const SettingsPane: FunctionComponent = () => {
const shouldShowCrossPartitionOption = userContext.apiType !== "Gremlin";
const shouldShowParallelismOption = userContext.apiType !== "Gremlin";
const shouldShowPriorityLevelOption = PriorityBasedExecutionUtils.isFeatureEnabled();
const handlerOnSubmit = (e: MouseEvent<HTMLButtonElement>) => {
const handlerOnSubmit = () => {
setIsExecuting(true);
LocalStorageUtility.setEntryNumber(
@@ -61,6 +77,7 @@ export const SettingsPane: FunctionComponent = () => {
isCustomPageOptionSelected() ? customItemPerPage : Constants.Queries.unlimitedItemsPerPage,
);
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage);
LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled);
LocalStorageUtility.setEntryString(StorageKey.ContainerPaginationEnabled, containerPaginationEnabled.toString());
LocalStorageUtility.setEntryString(StorageKey.IsCrossPartitionQueryEnabled, crossPartitionQueryEnabled.toString());
LocalStorageUtility.setEntryNumber(StorageKey.MaxDegreeOfParellism, maxDegreeOfParallelism);
@@ -73,6 +90,14 @@ export const SettingsPane: FunctionComponent = () => {
);
}
if (queryTimeoutEnabled) {
LocalStorageUtility.setEntryNumber(StorageKey.QueryTimeout, queryTimeout);
LocalStorageUtility.setEntryBoolean(
StorageKey.AutomaticallyCancelQueryAfterTimeout,
automaticallyCancelQueryAfterTimeout,
);
}
setIsExecuting(false);
logConsoleInfo(
`Updated items per page setting to ${LocalStorageUtility.getEntryNumber(StorageKey.ActualItemPerPage)}`,
@@ -97,7 +122,6 @@ export const SettingsPane: FunctionComponent = () => {
`Updated query setting to ${LocalStorageUtility.getEntryString(StorageKey.SetPartitionKeyUndefined)}`,
);
closeSidePanel();
e.preventDefault();
};
const isCustomPageOptionSelected = () => {
@@ -112,7 +136,7 @@ export const SettingsPane: FunctionComponent = () => {
formError: "",
isExecuting,
submitButtonText: "Apply",
onSubmit: () => handlerOnSubmit(undefined),
onSubmit: () => handlerOnSubmit(),
};
const pageOptionList: IChoiceGroupOption[] = [
{ key: Constants.Queries.CustomPageOption, text: "Custom" },
@@ -140,6 +164,21 @@ export const SettingsPane: FunctionComponent = () => {
setPageOption(option.key);
};
const handleOnQueryTimeoutToggleChange = (ev: React.MouseEvent<HTMLElement>, checked?: boolean): void => {
setQueryTimeoutEnabled(checked);
};
const handleOnAutomaticallyCancelQueryToggleChange = (ev: React.MouseEvent<HTMLElement>, checked?: boolean): void => {
setAutomaticallyCancelQueryAfterTimeout(checked);
};
const handleOnQueryTimeoutSpinButtonChange = (ev: React.MouseEvent<HTMLElement>, newValue?: string): void => {
const queryTimeout = Number(newValue);
if (!isNaN(queryTimeout)) {
setQueryTimeout(queryTimeout);
}
};
const choiceButtonStyles = {
root: {
clear: "both",
@@ -161,6 +200,35 @@ export const SettingsPane: FunctionComponent = () => {
},
],
};
const queryTimeoutToggleStyles: IToggleStyles = {
label: {
fontSize: 12,
fontWeight: 400,
display: "block",
},
root: {},
container: {},
pill: {},
thumb: {},
text: {},
};
const queryTimeoutSpinButtonStyles: ISpinButtonStyles = {
label: {
fontSize: 12,
fontWeight: 400,
},
root: {
paddingBottom: 10,
},
labelWrapper: {},
icon: {},
spinButtonWrapper: {},
input: {},
arrowButtonsContainer: {},
};
return (
<RightPaneForm {...genericPaneProps}>
<div className="paneMainContent">
@@ -211,6 +279,50 @@ export const SettingsPane: FunctionComponent = () => {
</div>
</div>
)}
{userContext.apiType === "SQL" && (
<div className="settingsSection">
<div className="settingsSectionPart">
<div>
<legend id="queryTimeoutLabel" className="settingsSectionLabel legendLabel">
Query Timeout
</legend>
<InfoTooltip>
When a query reaches a specified time limit, a popup with an option to cancel the query will show
unless automatic cancellation has been enabled
</InfoTooltip>
</div>
<div>
<Toggle
styles={queryTimeoutToggleStyles}
label="Enable query timeout"
onChange={handleOnQueryTimeoutToggleChange}
defaultChecked={queryTimeoutEnabled}
/>
</div>
{queryTimeoutEnabled && (
<div>
<SpinButton
label="Query timeout (ms)"
labelPosition={Position.top}
defaultValue={(queryTimeout || 5000).toString()}
min={100}
step={1000}
onChange={handleOnQueryTimeoutSpinButtonChange}
incrementButtonAriaLabel="Increase value by 1000"
decrementButtonAriaLabel="Decrease value by 1000"
styles={queryTimeoutSpinButtonStyles}
/>
<Toggle
label="Automatically cancel query after timeout"
styles={queryTimeoutToggleStyles}
onChange={handleOnAutomaticallyCancelQueryToggleChange}
defaultChecked={automaticallyCancelQueryAfterTimeout}
/>
</div>
)}
</div>
</div>
)}
<div className="settingsSection">
<div className="settingsSectionPart">
<div className="settingsSectionLabel">

View File

@@ -97,6 +97,46 @@ exports[`Settings Pane should render Default properly 1`] = `
</div>
</div>
</div>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<div>
<legend
className="settingsSectionLabel legendLabel"
id="queryTimeoutLabel"
>
Query Timeout
</legend>
<InfoTooltip>
When a query reaches a specified time limit, a popup with an option to cancel the query will show unless automatic cancellation has been enabled
</InfoTooltip>
</div>
<div>
<StyledToggleBase
defaultChecked={false}
label="Enable query timeout"
onChange={[Function]}
styles={
Object {
"container": Object {},
"label": Object {
"display": "block",
"fontSize": 12,
"fontWeight": 400,
},
"pill": Object {},
"root": Object {},
"text": Object {},
"thumb": Object {},
}
}
/>
</div>
</div>
</div>
<div
className="settingsSection"
>

View File

@@ -390,6 +390,9 @@ export const EditTableEntityPanel: FunctionComponent<EditTableEntityPanelProps>
</Stack>
)}
</div>
<div className="panelNullWarning" style={{ padding: "20px", color: "red" }}>
Warning: Null fields will not be displayed for editing.
</div>
</RightPaneForm>
);
};

View File

@@ -355,6 +355,17 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
</div>
</Stack>
</div>
<div
className="panelNullWarning"
style={
Object {
"color": "red",
"padding": "20px",
}
}
>
Warning: Null fields will not be displayed for editing.
</div>
<PanelFooterComponent
buttonLabel="Update"
isButtonDisabled={false}

View File

@@ -323,21 +323,19 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
</IconBase>
</StyledIconBase>
<span
aria-live="assertive"
className="panelWarningErrorDetailsLinkContainer"
key=".0:$.1"
role="alert"
>
<Text
aria-label="Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources."
aria-live="assertive"
className="panelWarningErrorMessage"
role="alert"
variant="small"
>
<span
aria-label="Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources."
aria-live="assertive"
className="panelWarningErrorMessage css-56"
role="alert"
>
Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.
</span>

View File

@@ -102,7 +102,7 @@ describe("Query Copilot Feedback Modal snapshot test", () => {
expect(wrapper).toMatchSnapshot();
});
it("should submit submission", () => {
it("should not submit submission if required description field is null", () => {
const explorer = new Explorer();
const wrapper = shallow(<QueryCopilotFeedbackModal explorer={explorer} />);
@@ -110,12 +110,24 @@ describe("Query Copilot Feedback Modal snapshot test", () => {
submitButton.simulate("click");
wrapper.setProps({});
expect(SubmitFeedback).toHaveBeenCalledTimes(0);
});
it("should submit submission", () => {
useQueryCopilot.getState().openFeedbackModal("test query", false, "test prompt");
const explorer = new Explorer();
const wrapper = shallow(<QueryCopilotFeedbackModal explorer={explorer} />);
const submitButton = wrapper.find("form");
submitButton.simulate("submit");
wrapper.setProps({});
expect(SubmitFeedback).toHaveBeenCalledTimes(1);
expect(SubmitFeedback).toHaveBeenCalledWith({
params: {
likeQuery: false,
generatedQuery: "",
userPrompt: "",
generatedQuery: "test query",
userPrompt: "test prompt",
description: "",
contact: getUserEmail(),
},

View File

@@ -25,93 +25,94 @@ export const QueryCopilotFeedbackModal = ({ explorer }: { explorer: Explorer }):
closeFeedbackModal,
setHideFeedbackModalForLikedQueries,
} = useQueryCopilot();
const [isContactAllowed, setIsContactAllowed] = React.useState<boolean>(true);
const [isContactAllowed, setIsContactAllowed] = React.useState<boolean>(false);
const [description, setDescription] = React.useState<string>("");
const [doNotShowAgainChecked, setDoNotShowAgainChecked] = React.useState<boolean>(false);
const [contact, setContact] = React.useState<string>(getUserEmail());
const handleSubmit = () => {
closeFeedbackModal();
setHideFeedbackModalForLikedQueries(doNotShowAgainChecked);
SubmitFeedback({
params: { generatedQuery, likeQuery, description, userPrompt, contact },
explorer: explorer,
});
};
return (
<Modal isOpen={showFeedbackModal}>
<Stack style={{ padding: 24 }}>
<Stack horizontal horizontalAlign="space-between">
<Text style={{ fontSize: 20, fontWeight: 600, marginBottom: 20 }}>Send feedback to Microsoft</Text>
<IconButton iconProps={{ iconName: "Cancel" }} onClick={() => closeFeedbackModal()} />
</Stack>
<Text style={{ fontSize: 14, marginBottom: 14 }}>Your feedback will help improve the experience.</Text>
<TextField
styles={{ root: { marginBottom: 14 } }}
label="Description"
required
placeholder="Provide more details"
value={description}
onChange={(_, newValue) => setDescription(newValue)}
multiline
rows={3}
/>
<TextField
styles={{ root: { marginBottom: 14 } }}
label="Query generated"
defaultValue={generatedQuery}
readOnly
/>
<ChoiceGroup
styles={{
root: {
marginBottom: 14,
},
flexContainer: {
selectors: {
".ms-ChoiceField-field::before": { marginTop: 4 },
".ms-ChoiceField-field::after": { marginTop: 4 },
".ms-ChoiceFieldLabel": { paddingLeft: 6 },
},
},
}}
label="May we contact you about your feedback?"
options={[
{ key: "yes", text: "Yes, you may contact me." },
{ key: "no", text: "No, do not contact me." },
]}
selectedKey={isContactAllowed ? "yes" : "no"}
onChange={(_, option) => {
setIsContactAllowed(option.key === "yes");
setContact(option.key === "yes" ? getUserEmail() : "");
}}
></ChoiceGroup>
<Text style={{ fontSize: 12, marginBottom: 14 }}>
By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the{" "}
{
<Link href="https://privacy.microsoft.com/privacystatement" target="_blank">
Privacy statement
</Link>
}{" "}
for more information.
</Text>
{likeQuery && (
<Checkbox
styles={{ label: { paddingLeft: 0 }, root: { marginBottom: 14 } }}
label="Don't show me this next time"
checked={doNotShowAgainChecked}
onChange={(_, checked) => setDoNotShowAgainChecked(checked)}
<form onSubmit={handleSubmit}>
<Stack style={{ padding: 24 }}>
<Stack horizontal horizontalAlign="space-between">
<Text style={{ fontSize: 20, fontWeight: 600, marginBottom: 20 }}>Send feedback to Microsoft</Text>
<IconButton iconProps={{ iconName: "Cancel" }} onClick={() => closeFeedbackModal()} />
</Stack>
<Text style={{ fontSize: 14, marginBottom: 14 }}>Your feedback will help improve the experience.</Text>
<TextField
styles={{ root: { marginBottom: 14 } }}
label="Description"
required
placeholder="Provide more details"
value={description}
onChange={(_, newValue) => setDescription(newValue)}
multiline
rows={3}
/>
)}
<Stack horizontal horizontalAlign="end">
<PrimaryButton
styles={{ root: { marginRight: 8 } }}
onClick={() => {
closeFeedbackModal();
setHideFeedbackModalForLikedQueries(doNotShowAgainChecked);
SubmitFeedback({
params: { generatedQuery, likeQuery, description, userPrompt, contact },
explorer: explorer,
});
<TextField
styles={{ root: { marginBottom: 14 } }}
label="Query generated"
defaultValue={generatedQuery}
readOnly
/>
<ChoiceGroup
styles={{
root: {
marginBottom: 14,
},
flexContainer: {
selectors: {
".ms-ChoiceField-field::before": { marginTop: 4 },
".ms-ChoiceField-field::after": { marginTop: 4 },
".ms-ChoiceFieldLabel": { paddingLeft: 6 },
},
},
}}
>
Submit
</PrimaryButton>
<DefaultButton onClick={() => closeFeedbackModal()}>Cancel</DefaultButton>
label="May we contact you about your feedback?"
options={[
{ key: "yes", text: "Yes, you may contact me." },
{ key: "no", text: "No, do not contact me." },
]}
selectedKey={isContactAllowed ? "yes" : "no"}
onChange={(_, option) => {
setIsContactAllowed(option.key === "yes");
setContact(option.key === "yes" ? getUserEmail() : "");
}}
></ChoiceGroup>
<Text style={{ fontSize: 12, marginBottom: 14 }}>
By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the{" "}
{
<Link href="https://privacy.microsoft.com/privacystatement" target="_blank">
Privacy statement
</Link>
}{" "}
for more information.
</Text>
{likeQuery && (
<Checkbox
styles={{ label: { paddingLeft: 0 }, root: { marginBottom: 14 } }}
label="Don't show me this next time"
checked={doNotShowAgainChecked}
onChange={(_, checked) => setDoNotShowAgainChecked(checked)}
/>
)}
<Stack horizontal horizontalAlign="end">
<PrimaryButton styles={{ root: { marginRight: 8 } }} type="submit">
Submit
</PrimaryButton>
<DefaultButton onClick={() => closeFeedbackModal()}>Cancel</DefaultButton>
</Stack>
</Stack>
</Stack>
</form>
</Modal>
);
};

View File

@@ -52,7 +52,7 @@ export const WelcomeModal = ({ visible }: { visible: boolean }): JSX.Element =>
</Stack>
<Stack horizontalAlign="center">
<Stack.Item align="center" style={{ textAlign: "center" }}>
<Text className="title bold">Welcome to Query Copilot for Azure Cosmos DB (Private Preview)</Text>
<Text className="title bold">Welcome to Copilot in Azure Cosmos DB (Private Preview)</Text>
</Stack.Item>
<Stack.Item align="center" className="text">
<Stack horizontal>
@@ -61,7 +61,7 @@ export const WelcomeModal = ({ visible }: { visible: boolean }): JSX.Element =>
</StackItem>
<StackItem align="start">
<Text className="bold">
Let Query Copilot do the work for you
Let Copilot do the work for you
<br />
</Text>
</StackItem>

View File

@@ -76,7 +76,7 @@ exports[`Query Copilot Welcome Modal snapshot test should render when isOpen is
<Text
className="title bold"
>
Welcome to Query Copilot for Azure Cosmos DB (Private Preview)
Welcome to Copilot in Azure Cosmos DB (Private Preview)
</Text>
</StackItem>
<StackItem
@@ -100,7 +100,7 @@ exports[`Query Copilot Welcome Modal snapshot test should render when isOpen is
<Text
className="bold"
>
Let Query Copilot do the work for you
Let Copilot do the work for you
<br />
</Text>
</StackItem>

View File

@@ -0,0 +1,567 @@
import {
Callout,
CommandBarButton,
DefaultButton,
DirectionalHint,
IButtonStyles,
IconButton,
Image,
Link,
MessageBar,
MessageBarType,
Separator,
Spinner,
Stack,
TeachingBubble,
Text,
TextField,
} from "@fluentui/react";
import { useBoolean } from "@fluentui/react-hooks";
import {
ContainerStatusType,
PoolIdType,
QueryCopilotSampleContainerSchema,
ShortenedQueryCopilotSampleContainerSchema,
} from "Common/Constants";
import { handleError } from "Common/ErrorHandlingUtils";
import { createUri } from "Common/UrlUtility";
import { WelcomeModal } from "Explorer/QueryCopilot/Modal/WelcomeModal";
import { CopyPopup } from "Explorer/QueryCopilot/Popup/CopyPopup";
import { DeletePopup } from "Explorer/QueryCopilot/Popup/DeletePopup";
import { SubmitFeedback } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { GenerateSQLQueryResponse, QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { SamplePrompts, SamplePromptsProps } from "Explorer/QueryCopilot/Shared/SamplePrompts/SamplePrompts";
import { userContext } from "UserContext";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import React, { useRef, useState } from "react";
import HintIcon from "../../../images/Hint.svg";
import CopilotIcon from "../../../images/QueryCopilotNewLogo.svg";
import RecentIcon from "../../../images/Recent.svg";
import errorIcon from "../../../images/close-black.svg";
import { useTabs } from "../../hooks/useTabs";
type QueryCopilotPromptProps = QueryCopilotProps & {
toggleCopilot: (toggle: boolean) => void;
};
interface SuggestedPrompt {
id: number;
text: string;
}
const promptStyles: IButtonStyles = {
root: { border: 0, selectors: { ":hover": { outline: "1px dashed #605e5c" } } },
label: { fontWeight: 400, textAlign: "left", paddingLeft: 8 },
};
export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
explorer,
toggleCopilot,
}: QueryCopilotPromptProps): JSX.Element => {
const [copilotTeachingBubbleVisible, { toggle: toggleCopilotTeachingBubbleVisible }] = useBoolean(false);
const inputEdited = useRef(false);
const {
hideFeedbackModalForLikedQueries,
userPrompt,
setUserPrompt,
generatedQuery,
setGeneratedQuery,
query,
setQuery,
isGeneratingQuery,
setIsGeneratingQuery,
likeQuery,
setLikeQuery,
dislikeQuery,
setDislikeQuery,
showCallout,
setShowCallout,
isSamplePromptsOpen,
setIsSamplePromptsOpen,
showSamplePrompts,
setShowSamplePrompts,
showDeletePopup,
setShowDeletePopup,
showFeedbackBar,
setShowFeedbackBar,
showCopyPopup,
setshowCopyPopup,
showErrorMessageBar,
showInvalidQueryMessageBar,
setShowInvalidQueryMessageBar,
setShowErrorMessageBar,
setGeneratedQueryComments,
setQueryResults,
setErrorMessage,
} = useQueryCopilot();
const sampleProps: SamplePromptsProps = {
isSamplePromptsOpen: isSamplePromptsOpen,
setIsSamplePromptsOpen: setIsSamplePromptsOpen,
setTextBox: setUserPrompt,
};
const copyGeneratedCode = () => {
if (!query) {
return;
}
const queryElement = document.createElement("textarea");
queryElement.value = query;
document.body.appendChild(queryElement);
queryElement.select();
document.execCommand("copy");
document.body.removeChild(queryElement);
setshowCopyPopup(true);
setTimeout(() => {
setshowCopyPopup(false);
}, 6000);
};
const cachedHistoriesString = localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotHistories`);
const cachedHistories = cachedHistoriesString?.split("|");
const [histories, setHistories] = useState<string[]>(cachedHistories || []);
const suggestedPrompts: SuggestedPrompt[] = [
{ id: 1, text: 'Show all products that have the word "ultra" in the name or description' },
{ id: 2, text: "What are all of the possible categories for the products, and their counts?" },
{ id: 3, text: 'Show me all products that have been reviewed by someone with a username that contains "bob"' },
];
const [filteredHistories, setFilteredHistories] = useState<string[]>(histories);
const [filteredSuggestedPrompts, setFilteredSuggestedPrompts] = useState<SuggestedPrompt[]>(suggestedPrompts);
const handleUserPromptChange = (event: React.ChangeEvent<HTMLInputElement>) => {
inputEdited.current = true;
const { value } = event.target;
setUserPrompt(value);
// Filter history prompts
const filteredHistory = histories.filter((history) => history.toLowerCase().includes(value.toLowerCase()));
setFilteredHistories(filteredHistory);
// Filter suggested prompts
const filteredSuggested = suggestedPrompts.filter((prompt) =>
prompt.text.toLowerCase().includes(value.toLowerCase()),
);
setFilteredSuggestedPrompts(filteredSuggested);
};
const updateHistories = (): void => {
const formattedUserPrompt = userPrompt.replace(/\s+/g, " ").trim();
const existingHistories = histories.map((history) => history.replace(/\s+/g, " ").trim());
const updatedHistories = existingHistories.filter(
(history) => history.toLowerCase() !== formattedUserPrompt.toLowerCase(),
);
const newHistories = [formattedUserPrompt, ...updatedHistories.slice(0, 2)];
setHistories(newHistories);
localStorage.setItem(`${userContext.databaseAccount.id}-queryCopilotHistories`, newHistories.join("|"));
};
const resetMessageStates = (): void => {
setShowErrorMessageBar(false);
setShowInvalidQueryMessageBar(false);
setShowFeedbackBar(false);
};
const resetQueryResults = (): void => {
setQueryResults(null);
setErrorMessage("");
};
const generateSQLQuery = async (): Promise<void> => {
try {
resetMessageStates();
setIsGeneratingQuery(true);
setShowDeletePopup(false);
useTabs.getState().setIsTabExecuting(true);
useTabs.getState().setIsQueryErrorThrown(false);
if (
useQueryCopilot.getState().containerStatus.status !== ContainerStatusType.Active &&
!userContext.features.disableCopilotPhoenixGateaway
) {
await explorer.allocateContainer(PoolIdType.QueryCopilot);
}
const payload = {
containerSchema: userContext.features.enableCopilotFullSchema
? QueryCopilotSampleContainerSchema
: ShortenedQueryCopilotSampleContainerSchema,
userPrompt: userPrompt,
};
useQueryCopilot.getState().refreshCorrelationId();
const serverInfo = useQueryCopilot.getState().notebookServerInfo;
const queryUri = userContext.features.disableCopilotPhoenixGateaway
? createUri("https://copilotorchestrater.azurewebsites.net/", "generateSQLQuery")
: createUri(serverInfo.notebookServerEndpoint, "generateSQLQuery");
const response = await fetch(queryUri, {
method: "POST",
headers: {
"content-type": "application/json",
"x-ms-correlationid": useQueryCopilot.getState().correlationId,
},
body: JSON.stringify(payload),
});
const generateSQLQueryResponse: GenerateSQLQueryResponse = await response?.json();
if (response.ok) {
if (generateSQLQueryResponse?.sql !== "N/A") {
let query = `-- **Prompt:** ${userPrompt}\r\n`;
if (generateSQLQueryResponse.explanation) {
query += `-- **Explanation of query:** ${generateSQLQueryResponse.explanation}\r\n`;
}
query += generateSQLQueryResponse.sql;
setQuery(query);
setGeneratedQuery(generateSQLQueryResponse.sql);
setGeneratedQueryComments(generateSQLQueryResponse.explanation);
setShowFeedbackBar(true);
resetQueryResults();
} else {
setShowInvalidQueryMessageBar(true);
}
} else {
handleError(JSON.stringify(generateSQLQueryResponse), "copilotInternalServerError");
useTabs.getState().setIsQueryErrorThrown(true);
setShowErrorMessageBar(true);
}
} catch (error) {
handleError(error, "executeNaturalLanguageQuery");
useTabs.getState().setIsQueryErrorThrown(true);
setShowErrorMessageBar(true);
throw error;
} finally {
setIsGeneratingQuery(false);
useTabs.getState().setIsTabExecuting(false);
}
};
const showTeachingBubble = (): void => {
if (!inputEdited.current) {
setTimeout(() => {
if (!inputEdited.current && !isWelcomModalVisible()) {
toggleCopilotTeachingBubbleVisible();
inputEdited.current = true;
}
}, 30000);
}
};
const isWelcomModalVisible = (): boolean => {
return localStorage.getItem("hideWelcomeModal") !== "true";
};
const clearFeedback = () => {
resetButtonState();
resetQueryResults();
};
const resetButtonState = () => {
setDislikeQuery(false);
setLikeQuery(false);
setShowCallout(false);
};
const startGenerateQueryProcess = () => {
updateHistories();
generateSQLQuery();
resetButtonState();
};
React.useEffect(() => {
showTeachingBubble();
useTabs.getState().setIsQueryErrorThrown(false);
}, []);
return (
<Stack className="copilot-prompt-pane" styles={{ root: { backgroundColor: "#FAFAFA", padding: "16px 24px 0px" } }}>
<Stack horizontal>
<Image src={CopilotIcon} style={{ width: 24, height: 24 }} />
<Text style={{ marginLeft: 8, fontWeight: 600, fontSize: 16 }}>Copilot</Text>
<IconButton
iconProps={{ imageProps: { src: errorIcon } }}
onClick={() => {
toggleCopilot(false);
clearFeedback();
resetMessageStates();
}}
styles={{
root: {
marginLeft: "auto !important",
},
}}
ariaLabel="Close"
/>
</Stack>
<Stack horizontal verticalAlign="center">
<TextField
id="naturalLanguageInput"
value={userPrompt}
onChange={handleUserPromptChange}
onClick={() => {
inputEdited.current = true;
setShowSamplePrompts(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && userPrompt) {
inputEdited.current = true;
startGenerateQueryProcess();
}
}}
style={{ lineHeight: 30 }}
styles={{ root: { width: "95%" }, fieldGroup: { borderRadius: 6 } }}
disabled={isGeneratingQuery}
autoComplete="off"
/>
{copilotTeachingBubbleVisible && (
<TeachingBubble
calloutProps={{ directionalHint: DirectionalHint.bottomCenter }}
target="#naturalLanguageInput"
hasCloseButton={true}
closeButtonAriaLabel="Close"
onDismiss={toggleCopilotTeachingBubbleVisible}
hasSmallHeadline={true}
headline="Write a prompt"
>
Write a prompt here and Copilot will generate the query for you. You can also choose from our{" "}
<Link
onClick={() => {
setShowSamplePrompts(true);
toggleCopilotTeachingBubbleVisible();
}}
style={{ color: "white", fontWeight: 600 }}
>
sample prompts
</Link>{" "}
or write your own query
</TeachingBubble>
)}
<IconButton
iconProps={{ iconName: "Send" }}
disabled={isGeneratingQuery || !userPrompt.trim()}
style={{ marginLeft: 8 }}
onClick={() => startGenerateQueryProcess()}
/>
{isGeneratingQuery && <Spinner style={{ marginLeft: 8 }} />}
{showSamplePrompts && (
<Callout
styles={{ root: { minWidth: 400 } }}
target="#naturalLanguageInput"
isBeakVisible={false}
onDismiss={() => setShowSamplePrompts(false)}
directionalHintFixed={true}
directionalHint={DirectionalHint.bottomLeftEdge}
alignTargetEdge={true}
gapSpace={4}
>
<Stack>
{filteredHistories?.length > 0 && (
<Stack>
<Text
style={{
width: "100%",
fontSize: 14,
fontWeight: 600,
color: "#0078D4",
marginLeft: 16,
padding: "4px 0",
}}
>
Recent
</Text>
{filteredHistories.map((history, i) => (
<DefaultButton
key={i}
onClick={() => {
setUserPrompt(history);
setShowSamplePrompts(false);
inputEdited.current = true;
}}
onRenderIcon={() => <Image src={RecentIcon} />}
styles={promptStyles}
>
{history}
</DefaultButton>
))}
</Stack>
)}
{filteredSuggestedPrompts?.length > 0 && (
<Stack>
<Text
style={{
width: "100%",
fontSize: 14,
fontWeight: 600,
color: "#0078D4",
marginLeft: 16,
padding: "4px 0",
}}
>
Suggested Prompts
</Text>
{filteredSuggestedPrompts.map((prompt) => (
<DefaultButton
key={prompt.id}
onClick={() => {
setUserPrompt(prompt.text);
setShowSamplePrompts(false);
inputEdited.current = true;
}}
onRenderIcon={() => <Image src={HintIcon} />}
styles={promptStyles}
>
{prompt.text}
</DefaultButton>
))}
</Stack>
)}
{(filteredHistories?.length > 0 || filteredSuggestedPrompts?.length > 0) && (
<Stack>
<Separator
styles={{
root: {
selectors: { "::before": { background: "#E1DFDD" } },
padding: 0,
},
}}
/>
<Text
style={{
width: "100%",
fontSize: 14,
marginLeft: 16,
padding: "4px 0",
}}
>
Learn about{" "}
<Link target="_blank" href="https://aka.ms/cdb-copilot-writing">
writing effective prompts
</Link>
</Text>
</Stack>
)}
</Stack>
</Callout>
)}
</Stack>
<Stack style={{ margin: "8px 0" }}>
<Text style={{ fontSize: 12 }}>
AI-generated content can have mistakes. Make sure it&apos;s accurate and appropriate before using it.{" "}
<Link href="https://aka.ms/cdb-copilot-preview-terms" target="_blank">
Read preview terms
</Link>
{showErrorMessageBar && (
<MessageBar messageBarType={MessageBarType.error}>
We ran into an error and were not able to execute query.
</MessageBar>
)}
{showInvalidQueryMessageBar && (
<MessageBar
messageBarType={MessageBarType.info}
styles={{ root: { backgroundColor: "#F0F6FF" }, icon: { color: "#015CDA" } }}
>
We were unable to generate a query based upon the prompt provided. Please modify the prompt and try again.
For examples of how to write a good prompt, please read
<Link href="https://aka.ms/cdb-copilot-writing" target="_blank">
this article.
</Link>{" "}
Our content guidelines can be found
<Link href="https://aka.ms/cdb-query-copilot" target="_blank">
here.
</Link>
</MessageBar>
)}
</Text>
</Stack>
{showFeedbackBar && (
<Stack style={{ backgroundColor: "#FFF8F0", padding: "2px 8px" }} horizontal verticalAlign="center">
<Text style={{ fontWeight: 600, fontSize: 12 }}>Provide feedback on the query generated</Text>
{showCallout && !hideFeedbackModalForLikedQueries && (
<Callout
style={{ padding: 8 }}
target="#likeBtn"
onDismiss={() => {
setShowCallout(false);
SubmitFeedback({
params: {
generatedQuery: generatedQuery,
likeQuery: likeQuery,
description: "",
userPrompt: userPrompt,
},
explorer: explorer,
});
}}
directionalHint={DirectionalHint.topCenter}
>
<Text>
Thank you. Need to give{" "}
<Link
onClick={() => {
setShowCallout(false);
useQueryCopilot.getState().openFeedbackModal(generatedQuery, true, userPrompt);
}}
>
more feedback?
</Link>
</Text>
</Callout>
)}
<IconButton
id="likeBtn"
style={{ marginLeft: 20 }}
iconProps={{ iconName: likeQuery === true ? "LikeSolid" : "Like" }}
onClick={() => {
setShowCallout(!likeQuery);
setLikeQuery(!likeQuery);
if (dislikeQuery) {
setDislikeQuery(!dislikeQuery);
}
}}
/>
<IconButton
style={{ margin: "0 10px" }}
iconProps={{ iconName: dislikeQuery === true ? "DislikeSolid" : "Dislike" }}
onClick={() => {
if (!dislikeQuery) {
useQueryCopilot.getState().openFeedbackModal(generatedQuery, false, userPrompt);
setLikeQuery(false);
}
setDislikeQuery(!dislikeQuery);
setShowCallout(false);
}}
/>
<Separator vertical style={{ color: "#EDEBE9" }} />
<CommandBarButton
onClick={copyGeneratedCode}
iconProps={{ iconName: "Copy" }}
style={{ margin: "0 10px", backgroundColor: "#FFF8F0", transition: "background-color 0.3s ease" }}
>
Copy query
</CommandBarButton>
<CommandBarButton
onClick={() => {
setShowDeletePopup(true);
}}
iconProps={{ iconName: "Delete" }}
style={{ margin: "0 10px", backgroundColor: "#FFF8F0", transition: "background-color 0.3s ease" }}
>
Delete query
</CommandBarButton>
</Stack>
)}
<WelcomeModal visible={isWelcomModalVisible()} />
{isSamplePromptsOpen && <SamplePrompts sampleProps={sampleProps} />}
{query !== "" && query.trim().length !== 0 && (
<DeletePopup
showDeletePopup={showDeletePopup}
setShowDeletePopup={setShowDeletePopup}
setQuery={setQuery}
clearFeedback={clearFeedback}
showFeedbackBar={setShowFeedbackBar}
/>
)}
<CopyPopup showCopyPopup={showCopyPopup} setShowCopyPopup={setshowCopyPopup} />
</Stack>
);
};

View File

@@ -1,242 +1,33 @@
/* eslint-disable no-console */
import {
Callout,
CommandBarButton,
DefaultButton,
DirectionalHint,
IButtonStyles,
IconButton,
Image,
Link,
MessageBar,
MessageBarType,
Separator,
Spinner,
Stack,
TeachingBubble,
Text,
TextField,
} from "@fluentui/react";
import { useBoolean } from "@fluentui/react-hooks";
import {
ContainerStatusType,
PoolIdType,
QueryCopilotSampleContainerSchema,
ShortenedQueryCopilotSampleContainerSchema,
} from "Common/Constants";
import { handleError } from "Common/ErrorHandlingUtils";
import { createUri } from "Common/UrlUtility";
import { Stack } from "@fluentui/react";
import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { SaveQueryPane } from "Explorer/Panes/SaveQueryPane/SaveQueryPane";
import { WelcomeModal } from "Explorer/QueryCopilot/Modal/WelcomeModal";
import { CopyPopup } from "Explorer/QueryCopilot/Popup/CopyPopup";
import { DeletePopup } from "Explorer/QueryCopilot/Popup/DeletePopup";
import { OnExecuteQueryClick, SubmitFeedback } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { GenerateSQLQueryResponse, QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
import { OnExecuteQueryClick } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { QueryCopilotResults } from "Explorer/QueryCopilot/Shared/QueryCopilotResults";
import { SamplePrompts, SamplePromptsProps } from "Explorer/QueryCopilot/Shared/SamplePrompts/SamplePrompts";
import { userContext } from "UserContext";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import { useSidePanel } from "hooks/useSidePanel";
import React, { useRef, useState } from "react";
import React, { useState } from "react";
import SplitterLayout from "react-splitter-layout";
import QueryCommandIcon from "../../../images/CopilotCommand.svg";
import ExecuteQueryIcon from "../../../images/ExecuteQuery.svg";
import HintIcon from "../../../images/Hint.svg";
import CopilotIcon from "../../../images/QueryCopilotNewLogo.svg";
import RecentIcon from "../../../images/Recent.svg";
import SaveQueryIcon from "../../../images/save-cosmos.svg";
import { useTabs } from "../../hooks/useTabs";
interface SuggestedPrompt {
id: number;
text: string;
}
const promptStyles: IButtonStyles = {
root: { border: 0, selectors: { ":hover": { outline: "1px dashed #605e5c" } } },
label: { fontWeight: 400, textAlign: "left", paddingLeft: 8 },
};
import * as StringUtility from "../../Shared/StringUtility";
export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: QueryCopilotProps): JSX.Element => {
const [copilotTeachingBubbleVisible, { toggle: toggleCopilotTeachingBubbleVisible }] = useBoolean(false);
const inputEdited = useRef(false);
const {
hideFeedbackModalForLikedQueries,
userPrompt,
setUserPrompt,
generatedQuery,
setGeneratedQuery,
query,
setQuery,
selectedQuery,
setSelectedQuery,
isGeneratingQuery,
setIsGeneratingQuery,
likeQuery,
setLikeQuery,
dislikeQuery,
setDislikeQuery,
showCallout,
setShowCallout,
showSamplePrompts,
setShowSamplePrompts,
isSamplePromptsOpen,
setIsSamplePromptsOpen,
showDeletePopup,
setShowDeletePopup,
showFeedbackBar,
setShowFeedbackBar,
showCopyPopup,
setshowCopyPopup,
showErrorMessageBar,
showInvalidQueryMessageBar,
setShowInvalidQueryMessageBar,
setShowErrorMessageBar,
setGeneratedQueryComments,
setQueryResults,
setErrorMessage,
} = useQueryCopilot();
const { query, setQuery, selectedQuery, setSelectedQuery, isGeneratingQuery } = useQueryCopilot();
const sampleProps: SamplePromptsProps = {
isSamplePromptsOpen: isSamplePromptsOpen,
setIsSamplePromptsOpen: setIsSamplePromptsOpen,
setTextBox: setUserPrompt,
};
const copyGeneratedCode = () => {
if (!query) {
return;
}
const queryElement = document.createElement("textarea");
queryElement.value = query;
document.body.appendChild(queryElement);
queryElement.select();
document.execCommand("copy");
document.body.removeChild(queryElement);
setshowCopyPopup(true);
setTimeout(() => {
setshowCopyPopup(false);
}, 6000);
};
const cachedHistoriesString = localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotHistories`);
const cachedHistories = cachedHistoriesString?.split("|");
const [histories, setHistories] = useState<string[]>(cachedHistories || []);
const suggestedPrompts: SuggestedPrompt[] = [
{ id: 1, text: 'Show all products that have the word "ultra" in the name or description' },
{ id: 2, text: "What are all of the possible categories for the products, and their counts?" },
{ id: 3, text: 'Show me all products that have been reviewed by someone with a username that contains "bob"' },
];
const [filteredHistories, setFilteredHistories] = useState<string[]>(histories);
const [filteredSuggestedPrompts, setFilteredSuggestedPrompts] = useState<SuggestedPrompt[]>(suggestedPrompts);
const handleUserPromptChange = (event: React.ChangeEvent<HTMLInputElement>) => {
inputEdited.current = true;
const { value } = event.target;
setUserPrompt(value);
// Filter history prompts
const filteredHistory = histories.filter((history) => history.toLowerCase().includes(value.toLowerCase()));
setFilteredHistories(filteredHistory);
// Filter suggested prompts
const filteredSuggested = suggestedPrompts.filter((prompt) =>
prompt.text.toLowerCase().includes(value.toLowerCase()),
);
setFilteredSuggestedPrompts(filteredSuggested);
};
const updateHistories = (): void => {
const formattedUserPrompt = userPrompt.replace(/\s+/g, " ").trim();
const existingHistories = histories.map((history) => history.replace(/\s+/g, " ").trim());
const updatedHistories = existingHistories.filter(
(history) => history.toLowerCase() !== formattedUserPrompt.toLowerCase(),
);
const newHistories = [formattedUserPrompt, ...updatedHistories.slice(0, 2)];
setHistories(newHistories);
localStorage.setItem(`${userContext.databaseAccount.id}-queryCopilotHistories`, newHistories.join("|"));
};
const resetMessageStates = (): void => {
setShowErrorMessageBar(false);
setShowInvalidQueryMessageBar(false);
setShowFeedbackBar(false);
};
const resetQueryResults = (): void => {
setQueryResults(null);
setErrorMessage("");
};
const generateSQLQuery = async (): Promise<void> => {
try {
resetMessageStates();
setIsGeneratingQuery(true);
setShowDeletePopup(false);
useTabs.getState().setIsTabExecuting(true);
useTabs.getState().setIsQueryErrorThrown(false);
if (
useQueryCopilot.getState().containerStatus.status !== ContainerStatusType.Active &&
!userContext.features.disableCopilotPhoenixGateaway
) {
await explorer.allocateContainer(PoolIdType.QueryCopilot);
}
const payload = {
containerSchema: userContext.features.enableCopilotFullSchema
? QueryCopilotSampleContainerSchema
: ShortenedQueryCopilotSampleContainerSchema,
userPrompt: userPrompt,
};
useQueryCopilot.getState().refreshCorrelationId();
const serverInfo = useQueryCopilot.getState().notebookServerInfo;
const queryUri = userContext.features.disableCopilotPhoenixGateaway
? createUri("https://copilotorchestrater.azurewebsites.net/", "generateSQLQuery")
: createUri(serverInfo.notebookServerEndpoint, "generateSQLQuery");
const response = await fetch(queryUri, {
method: "POST",
headers: {
"content-type": "application/json",
"x-ms-correlationid": useQueryCopilot.getState().correlationId,
},
body: JSON.stringify(payload),
});
const generateSQLQueryResponse: GenerateSQLQueryResponse = await response?.json();
if (response.ok) {
if (generateSQLQueryResponse?.sql !== "N/A") {
let query = `-- **Prompt:** ${userPrompt}\r\n`;
if (generateSQLQueryResponse.explanation) {
query += `-- **Explanation of query:** ${generateSQLQueryResponse.explanation}\r\n`;
}
query += generateSQLQueryResponse.sql;
setQuery(query);
setGeneratedQuery(generateSQLQueryResponse.sql);
setGeneratedQueryComments(generateSQLQueryResponse.explanation);
setShowFeedbackBar(true);
resetQueryResults();
} else {
setShowInvalidQueryMessageBar(true);
}
} else {
handleError(JSON.stringify(generateSQLQueryResponse), "copilotInternalServerError");
useTabs.getState().setIsQueryErrorThrown(true);
setShowErrorMessageBar(true);
}
} catch (error) {
handleError(error, "executeNaturalLanguageQuery");
useTabs.getState().setIsQueryErrorThrown(true);
setShowErrorMessageBar(true);
throw error;
} finally {
setIsGeneratingQuery(false);
useTabs.getState().setIsTabExecuting(false);
}
};
const cachedCopilotToggleStatus: string = localStorage.getItem(
`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`,
);
const copilotInitialActive: boolean = cachedCopilotToggleStatus
? StringUtility.toBoolean(cachedCopilotToggleStatus)
: true;
const [copilotActive, setCopilotActive] = useState<boolean>(copilotInitialActive);
const getCommandbarButtons = (): CommandButtonComponentProps[] => {
const executeQueryBtnLabel = selectedQuery ? "Execute Selection" : "Execute Query";
@@ -261,324 +52,47 @@ export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: Query
disabled: true,
};
// Sample Prompts temporary disabled due current design
// const samplePromptsBtn = {
// iconSrc: SamplePromptsIcon,
// iconAlt: "Sample Prompts",
// onCommandClick: () => setIsSamplePromptsOpen(true),
// commandButtonLabel: "Sample Prompts",
// ariaLabel: "Sample Prompts",
// hasPopup: false,
// };
const toggleCopilotButton = {
iconSrc: QueryCommandIcon,
iconAlt: "Copilot",
onCommandClick: () => {
toggleCopilot(true);
},
commandButtonLabel: "Copilot",
ariaLabel: "Copilot",
hasPopup: false,
disabled: copilotActive,
};
return [executeQueryBtn, saveQueryBtn];
};
const showTeachingBubble = (): void => {
if (!inputEdited.current) {
setTimeout(() => {
if (!inputEdited.current) {
toggleCopilotTeachingBubbleVisible();
inputEdited.current = true;
}
}, 30000);
}
};
const clearFeedback = () => {
resetButtonState();
resetQueryResults();
};
const resetButtonState = () => {
setDislikeQuery(false);
setLikeQuery(false);
setShowCallout(false);
};
const startGenerateQueryProcess = () => {
updateHistories();
generateSQLQuery();
resetButtonState();
return [executeQueryBtn, saveQueryBtn, toggleCopilotButton];
};
React.useEffect(() => {
useCommandBar.getState().setContextButtons(getCommandbarButtons());
}, [query, selectedQuery]);
}, [query, selectedQuery, copilotActive]);
React.useEffect(() => {
showTeachingBubble();
useTabs.getState().setIsQueryErrorThrown(false);
return () => {
const commandbarButtons = getCommandbarButtons();
commandbarButtons.pop();
commandbarButtons.map((props: CommandButtonComponentProps) => (props.disabled = true));
useCommandBar.getState().setContextButtons(commandbarButtons);
};
}, []);
const toggleCopilot = (toggle: boolean) => {
setCopilotActive(toggle);
localStorage.setItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, toggle.toString());
};
return (
<Stack className="tab-pane" style={{ padding: 24, width: "100%" }}>
<Stack className="tab-pane" style={{ width: "100%" }}>
<div style={isGeneratingQuery ? { height: "100%" } : { overflowY: "auto", height: "100%" }}>
<Stack horizontal verticalAlign="center">
<Image src={CopilotIcon} style={{ width: 24, height: 24 }} />
<Text style={{ marginLeft: 8, fontWeight: 600, fontSize: 16 }}>Copilot</Text>
</Stack>
<Stack horizontal verticalAlign="center" style={{ marginTop: 16, width: "100%", position: "relative" }}>
<TextField
id="naturalLanguageInput"
value={userPrompt}
onChange={handleUserPromptChange}
onClick={() => {
inputEdited.current = true;
setShowSamplePrompts(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
inputEdited.current = true;
startGenerateQueryProcess();
}
}}
style={{ lineHeight: 30 }}
styles={{ root: { width: "95%" } }}
disabled={isGeneratingQuery}
autoComplete="off"
/>
{copilotTeachingBubbleVisible && (
<TeachingBubble
calloutProps={{ directionalHint: DirectionalHint.bottomCenter }}
target="#naturalLanguageInput"
hasCloseButton={true}
closeButtonAriaLabel="Close"
onDismiss={toggleCopilotTeachingBubbleVisible}
hasSmallHeadline={true}
headline="Write a prompt"
>
Write a prompt here and Copilot will generate the query for you. You can also choose from our{" "}
<Link
onClick={() => {
setShowSamplePrompts(true);
toggleCopilotTeachingBubbleVisible();
}}
style={{ color: "white", fontWeight: 600 }}
>
sample prompts
</Link>{" "}
or write your own query
</TeachingBubble>
)}
<IconButton
iconProps={{ iconName: "Send" }}
disabled={isGeneratingQuery || !userPrompt.trim()}
style={{ marginLeft: 8 }}
onClick={() => startGenerateQueryProcess()}
/>
{isGeneratingQuery && <Spinner style={{ marginLeft: 8 }} />}
{showSamplePrompts && (
<Callout
styles={{ root: { minWidth: 400 } }}
target="#naturalLanguageInput"
isBeakVisible={false}
onDismiss={() => setShowSamplePrompts(false)}
directionalHintFixed={true}
directionalHint={DirectionalHint.bottomLeftEdge}
alignTargetEdge={true}
gapSpace={4}
>
<Stack>
{filteredHistories?.length > 0 && (
<Stack>
<Text
style={{
width: "100%",
fontSize: 14,
fontWeight: 600,
color: "#0078D4",
marginLeft: 16,
padding: "4px 0",
}}
>
Recent
</Text>
{filteredHistories.map((history, i) => (
<DefaultButton
key={i}
onClick={() => {
setUserPrompt(history);
setShowSamplePrompts(false);
inputEdited.current = true;
}}
onRenderIcon={() => <Image src={RecentIcon} />}
styles={promptStyles}
>
{history}
</DefaultButton>
))}
</Stack>
)}
{filteredSuggestedPrompts?.length > 0 && (
<Stack>
<Text
style={{
width: "100%",
fontSize: 14,
fontWeight: 600,
color: "#0078D4",
marginLeft: 16,
padding: "4px 0",
}}
>
Suggested Prompts
</Text>
{filteredSuggestedPrompts.map((prompt) => (
<DefaultButton
key={prompt.id}
onClick={() => {
setUserPrompt(prompt.text);
setShowSamplePrompts(false);
inputEdited.current = true;
}}
onRenderIcon={() => <Image src={HintIcon} />}
styles={promptStyles}
>
{prompt.text}
</DefaultButton>
))}
</Stack>
)}
{(filteredHistories?.length > 0 || filteredSuggestedPrompts?.length > 0) && (
<Stack>
<Separator
styles={{
root: {
selectors: { "::before": { background: "#E1DFDD" } },
padding: 0,
},
}}
/>
<Text
style={{
width: "100%",
fontSize: 14,
marginLeft: 16,
padding: "4px 0",
}}
>
Learn about{" "}
<Link target="_blank" href="https://aka.ms/cdb-copilot-writing">
writing effective prompts
</Link>
</Text>
</Stack>
)}
</Stack>
</Callout>
)}
</Stack>
<Stack style={{ marginTop: 8, marginBottom: 24 }}>
<Text style={{ fontSize: 12 }}>
AI-generated content can have mistakes. Make sure it&apos;s accurate and appropriate before using it.{" "}
<Link href="https://aka.ms/cdb-copilot-preview-terms" target="_blank">
Read preview terms
</Link>
{showErrorMessageBar && (
<MessageBar messageBarType={MessageBarType.error}>
We ran into an error and were not able to execute query.
</MessageBar>
)}
{showInvalidQueryMessageBar && (
<MessageBar
messageBarType={MessageBarType.info}
styles={{ root: { backgroundColor: "#F0F6FF" }, icon: { color: "#015CDA" } }}
>
We were unable to generate a query based upon the prompt provided. Please modify the prompt and try
again. For examples of how to write a good prompt, please read
<Link href="https://aka.ms/cdb-copilot-writing" target="_blank">
this article.
</Link>{" "}
Our content guidelines can be found
<Link href="https://aka.ms/cdb-query-copilot" target="_blank">
here.
</Link>
</MessageBar>
)}
</Text>
</Stack>
{showFeedbackBar && (
<Stack style={{ backgroundColor: "#FFF8F0", padding: "2px 8px" }} horizontal verticalAlign="center">
<Text style={{ fontWeight: 600, fontSize: 12 }}>Provide feedback on the query generated</Text>
{showCallout && !hideFeedbackModalForLikedQueries && (
<Callout
style={{ padding: 8 }}
target="#likeBtn"
onDismiss={() => {
setShowCallout(false);
SubmitFeedback({
params: {
generatedQuery: generatedQuery,
likeQuery: likeQuery,
description: "",
userPrompt: userPrompt,
},
explorer: explorer,
});
}}
directionalHint={DirectionalHint.topCenter}
>
<Text>
Thank you. Need to give{" "}
<Link
onClick={() => {
setShowCallout(false);
useQueryCopilot.getState().openFeedbackModal(generatedQuery, true, userPrompt);
}}
>
more feedback?
</Link>
</Text>
</Callout>
)}
<IconButton
id="likeBtn"
style={{ marginLeft: 20 }}
iconProps={{ iconName: likeQuery === true ? "LikeSolid" : "Like" }}
onClick={() => {
setShowCallout(!likeQuery);
setLikeQuery(!likeQuery);
if (dislikeQuery) {
setDislikeQuery(!dislikeQuery);
}
}}
/>
<IconButton
style={{ margin: "0 10px" }}
iconProps={{ iconName: dislikeQuery === true ? "DislikeSolid" : "Dislike" }}
onClick={() => {
if (!dislikeQuery) {
useQueryCopilot.getState().openFeedbackModal(generatedQuery, false, userPrompt);
setLikeQuery(false);
}
setDislikeQuery(!dislikeQuery);
setShowCallout(false);
}}
/>
<Separator vertical style={{ color: "#EDEBE9" }} />
<CommandBarButton
onClick={copyGeneratedCode}
iconProps={{ iconName: "Copy" }}
style={{ margin: "0 10px", backgroundColor: "#FFF8F0", transition: "background-color 0.3s ease" }}
>
Copy code
</CommandBarButton>
<CommandBarButton
onClick={() => {
setShowDeletePopup(true);
}}
iconProps={{ iconName: "Delete" }}
style={{ margin: "0 10px", backgroundColor: "#FFF8F0", transition: "background-color 0.3s ease" }}
>
Delete code
</CommandBarButton>
</Stack>
{copilotActive && (
<QueryCopilotPromptbar explorer={explorer} toggleCopilot={toggleCopilot}></QueryCopilotPromptbar>
)}
<Stack className="tabPaneContentContainer">
<SplitterLayout vertical={true} primaryIndex={0} primaryMinSize={100} secondaryMinSize={200}>
<SplitterLayout percentage={true} vertical={true} primaryIndex={0} primaryMinSize={30} secondaryMinSize={70}>
<EditorReact
language={"sql"}
content={query}
@@ -592,18 +106,6 @@ export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: Query
<QueryCopilotResults />
</SplitterLayout>
</Stack>
<WelcomeModal visible={localStorage.getItem("hideWelcomeModal") !== "true"} />
{isSamplePromptsOpen && <SamplePrompts sampleProps={sampleProps} />}
{query !== "" && query.trim().length !== 0 && (
<DeletePopup
showDeletePopup={showDeletePopup}
setShowDeletePopup={setShowDeletePopup}
setQuery={setQuery}
clearFeedback={clearFeedback}
showFeedbackBar={setShowFeedbackBar}
/>
)}
<CopyPopup showCopyPopup={showCopyPopup} setShowCopyPopup={setshowCopyPopup} />
</div>
</Stack>
);

View File

@@ -21,6 +21,7 @@ import { userContext } from "UserContext";
import { queryPagesUntilContentPresent } from "Utils/QueryUtils";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import { useTabs } from "hooks/useTabs";
import * as StringUtility from "../../../Shared/StringUtility";
export const SendQueryRequest = async ({
userPrompt,
@@ -184,15 +185,20 @@ export const QueryDocumentsPerPage = async (
correlationId: useQueryCopilot.getState().correlationId,
});
} catch (error) {
const isCopilotActive = StringUtility.toBoolean(
localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`),
);
const errorMessage = getErrorMessage(error);
traceFailure(Action.ExecuteQueryGeneratedFromQueryCopilot, {
correlationId: useQueryCopilot.getState().correlationId,
errorMessage: errorMessage,
});
useQueryCopilot.getState().setErrorMessage(errorMessage);
handleError(errorMessage, "executeQueryCopilotTab");
useTabs.getState().setIsQueryErrorThrown(true);
useQueryCopilot.getState().setShowErrorMessageBar(true);
if (isCopilotActive) {
useQueryCopilot.getState().setErrorMessage(errorMessage);
useQueryCopilot.getState().setShowErrorMessageBar(true);
}
} finally {
useQueryCopilot.getState().setIsExecuting(false);
useTabs.getState().setIsTabExecuting(false);

View File

@@ -5,7 +5,6 @@ exports[`Query copilot tab snapshot test should render with initial input 1`] =
className="tab-pane"
style={
Object {
"padding": 24,
"width": "100%",
}
}
@@ -18,103 +17,37 @@ exports[`Query copilot tab snapshot test should render with initial input 1`] =
}
}
>
<Stack
horizontal={true}
verticalAlign="center"
>
<Image
src={Object {}}
style={
Object {
"height": 24,
"width": 24,
}
}
/>
<Text
style={
Object {
"fontSize": 16,
"fontWeight": 600,
"marginLeft": 8,
}
}
>
Copilot
</Text>
</Stack>
<Stack
horizontal={true}
style={
Object {
"marginTop": 16,
"position": "relative",
"width": "100%",
}
}
verticalAlign="center"
>
<StyledTextFieldBase
autoComplete="off"
disabled={false}
id="naturalLanguageInput"
onChange={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"lineHeight": 30,
}
}
styles={
Object {
"root": Object {
"width": "95%",
<QueryCopilotPromptbar
explorer={
Explorer {
"_isInitializingNotebooks": false,
"_resetNotebookWorkspace": [Function],
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": Object {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
}
}
value=""
/>
<CustomizedIconButton
disabled={true}
iconProps={
Object {
"iconName": "Send",
}
}
onClick={[Function]}
style={
Object {
"marginLeft": 8,
}
}
/>
</Stack>
<Stack
style={
Object {
"marginBottom": 24,
"marginTop": 8,
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"copyNotebook": [Function],
"parameters": [Function],
},
}
}
>
<Text
style={
Object {
"fontSize": 12,
}
}
>
AI-generated content can have mistakes. Make sure it's accurate and appropriate before using it.
<StyledLinkBase
href="https://aka.ms/cdb-copilot-preview-terms"
target="_blank"
>
Read preview terms
</StyledLinkBase>
</Text>
</Stack>
toggleCopilot={[Function]}
/>
<Stack
className="tabPaneContentContainer"
>
@@ -123,10 +56,10 @@ exports[`Query copilot tab snapshot test should render with initial input 1`] =
onDragEnd={null}
onDragStart={null}
onSecondaryPaneSizeChange={null}
percentage={false}
percentage={true}
primaryIndex={0}
primaryMinSize={100}
secondaryMinSize={200}
primaryMinSize={30}
secondaryMinSize={70}
vertical={true}
>
<EditorReact
@@ -142,20 +75,6 @@ exports[`Query copilot tab snapshot test should render with initial input 1`] =
<QueryCopilotResults />
</t>
</Stack>
<WelcomeModal
visible={true}
/>
<DeletePopup
clearFeedback={[Function]}
setQuery={[Function]}
setShowDeletePopup={[Function]}
showDeletePopup={false}
showFeedbackBar={[Function]}
/>
<CopyPopup
setShowCopyPopup={[Function]}
showCopyPopup={false}
/>
</div>
</Stack>
`;

View File

@@ -9,8 +9,6 @@ import {
Text,
TextField,
} from "@fluentui/react";
import { sendMessage } from "Common/MessageHandler";
import { MessageTypes } from "Contracts/ExplorerContracts";
import { customPivotHeaderRenderer } from "Explorer/Quickstart/Shared/QuickstartRenderUtilities";
import {
loadDataCommand,
@@ -63,28 +61,16 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
>
<Stack style={{ marginTop: 20 }}>
<Text>
A hosted mongosh (mongo shell) is provided for this quick start. You are automatically logged in to
mongosh, allowing you to interact with your database directly.
This tutorial guides you to create and query distributed tables using a sample dataset.
<br />
<br />
When not in the quick start guide, connecting to Azure Cosmos DB for MongoDB vCore is straightforward
using your connection string.
To start, input the admin password you used during the cluster creation process into the MongoDB vCore
terminal.
<br />
<br />
<Link
aria-label="View connection string"
href=""
onClick={() => sendMessage({ type: MessageTypes.OpenVCoreMongoConnectionStringsBlade })}
>
View connection string
</Link>
<br />
<br />
This string contains placeholders for &lt;user&gt; and &lt;password&gt;. Replace them with your chosen
username and password to establish a secure connection to your cluster. Depending on your environment,
you may need to adjust firewall rules or configure private endpoints in the &lsquo;Networking&rsquo;
tab of your database settings, or modify your own network&apos;s firewall settings, to successfully
connect.
Note: If you navigate out of the Quick start blade &#40;MongoDB vCore Shell&#41;, the session will be
closed and all ongoing commands might be interrupted.
</Text>
</Stack>
</PivotItem>
@@ -103,6 +89,7 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
documents are similar to the columns in a relational database table. One key advantage of MongoDB is
that these documents within a collection can have different fields.
<br />
<br />
You&apos;re now going to create a new database and a collection within that database using the Mongo
shell. In MongoDB, creating a database or a collection is implicit. This means that databases and
collections are created when you first reference them in a command, so no explicit creation command is
@@ -153,14 +140,14 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
with data. In MongoDB, data is stored as documents, which are structured as field and value pairs.
<br />
<br />
Let&apos;s populate your sampleCollection with data. We&apos;ll add 10 documents representing books,
each with a title, author, and number of pages, to your sampleCollection in the quickstartDB database.
We&apos;ll add 10 documents representing books, each with a title, author, and number of pages, to
your sampleCollection in the quickstartDB database.
</Text>
<DefaultButton
style={{ marginTop: 16, width: 200 }}
onClick={() => useTerminal.getState().sendMessage(loadDataCommand)}
>
Create distributed table
Load data
</DefaultButton>
<Stack horizontal style={{ marginTop: 16 }}>
<TextField
@@ -197,7 +184,7 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
>
<Stack style={{ marginTop: 20 }}>
<Text>
Once youve inserted data into your sampleCollection, you can retrieve it using queries. MongoDB
Once you&apos;ve inserted data into your sampleCollection, you can retrieve it using queries. MongoDB
queries can be as simple or as complex as you need them to be, allowing you to filter, sort, and limit
results.
</Text>
@@ -205,7 +192,7 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
style={{ marginTop: 16, width: 110 }}
onClick={() => useTerminal.getState().sendMessage(queriesCommand)}
>
Load data
Try query
</DefaultButton>
<Stack horizontal style={{ marginTop: 16 }}>
<TextField
@@ -233,7 +220,7 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
</Stack>
</PivotItem>
<PivotItem
headerText="Integrations"
headerText="Next steps"
onRenderItemLink={(props, defaultRenderer) =>
customPivotHeaderRenderer(props, defaultRenderer, currentStep, 4)
}
@@ -242,46 +229,18 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
>
<Stack>
<Text>
Cosmos DB for MongoDB vCore seamlessly integrates with Azure services. These integrations enable
Cosmos DB for MongoDB and its partner products to directly interoperate, ensuring a smooth and unified
experience, that just works.
<b>Migrate existing data</b>
<br />
<br />
Modernize your data seamlessly from an existing MongoDB cluster, whether it&apos;s on-premises or
hosted in the cloud, to Azure Cosmos DB for MongoDB vCore.&nbsp;
<Link
target="_blank"
href="https://learn.microsoft.com/azure-data-studio/extensions/azure-cosmos-db-mongodb-extension"
>
Learn more
</Link>
</Text>
<Stack horizontal>
<Stack style={{ marginTop: 20, marginRight: 20 }}>
<Text>
<b>First party integrations</b>
<br />
<br />
<b>Azure Monitor</b>
<br />
Azure monitor provides comprehensive monitoring and diagnostics for Cosmos DB for Mongo DB. Learn
more
<br />
<br />
<b>Azure Networking</b>
<br />
Azure Networking seamlessly integrates with Azure Cosmos DB for Mongo DB for fast and secure data
access. Learn more
<br />
<br />
<b>PowerShell/CLI/ARM</b>
<br />
PowerShell/CLI/ARM seamlessly integrates with Azure Cosmos DB for Mongo DB for efficient
management and automation. Learn more
</Text>
</Stack>
<Stack style={{ marginTop: 20, marginLeft: 20 }}>
<Text>
<b>Application platforms integrations</b>
<br />
<br />
<b>Vercel</b>
<br />
Vercel is a cloud platform for hosting static front ends and serverless functions, with instant
deployments, automated scaling, and Next.js integration. Learn more
</Text>
</Stack>
</Stack>
</Stack>
</PivotItem>
</Pivot>

View File

@@ -147,7 +147,7 @@ export class CassandraAPIDataClient extends TableDataClient {
let properties = "(";
let values = "(";
for (let property in entity) {
if (entity[property]._ === null) {
if (entity[property]._ === "" || entity[property]._ === undefined) {
continue;
}
properties = properties.concat(`${property}, `);
@@ -208,6 +208,9 @@ export class CassandraAPIDataClient extends TableDataClient {
!originalDocument[property] ||
newEntity[property]._.toString() !== originalDocument[property]._.toString()
) {
if (newEntity[property]._.toString() === "" || newEntity[property]._ === undefined) {
continue;
}
let propertyQuerySegment = this.isStringType(newEntity[property].$)
? `${property} = '${newEntity[property]._}',`
: `${property} = ${newEntity[property]._},`;

View File

@@ -98,7 +98,7 @@
<button
class="filterbtnstyle queryButton"
data-bind="
click: refreshDocumentsGrid,
click: refreshDocumentsGrid.bind($data, true),
enable: applyFilterButton.enabled"
aria-label="Apply filter"
tabindex="0"
@@ -176,7 +176,7 @@
<img
class="refreshcol"
src="/refresh-cosmos.svg"
data-bind="click: refreshDocumentsGrid"
data-bind="click: refreshDocumentsGrid.bind($data, false)"
alt="Refresh documents"
tabindex="0"
/>
@@ -209,7 +209,10 @@
</table>
</div>
<div class="loadMore">
<a role="button" data-bind="click: loadNextPage, event: { keypress: onLoadMoreKeyInput }" tabindex="0"
<a
role="button"
data-bind="click: loadNextPage.bind($data, false), event: { keypress: onLoadMoreKeyInput }"
tabindex="0"
>Load more</a
>
</div>

View File

@@ -1,12 +1,15 @@
import { extractPartitionKey, ItemDefinition, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import * as ko from "knockout";
import Q from "q";
import { format } from "react-string-format";
import { QueryConstants } from "Shared/Constants";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import DeleteDocumentIcon from "../../../images/DeleteDocument.svg";
import DiscardIcon from "../../../images/discard.svg";
import NewDocumentIcon from "../../../images/NewDocument.svg";
import SaveIcon from "../../../images/save-cosmos.svg";
import UploadIcon from "../../../images/Upload_16x16.svg";
import DiscardIcon from "../../../images/discard.svg";
import SaveIcon from "../../../images/save-cosmos.svg";
import * as Constants from "../../Common/Constants";
import {
DocumentsGridMetrics,
@@ -14,15 +17,15 @@ import {
QueryCopilotSampleContainerId,
QueryCopilotSampleDatabaseId,
} from "../../Common/Constants";
import editable from "../../Common/EditableUtility";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
import * as HeadersUtility from "../../Common/HeadersUtility";
import { Splitter, SplitterBounds, SplitterDirection } from "../../Common/Splitter";
import { createDocument } from "../../Common/dataAccess/createDocument";
import { deleteDocument } from "../../Common/dataAccess/deleteDocument";
import { queryDocuments } from "../../Common/dataAccess/queryDocuments";
import { readDocument } from "../../Common/dataAccess/readDocument";
import { updateDocument } from "../../Common/dataAccess/updateDocument";
import editable from "../../Common/EditableUtility";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
import * as HeadersUtility from "../../Common/HeadersUtility";
import { Splitter, SplitterBounds, SplitterDirection } from "../../Common/Splitter";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
@@ -30,6 +33,7 @@ import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext";
import { logConsoleError } from "../../Utils/NotificationConsoleUtils";
import * as QueryUtils from "../../Utils/QueryUtils";
import { extractPartitionKeyValues } from "../../Utils/QueryUtils";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { useDialog } from "../Controls/Dialog";
import Explorer from "../Explorer";
@@ -79,6 +83,7 @@ export default class DocumentsTab extends TabsBase {
private _resourceTokenPartitionKey: string;
private _isQueryCopilotSampleContainer: boolean;
private queryAbortController: AbortController;
private cancelQueryTimeoutID: NodeJS.Timeout;
constructor(options: ViewModels.DocumentsTabOptions) {
super(options);
@@ -346,6 +351,22 @@ export default class DocumentsTab extends TabsBase {
return true;
}
/**
* Query first page of documents
* Select and query first document and display content
*/
private async autoPopulateContent(applyFilterButtonPressed?: boolean) {
// reset iterator
this._documentsIterator = this.createIterator();
// load documents
await this.loadNextPage(applyFilterButtonPressed);
// Select first document and load content
if (this.documentIds().length > 0) {
this.documentIds()[0].click();
}
}
public onShowFilterClick(): Q.Promise<any> {
this.isFilterCreated(true);
this.isFilterExpanded(true);
@@ -375,15 +396,14 @@ export default class DocumentsTab extends TabsBase {
return true;
};
public async refreshDocumentsGrid(): Promise<void> {
public async refreshDocumentsGrid(applyFilterButtonPressed?: boolean): Promise<void> {
// clear documents grid
this.documentIds([]);
try {
// reset iterator
this._documentsIterator = this.createIterator();
// load documents
await this.loadNextPage();
await this.autoPopulateContent(applyFilterButtonPressed);
// collapse filter
this.appliedFilter(this.filterContent());
this.isFilterExpanded(false);
@@ -406,6 +426,11 @@ export default class DocumentsTab extends TabsBase {
this.queryAbortController.abort();
}
/**
* TODO Doesn't seem to be used: remove?
* @param clickedDocumentId
* @returns
*/
public onDocumentIdClick(clickedDocumentId: DocumentId): Q.Promise<any> {
if (this.editorState() !== ViewModels.DocumentExplorerState.noDocumentSelected) {
return Q();
@@ -455,7 +480,7 @@ export default class DocumentsTab extends TabsBase {
const value: string = this.renderObjectForEditor(savedDocument || {}, null, 4);
this.selectedDocumentContent.setBaseline(value);
this.initialDocumentContent(value);
const partitionKeyValueArray = extractPartitionKey(
const partitionKeyValueArray: PartitionKey[] = extractPartitionKeyValues(
savedDocument,
this.partitionKey as PartitionKeyDefinition,
);
@@ -506,7 +531,10 @@ export default class DocumentsTab extends TabsBase {
const selectedDocumentId = this.selectedDocumentId();
const documentContent = JSON.parse(this.selectedDocumentContent());
const partitionKeyValueArray = extractPartitionKey(documentContent, this.partitionKey as PartitionKeyDefinition);
const partitionKeyValueArray: PartitionKey[] = extractPartitionKeyValues(
documentContent,
this.partitionKey as PartitionKeyDefinition,
);
selectedDocumentId.partitionKeyValue = partitionKeyValueArray;
this.isExecutionError(false);
@@ -624,8 +652,7 @@ export default class DocumentsTab extends TabsBase {
if (!this._documentsIterator) {
try {
this._documentsIterator = this.createIterator();
await this.loadNextPage();
await this.autoPopulateContent();
} catch (error) {
if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) {
TelemetryProcessor.traceFailure(
@@ -716,9 +743,35 @@ export default class DocumentsTab extends TabsBase {
this.initDocumentEditor(documentId, content);
}
public loadNextPage(): Q.Promise<any> {
public loadNextPage(applyFilterButtonClicked?: boolean): Q.Promise<any> {
this.isExecuting(true);
this.isExecutionError(false);
let automaticallyCancelQueryAfterTimeout: boolean;
if (applyFilterButtonClicked && this.queryTimeoutEnabled()) {
const queryTimeout: number = LocalStorageUtility.getEntryNumber(StorageKey.QueryTimeout);
automaticallyCancelQueryAfterTimeout = LocalStorageUtility.getEntryBoolean(
StorageKey.AutomaticallyCancelQueryAfterTimeout,
);
const cancelQueryTimeoutID: NodeJS.Timeout = setTimeout(() => {
if (this.isExecuting()) {
if (automaticallyCancelQueryAfterTimeout) {
this.queryAbortController.abort();
} else {
useDialog
.getState()
.showOkCancelModalDialog(
QueryConstants.CancelQueryTitle,
format(QueryConstants.CancelQuerySubTextTemplate, QueryConstants.CancelQueryTimeoutThresholdReached),
"Yes",
() => this.queryAbortController.abort(),
"No",
undefined,
);
}
}
}, queryTimeout);
this.cancelQueryTimeoutID = cancelQueryTimeoutID;
}
return this._loadNextPageInternal()
.then(
(documentsIdsResponse = []) => {
@@ -774,7 +827,15 @@ export default class DocumentsTab extends TabsBase {
}
},
)
.finally(() => this.isExecuting(false));
.finally(() => {
this.isExecuting(false);
if (applyFilterButtonClicked && this.queryTimeoutEnabled()) {
clearTimeout(this.cancelQueryTimeoutID);
if (!automaticallyCancelQueryAfterTimeout) {
useDialog.getState().closeDialog();
}
}
});
}
public onLoadMoreKeyInput = (source: any, event: KeyboardEvent): void => {
@@ -952,4 +1013,8 @@ export default class DocumentsTab extends TabsBase {
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
};
}
private queryTimeoutEnabled(): boolean {
return !this.isPreferredApiMongoDB && LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled);
}
}

View File

@@ -1,4 +1,5 @@
import { extractPartitionKey, PartitionKeyDefinition } from "@azure/cosmos";
import { PartitionKey, PartitionKeyDefinition } from "@azure/cosmos";
import { extractPartitionKeyValues } from "Utils/QueryUtils";
import * as ko from "knockout";
import Q from "q";
import * as Constants from "../../Common/Constants";
@@ -88,7 +89,7 @@ export default class MongoDocumentsTab extends DocumentsTab {
)
.then(
(savedDocument: any) => {
let partitionKeyArray = extractPartitionKey(
const partitionKeyArray: PartitionKey[] = extractPartitionKeyValues(
savedDocument,
this._getPartitionKeyDefinition() as PartitionKeyDefinition,
);
@@ -150,7 +151,7 @@ export default class MongoDocumentsTab extends DocumentsTab {
this.documentIds().forEach((documentId: DocumentId) => {
if (documentId.rid === updatedDocument._rid) {
const partitionKeyArray = extractPartitionKey(
const partitionKeyArray: PartitionKey[] = extractPartitionKeyValues(
updatedDocument,
this._getPartitionKeyDefinition() as PartitionKeyDefinition,
);
@@ -289,7 +290,7 @@ export default class MongoDocumentsTab extends DocumentsTab {
}
private _hasShardKeySpecified(document: any): boolean {
return Boolean(extractPartitionKey(document, this._getPartitionKeyDefinition() as PartitionKeyDefinition));
return Boolean(extractPartitionKeyValues(document, this._getPartitionKeyDefinition() as PartitionKeyDefinition));
}
private _getPartitionKeyDefinition(): DataModels.PartitionKey {

View File

@@ -1,12 +1,16 @@
import { FeedOptions } from "@azure/cosmos";
import { useDialog } from "Explorer/Controls/Dialog";
import { OnExecuteQueryClick } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { QueryCopilotResults } from "Explorer/QueryCopilot/Shared/QueryCopilotResults";
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
import { QueryConstants } from "Shared/Constants";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
import React, { Fragment } from "react";
import SplitterLayout from "react-splitter-layout";
import "react-splitter-layout/lib/index.css";
import { format } from "react-string-format";
import LaunchCopilot from "../../../../images/CopilotTabIcon.svg";
import CancelQueryIcon from "../../../../images/Entity_cancel.svg";
import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg";
@@ -80,6 +84,7 @@ interface IQueryTabStates {
isExecuting: boolean;
showCopilotSidebar: boolean;
queryCopilotGeneratedQuery: string;
cancelQueryTimeoutID: NodeJS.Timeout;
}
export default class QueryTabComponent extends React.Component<IQueryTabComponentProps, IQueryTabStates> {
@@ -107,13 +112,13 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
isExecuting: false,
showCopilotSidebar: useQueryCopilot.getState().showCopilotSidebar,
queryCopilotGeneratedQuery: useQueryCopilot.getState().query,
cancelQueryTimeoutID: undefined,
};
this.isCloseClicked = false;
this.splitterId = this.props.tabId + "_splitter";
this.queryEditorId = `queryeditor${this.props.tabId}`;
this.isPreferredApiMongoDB = this.props.isPreferredApiMongoDB;
this.isCopilotTabActive = QueryCopilotSampleDatabaseId === this.props.collection.databaseId;
this.executeQueryButton = {
enabled: !!this.state.sqlQueryEditorContent && this.state.sqlQueryEditorContent.length > 0,
visible: true,
@@ -250,6 +255,34 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
this.setState({
isExecuting: true,
});
let automaticallyCancelQueryAfterTimeout: boolean;
if (this.queryTimeoutEnabled()) {
const queryTimeout: number = LocalStorageUtility.getEntryNumber(StorageKey.QueryTimeout);
automaticallyCancelQueryAfterTimeout = LocalStorageUtility.getEntryBoolean(
StorageKey.AutomaticallyCancelQueryAfterTimeout,
);
const cancelQueryTimeoutID: NodeJS.Timeout = setTimeout(() => {
if (this.state.isExecuting) {
if (automaticallyCancelQueryAfterTimeout) {
this.queryAbortController.abort();
} else {
useDialog
.getState()
.showOkCancelModalDialog(
QueryConstants.CancelQueryTitle,
format(QueryConstants.CancelQuerySubTextTemplate, QueryConstants.CancelQueryTimeoutThresholdReached),
"Yes",
() => this.queryAbortController.abort(),
"No",
undefined,
);
}
}
}, queryTimeout);
this.setState({
cancelQueryTimeoutID,
});
}
useCommandBar.getState().setContextButtons(this.getTabsButtons());
try {
@@ -273,7 +306,14 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
this.props.tabsBaseInstance.isExecuting(false);
this.setState({
isExecuting: false,
cancelQueryTimeoutID: undefined,
});
if (this.queryTimeoutEnabled()) {
clearTimeout(this.state.cancelQueryTimeoutID);
if (!automaticallyCancelQueryAfterTimeout) {
useDialog.getState().closeDialog();
}
}
this.togglesOnFocus();
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}
@@ -405,6 +445,10 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
return this.state.sqlQueryEditorContent;
}
private queryTimeoutEnabled(): boolean {
return !this.isPreferredApiMongoDB && LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled);
}
private unsubscribeCopilotSidebar: () => void;
componentDidMount(): void {

View File

@@ -1,4 +1,4 @@
import { extractPartitionKey } from "@azure/cosmos";
import { extractPartitionKeyValues } from "Utils/QueryUtils";
import * as ko from "knockout";
import * as Constants from "../../Common/Constants";
import { readDocument } from "../../Common/dataAccess/readDocument";
@@ -42,7 +42,7 @@ export default class ConflictId {
}
this.partitionKeyProperty = container && container.partitionKeyProperty;
this.partitionKey = container && container.partitionKey;
this.partitionKeyValue = extractPartitionKey(this.parsedContent, this.partitionKey as any);
this.partitionKeyValue = extractPartitionKeyValues(this.parsedContent, this.partitionKey as any);
this.stringPartitionKeyValue = this.getPartitionKeyValueAsString();
this.id = ko.observable(data.id);
this.isDirty = ko.observable(false);

View File

@@ -5,7 +5,6 @@ import { useDatabases } from "Explorer/useDatabases";
import { useTabs } from "hooks/useTabs";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
import * as ViewModels from "../../Contracts/ViewModels";
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { useSelectedNode } from "../useSelectedNode";
@@ -22,7 +21,7 @@ export const useDatabaseTreeNodes = (container: Explorer, isNotebookEnabled: boo
className: "databaseHeader",
children: [],
isSelected: () => useSelectedNode.getState().isDataNodeSelected(database.id()),
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
contextMenu: undefined, // TODO Disable this for now as the actions don't work. ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
onExpanded: async () => {
useSelectedNode.getState().setSelectedNode(database);
if (!databaseNode.children || databaseNode.children?.length === 0) {

View File

@@ -7,9 +7,6 @@ import "../less/hostedexplorer.less";
import { AuthType } from "./AuthType";
import { DatabaseAccount } from "./Contracts/DataModels";
import "./Explorer/Menus/NavBar/MeControlComponent.less";
import { useAADAuth } from "./hooks/useAADAuth";
import { useConfig } from "./hooks/useConfig";
import { useTokenMetadata } from "./hooks/usePortalAccessToken";
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
import { AccountSwitcher } from "./Platform/Hosted/Components/AccountSwitcher";
import { ConnectExplorer } from "./Platform/Hosted/Components/ConnectExplorer";
@@ -20,6 +17,9 @@ import { SignInButton } from "./Platform/Hosted/Components/SignInButton";
import "./Platform/Hosted/ConnectScreen.less";
import { extractMasterKeyfromConnectionString } from "./Platform/Hosted/HostedUtils";
import "./Shared/appInsights";
import { useAADAuth } from "./hooks/useAADAuth";
import { useConfig } from "./hooks/useConfig";
import { useTokenMetadata } from "./hooks/usePortalAccessToken";
initializeIcons();
@@ -51,6 +51,7 @@ const App: React.FunctionComponent = () => {
authType: AuthType.AAD,
databaseAccount,
authorizationToken: armToken,
graphAuthorizationToken: graphToken
};
} else if (authType === AuthType.EncryptedToken) {
frameWindow.hostedConfig = {

View File

@@ -10,6 +10,7 @@ export interface AAD {
authType: AuthType.AAD;
databaseAccount: DatabaseAccount;
authorizationToken: string;
graphAuthorizationToken: string;
}
export interface ConnectionString {

View File

@@ -1,13 +1,13 @@
// CSS Dependencies
import { initializeIcons, loadTheme } from "@fluentui/react";
import "bootstrap/dist/css/bootstrap.css";
import { QuickstartCarousel } from "Explorer/Quickstart/QuickstartCarousel";
import { MongoQuickstartTutorial } from "Explorer/Quickstart/Tutorials/MongoQuickstartTutorial";
import { SQLQuickstartTutorial } from "Explorer/Quickstart/Tutorials/SQLQuickstartTutorial";
import { userContext } from "UserContext";
import "bootstrap/dist/css/bootstrap.css";
import { useCarousel } from "hooks/useCarousel";
import React, { useState } from "react";
import ReactDOM from "react-dom";
import { userContext } from "UserContext";
import "../externals/jquery-ui.min.css";
import "../externals/jquery-ui.min.js";
import "../externals/jquery-ui.structure.min.css";
@@ -16,27 +16,27 @@ import "../externals/jquery.dataTables.min.css";
import "../externals/jquery.typeahead.min.css";
import "../externals/jquery.typeahead.min.js";
// Image Dependencies
import { Platform } from "ConfigContext";
import { QueryCopilotCarousel } from "Explorer/QueryCopilot/CopilotCarousel";
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import "../images/CosmosDB_rgb_ui_lighttheme.ico";
import hdeConnectImage from "../images/HdeConnectCosmosDB.svg";
import "../images/favicon.ico";
import "../less/TableStyles/CustomizeColumns.less";
import "../less/TableStyles/EntityEditor.less";
import "../less/TableStyles/fulldatatables.less";
import "../less/TableStyles/queryBuilder.less";
import * as StyleConstants from "./Common/StyleConstants";
import { configContext, Platform } from "ConfigContext";
import hdeConnectImage from "../images/HdeConnectCosmosDB.svg";
import "../less/documentDB.less";
import "../less/forms.less";
import "../less/infobox.less";
import "../less/menus.less";
import "../less/messagebox.less";
import "../less/resourceTree.less";
import "../less/TableStyles/CustomizeColumns.less";
import "../less/TableStyles/EntityEditor.less";
import "../less/TableStyles/fulldatatables.less";
import "../less/TableStyles/queryBuilder.less";
import "../less/tree.less";
import { CollapsedResourceTree } from "./Common/CollapsedResourceTree";
import { ResourceTreeContainer } from "./Common/ResourceTreeContainer";
import * as StyleConstants from "./Common/StyleConstants";
import "./Explorer/Controls/Accordion/AccordionComponent.less";
import "./Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.less";
import { Dialog } from "./Explorer/Controls/Dialog";
@@ -55,11 +55,11 @@ import "./Explorer/Panes/PanelComponent.less";
import { SidePanel } from "./Explorer/Panes/PanelContainerComponent";
import "./Explorer/SplashScreen/SplashScreen.less";
import { Tabs } from "./Explorer/Tabs/Tabs";
import "./Libs/jquery";
import "./Shared/appInsights";
import { useConfig } from "./hooks/useConfig";
import { useKnockoutExplorer } from "./hooks/useKnockoutExplorer";
import "./Libs/jquery";
import { appThemeFabric } from "./Platform/Fabric/FabricTheme";
import "./Shared/appInsights";
initializeIcons();
@@ -72,6 +72,7 @@ const App: React.FunctionComponent = () => {
const config = useConfig();
if (config?.platform === Platform.Fabric) {
loadTheme(appThemeFabric);
import("../less/documentDBFabric.less");
}
StyleConstants.updateStyles();
const explorer = useKnockoutExplorer(config?.platform);
@@ -91,7 +92,6 @@ const App: React.FunctionComponent = () => {
return (
<div className="flexContainer" aria-hidden="false">
<LoadFabricOverrides />
<div id="divExplorer" className="flexContainer hideOverflows">
<div id="freeTierTeachingBubble"> </div>
{/* Main Command Bar - Start */}
@@ -141,20 +141,8 @@ const App: React.FunctionComponent = () => {
);
};
ReactDOM.render(<App />, document.body);
function LoadFabricOverrides(): JSX.Element {
if (configContext.platform === Platform.Fabric) {
const FabricStyle = React.lazy(() => import("./Platform/Fabric/FabricPlatform"));
return (
<React.Suspense fallback={<div></div>}>
<FabricStyle />
</React.Suspense>
);
} else {
return <></>;
}
}
const mainElement = document.getElementById("Main");
ReactDOM.render(<App />, mainElement);
function LoadingExplorer(): JSX.Element {
return (

View File

@@ -1,7 +0,0 @@
import React from "react";
import "../../../less/documentDBFabric.less";
// This is a dummy export, allowing us to conditionally import documentDBFabric.less
// by lazy-importing this in Main.tsx (see LoadFabricOverrides() there)
export default function InitFabric() {
return <></>;
}

View File

@@ -0,0 +1,53 @@
import { sendCachedDataMessage } from "Common/MessageHandler";
import { FabricDatabaseConnectionInfo } from "Contracts/FabricContract";
import { MessageTypes } from "Contracts/MessageTypes";
import Explorer from "Explorer/Explorer";
import { updateUserContext } from "UserContext";
const TOKEN_VALIDITY_MS = (3600 - 600) * 1000; // 1 hour minus 10 minutes to be safe
let timeoutId: NodeJS.Timeout;
// Prevents multiple parallel requests
let isRequestPending = false;
export const requestDatabaseResourceTokens = (): void => {
if (isRequestPending) {
return;
}
// TODO Make Fabric return the message id so we can handle this promise
isRequestPending = true;
sendCachedDataMessage<FabricDatabaseConnectionInfo>(MessageTypes.GetAllResourceTokens, []);
};
export const handleRequestDatabaseResourceTokensResponse = (
explorer: Explorer,
fabricDatabaseConnectionInfo: FabricDatabaseConnectionInfo,
): void => {
isRequestPending = false;
updateUserContext({ fabricDatabaseConnectionInfo });
scheduleRefreshDatabaseResourceToken();
explorer.refreshAllDatabases();
};
/**
* Check token validity and schedule a refresh if necessary
* @param tokenTimestamp
* @returns
*/
export const scheduleRefreshDatabaseResourceToken = (): void => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
timeoutId = setTimeout(() => {
requestDatabaseResourceTokens();
}, TOKEN_VALIDITY_MS);
};
export const checkDatabaseResourceTokensValidity = (tokenTimestamp: number): void => {
if (tokenTimestamp + TOKEN_VALIDITY_MS < Date.now()) {
requestDatabaseResourceTokens();
}
};

View File

@@ -1,6 +1,8 @@
jest.mock("../../../hooks/useDirectories");
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { extractFeatures } from "Platform/Hosted/extractFeatures";
import { updateUserContext, userContext } from "UserContext";
import React from "react";
import { ConnectExplorer } from "./ConnectExplorer";
@@ -16,3 +18,24 @@ it("shows the connect form", () => {
fireEvent.click(screen.getByText("Connect to your account with connection string"));
expect(screen.queryByPlaceholderText("Please enter a connection string")).toBeDefined();
});
it("hides the connection string link when feature.disableConnectionStringLogin is true", () => {
const connectionString = "fakeConnectionString";
const login = jest.fn();
const setConnectionString = jest.fn();
const setEncryptedToken = jest.fn();
const setAuthType = jest.fn();
const oldFeatures = userContext.features;
const params = new URLSearchParams({
"feature.disableConnectionStringLogin": "true",
});
const testFeatures = extractFeatures(params);
updateUserContext({ features: testFeatures });
render(<ConnectExplorer {...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString }} />);
expect(screen.queryByPlaceholderText("Connect to your account with connection string")).toBeNull();
updateUserContext({ features: oldFeatures });
});

View File

@@ -1,4 +1,5 @@
import { useBoolean } from "@fluentui/react-hooks";
import { userContext } from "UserContext";
import * as React from "react";
import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg";
import ErrorImage from "../../../../images/error.svg";
@@ -29,6 +30,18 @@ export const fetchEncryptedToken = async (connectionString: string): Promise<str
return decodeURIComponent(result.readWrite || result.read);
};
export const isAccountRestrictedForConnectionStringLogin = async (connectionString: string): Promise<boolean> => {
const headers = new Headers();
headers.append(HttpHeaders.connectionString, connectionString);
const url = configContext.BACKEND_ENDPOINT + "/api/guest/accountrestrictions/checkconnectionstringlogin";
const response = await fetch(url, { headers, method: "POST" });
if (!response.ok) {
throw response;
}
return (await response.text()) === "True";
};
export const ConnectExplorer: React.FunctionComponent<Props> = ({
setEncryptedToken,
login,
@@ -37,6 +50,8 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
setConnectionString,
}: Props) => {
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
const [errorMessage, setErrorMessage] = React.useState("");
const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin;
return (
<div id="connectExplorer" className="connectExplorerContainer" style={{ display: "flex" }}>
@@ -46,11 +61,17 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
<img src={ConnectImage} alt="Azure Cosmos DB" />
</p>
<p className="welcomeText">Welcome to Azure Cosmos DB</p>
{isFormVisible ? (
{isFormVisible && enableConnectionStringLogin ? (
<form
id="connectWithConnectionString"
onSubmit={async (event) => {
event.preventDefault();
setErrorMessage("");
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
setErrorMessage("This account has been blocked from connection-string login.");
return;
}
if (isResourceTokenConnectionString(connectionString)) {
setAuthType(AuthType.ResourceToken);
@@ -74,10 +95,12 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
setConnectionString(event.target.value);
}}
/>
<span className="errorDetailsInfoTooltip" style={{ display: "none" }}>
<img className="errorImg" src={ErrorImage} alt="Error notification" />
<span className="errorDetails"></span>
</span>
{errorMessage.length > 0 && (
<span className="errorDetailsInfoTooltip">
<img className="errorImg" src={ErrorImage} alt="Error notification" />
<span className="errorDetails">{errorMessage}</span>
</span>
)}
</p>
<p className="connectExplorerContent">
<input className="filterbtnstyle" type="submit" value="Connect" />
@@ -89,9 +112,11 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
) : (
<div id="connectWithAad">
<input className="filterbtnstyle" type="button" value="Sign In" onClick={login} />
<p className="switchConnectTypeText" onClick={showForm}>
Connect to your account with connection string
</p>
{enableConnectionStringLogin && (
<p className="switchConnectTypeText" onClick={showForm}>
Connect to your account with connection string
</p>
)}
</div>
)}
</div>

View File

@@ -41,6 +41,7 @@ export type Features = {
readonly enableCopilotFullSchema: boolean;
readonly copilotChatFixedMonacoEditorHeight: boolean;
readonly enablePriorityBasedExecution: boolean;
readonly disableConnectionStringLogin: boolean;
// can be set via both flight and feature flag
autoscaleDefault: boolean;
@@ -114,6 +115,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
enableCopilotFullSchema: "true" === get("enablecopilotfullschema", "true"),
copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"),
enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"),
disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"),
};
}

View File

@@ -208,3 +208,9 @@ export class FreeTierLimits {
public static RU: number = 1000;
public static Storage: number = 25;
}
export class QueryConstants {
public static readonly CancelQueryTitle: string = "Cancel query";
public static readonly CancelQuerySubTextTemplate: string = "{0} Do you want to cancel this query?";
public static readonly CancelQueryTimeoutThresholdReached: string = "The query timeout threshold has been reached.";
}

View File

@@ -4,6 +4,9 @@ import * as SessionStorageUtility from "./SessionStorageUtility";
export { LocalStorageUtility, SessionStorageUtility };
export enum StorageKey {
ActualItemPerPage,
QueryTimeoutEnabled,
QueryTimeout,
AutomaticallyCancelQueryAfterTimeout,
ContainerPaginationEnabled,
CustomItemPerPage,
DatabaseAccountId,

View File

@@ -1,3 +1,4 @@
import { FabricDatabaseConnectionInfo } from "Contracts/FabricContract";
import { ParsedResourceTokenConnectionString } from "Platform/Hosted/Helpers/ResourceTokenUtils";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
@@ -47,6 +48,7 @@ export interface VCoreMongoConnectionParams {
}
interface UserContext {
readonly fabricDatabaseConnectionInfo?: FabricDatabaseConnectionInfo;
readonly authType?: AuthType;
readonly masterKey?: string;
readonly subscriptionId?: string;
@@ -77,6 +79,7 @@ interface UserContext {
collectionCreationDefaults: CollectionCreationDefaults;
sampleDataConnectionInfo?: ParsedResourceTokenConnectionString;
readonly vcoreMongoConnectionParams?: VCoreMongoConnectionParams;
readonly accountRestrictedFromUser?: boolean;
}
export type ApiType = "SQL" | "Mongo" | "Gremlin" | "Tables" | "Cassandra" | "Postgres" | "VCoreMongo";
@@ -169,3 +172,4 @@ function apiType(account: DatabaseAccount | undefined): ApiType {
}
export { updateUserContext, userContext };

View File

@@ -60,3 +60,27 @@ export function getMsalInstance() {
const msalInstance = new msal.PublicClientApplication(config);
return msalInstance;
}
export async function isAccountRestrictedFromUser(accountName: string, graphToken: string): Promise<boolean> {
const checkUserAccessUrl: string = "https://localhost:12901/api/guest/accountrestrictions/accountrestrictedfromuser";
// const authorizationHeader = getAuthorizationHeader();
try {
const response: Response = await fetch(checkUserAccessUrl, {
method: "POST",
body: JSON.stringify({
accountName
}),
headers: {
// [authorizationHeader.header]: authorizationHeader.token,
[Constants.HttpHeaders.graphAuthorization]: graphToken,
[Constants.HttpHeaders.contentType]: "application/json",
}
});
const responseText: string = await response.text();
return responseText.toLowerCase() === "true";
} catch (e) {
console.log(e);
throw new Error(e);
}
}

View File

@@ -29,7 +29,7 @@ export function getPriorityLevel(): PriorityLevel {
}
}
export const requestPlugin: Cosmos.Plugin<any> = async (requestContext, next) => {
export const requestPlugin: Cosmos.Plugin<any> = async (requestContext, undefined, next) => {
if (isRelevantRequest(requestContext)) {
const priorityLevel: PriorityLevel = getPriorityLevel();
requestContext.headers["x-ms-cosmos-priority-level"] = priorityLevel as string;

View File

@@ -1,8 +1,10 @@
import { PartitionKey, PartitionKeyDefinition, PartitionKeyKind } from "@azure/cosmos";
import * as Q from "q";
import * as sinon from "sinon";
import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
import * as QueryUtils from "./QueryUtils";
import { extractPartitionKeyValues } from "./QueryUtils";
describe("Query Utils", () => {
const generatePartitionKeyForPath = (path: string): DataModels.PartitionKey => {
@@ -94,4 +96,69 @@ describe("Query Utils", () => {
expect(queryStub.getCall(0).args[0]).toBe(0);
});
});
describe("extractPartitionKey", () => {
const documentContent = {
"Volcano Name": "Adams",
Country: "United States",
Region: "US-Washington",
Location: {
type: "Point",
coordinates: [-121.49, 46.206],
},
Elevation: 3742,
Type: "Stratovolcano",
Status: "Tephrochronology",
"Last Known Eruption": "Last known eruption from A.D. 1-1499, inclusive",
id: "9e3c494e-8367-3f50-1f56-8c6fcb961363",
_rid: "xzo0AJRYUxUFAAAAAAAAAA==",
_self: "dbs/xzo0AA==/colls/xzo0AJRYUxU=/docs/xzo0AJRYUxUFAAAAAAAAAA==/",
_etag: '"ce00fa43-0000-0100-0000-652840440000"',
_attachments: "attachments/",
_ts: 1697136708,
};
it("should extract single partition key value", () => {
const singlePartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.Hash,
paths: ["/Elevation"],
};
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(
documentContent,
singlePartitionKeyDefinition,
);
expect(partitionKeyValues.length).toBe(1);
expect(partitionKeyValues[0]).toEqual(3742);
});
it("should extract two partition key values", () => {
const multiPartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.MultiHash,
paths: ["/Type", "/Status"],
};
const expectedPartitionKeyValues: string[] = ["Stratovolcano", "Tephrochronology"];
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(
documentContent,
multiPartitionKeyDefinition,
);
expect(partitionKeyValues.length).toBe(2);
expect(expectedPartitionKeyValues).toContain(documentContent["Type"]);
expect(expectedPartitionKeyValues).toContain(documentContent["Status"]);
});
it("should extract no partition key values", () => {
const singlePartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.Hash,
paths: ["/InvalidPartitionKeyPath"],
};
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(
documentContent,
singlePartitionKeyDefinition,
);
expect(partitionKeyValues.length).toBe(0);
});
});
});

View File

@@ -1,3 +1,4 @@
import { PartitionKey, PartitionKeyDefinition } from "@azure/cosmos";
import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
@@ -82,3 +83,22 @@ export const queryPagesUntilContentPresent = async (
return await doRequest(firstItemIndex);
};
/* eslint-disable @typescript-eslint/no-explicit-any */
export const extractPartitionKeyValues = (
documentContent: any,
partitionKeyDefinition: PartitionKeyDefinition,
): PartitionKey[] => {
if (!partitionKeyDefinition.paths || partitionKeyDefinition.paths.length === 0) {
return undefined;
}
const partitionKeyValues: PartitionKey[] = [];
partitionKeyDefinition.paths.forEach((partitionKeyPath: string) => {
const partitionKeyPathWithoutSlash: string = partitionKeyPath.substring(1);
if (documentContent[partitionKeyPathWithoutSlash]) {
partitionKeyValues.push(documentContent[partitionKeyPathWithoutSlash]);
}
});
return partitionKeyValues;
};

View File

@@ -8,5 +8,7 @@
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
</head>
<body></body>
<body>
<div id="Main" style="height: 100%"></div>
</body>
</html>

View File

@@ -1,16 +1,18 @@
import { createUri } from "Common/UrlUtility";
import { FabricMessage } from "Contracts/FabricContract";
import { FabricDatabaseConnectionInfo, FabricMessage } from "Contracts/FabricContract";
import Explorer from "Explorer/Explorer";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { fetchEncryptedToken } from "Platform/Hosted/Components/ConnectExplorer";
import {
handleRequestDatabaseResourceTokensResponse,
scheduleRefreshDatabaseResourceToken,
} from "Platform/Fabric/FabricUtil";
import { getNetworkSettingsWarningMessage } from "Utils/NetworkUtility";
import { fetchAccessData } from "hooks/usePortalAccessToken";
import { ReactTabKind, useTabs } from "hooks/useTabs";
import { useEffect, useState } from "react";
import { AuthType } from "../AuthType";
import { AccountKind, Flights } from "../Common/Constants";
import { normalizeArmEndpoint } from "../Common/EnvironmentUtility";
import { sendMessage, sendReadyMessage } from "../Common/MessageHandler";
import { handleCachedDataMessage, sendMessage, sendReadyMessage } from "../Common/MessageHandler";
import { Platform, configContext, updateConfigContext } from "../ConfigContext";
import { ActionType, DataExplorerAction, TabKind } from "../Contracts/ActionContracts";
import { MessageTypes } from "../Contracts/ExplorerContracts";
@@ -34,7 +36,7 @@ import { extractFeatures } from "../Platform/Hosted/extractFeatures";
import { CollectionCreation } from "../Shared/Constants";
import { DefaultExperienceUtility } from "../Shared/DefaultExperienceUtility";
import { Node, PortalEnv, updateUserContext, userContext } from "../UserContext";
import { getAuthorizationHeader, getMsalInstance } from "../Utils/AuthorizationUtils";
import { getAuthorizationHeader, getMsalInstance, isAccountRestrictedFromUser } from "../Utils/AuthorizationUtils";
import { isInvalidParentFrameOrigin, shouldProcessMessage } from "../Utils/MessageValidation";
import { listKeys } from "../Utils/arm/generatedClients/cosmos/databaseAccounts";
import { DatabaseAccountListKeysResult } from "../Utils/arm/generatedClients/cosmos/types";
@@ -100,70 +102,37 @@ async function configureFabric(): Promise<Explorer> {
}
const data: FabricMessage = event.data?.data;
if (!data) {
return;
}
switch (data.type) {
case "initialize": {
// TODO For now, retrieve info from session storage. Replace with info injected into Data Explorer
const connectionString = data.connectionString ?? sessionStorage.getItem("connectionString");
if (!connectionString) {
console.error("No connection string found in session storage");
return undefined;
}
const encryptedToken = await fetchEncryptedToken(connectionString);
// TODO Duplicated from useTokenMetadata
const encryptedTokenMetadata = await fetchAccessData(encryptedToken);
const hostedConfig: EncryptedToken = {
authType: AuthType.EncryptedToken,
encryptedToken,
encryptedTokenMetadata,
const fabricDatabaseConnectionInfo: FabricDatabaseConnectionInfo = {
endpoint: data.message.endpoint,
databaseId: data.message.databaseId,
resourceTokens: data.message.resourceTokens as { [resourceId: string]: string },
resourceTokensTimestamp: data.message.resourceTokensTimestamp,
};
explorer = await configureWithEncryptedToken(hostedConfig);
explorer = await createExplorerFabric(fabricDatabaseConnectionInfo);
resolve(explorer);
explorer.refreshAllDatabases().then(() => {
openFirstContainer(explorer, fabricDatabaseConnectionInfo.databaseId);
});
scheduleRefreshDatabaseResourceToken();
break;
}
case "newContainer":
explorer.onNewCollectionClicked();
break;
case "openTab": {
// Expand database first
const databaseName = sessionStorage.getItem("openDatabaseName") ?? data.databaseName;
const database = useDatabases.getState().databases.find((db) => db.id() === databaseName);
if (database) {
await database.expandDatabase();
useDatabases.getState().updateDatabase(database);
useSelectedNode.getState().setSelectedNode(database);
let collectionResourceId = data.collectionName;
if (collectionResourceId === undefined) {
// Pick first collection if collectionName not specified in message
collectionResourceId = database.collections()[0]?.id();
}
if (collectionResourceId !== undefined) {
// Expand collection
const collection = database.collections().find((coll) => coll.id() === collectionResourceId);
collection.expandCollection();
useSelectedNode.getState().setSelectedNode(collection);
handleOpenAction(
{
actionType: ActionType.OpenCollectionTab,
databaseResourceId: databaseName,
collectionResourceId: data.collectionName,
tabKind: TabKind.SQLDocuments,
} as DataExplorerAction,
useDatabases.getState().databases,
explorer,
);
}
}
case "authorizationToken": {
handleCachedDataMessage(data);
break;
}
case "allResourceTokens": {
// TODO call handleCachedDataMessage when Fabric echoes message id back
handleRequestDatabaseResourceTokensResponse(explorer, data.message as FabricDatabaseConnectionInfo);
break;
}
default:
@@ -178,6 +147,41 @@ async function configureFabric(): Promise<Explorer> {
});
}
const openFirstContainer = async (explorer: Explorer, databaseName: string, collectionName?: string) => {
// Expand database first
databaseName = sessionStorage.getItem("openDatabaseName") ?? databaseName;
const database = useDatabases.getState().databases.find((db) => db.id() === databaseName);
if (database) {
await database.expandDatabase();
useDatabases.getState().updateDatabase(database);
useSelectedNode.getState().setSelectedNode(database);
let collectionResourceId = collectionName;
if (collectionResourceId === undefined) {
// Pick first collection if collectionName not specified in message
collectionResourceId = database.collections()[0]?.id();
}
if (collectionResourceId !== undefined) {
// Expand collection
const collection = database.collections().find((coll) => coll.id() === collectionResourceId);
collection.expandCollection();
useSelectedNode.getState().setSelectedNode(collection);
handleOpenAction(
{
actionType: ActionType.OpenCollectionTab,
databaseResourceId: databaseName,
collectionResourceId: collectionName,
tabKind: TabKind.SQLDocuments,
} as DataExplorerAction,
useDatabases.getState().databases,
explorer,
);
}
}
};
async function configureHosted(): Promise<Explorer> {
const win = window as unknown as HostedExplorerChildFrame;
let explorer: Explorer;
@@ -223,9 +227,11 @@ async function configureHosted(): Promise<Explorer> {
async function configureHostedWithAAD(config: AAD): Promise<Explorer> {
// TODO: Refactor. updateUserContext needs to be called twice because listKeys below depends on userContext.authorizationToken
const accountRestrictedFromUser: boolean = await isAccountRestrictedFromUser(config.databaseAccount.name, config.graphAuthorizationToken);
updateUserContext({
authType: AuthType.AAD,
authorizationToken: `Bearer ${config.authorizationToken}`,
accountRestrictedFromUser
});
const account = config.databaseAccount;
const accountResourceId = account.id;
@@ -315,6 +321,25 @@ function configureHostedWithResourceToken(config: ResourceToken): Explorer {
return explorer;
}
function createExplorerFabric(fabricDatabaseConnectionInfo: FabricDatabaseConnectionInfo): Explorer {
updateUserContext({
fabricDatabaseConnectionInfo,
authType: AuthType.ConnectionString,
databaseAccount: {
id: "",
location: "",
type: "",
name: "Mounted",
kind: AccountKind.Default,
properties: {
documentEndpoint: fabricDatabaseConnectionInfo.endpoint,
},
},
});
const explorer = new Explorer();
return explorer;
}
function configureWithEncryptedToken(config: EncryptedToken): Explorer {
const apiExperience = DefaultExperienceUtility.getDefaultExperienceFromApiKind(config.encryptedTokenMetadata.apiKind);
updateUserContext({

View File

@@ -128,7 +128,7 @@ export const useQueryCopilot: QueryCopilotStore = create((set) => ({
openFeedbackModal: (generatedQuery: string, likeQuery: boolean, userPrompt: string) =>
set({ generatedQuery, likeQuery, userPrompt, showFeedbackModal: true }),
closeFeedbackModal: () => set({ generatedQuery: "", likeQuery: false, userPrompt: "", showFeedbackModal: false }),
closeFeedbackModal: () => set({ showFeedbackModal: false }),
setHideFeedbackModalForLikedQueries: (hideFeedbackModalForLikedQueries: boolean) =>
set({ hideFeedbackModalForLikedQueries }),
refreshCorrelationId: () => set({ correlationId: guid() }),

View File

@@ -6,6 +6,15 @@
<mimeMap fileExtension="woff" mimeType="application/font-woff" />
</staticContent>
<rewrite>
<rules>
<rule name="AAD-Redirect" stopProcessing="true">
<match url="^aad" ignoreCase="true"/>
<conditions>
<add input="{HTTP_HOST}" pattern="^cosmos.azure.com" />
</conditions>
<action type="Redirect" url="/?feature.enableAadDataPlane=true&amp;feature.disableConnectionStringLogin=true" redirectType="Permanent" />
</rule>
</rules>
<outboundRules>
<rule name="Strict-Transport-Security" enabled="true">
<match serverVariable="RESPONSE_Strict_Transport_Security" pattern=".*" />