Compare commits

...

20 Commits

Author SHA1 Message Date
sunghyunkang1111
d7c9edf1d4 Fix P2 bgs 2023-11-20 11:44:08 -06:00
sunghyunkang1111
3283d4073b Fix P2 bgs 2023-11-20 11:20:54 -06:00
bogercraig
6af1925d15 Enabling enableEndpointDiscovery to test DE Region Outage Resiliency (#1694)
* Setting enableEndpointDiscovery to true to test client resilience during region outage.
When this is set to false, it can impact the SDK's failover behavior.
2023-11-16 11:56:39 -08:00
sunghyunkang1111
b9cbfc924f do not throw error when checking for initial checking of copilot enablement (#1693) 2023-11-14 17:22:12 -06:00
sindhuba
1aa4c18119 Increase NPS sample size and remove constraints (#1692)
* Increase NPS sample size and remove constraints

* Run npm format

* Run npm format:check
2023-11-14 22:26:57 +05:30
sunghyunkang1111
0ab07419ce fix the phoenix endpoint (#1691) 2023-11-10 18:53:23 -06:00
sunghyunkang1111
40b8127a6f P1 bugs for copilot (#1689)
* P1 bugs for copilot

* P1 bugs for copilot

* P1 bugs for copilot

* Update branding and AFEC

* Update branding and AFEC

* add timeout for ARM calls

* increase test timeout

* fix formatting

* fix formatting

* fix formatting

* fix formatting

* don't call ARM when connectionstring login
2023-11-10 16:55:39 -06:00
sunghyunkang1111
0e124f4881 Copilot user db (#1672)
* Implement copilot for user database

* Fix minor bugs

* fix bugs

* Add user database copilot

* Add placeholder text on copilot

* Add AFEC adn killswitch

* Add new v2 sampledatabase endpoint

* Add telemetry

* fix telemetry bug

* Add query edited telemetry

* add authorization header

* Add back to the staging env for phoenix

* point to stage for phoenix

* Preview commit for test env

* Preview link for staging

* change the staging url

* fix lint, unit tests

* fix lint, unit tests

* fix formatting
2023-11-09 11:55:25 -06:00
vchske
a5e1b37ba6 Bugbash updates (#1684)
* Minor changes to vcore mongo quickstart based on feedback

* Text change vcore to vCore
2023-11-08 16:04:06 -08:00
jawelton74
15d111e3db Add directions to use AAD login if account blocked from connection (#1683)
string login.
2023-11-01 15:46:54 -07:00
Asier Isayas
1726e4df51 Remove enableResourceGraph feature flag (#1681)
* fetch subs and accounts via graph

* fixed subscription rendering

* add feature flag enableResourceGraph

* add feature flag enableResourceGraph

* remove enableResourceGraph feature flag

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2023-10-27 14:09:21 -04:00
vchske
bc68b4dbf9 Minor changes to vcore mongo quickstart based on feedback (#1678) 2023-10-26 16:46:01 -07:00
vchske
15e35eaa82 Adds restarting vcore mongo shell if user enters wrong password (#1675)
* Adds restarting vcore mongo shell if user enters wrong password

* Deleted unnecessary comment
2023-10-26 16:40:59 -07:00
Asier Isayas
f69cd4c495 Fetch subs and accounts via MS graph (#1677)
* fetch subs and accounts via graph

* fixed subscription rendering

* add feature flag enableResourceGraph

* add feature flag enableResourceGraph

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2023-10-26 12:00:39 -04:00
Laurent Nguyen
661f6f4bfb Clear console and properly handle error message when querying database or containers (#1679) 2023-10-26 15:55:48 +00: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
72 changed files with 1692 additions and 1170 deletions

View File

@@ -171,6 +171,7 @@ export class Areas {
public static Tab: string = "Tab";
public static ShareDialog: string = "Share Access Dialog";
public static Notebook: string = "Notebook";
public static Copilot: string = "Copilot";
}
export class HttpHeaders {

View File

@@ -1,6 +1,8 @@
import * as Cosmos from "@azure/cosmos";
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";
@@ -28,12 +30,33 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
}
if (configContext.platform === Platform.Fabric) {
const authorizationToken = await sendCachedDataMessage<AuthorizationToken>(MessageTypes.GetAuthorizationToken, [
requestInfo,
]);
console.log("Response from Fabric: ", authorizationToken);
headers[HttpHeaders.msDate] = authorizationToken.XDate;
return authorizationToken.PrimaryReadWriteToken;
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) {
@@ -125,9 +148,6 @@ export function client(): Cosmos.CosmosClient {
endpoint: endpoint() || "https://cosmos.azure.com", // CosmosClient gets upset if we pass a bad URL. This should never actually get called
key: userContext.masterKey,
tokenProvider,
connectionPolicy: {
enableEndpointDiscovery: false,
},
userAgentSuffix: "Azure Portal",
defaultHeaders: _defaultHeaders,
};

View File

@@ -1,18 +1,57 @@
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());
}
}
try {
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;
} catch (error) {
handleError(error, "ReadCollections", `Error while querying containers for database ${databaseId}`);
throw error;
} finally {
clearMessage();
}
}
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,53 @@
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("/");
if (resourceIdObj.length !== 4) {
handleError(`Resource key not recognized: ${resourceIdObj}`, "ReadDatabases", `Error while querying databases`);
clearMessage();
return [];
}
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: [],
}));
clearMessage();
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

@@ -1,3 +1,4 @@
import { JunoEndpoints } from "Common/Constants";
import {
allowedAadEndpoints,
allowedArcadiaEndpoints,
@@ -64,6 +65,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/",
@@ -77,7 +79,7 @@ let configContext: Readonly<ConfigContext> = {
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: "dev.azuresynapse.net",
GITHUB_CLIENT_ID: "6cb2f63cf6f7b5cbdeca", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1189306
GITHUB_TEST_ENV_CLIENT_ID: "b63fc8cbf87fd3c6e2eb", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1777772
JUNO_ENDPOINT: "https://tools.cosmos.azure.com",
JUNO_ENDPOINT: JunoEndpoints.Prod,
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
isTerminalEnabled: false,
isPhoenixEnabled: false,

View File

@@ -0,0 +1,13 @@
export interface QueryRequestOptions {
$skipToken?: string;
$top?: number;
subscriptions: string[];
}
export interface QueryResponse {
$skipToken: string;
count: number;
data: any;
resultTruncated: boolean;
totalRecords: number;
}

View File

@@ -88,13 +88,13 @@ export interface GenerateTokenResponse {
}
export interface Subscription {
uniqueDisplayName: string;
uniqueDisplayName?: string;
displayName: string;
subscriptionId: string;
tenantId: string;
tenantId?: string;
state: string;
subscriptionPolicies: SubscriptionPolicies;
authorizationSource: string;
subscriptionPolicies?: SubscriptionPolicies;
authorizationSource?: string;
}
export interface SubscriptionPolicies {
@@ -457,8 +457,11 @@ export interface ContainerInfo {
}
export interface IProvisionData {
cosmosEndpoint: string;
cosmosEndpoint?: string;
poolId: string;
databaseId?: string;
containerId?: string;
mode?: string;
}
export interface IContainerData {
@@ -601,3 +604,14 @@ export enum PhoenixErrorType {
PhoenixFlightFallback = "PhoenixFlightFallback",
UserMissingPermissionsError = "UserMissingPermissionsError",
}
export interface CopilotEnabledConfiguration {
isEnabled: boolean;
}
export interface FeatureRegistration {
name: string;
properties: {
state: string;
};
}

View File

@@ -9,14 +9,12 @@ export type FabricMessage =
type: "initialize";
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: {
@@ -24,6 +22,15 @@ export type FabricMessage =
error: string | undefined;
data: AuthorizationToken | undefined;
};
}
| {
type: "allResourceTokens";
message: {
endpoint: string | undefined;
databaseId: string | undefined;
resourceTokens: unknown | undefined;
resourceTokensTimestamp: number | undefined;
};
};
export type DataExploreMessage =
@@ -40,6 +47,9 @@ export type DataExploreMessage =
type: MessageTypes.GetAuthorizationToken;
id: string;
params: GetCosmosTokenMessageOptions[];
}
| {
type: MessageTypes.GetAllResourceTokens;
};
export type GetCosmosTokenMessageOptions = {
@@ -55,4 +65,13 @@ export type CosmosDBTokenResponse = {
export type CosmosDBConnectionInfoResponse = {
endpoint: string;
databaseId: string;
resourceTokens: unknown;
};
export interface FabricDatabaseConnectionInfo {
endpoint: string;
databaseId: string;
resourceTokens: { [resourceId: string]: string };
resourceTokensTimestamp: number;
}

View File

@@ -40,6 +40,7 @@ export enum MessageTypes {
// Data Explorer -> Fabric communication
GetAuthorizationToken,
GetAllResourceTokens,
}
export interface AuthorizationToken {

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

@@ -48,7 +48,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
public componentDidUpdate(previous: EditorReactProps) {
if (this.props.content !== previous.content) {
this.editor.setValue(this.props.content);
this.editor?.setValue(this.props.content);
}
}
@@ -111,7 +111,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
this.rootNode.innerHTML = "";
const monaco = await loadMonaco();
createCallback(monaco.editor.create(this.rootNode, options));
createCallback(monaco?.editor?.create(this.rootNode, options));
if (this.rootNode.innerHTML) {
this.setState({

View File

@@ -3,7 +3,9 @@ import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext } from "ConfigContext";
import { MessageTypes } from "Contracts/ExplorerContracts";
import { getCopilotEnabled, isCopilotFeatureRegistered } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
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";
@@ -91,7 +93,7 @@ export default class Explorer {
};
private static readonly MaxNbDatabasesToAutoExpand = 5;
private phoenixClient: PhoenixClient;
public phoenixClient: PhoenixClient;
constructor() {
const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, {
dataExplorerArea: Constants.Areas.ResourceTree,
@@ -273,6 +275,7 @@ export default class Explorer {
const NINETY_DAYS_IN_MS = 7776000000;
const ONE_DAY_IN_MS = 86400000;
const THREE_DAYS_IN_MS = 259200000;
const isAccountNewerThanNinetyDays = isAccountNewerThanThresholdInMs(
userContext.databaseAccount?.systemData?.createdAt || "",
NINETY_DAYS_IN_MS,
@@ -292,32 +295,32 @@ export default class Explorer {
}
}
// Try Cosmos DB subscription - survey shown to random 25% of users at day 1 in Data Explorer.
// Try Cosmos DB subscription - survey shown to 100% of users at day 1 in Data Explorer.
if (userContext.isTryCosmosDBSubscription) {
if (
isAccountNewerThanThresholdInMs(userContext.databaseAccount?.systemData?.createdAt || "", ONE_DAY_IN_MS) &&
this.getRandomInt(100) < 25
) {
sendMessage({ type: MessageTypes.DisplayNPSSurvey });
localStorage.setItem("lastSubmitted", Date.now().toString());
if (isAccountNewerThanThresholdInMs(userContext.databaseAccount?.systemData?.createdAt || "", ONE_DAY_IN_MS)) {
this.sendNPSMessage();
}
} else {
// An existing account is lesser than 90 days old. For existing account show to random 10 % of users in Data Explorer.
if (isAccountNewerThanNinetyDays) {
if (this.getRandomInt(100) < 10) {
sendMessage({ type: MessageTypes.DisplayNPSSurvey });
localStorage.setItem("lastSubmitted", Date.now().toString());
}
// An existing account is older than 3 days but less than 90 days old. For existing account show to 100% of users in Data Explorer.
if (
!isAccountNewerThanThresholdInMs(userContext.databaseAccount?.systemData?.createdAt || "", THREE_DAYS_IN_MS) &&
isAccountNewerThanNinetyDays
) {
this.sendNPSMessage();
} else {
// An existing account is greater than 90 days. For existing account show to random 25 % of users in Data Explorer.
if (this.getRandomInt(100) < 25) {
sendMessage({ type: MessageTypes.DisplayNPSSurvey });
localStorage.setItem("lastSubmitted", Date.now().toString());
// An existing account is greater than 90 days. For existing account show to random 33% of users in Data Explorer.
if (this.getRandomInt(100) < 33) {
this.sendNPSMessage();
}
}
}
}
private sendNPSMessage() {
sendMessage({ type: MessageTypes.DisplayNPSSurvey });
localStorage.setItem("lastSubmitted", Date.now().toString());
}
public async refreshDatabaseForResourceToken(): Promise<void> {
const databaseId = userContext.parsedResourceToken?.databaseId;
const collectionId = userContext.parsedResourceToken?.collectionId;
@@ -379,6 +382,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();
@@ -403,7 +413,7 @@ export default class Explorer {
this._isInitializingNotebooks = false;
}
public async allocateContainer(poolId: PoolIdType): Promise<void> {
public async allocateContainer(poolId: PoolIdType, mode?: string): Promise<void> {
const shouldUseNotebookStates = poolId === PoolIdType.DefaultPoolId ? true : false;
const notebookServerInfo = shouldUseNotebookStates
? useNotebook.getState().notebookServerInfo
@@ -417,10 +427,6 @@ export default class Explorer {
(notebookServerInfo === undefined ||
(notebookServerInfo && notebookServerInfo.notebookServerEndpoint === undefined))
) {
const provisionData: IProvisionData = {
cosmosEndpoint: userContext?.databaseAccount?.properties?.documentEndpoint,
poolId: shouldUseNotebookStates ? undefined : poolId,
};
const connectionStatus: ContainerConnectionInfo = {
status: ConnectionStatusType.Connecting,
};
@@ -428,14 +434,26 @@ export default class Explorer {
shouldUseNotebookStates && useNotebook.getState().setConnectionInfo(connectionStatus);
let connectionInfo;
let provisionData: IProvisionData;
try {
TelemetryProcessor.traceStart(Action.PhoenixConnection, {
dataExplorerArea: Areas.Notebook,
});
shouldUseNotebookStates
? useNotebook.getState().setIsAllocating(true)
: useQueryCopilot.getState().setIsAllocatingContainer(true);
if (shouldUseNotebookStates) {
useNotebook.getState().setIsAllocating(true);
provisionData = {
cosmosEndpoint: userContext?.databaseAccount?.properties?.documentEndpoint,
poolId: undefined,
};
} else {
useQueryCopilot.getState().setIsAllocatingContainer(true);
provisionData = {
poolId: poolId,
databaseId: useTabs.getState().activeTab.collection.databaseId,
containerId: useTabs.getState().activeTab.collection.id(),
mode: mode,
};
}
connectionInfo = await this.phoenixClient.allocateContainer(provisionData);
if (!connectionInfo?.data?.phoenixServiceUrl) {
throw new Error(`PhoenixServiceUrl is invalid!`);
@@ -451,19 +469,21 @@ export default class Explorer {
error: getErrorMessage(error),
errorStack: getErrorStack(error),
});
connectionStatus.status = ConnectionStatusType.Failed;
shouldUseNotebookStates
? useNotebook.getState().resetContainerConnection(connectionStatus)
: useQueryCopilot.getState().resetContainerConnection();
if (error?.status === HttpStatusCodes.Forbidden && error.message) {
useDialog.getState().showOkModalDialog("Connection Failed", `${error.message}`);
} else {
useDialog
.getState()
.showOkModalDialog(
"Connection Failed",
"We are unable to connect to the temporary workspace. Please try again in a few minutes. If the error persists, file a support ticket.",
);
if (shouldUseNotebookStates) {
connectionStatus.status = ConnectionStatusType.Failed;
shouldUseNotebookStates
? useNotebook.getState().resetContainerConnection(connectionStatus)
: useQueryCopilot.getState().resetContainerConnection();
if (error?.status === HttpStatusCodes.Forbidden && error.message) {
useDialog.getState().showOkModalDialog("Connection Failed", `${error.message}`);
} else {
useDialog
.getState()
.showOkModalDialog(
"Connection Failed",
"We are unable to connect to the temporary workspace. Please try again in a few minutes. If the error persists, file a support ticket.",
);
}
}
throw error;
} finally {
@@ -477,11 +497,11 @@ export default class Explorer {
}
}
private async setNotebookInfo(
public async setNotebookInfo(
shouldUseNotebookStates: boolean,
connectionInfo: IResponse<IPhoenixServiceInfo>,
connectionStatus: DataModels.ContainerConnectionInfo,
) {
): Promise<void> {
const containerData = {
forwardingId: connectionInfo.data.forwardingId,
dbAccountName: userContext.databaseAccount.name,
@@ -502,6 +522,7 @@ export default class Explorer {
shouldUseNotebookStates
? useNotebook.getState().setNotebookServerInfo(noteBookServerInfo)
: useQueryCopilot.getState().setNotebookServerInfo(noteBookServerInfo);
shouldUseNotebookStates &&
this.notebookManager?.notebookClient
.getMemoryUsage()
@@ -1364,6 +1385,21 @@ export default class Explorer {
await this.refreshSampleData();
}
public async configureCopilot(): Promise<void> {
if (userContext.apiType !== "SQL" || !userContext.subscriptionId) {
return;
}
const copilotEnabledPromise = getCopilotEnabled();
const copilotUserDBEnabledPromise = isCopilotFeatureRegistered(userContext.subscriptionId);
const [copilotEnabled, copilotUserDBEnabled] = await Promise.all([
copilotEnabledPromise,
copilotUserDBEnabledPromise,
]);
useQueryCopilot.getState().setCopilotEnabled(copilotEnabled);
useQueryCopilot.getState().setCopilotUserDBEnabled(copilotUserDBEnabled);
useQueryCopilot.getState().setShowWelcomeModal(window.localStorage.getItem("hideWelcomeModal") !== "true");
}
public async refreshSampleData(): Promise<void> {
if (!userContext.sampleDataConnectionInfo) {
return;

View File

@@ -345,7 +345,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
describe("Open Postgres and vCore Mongo buttons", () => {
const openPostgresShellButtonLabel = "Open PSQL shell";
const openVCoreMongoShellButtonLabel = "Open MongoDB (vcore) shell";
const openVCoreMongoShellButtonLabel = "Open MongoDB (vCore) shell";
beforeAll(() => {
mockExplorer = {} as Explorer;

View File

@@ -1,6 +1,3 @@
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
import { ReactTabKind, useTabs } from "hooks/useTabs";
import * as React from "react";
import AddCollectionIcon from "../../../../images/AddCollection.svg";
import AddDatabaseIcon from "../../../../images/AddDatabase.svg";
@@ -50,31 +47,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" && configContext.platform !== Platform.Fabric) {
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 +130,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);
}
@@ -332,13 +334,8 @@ function createNewSQLQueryButton(selectedNodeState: SelectedNodeState): CommandB
iconSrc: AddSqlQueryIcon,
iconAlt: label,
onCommandClick: () => {
if (useSelectedNode.getState().isQueryCopilotCollectionSelected()) {
useTabs.getState().openAndActivateReactTab(ReactTabKind.QueryCopilot);
traceOpen(Action.OpenQueryCopilotFromNewQuery, { apiType: userContext.apiType });
} else {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
selectedCollection && selectedCollection.onNewQueryClick(selectedCollection);
}
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
selectedCollection && selectedCollection.onNewQueryClick(selectedCollection);
},
commandButtonLabel: label,
ariaLabel: label,
@@ -518,7 +515,7 @@ function createOpenTerminalButtonByKind(
case ViewModels.TerminalKind.Postgres:
return "PSQL";
case ViewModels.TerminalKind.VCoreMongo:
return "MongoDB (vcore)";
return "MongoDB (vCore)";
default:
return "";
}

View File

@@ -6,6 +6,7 @@ import {
IDropdownOption,
IDropdownStyles,
} from "@fluentui/react";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import * as React from "react";
import _ from "underscore";
import ChevronDownIcon from "../../../../images/Chevron_down.svg";
@@ -57,7 +58,11 @@ export const convertButton = (btns: CommandButtonComponentProps[], backgroundCol
},
onClick: (ev?: React.MouseEvent<HTMLElement, MouseEvent> | React.KeyboardEvent<HTMLElement>) => {
btn.onCommandClick(ev);
TelemetryProcessor.trace(Action.ClickCommandBarButton, ActionModifiers.Mark, { label });
let copilotEnabled = false;
if (useQueryCopilot.getState().copilotEnabled && useQueryCopilot.getState().copilotUserDBEnabled) {
copilotEnabled = useQueryCopilot.getState().copilotEnabledforExecution;
}
TelemetryProcessor.trace(Action.ClickCommandBarButton, ActionModifiers.Mark, { label, copilotEnabled });
},
key: `${btn.commandButtonLabel}${index}`,
text: label,

View File

@@ -1431,8 +1431,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
this.setState({ isExecuting: false });
TelemetryProcessor.traceSuccess(Action.CreateCollection, telemetryData, startKey);
useSidePanel.getState().closeSidePanel();
// open NPS Survey Dialog once the collection is created
this.props.explorer.openNPSSurveyDialog();
} catch (error) {
const errorMessage: string = getErrorMessage(error);
this.setState({ isExecuting: false, errorMessage, showErrorDetails: true });

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

@@ -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

@@ -336,7 +336,8 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
directionalHint={4}
>
<Icon
ariaLabel="Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads."
ariaLabel="Enable analytical store capability to perform near real-time analytics on your operational data, without
impacting the performance of transactional workloads."
className="panelInfoIcon"
iconName="Info"
tabIndex={0}

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

@@ -1,10 +1,10 @@
import { Checkbox, ChoiceGroup, DefaultButton, IconButton, PrimaryButton, TextField } from "@fluentui/react";
import { Checkbox, DefaultButton, IconButton, PrimaryButton, TextField } from "@fluentui/react";
import Explorer from "Explorer/Explorer";
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
import { SubmitFeedback } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { getUserEmail } from "Utils/UserUtils";
import { shallow } from "enzyme";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import React from "react";
jest.mock("Utils/UserUtils");
@@ -13,21 +13,49 @@ jest.mock("Utils/UserUtils");
jest.mock("Explorer/QueryCopilot/Shared/QueryCopilotClient");
SubmitFeedback as jest.Mock;
jest.mock("Explorer/QueryCopilot/QueryCopilotContext");
const mockUseCopilotStore = useCopilotStore as jest.Mock;
const mockReturnValue = {
generatedQuery: "test query",
userPrompt: "test prompt",
likeQuery: false,
showFeedbackModal: false,
closeFeedbackModal: jest.fn,
setHideFeedbackModalForLikedQueries: jest.fn,
};
describe("Query Copilot Feedback Modal snapshot test", () => {
beforeEach(() => {
mockUseCopilotStore.mockReturnValue(mockReturnValue);
jest.clearAllMocks();
});
it("shoud render and match snapshot", () => {
useQueryCopilot.getState().openFeedbackModal("test query", false, "test prompt");
const wrapper = shallow(<QueryCopilotFeedbackModal explorer={new Explorer()} />);
mockUseCopilotStore.mockReturnValue({
...mockReturnValue,
showFeedbackModal: true,
});
const wrapper = shallow(
<QueryCopilotFeedbackModal
explorer={new Explorer()}
databaseId="CopilotUserDb"
containerId="CopilotUserContainer"
mode="User"
/>,
);
expect(wrapper.props().isOpen).toBeTruthy();
expect(wrapper).toMatchSnapshot();
});
it("should close on cancel click", () => {
const wrapper = shallow(<QueryCopilotFeedbackModal explorer={new Explorer()} />);
const wrapper = shallow(
<QueryCopilotFeedbackModal
explorer={new Explorer()}
databaseId="CopilotUserDb"
containerId="CopilotUserContainer"
mode="User"
/>,
);
const cancelButton = wrapper.find(IconButton);
cancelButton.simulate("click");
@@ -38,7 +66,14 @@ describe("Query Copilot Feedback Modal snapshot test", () => {
});
it("should get user unput", () => {
const wrapper = shallow(<QueryCopilotFeedbackModal explorer={new Explorer()} />);
const wrapper = shallow(
<QueryCopilotFeedbackModal
explorer={new Explorer()}
databaseId="CopilotUserDb"
containerId="CopilotUserContainer"
mode="User"
/>,
);
const testUserInput = "test user input";
const userInput = wrapper.find(TextField).first();
@@ -48,30 +83,15 @@ describe("Query Copilot Feedback Modal snapshot test", () => {
expect(wrapper).toMatchSnapshot();
});
it("should record user contact choice no", () => {
const wrapper = shallow(<QueryCopilotFeedbackModal explorer={new Explorer()} />);
const contactAllowed = wrapper.find(ChoiceGroup);
contactAllowed.simulate("change", {}, { key: "no" });
expect(getUserEmail).toHaveBeenCalledTimes(3);
expect(wrapper.find(ChoiceGroup).props().selectedKey).toEqual("no");
expect(wrapper).toMatchSnapshot();
});
it("should record user contact choice yes", () => {
const wrapper = shallow(<QueryCopilotFeedbackModal explorer={new Explorer()} />);
const contactAllowed = wrapper.find(ChoiceGroup);
contactAllowed.simulate("change", {}, { key: "yes" });
expect(getUserEmail).toHaveBeenCalledTimes(4);
expect(wrapper.find(ChoiceGroup).props().selectedKey).toEqual("yes");
expect(wrapper).toMatchSnapshot();
});
it("should not render dont show again button", () => {
const wrapper = shallow(<QueryCopilotFeedbackModal explorer={new Explorer()} />);
const wrapper = shallow(
<QueryCopilotFeedbackModal
explorer={new Explorer()}
databaseId="CopilotUserDb"
containerId="CopilotUserContainer"
mode="User"
/>,
);
const dontShowAgain = wrapper.find(Checkbox);
@@ -80,8 +100,19 @@ describe("Query Copilot Feedback Modal snapshot test", () => {
});
it("should render dont show again button and check it ", () => {
useQueryCopilot.getState().openFeedbackModal("test query", true, "test prompt");
const wrapper = shallow(<QueryCopilotFeedbackModal explorer={new Explorer()} />);
mockUseCopilotStore.mockReturnValue({
...mockReturnValue,
showFeedbackModal: true,
likeQuery: true,
});
const wrapper = shallow(
<QueryCopilotFeedbackModal
explorer={new Explorer()}
databaseId="CopilotUserDb"
containerId="CopilotUserContainer"
mode="User"
/>,
);
const dontShowAgain = wrapper.find(Checkbox);
dontShowAgain.simulate("change", {}, true);
@@ -92,7 +123,14 @@ describe("Query Copilot Feedback Modal snapshot test", () => {
});
it("should cancel submission", () => {
const wrapper = shallow(<QueryCopilotFeedbackModal explorer={new Explorer()} />);
const wrapper = shallow(
<QueryCopilotFeedbackModal
explorer={new Explorer()}
databaseId="CopilotUserDb"
containerId="CopilotUserContainer"
mode="User"
/>,
);
const cancelButton = wrapper.find(DefaultButton);
cancelButton.simulate("click");
@@ -104,7 +142,14 @@ describe("Query Copilot Feedback Modal snapshot test", () => {
it("should not submit submission if required description field is null", () => {
const explorer = new Explorer();
const wrapper = shallow(<QueryCopilotFeedbackModal explorer={explorer} />);
const wrapper = shallow(
<QueryCopilotFeedbackModal
explorer={explorer}
databaseId="CopilotUserDb"
containerId="CopilotUserContainer"
mode="User"
/>,
);
const submitButton = wrapper.find(PrimaryButton);
submitButton.simulate("click");
@@ -114,9 +159,15 @@ describe("Query Copilot Feedback Modal snapshot test", () => {
});
it("should submit submission", () => {
useQueryCopilot.getState().openFeedbackModal("test query", false, "test prompt");
const explorer = new Explorer();
const wrapper = shallow(<QueryCopilotFeedbackModal explorer={explorer} />);
const wrapper = shallow(
<QueryCopilotFeedbackModal
explorer={explorer}
databaseId="CopilotUserDb"
containerId="CopilotUserContainer"
mode="User"
/>,
);
const submitButton = wrapper.find("form");
submitButton.simulate("submit");
@@ -124,12 +175,14 @@ describe("Query Copilot Feedback Modal snapshot test", () => {
expect(SubmitFeedback).toHaveBeenCalledTimes(1);
expect(SubmitFeedback).toHaveBeenCalledWith({
containerId: "CopilotUserContainer",
databaseId: "CopilotUserDb",
mode: "User",
params: {
likeQuery: false,
generatedQuery: "test query",
userPrompt: "test prompt",
description: "",
contact: getUserEmail(),
},
explorer: explorer,
});

View File

@@ -1,6 +1,5 @@
import {
Checkbox,
ChoiceGroup,
DefaultButton,
IconButton,
Link,
@@ -11,12 +10,21 @@ import {
TextField,
} from "@fluentui/react";
import Explorer from "Explorer/Explorer";
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
import { SubmitFeedback } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import React from "react";
import { getUserEmail } from "../../../Utils/UserUtils";
export const QueryCopilotFeedbackModal = ({ explorer }: { explorer: Explorer }): JSX.Element => {
export const QueryCopilotFeedbackModal = ({
explorer,
databaseId,
containerId,
mode,
}: {
explorer: Explorer;
databaseId: string;
containerId: string;
mode: string;
}): JSX.Element => {
const {
generatedQuery,
userPrompt,
@@ -24,18 +32,19 @@ export const QueryCopilotFeedbackModal = ({ explorer }: { explorer: Explorer }):
showFeedbackModal,
closeFeedbackModal,
setHideFeedbackModalForLikedQueries,
} = useQueryCopilot();
const [isContactAllowed, setIsContactAllowed] = React.useState<boolean>(false);
} = useCopilotStore();
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,
params: { generatedQuery, likeQuery, description, userPrompt },
explorer,
databaseId,
containerId,
mode: mode,
});
};
@@ -64,30 +73,6 @@ export const QueryCopilotFeedbackModal = ({ explorer }: { explorer: Explorer }):
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{" "}
{

View File

@@ -1,7 +1,6 @@
.modalContentPadding {
padding-top: 15px;
width: 513px;
height: 638px;
}
.exitPadding {
@@ -39,7 +38,7 @@
}
.buttonPadding {
padding: 15px 0px 0px 0px;
padding: 15px 0px 15px 0px;
}
.tryButton {

View File

@@ -1,7 +1,7 @@
import { IconButton, Image, Link, Modal, PrimaryButton, Stack, StackItem, Text } from "@fluentui/react";
import { useBoolean } from "@fluentui/react-hooks";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import React from "react";
import Database from "../../../../images/CopilotDatabase.svg";
import Flash from "../../../../images/CopilotFlash.svg";
import Thumb from "../../../../images/CopilotThumb.svg";
import CoplilotWelcomeIllustration from "../../../../images/CopliotWelcomeIllustration.svg";
@@ -14,7 +14,11 @@ export const WelcomeModal = ({ visible }: { visible: boolean }): JSX.Element =>
if (visible) {
window.localStorage.setItem("hideWelcomeModal", "true");
}
});
}, []);
React.useEffect(() => {
useQueryCopilot.getState().setShowWelcomeModal(isModalVisible);
}, [isModalVisible]);
return (
<>
@@ -23,8 +27,10 @@ export const WelcomeModal = ({ visible }: { visible: boolean }): JSX.Element =>
onDismiss={hideModal}
isBlocking={false}
styles={{
scrollableContent: {
minHeight: 680,
main: {
maxHeight: 600,
borderRadius: 10,
overflow: "hidden",
},
}}
>
@@ -52,7 +58,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 Copilot in Azure Cosmos DB (Private Preview)</Text>
<Text className="title bold">Welcome to Microsoft Copilot for Azure in Cosmos DB (preview)</Text>
</Stack.Item>
<Stack.Item align="center" className="text">
<Stack horizontal>
@@ -69,7 +75,7 @@ export const WelcomeModal = ({ visible }: { visible: boolean }): JSX.Element =>
<Text>
Ask Copilot to generate a query by describing the query in your words.
<br />
<Link target="_blank" href="https://aka.ms/cdb-copilot-learn-more">
<Link target="_blank" href="https://aka.ms/MicrosoftCopilotForAzureInCDBHowTo">
Learn more
</Link>
</Text>
@@ -87,31 +93,11 @@ export const WelcomeModal = ({ visible }: { visible: boolean }): JSX.Element =>
</StackItem>
</Stack>
<Text>
AI-generated content can have mistakes. Make sure its accurate and appropriate before using it.
AI-generated content can have mistakes. Make sure it is accurate and appropriate before executing the
query.
<br />
<Link target="_blank" href="https://aka.ms/cdb-copilot-preview-terms">
Read preview terms
</Link>
</Text>
</Stack.Item>
<Stack.Item align="center" className="text">
<Stack horizontal>
<StackItem align="start" className="imageTextPadding">
<Image src={Database} />
</StackItem>
<StackItem align="start">
<Text className="bold">
Query Copilot works on a sample database.
<br />
</Text>
</StackItem>
</Stack>
<Text>
While in Private Preview, Query Copilot is setup to work on sample database we have configured for you
at no cost.
<br />
<Link target="_blank" href="https://aka.ms/cdb-copilot-learn-more">
Learn more
Read our preview terms here
</Link>
</Text>
</Stack.Item>

View File

@@ -76,43 +76,6 @@ exports[`Query Copilot Feedback Modal snapshot test shoud render and match snaps
}
}
/>
<StyledChoiceGroup
label="May we contact you about your feedback?"
onChange={[Function]}
options={
Array [
Object {
"key": "yes",
"text": "Yes, you may contact me.",
},
Object {
"key": "no",
"text": "No, do not contact me.",
},
]
}
selectedKey="no"
styles={
Object {
"flexContainer": Object {
"selectors": Object {
".ms-ChoiceField-field::after": Object {
"marginTop": 4,
},
".ms-ChoiceField-field::before": Object {
"marginTop": 4,
},
".ms-ChoiceFieldLabel": Object {
"paddingLeft": 6,
},
},
},
"root": Object {
"marginBottom": 14,
},
}
}
/>
<Text
style={
Object {
@@ -235,43 +198,6 @@ exports[`Query Copilot Feedback Modal snapshot test should cancel submission 1`]
}
}
/>
<StyledChoiceGroup
label="May we contact you about your feedback?"
onChange={[Function]}
options={
Array [
Object {
"key": "yes",
"text": "Yes, you may contact me.",
},
Object {
"key": "no",
"text": "No, do not contact me.",
},
]
}
selectedKey="no"
styles={
Object {
"flexContainer": Object {
"selectors": Object {
".ms-ChoiceField-field::after": Object {
"marginTop": 4,
},
".ms-ChoiceField-field::before": Object {
"marginTop": 4,
},
".ms-ChoiceFieldLabel": Object {
"paddingLeft": 6,
},
},
},
"root": Object {
"marginBottom": 14,
},
}
}
/>
<Text
style={
Object {
@@ -291,21 +217,6 @@ exports[`Query Copilot Feedback Modal snapshot test should cancel submission 1`]
for more information.
</Text>
<StyledCheckboxBase
checked={false}
label="Don't show me this next time"
onChange={[Function]}
styles={
Object {
"label": Object {
"paddingLeft": 0,
},
"root": Object {
"marginBottom": 14,
},
}
}
/>
<Stack
horizontal={true}
horizontalAlign="end"
@@ -409,43 +320,6 @@ exports[`Query Copilot Feedback Modal snapshot test should close on cancel click
}
}
/>
<StyledChoiceGroup
label="May we contact you about your feedback?"
onChange={[Function]}
options={
Array [
Object {
"key": "yes",
"text": "Yes, you may contact me.",
},
Object {
"key": "no",
"text": "No, do not contact me.",
},
]
}
selectedKey="no"
styles={
Object {
"flexContainer": Object {
"selectors": Object {
".ms-ChoiceField-field::after": Object {
"marginTop": 4,
},
".ms-ChoiceField-field::before": Object {
"marginTop": 4,
},
".ms-ChoiceFieldLabel": Object {
"paddingLeft": 6,
},
},
},
"root": Object {
"marginBottom": 14,
},
}
}
/>
<Text
style={
Object {
@@ -568,43 +442,6 @@ exports[`Query Copilot Feedback Modal snapshot test should get user unput 1`] =
}
}
/>
<StyledChoiceGroup
label="May we contact you about your feedback?"
onChange={[Function]}
options={
Array [
Object {
"key": "yes",
"text": "Yes, you may contact me.",
},
Object {
"key": "no",
"text": "No, do not contact me.",
},
]
}
selectedKey="no"
styles={
Object {
"flexContainer": Object {
"selectors": Object {
".ms-ChoiceField-field::after": Object {
"marginTop": 4,
},
".ms-ChoiceField-field::before": Object {
"marginTop": 4,
},
".ms-ChoiceFieldLabel": Object {
"paddingLeft": 6,
},
},
},
"root": Object {
"marginBottom": 14,
},
}
}
/>
<Text
style={
Object {
@@ -727,361 +564,6 @@ exports[`Query Copilot Feedback Modal snapshot test should not render dont show
}
}
/>
<StyledChoiceGroup
label="May we contact you about your feedback?"
onChange={[Function]}
options={
Array [
Object {
"key": "yes",
"text": "Yes, you may contact me.",
},
Object {
"key": "no",
"text": "No, do not contact me.",
},
]
}
selectedKey="no"
styles={
Object {
"flexContainer": Object {
"selectors": Object {
".ms-ChoiceField-field::after": Object {
"marginTop": 4,
},
".ms-ChoiceField-field::before": Object {
"marginTop": 4,
},
".ms-ChoiceFieldLabel": Object {
"paddingLeft": 6,
},
},
},
"root": Object {
"marginBottom": 14,
},
}
}
/>
<Text
style={
Object {
"fontSize": 12,
"marginBottom": 14,
}
}
>
By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the
<StyledLinkBase
href="https://privacy.microsoft.com/privacystatement"
target="_blank"
>
Privacy statement
</StyledLinkBase>
for more information.
</Text>
<Stack
horizontal={true}
horizontalAlign="end"
>
<CustomizedPrimaryButton
styles={
Object {
"root": Object {
"marginRight": 8,
},
}
}
type="submit"
>
Submit
</CustomizedPrimaryButton>
<CustomizedDefaultButton
onClick={[Function]}
>
Cancel
</CustomizedDefaultButton>
</Stack>
</Stack>
</form>
</Modal>
`;
exports[`Query Copilot Feedback Modal snapshot test should record user contact choice no 1`] = `
<Modal
isOpen={false}
>
<form
onSubmit={[Function]}
>
<Stack
style={
Object {
"padding": 24,
}
}
>
<Stack
horizontal={true}
horizontalAlign="space-between"
>
<Text
style={
Object {
"fontSize": 20,
"fontWeight": 600,
"marginBottom": 20,
}
}
>
Send feedback to Microsoft
</Text>
<CustomizedIconButton
iconProps={
Object {
"iconName": "Cancel",
}
}
onClick={[Function]}
/>
</Stack>
<Text
style={
Object {
"fontSize": 14,
"marginBottom": 14,
}
}
>
Your feedback will help improve the experience.
</Text>
<StyledTextFieldBase
label="Description"
multiline={true}
onChange={[Function]}
placeholder="Provide more details"
required={true}
rows={3}
styles={
Object {
"root": Object {
"marginBottom": 14,
},
}
}
value=""
/>
<StyledTextFieldBase
defaultValue="test query"
label="Query generated"
readOnly={true}
styles={
Object {
"root": Object {
"marginBottom": 14,
},
}
}
/>
<StyledChoiceGroup
label="May we contact you about your feedback?"
onChange={[Function]}
options={
Array [
Object {
"key": "yes",
"text": "Yes, you may contact me.",
},
Object {
"key": "no",
"text": "No, do not contact me.",
},
]
}
selectedKey="no"
styles={
Object {
"flexContainer": Object {
"selectors": Object {
".ms-ChoiceField-field::after": Object {
"marginTop": 4,
},
".ms-ChoiceField-field::before": Object {
"marginTop": 4,
},
".ms-ChoiceFieldLabel": Object {
"paddingLeft": 6,
},
},
},
"root": Object {
"marginBottom": 14,
},
}
}
/>
<Text
style={
Object {
"fontSize": 12,
"marginBottom": 14,
}
}
>
By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the
<StyledLinkBase
href="https://privacy.microsoft.com/privacystatement"
target="_blank"
>
Privacy statement
</StyledLinkBase>
for more information.
</Text>
<Stack
horizontal={true}
horizontalAlign="end"
>
<CustomizedPrimaryButton
styles={
Object {
"root": Object {
"marginRight": 8,
},
}
}
type="submit"
>
Submit
</CustomizedPrimaryButton>
<CustomizedDefaultButton
onClick={[Function]}
>
Cancel
</CustomizedDefaultButton>
</Stack>
</Stack>
</form>
</Modal>
`;
exports[`Query Copilot Feedback Modal snapshot test should record user contact choice yes 1`] = `
<Modal
isOpen={false}
>
<form
onSubmit={[Function]}
>
<Stack
style={
Object {
"padding": 24,
}
}
>
<Stack
horizontal={true}
horizontalAlign="space-between"
>
<Text
style={
Object {
"fontSize": 20,
"fontWeight": 600,
"marginBottom": 20,
}
}
>
Send feedback to Microsoft
</Text>
<CustomizedIconButton
iconProps={
Object {
"iconName": "Cancel",
}
}
onClick={[Function]}
/>
</Stack>
<Text
style={
Object {
"fontSize": 14,
"marginBottom": 14,
}
}
>
Your feedback will help improve the experience.
</Text>
<StyledTextFieldBase
label="Description"
multiline={true}
onChange={[Function]}
placeholder="Provide more details"
required={true}
rows={3}
styles={
Object {
"root": Object {
"marginBottom": 14,
},
}
}
value=""
/>
<StyledTextFieldBase
defaultValue="test query"
label="Query generated"
readOnly={true}
styles={
Object {
"root": Object {
"marginBottom": 14,
},
}
}
/>
<StyledChoiceGroup
label="May we contact you about your feedback?"
onChange={[Function]}
options={
Array [
Object {
"key": "yes",
"text": "Yes, you may contact me.",
},
Object {
"key": "no",
"text": "No, do not contact me.",
},
]
}
selectedKey="yes"
styles={
Object {
"flexContainer": Object {
"selectors": Object {
".ms-ChoiceField-field::after": Object {
"marginTop": 4,
},
".ms-ChoiceField-field::before": Object {
"marginTop": 4,
},
".ms-ChoiceFieldLabel": Object {
"paddingLeft": 6,
},
},
},
"root": Object {
"marginBottom": 14,
},
}
}
/>
<Text
style={
Object {
@@ -1204,43 +686,6 @@ exports[`Query Copilot Feedback Modal snapshot test should render dont show agai
}
}
/>
<StyledChoiceGroup
label="May we contact you about your feedback?"
onChange={[Function]}
options={
Array [
Object {
"key": "yes",
"text": "Yes, you may contact me.",
},
Object {
"key": "no",
"text": "No, do not contact me.",
},
]
}
selectedKey="no"
styles={
Object {
"flexContainer": Object {
"selectors": Object {
".ms-ChoiceField-field::after": Object {
"marginTop": 4,
},
".ms-ChoiceField-field::before": Object {
"marginTop": 4,
},
".ms-ChoiceFieldLabel": Object {
"paddingLeft": 6,
},
},
},
"root": Object {
"marginBottom": 14,
},
}
}
/>
<Text
style={
Object {
@@ -1378,43 +823,6 @@ exports[`Query Copilot Feedback Modal snapshot test should submit submission 1`]
}
}
/>
<StyledChoiceGroup
label="May we contact you about your feedback?"
onChange={[Function]}
options={
Array [
Object {
"key": "yes",
"text": "Yes, you may contact me.",
},
Object {
"key": "no",
"text": "No, do not contact me.",
},
]
}
selectedKey="no"
styles={
Object {
"flexContainer": Object {
"selectors": Object {
".ms-ChoiceField-field::after": Object {
"marginTop": 4,
},
".ms-ChoiceField-field::before": Object {
"marginTop": 4,
},
".ms-ChoiceFieldLabel": Object {
"paddingLeft": 6,
},
},
},
"root": Object {
"marginBottom": 14,
},
}
}
/>
<Text
style={
Object {

View File

@@ -8,8 +8,10 @@ exports[`Query Copilot Welcome Modal snapshot test should render when isOpen is
onDismiss={[Function]}
styles={
Object {
"scrollableContent": Object {
"minHeight": 680,
"main": Object {
"borderRadius": 10,
"maxHeight": 600,
"overflow": "hidden",
},
}
}
@@ -76,7 +78,7 @@ exports[`Query Copilot Welcome Modal snapshot test should render when isOpen is
<Text
className="title bold"
>
Welcome to Copilot in Azure Cosmos DB (Private Preview)
Welcome to Microsoft Copilot for Azure in Cosmos DB (preview)
</Text>
</StackItem>
<StackItem
@@ -109,7 +111,7 @@ exports[`Query Copilot Welcome Modal snapshot test should render when isOpen is
Ask Copilot to generate a query by describing the query in your words.
<br />
<StyledLinkBase
href="https://aka.ms/cdb-copilot-learn-more"
href="https://aka.ms/MicrosoftCopilotForAzureInCDBHowTo"
target="_blank"
>
Learn more
@@ -143,50 +145,13 @@ exports[`Query Copilot Welcome Modal snapshot test should render when isOpen is
</StackItem>
</Stack>
<Text>
AI-generated content can have mistakes. Make sure its accurate and appropriate before using it.
AI-generated content can have mistakes. Make sure it is accurate and appropriate before executing the query.
<br />
<StyledLinkBase
href="https://aka.ms/cdb-copilot-preview-terms"
target="_blank"
>
Read preview terms
</StyledLinkBase>
</Text>
</StackItem>
<StackItem
align="center"
className="text"
>
<Stack
horizontal={true}
>
<StackItem
align="start"
className="imageTextPadding"
>
<Image
src={Object {}}
/>
</StackItem>
<StackItem
align="start"
>
<Text
className="bold"
>
Query Copilot works on a sample database.
<br />
</Text>
</StackItem>
</Stack>
<Text>
While in Private Preview, Query Copilot is setup to work on sample database we have configured for you at no cost.
<br />
<StyledLinkBase
href="https://aka.ms/cdb-copilot-learn-more"
target="_blank"
>
Learn more
Read our preview terms here
</StyledLinkBase>
</Text>
</StackItem>

View File

@@ -0,0 +1,135 @@
import { MinimalQueryIterator } from "Common/IteratorUtilities";
import { QueryResults } from "Contracts/ViewModels";
import { CopilotMessage } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { guid } from "Explorer/Tables/Utilities";
import { QueryCopilotState } from "hooks/useQueryCopilot";
import React, { createContext, useContext, useState } from "react";
import create from "zustand";
const context = createContext(null);
const useCopilotStore = (): Partial<QueryCopilotState> => useContext(context);
const CopilotProvider = ({ children }: { children: React.ReactNode }): JSX.Element => {
const [useStore] = useState(() =>
create((set, get) => ({
generatedQuery: "",
likeQuery: false,
userPrompt: "",
showFeedbackModal: false,
hideFeedbackModalForLikedQueries: false,
correlationId: "",
query: "SELECT * FROM c",
selectedQuery: "",
isGeneratingQuery: false,
isGeneratingExplanation: false,
isExecuting: false,
dislikeQuery: undefined,
showCallout: false,
showSamplePrompts: false,
queryIterator: undefined,
queryResults: undefined,
errorMessage: "",
isSamplePromptsOpen: false,
showDeletePopup: false,
showFeedbackBar: false,
showCopyPopup: false,
showErrorMessageBar: false,
showInvalidQueryMessageBar: false,
generatedQueryComments: "",
wasCopilotUsed: false,
showWelcomeSidebar: true,
showCopilotSidebar: false,
chatMessages: [],
shouldIncludeInMessages: true,
showExplanationBubble: false,
isAllocatingContainer: false,
openFeedbackModal: (generatedQuery: string, likeQuery: boolean, userPrompt: string) =>
set({ generatedQuery, likeQuery, userPrompt, showFeedbackModal: true }),
closeFeedbackModal: () => set({ showFeedbackModal: false }),
setHideFeedbackModalForLikedQueries: (hideFeedbackModalForLikedQueries: boolean) =>
set({ hideFeedbackModalForLikedQueries }),
refreshCorrelationId: () => set({ correlationId: guid() }),
setUserPrompt: (userPrompt: string) => set({ userPrompt }),
setQuery: (query: string) => set({ query }),
setGeneratedQuery: (generatedQuery: string) => set({ generatedQuery }),
setSelectedQuery: (selectedQuery: string) => set({ selectedQuery }),
setIsGeneratingQuery: (isGeneratingQuery: boolean) => set({ isGeneratingQuery }),
setIsGeneratingExplanation: (isGeneratingExplanation: boolean) => set({ isGeneratingExplanation }),
setIsExecuting: (isExecuting: boolean) => set({ isExecuting }),
setLikeQuery: (likeQuery: boolean) => set({ likeQuery }),
setDislikeQuery: (dislikeQuery: boolean | undefined) => set({ dislikeQuery }),
setShowCallout: (showCallout: boolean) => set({ showCallout }),
setShowSamplePrompts: (showSamplePrompts: boolean) => set({ showSamplePrompts }),
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => set({ queryIterator }),
setQueryResults: (queryResults: QueryResults | undefined) => set({ queryResults }),
setErrorMessage: (errorMessage: string) => set({ errorMessage }),
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => set({ isSamplePromptsOpen }),
setShowDeletePopup: (showDeletePopup: boolean) => set({ showDeletePopup }),
setShowFeedbackBar: (showFeedbackBar: boolean) => set({ showFeedbackBar }),
setshowCopyPopup: (showCopyPopup: boolean) => set({ showCopyPopup }),
setShowErrorMessageBar: (showErrorMessageBar: boolean) => set({ showErrorMessageBar }),
setShowInvalidQueryMessageBar: (showInvalidQueryMessageBar: boolean) => set({ showInvalidQueryMessageBar }),
setGeneratedQueryComments: (generatedQueryComments: string) => set({ generatedQueryComments }),
setWasCopilotUsed: (wasCopilotUsed: boolean) => set({ wasCopilotUsed }),
setShowWelcomeSidebar: (showWelcomeSidebar: boolean) => set({ showWelcomeSidebar }),
setShowCopilotSidebar: (showCopilotSidebar: boolean) => set({ showCopilotSidebar }),
setChatMessages: (chatMessages: CopilotMessage[]) => set({ chatMessages }),
setShouldIncludeInMessages: (shouldIncludeInMessages: boolean) => set({ shouldIncludeInMessages }),
setShowExplanationBubble: (showExplanationBubble: boolean) => set({ showExplanationBubble }),
getState: () => {
return get();
},
resetQueryCopilotStates: () => {
set((state) => ({
...state,
generatedQuery: "",
likeQuery: false,
userPrompt: "",
showFeedbackModal: false,
hideFeedbackModalForLikedQueries: false,
correlationId: "",
query: "SELECT * FROM c",
selectedQuery: "",
isGeneratingQuery: false,
isGeneratingExplanation: false,
isExecuting: false,
dislikeQuery: undefined,
showCallout: false,
showSamplePrompts: false,
queryIterator: undefined,
queryResults: undefined,
errorMessage: "",
isSamplePromptsOpen: false,
showDeletePopup: false,
showFeedbackBar: false,
showCopyPopup: false,
showErrorMessageBar: false,
showInvalidQueryMessageBar: false,
generatedQueryComments: "",
wasCopilotUsed: false,
showCopilotSidebar: false,
chatMessages: [],
shouldIncludeInMessages: true,
showExplanationBubble: false,
notebookServerInfo: {
notebookServerEndpoint: undefined,
authToken: undefined,
forwardingId: undefined,
},
containerStatus: {
status: undefined,
durationLeftInMinutes: undefined,
phoenixServerInfo: undefined,
},
isAllocatingContainer: false,
}));
},
})),
);
return <context.Provider value={useStore()}>{children}</context.Provider>;
};
export { CopilotProvider, useCopilotStore };

View File

@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-console */
import {
Callout,
CommandBarButton,
@@ -17,50 +19,61 @@ import {
TextField,
} from "@fluentui/react";
import { useBoolean } from "@fluentui/react-hooks";
import {
ContainerStatusType,
PoolIdType,
QueryCopilotSampleContainerSchema,
ShortenedQueryCopilotSampleContainerSchema,
} from "Common/Constants";
import { HttpStatusCodes } 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 {
SuggestedPrompt,
getSampleDatabaseSuggestedPrompts,
getSuggestedPrompts,
} from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { SubmitFeedback, allocatePhoenixContainer } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { GenerateSQLQueryResponse, QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { SamplePrompts, SamplePromptsProps } from "Explorer/QueryCopilot/Shared/SamplePrompts/SamplePrompts";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { userContext } from "UserContext";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import { QueryCopilotState, 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 * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { useTabs } from "../../hooks/useTabs";
import { useCopilotStore } from "../QueryCopilot/QueryCopilotContext";
import { useSelectedNode } from "../useSelectedNode";
type QueryCopilotPromptProps = QueryCopilotProps & {
databaseId: string;
containerId: string;
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 },
label: {
fontWeight: 400,
textAlign: "left",
paddingLeft: 8,
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
},
textContainer: { overflow: "hidden" },
};
export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
explorer,
toggleCopilot,
databaseId,
containerId,
}: QueryCopilotPromptProps): JSX.Element => {
const [copilotTeachingBubbleVisible, { toggle: toggleCopilotTeachingBubbleVisible }] = useBoolean(false);
const inputEdited = useRef(false);
const {
openFeedbackModal,
hideFeedbackModalForLikedQueries,
userPrompt,
setUserPrompt,
@@ -93,7 +106,10 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
setGeneratedQueryComments,
setQueryResults,
setErrorMessage,
} = useQueryCopilot();
errorMessage,
} = useCopilotStore();
const inputEdited = useRef(!!userPrompt);
const sampleProps: SamplePromptsProps = {
isSamplePromptsOpen: isSamplePromptsOpen,
@@ -118,14 +134,13 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
}, 6000);
};
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
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 suggestedPrompts: SuggestedPrompt[] = isSampleCopilotActive
? getSampleDatabaseSuggestedPrompts()
: getSuggestedPrompts();
const [filteredHistories, setFilteredHistories] = useState<string[]>(histories);
const [filteredSuggestedPrompts, setFilteredSuggestedPrompts] = useState<SuggestedPrompt[]>(suggestedPrompts);
@@ -176,28 +191,24 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
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 mode: string = isSampleCopilotActive ? "Sample" : "User";
await allocatePhoenixContainer({ explorer, databaseId, containerId, mode });
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");
: createUri(serverInfo.notebookServerEndpoint, "public/generateSQLQuery");
const response = await fetch(queryUri, {
method: "POST",
headers: {
"content-type": "application/json",
"x-ms-correlationid": useQueryCopilot.getState().correlationId,
Authorization: `token ${useQueryCopilot.getState().notebookServerInfo.authToken}`,
},
body: JSON.stringify(payload),
});
@@ -215,13 +226,40 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
setGeneratedQueryComments(generateSQLQueryResponse.explanation);
setShowFeedbackBar(true);
resetQueryResults();
TelemetryProcessor.traceSuccess(Action.QueryGenerationFromCopilotPrompt, {
databaseName: databaseId,
collectionId: containerId,
copilotLatency:
Date.parse(generateSQLQueryResponse?.generateEnd) - Date.parse(generateSQLQueryResponse?.generateStart),
responseCode: response.status,
});
} else {
setShowInvalidQueryMessageBar(true);
TelemetryProcessor.traceFailure(Action.QueryGenerationFromCopilotPrompt, {
databaseName: databaseId,
collectionId: containerId,
responseCode: response.status,
});
}
} else if (response?.status === HttpStatusCodes.TooManyRequests) {
handleError(JSON.stringify(generateSQLQueryResponse), "copilotTooManyRequestError");
useTabs.getState().setIsQueryErrorThrown(true);
setShowErrorMessageBar(true);
setErrorMessage("Ratelimit exceeded 5 per 1 minute. Please try again after sometime");
TelemetryProcessor.traceFailure(Action.QueryGenerationFromCopilotPrompt, {
databaseName: databaseId,
collectionId: containerId,
responseCode: response.status,
});
} else {
handleError(JSON.stringify(generateSQLQueryResponse), "copilotInternalServerError");
useTabs.getState().setIsQueryErrorThrown(true);
setShowErrorMessageBar(true);
TelemetryProcessor.traceFailure(Action.QueryGenerationFromCopilotPrompt, {
databaseName: databaseId,
collectionId: containerId,
responseCode: response.status,
});
}
} catch (error) {
handleError(error, "executeNaturalLanguageQuery");
@@ -237,7 +275,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
const showTeachingBubble = (): void => {
if (!inputEdited.current) {
setTimeout(() => {
if (!inputEdited.current && !isWelcomModalVisible()) {
if (!useQueryCopilot.getState().showWelcomeModal && !userPrompt && !inputEdited.current) {
toggleCopilotTeachingBubbleVisible();
inputEdited.current = true;
}
@@ -245,10 +283,6 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
}
};
const isWelcomModalVisible = (): boolean => {
return localStorage.getItem("hideWelcomeModal") !== "true";
};
const clearFeedback = () => {
resetButtonState();
resetQueryResults();
@@ -268,6 +302,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
React.useEffect(() => {
showTeachingBubble();
useQueryCopilot.subscribe(showTeachingBubble, (state: QueryCopilotState) => state.showWelcomeModal);
useTabs.getState().setIsQueryErrorThrown(false);
}, []);
@@ -310,6 +345,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
styles={{ root: { width: "95%" }, fieldGroup: { borderRadius: 6 } }}
disabled={isGeneratingQuery}
autoComplete="off"
placeholder="Ask a question in natural language and well generate the query for you."
/>
{copilotTeachingBubbleVisible && (
<TeachingBubble
@@ -343,7 +379,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
{isGeneratingQuery && <Spinner style={{ marginLeft: 8 }} />}
{showSamplePrompts && (
<Callout
styles={{ root: { minWidth: 400 } }}
styles={{ root: { minWidth: 400, maxWidth: "70vw" } }}
target="#naturalLanguageInput"
isBeakVisible={false}
onDismiss={() => setShowSamplePrompts(false)}
@@ -375,7 +411,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
setShowSamplePrompts(false);
inputEdited.current = true;
}}
onRenderIcon={() => <Image src={RecentIcon} />}
onRenderIcon={() => <Image src={RecentIcon} styles={{ root: { overflow: "unset" } }} />}
styles={promptStyles}
>
{history}
@@ -451,7 +487,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
</Link>
{showErrorMessageBar && (
<MessageBar messageBarType={MessageBarType.error}>
We ran into an error and were not able to execute query.
{errorMessage ? errorMessage : "We ran into an error and were not able to execute query."}
</MessageBar>
)}
{showInvalidQueryMessageBar && (
@@ -489,7 +525,10 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
description: "",
userPrompt: userPrompt,
},
explorer: explorer,
explorer,
databaseId,
containerId,
mode: isSampleCopilotActive ? "Sample" : "User",
});
}}
directionalHint={DirectionalHint.topCenter}
@@ -499,7 +538,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
<Link
onClick={() => {
setShowCallout(false);
useQueryCopilot.getState().openFeedbackModal(generatedQuery, true, userPrompt);
openFeedbackModal(generatedQuery, true, userPrompt);
}}
>
more feedback?
@@ -524,7 +563,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
iconProps={{ iconName: dislikeQuery === true ? "DislikeSolid" : "Dislike" }}
onClick={() => {
if (!dislikeQuery) {
useQueryCopilot.getState().openFeedbackModal(generatedQuery, false, userPrompt);
openFeedbackModal(generatedQuery, false, userPrompt);
setLikeQuery(false);
}
setDislikeQuery(!dislikeQuery);
@@ -550,7 +589,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
</CommandBarButton>
</Stack>
)}
<WelcomeModal visible={isWelcomModalVisible()} />
<WelcomeModal visible={useQueryCopilot.getState().showWelcomeModal} />
{isSamplePromptsOpen && <SamplePrompts sampleProps={sampleProps} />}
{query !== "" && query.trim().length !== 0 && (
<DeletePopup

View File

@@ -1,5 +1,6 @@
/* eslint-disable no-console */
import { Stack } from "@fluentui/react";
import { QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants";
import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
@@ -11,6 +12,7 @@ import { QueryCopilotResults } from "Explorer/QueryCopilot/Shared/QueryCopilotRe
import { userContext } from "UserContext";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import { useSidePanel } from "hooks/useSidePanel";
import { ReactTabKind, TabsState, useTabs } from "hooks/useTabs";
import React, { useState } from "react";
import SplitterLayout from "react-splitter-layout";
import QueryCommandIcon from "../../../images/CopilotCommand.svg";
@@ -28,13 +30,14 @@ export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: Query
? StringUtility.toBoolean(cachedCopilotToggleStatus)
: true;
const [copilotActive, setCopilotActive] = useState<boolean>(copilotInitialActive);
const [tabActive, setTabActive] = useState<boolean>(true);
const getCommandbarButtons = (): CommandButtonComponentProps[] => {
const executeQueryBtnLabel = selectedQuery ? "Execute Selection" : "Execute Query";
const executeQueryBtn = {
iconSrc: ExecuteQueryIcon,
iconAlt: executeQueryBtnLabel,
onCommandClick: () => OnExecuteQueryClick(),
onCommandClick: () => OnExecuteQueryClick(useQueryCopilot),
commandButtonLabel: executeQueryBtnLabel,
ariaLabel: executeQueryBtnLabel,
hasPopup: false,
@@ -73,10 +76,13 @@ export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: Query
React.useEffect(() => {
return () => {
const commandbarButtons = getCommandbarButtons();
commandbarButtons.pop();
commandbarButtons.map((props: CommandButtonComponentProps) => (props.disabled = true));
useCommandBar.getState().setContextButtons(commandbarButtons);
useTabs.subscribe((state: TabsState) => {
if (state.activeReactTab === ReactTabKind.QueryCopilot) {
setTabActive(true);
} else {
setTabActive(false);
}
});
};
}, []);
@@ -88,8 +94,13 @@ export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: Query
return (
<Stack className="tab-pane" style={{ width: "100%" }}>
<div style={isGeneratingQuery ? { height: "100%" } : { overflowY: "auto", height: "100%" }}>
{copilotActive && (
<QueryCopilotPromptbar explorer={explorer} toggleCopilot={toggleCopilot}></QueryCopilotPromptbar>
{tabActive && copilotActive && (
<QueryCopilotPromptbar
explorer={explorer}
toggleCopilot={toggleCopilot}
databaseId={QueryCopilotSampleDatabaseId}
containerId={QueryCopilotSampleContainerId}
></QueryCopilotPromptbar>
)}
<Stack className="tabPaneContentContainer">
<SplitterLayout percentage={true} vertical={true} primaryIndex={0} primaryMinSize={30} secondaryMinSize={70}>

View File

@@ -7,6 +7,11 @@ import { getCommonQueryOptions } from "Common/dataAccess/queryDocuments";
import DocumentId from "Explorer/Tree/DocumentId";
import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
export interface SuggestedPrompt {
id: number;
text: string;
}
export const querySampleDocuments = (query: string, options: FeedOptions): QueryIterator<ItemDefinition & Resource> => {
options = getCommonQueryOptions(options);
return sampleDataClient()
@@ -33,3 +38,19 @@ export const readSampleDocument = async (documentId: DocumentId): Promise<Item>
clearMessage();
}
};
export const getSampleDatabaseSuggestedPrompts = (): SuggestedPrompt[] => {
return [
{ 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"' },
];
};
export const getSuggestedPrompts = (): SuggestedPrompt[] => {
return [
{ id: 1, text: "Show the first 10 items" },
{ id: 2, text: 'Count all the items in my data as "numItems"' },
{ id: 3, text: "Find the oldest item added to my collection" },
];
};

View File

@@ -1,4 +1,3 @@
import { QueryCopilotSampleContainerSchema, ShortenedQueryCopilotSampleContainerSchema } from "Common/Constants";
import { handleError } from "Common/ErrorHandlingUtils";
import { createUri } from "Common/UrlUtility";
import Explorer from "Explorer/Explorer";
@@ -37,9 +36,6 @@ describe("Query Copilot Client", () => {
userPrompt: "UserPrompt",
description: "Description",
contact: "Contact",
containerSchema: userContext.features.enableCopilotFullSchema
? QueryCopilotSampleContainerSchema
: ShortenedQueryCopilotSampleContainerSchema,
};
const mockStore = useQueryCopilot.getState();
@@ -52,13 +48,16 @@ describe("Query Copilot Client", () => {
const feedbackUri = userContext.features.disableCopilotPhoenixGateaway
? createUri("https://copilotorchestrater.azurewebsites.net/", "feedback")
: createUri(useQueryCopilot.getState().notebookServerInfo.notebookServerEndpoint, "feedback");
: createUri(useQueryCopilot.getState().notebookServerInfo.notebookServerEndpoint, "public/feedback");
it("should call fetch with the payload with like", async () => {
const mockFetch = jest.fn().mockResolvedValueOnce({});
globalThis.fetch = mockFetch;
await SubmitFeedback({
databaseId: "test",
containerId: "test",
mode: "User",
params: {
likeQuery: true,
generatedQuery: "GeneratedQuery",
@@ -91,6 +90,9 @@ describe("Query Copilot Client", () => {
globalThis.fetch = mockFetch;
await SubmitFeedback({
databaseId: "test",
containerId: "test",
mode: "User",
params: {
likeQuery: false,
generatedQuery: "GeneratedQuery",
@@ -108,6 +110,7 @@ describe("Query Copilot Client", () => {
headers: {
"content-type": "application/json",
"x-ms-correlationid": "mocked-correlation-id",
Authorization: "token mocked-token",
},
}),
);
@@ -120,6 +123,9 @@ describe("Query Copilot Client", () => {
globalThis.fetch = jest.fn().mockRejectedValueOnce(new Error("Mock error"));
await SubmitFeedback({
databaseId: "test",
containerId: "test",
mode: "User",
params: {
likeQuery: true,
generatedQuery: "GeneratedQuery",

View File

@@ -1,28 +1,192 @@
import { FeedOptions } from "@azure/cosmos";
import {
Areas,
ConnectionStatusType,
ContainerStatusType,
HttpStatusCodes,
PoolIdType,
QueryCopilotSampleContainerId,
QueryCopilotSampleContainerSchema,
ShortenedQueryCopilotSampleContainerSchema,
} from "Common/Constants";
import { getErrorMessage, handleError } from "Common/ErrorHandlingUtils";
import { getErrorMessage, getErrorStack, handleError } from "Common/ErrorHandlingUtils";
import { shouldEnableCrossPartitionKey } from "Common/HeadersUtility";
import { MinimalQueryIterator } from "Common/IteratorUtilities";
import { createUri } from "Common/UrlUtility";
import { queryDocumentsPage } from "Common/dataAccess/queryDocumentsPage";
import { QueryResults } from "Contracts/ViewModels";
import { configContext } from "ConfigContext";
import {
ContainerConnectionInfo,
CopilotEnabledConfiguration,
FeatureRegistration,
IProvisionData,
} from "Contracts/DataModels";
import { AuthorizationTokenHeaderMetadata, QueryResults } from "Contracts/ViewModels";
import { useDialog } from "Explorer/Controls/Dialog";
import Explorer from "Explorer/Explorer";
import { querySampleDocuments } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { FeedbackParams, GenerateSQLQueryResponse } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceStart, traceSuccess } from "Shared/Telemetry/TelemetryProcessor";
import { userContext } from "UserContext";
import { getAuthorizationHeader } from "Utils/AuthorizationUtils";
import { queryPagesUntilContentPresent } from "Utils/QueryUtils";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
import { useTabs } from "hooks/useTabs";
import * as StringUtility from "../../../Shared/StringUtility";
async function fetchWithTimeout(
url: string,
headers: {
[x: string]: string;
},
) {
const timeout = 10000;
const options = { timeout };
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
const response = await window.fetch(url, {
headers,
...options,
signal: controller.signal,
});
clearTimeout(id);
return response;
}
export const isCopilotFeatureRegistered = async (subscriptionId: string): Promise<boolean> => {
const api_version = "2021-07-01";
const url = `${configContext.ARM_ENDPOINT}/subscriptions/${subscriptionId}/providers/Microsoft.Features/featureProviders/Microsoft.DocumentDB/subscriptionFeatureRegistrations/MicrosoftCopilotForAzureInCDB?api-version=${api_version}`;
const authorizationHeader: AuthorizationTokenHeaderMetadata = getAuthorizationHeader();
const headers = { [authorizationHeader.header]: authorizationHeader.token };
let response;
try {
response = await fetchWithTimeout(url, headers);
} catch (error) {
return false;
}
if (!response?.ok) {
return false;
}
const featureRegistration = (await response?.json()) as FeatureRegistration;
return featureRegistration?.properties?.state === "Registered";
};
export const getCopilotEnabled = async (): Promise<boolean> => {
const url = `${configContext.BACKEND_ENDPOINT}/api/portalsettings/querycopilot`;
const authorizationHeader: AuthorizationTokenHeaderMetadata = getAuthorizationHeader();
const headers = { [authorizationHeader.header]: authorizationHeader.token };
let response;
try {
response = await fetchWithTimeout(url, headers);
} catch (error) {
return false;
}
if (!response?.ok) {
return false;
}
const copilotPortalConfiguration = (await response?.json()) as CopilotEnabledConfiguration;
return copilotPortalConfiguration?.isEnabled;
};
export const allocatePhoenixContainer = async ({
explorer,
databaseId,
containerId,
mode,
}: {
explorer: Explorer;
databaseId: string;
containerId: string;
mode: string;
}): Promise<void> => {
try {
if (
useQueryCopilot.getState().containerStatus.status !== ContainerStatusType.Active &&
!userContext.features.disableCopilotPhoenixGateaway
) {
await explorer.allocateContainer(PoolIdType.QueryCopilot, mode);
} else {
const currentAllocatedSchemaInfo = useQueryCopilot.getState().schemaAllocationInfo;
if (
currentAllocatedSchemaInfo.databaseId !== databaseId ||
currentAllocatedSchemaInfo.containerId !== containerId
) {
await resetPhoenixContainerSchema({ explorer, databaseId, containerId, mode });
}
}
useQueryCopilot.getState().setSchemaAllocationInfo({
databaseId,
containerId,
});
} catch (error) {
traceFailure(Action.PhoenixConnection, {
dataExplorerArea: Areas.Copilot,
status: error.status,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
});
useQueryCopilot.getState().resetContainerConnection();
if (error?.status === HttpStatusCodes.Forbidden && error.message) {
useDialog.getState().showOkModalDialog("Connection Failed", `${error.message}`);
} else {
useDialog
.getState()
.showOkModalDialog(
"Connection Failed",
"We are unable to connect to the temporary workspace. Please try again in a few minutes. If the error persists, file a support ticket.",
);
}
} finally {
useTabs.getState().setIsTabExecuting(false);
}
};
export const resetPhoenixContainerSchema = async ({
explorer,
databaseId,
containerId,
mode,
}: {
explorer: Explorer;
databaseId: string;
containerId: string;
mode: string;
}): Promise<void> => {
try {
const provisionData: IProvisionData = {
poolId: PoolIdType.QueryCopilot,
databaseId: databaseId,
containerId: containerId,
mode: mode,
};
const connectionInfo = await explorer.phoenixClient.allocateContainer(provisionData);
const connectionStatus: ContainerConnectionInfo = {
status: ConnectionStatusType.Connecting,
};
await explorer.setNotebookInfo(false, connectionInfo, connectionStatus);
} catch (error) {
traceFailure(Action.PhoenixConnection, {
dataExplorerArea: Areas.Copilot,
status: error.status,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
});
throw error;
}
};
export const SendQueryRequest = async ({
userPrompt,
explorer,
@@ -51,7 +215,7 @@ export const SendQueryRequest = async ({
const queryUri = userContext.features.disableCopilotPhoenixGateaway
? createUri("https://copilotorchestrater.azurewebsites.net/", "generateSQLQuery")
: createUri(serverInfo.notebookServerEndpoint, "generateSQLQuery");
: createUri(serverInfo.notebookServerEndpoint, "public/generateSQLQuery");
const payload = {
containerSchema: userContext.features.enableCopilotFullSchema
@@ -106,16 +270,19 @@ export const SendQueryRequest = async ({
export const SubmitFeedback = async ({
params,
explorer,
databaseId,
containerId,
mode,
}: {
params: FeedbackParams;
explorer: Explorer;
databaseId: string;
containerId: string;
mode: string;
}): Promise<void> => {
try {
const { likeQuery, generatedQuery, userPrompt, description, contact } = params;
const payload = {
containerSchema: userContext.features.enableCopilotFullSchema
? QueryCopilotSampleContainerSchema
: ShortenedQueryCopilotSampleContainerSchema,
like: likeQuery ? "like" : "dislike",
generatedSql: generatedQuery,
userPrompt,
@@ -126,17 +293,18 @@ export const SubmitFeedback = async ({
useQueryCopilot.getState().containerStatus.status !== ContainerStatusType.Active &&
!userContext.features.disableCopilotPhoenixGateaway
) {
await explorer.allocateContainer(PoolIdType.QueryCopilot);
await allocatePhoenixContainer({ explorer, databaseId, containerId, mode });
}
const serverInfo = useQueryCopilot.getState().notebookServerInfo;
const feedbackUri = userContext.features.disableCopilotPhoenixGateaway
? createUri("https://copilotorchestrater.azurewebsites.net/", "feedback")
: createUri(serverInfo.notebookServerEndpoint, "feedback");
: createUri(serverInfo.notebookServerEndpoint, "public/feedback");
await fetch(feedbackUri, {
method: "POST",
headers: {
"content-type": "application/json",
"x-ms-correlationid": useQueryCopilot.getState().correlationId,
Authorization: `token ${useQueryCopilot.getState().notebookServerInfo.authToken}`,
},
body: JSON.stringify(payload),
});
@@ -145,7 +313,7 @@ export const SubmitFeedback = async ({
}
};
export const OnExecuteQueryClick = async (): Promise<void> => {
export const OnExecuteQueryClick = async (useQueryCopilot: Partial<QueryCopilotState>): Promise<void> => {
traceStart(Action.ExecuteQueryGeneratedFromQueryCopilot, {
correlationId: useQueryCopilot.getState().correlationId,
userPrompt: useQueryCopilot.getState().userPrompt,
@@ -160,13 +328,14 @@ export const OnExecuteQueryClick = async (): Promise<void> => {
useQueryCopilot.getState().setQueryIterator(queryIterator);
setTimeout(async () => {
await QueryDocumentsPerPage(0, queryIterator);
await QueryDocumentsPerPage(0, queryIterator, useQueryCopilot);
}, 100);
};
export const QueryDocumentsPerPage = async (
firstItemIndex: number,
queryIterator: MinimalQueryIterator,
useQueryCopilot: Partial<QueryCopilotState>,
): Promise<void> => {
try {
useQueryCopilot.getState().setIsExecuting(true);

View File

@@ -32,3 +32,8 @@ export interface FeedbackParams {
export interface QueryCopilotProps {
explorer: Explorer;
}
export interface CopilotSchemaAllocationInfo {
databaseId: string;
containerId: string;
}

View File

@@ -12,7 +12,7 @@ export const QueryCopilotResults: React.FC = (): JSX.Element => {
queryResults={useQueryCopilot.getState().queryResults}
isExecuting={useQueryCopilot.getState().isExecuting}
executeQueryDocumentsPage={(firstItemIndex: number) =>
QueryDocumentsPerPage(firstItemIndex, useQueryCopilot.getState().queryIterator)
QueryDocumentsPerPage(firstItemIndex, useQueryCopilot.getState().queryIterator, useQueryCopilot)
}
/>
);

View File

@@ -18,6 +18,8 @@ exports[`Query copilot tab snapshot test should render with initial input 1`] =
}
>
<QueryCopilotPromptbar
containerId="SampleContainer"
databaseId="CopilotSampleDb"
explorer={
Explorer {
"_isInitializingNotebooks": false,

View File

@@ -1,5 +1,6 @@
export const newDbAndCollectionCommand = `use quickstartDB
db.createCollection('sampleCollection')`;
db.createCollection('sampleCollection')
`;
export const newDbAndCollectionCommandForDisplay = `use quickstartDB // Create new database named 'quickstartDB' or switch to it if it already exists
@@ -16,19 +17,25 @@ export const loadDataCommand = `db.sampleCollection.insertMany([
{title: "War and Peace", author: "Leo Tolstoy", pages: 1392},
{title: "The Odyssey", author: "Homer", pages: 374},
{title: "Ulysses", author: "James Joyce", pages: 730}
])`;
])
`;
export const queriesCommand = `db.sampleCollection.find({author: "George Orwell"})
export const findOrwellCommand = `db.sampleCollection.find({author: "George Orwell"})
`;
export const findOrwellCommandForDisplay = `// Query to find all books written by "George Orwell"
db.sampleCollection.find({author: "George Orwell"})`;
export const findByPagesCommand = `db.sampleCollection.find({pages: {$gt: 500}})
`;
export const findByPagesCommandForDisplay = `// Query to find all books with more than 500 pages
db.sampleCollection.find({pages: {$gt: 500}})
`;
db.sampleCollection.find({}).sort({pages: 1})`;
export const findAndSortCommand = `db.sampleCollection.find({}).sort({pages: 1})
`;
export const queriesCommandForDisplay = `// Query to find all books written by "George Orwell"
db.sampleCollection.find({author: "George Orwell"})
// Query to find all books with more than 500 pages
db.sampleCollection.find({pages: {$gt: 500}})
// Query to find all books and sort them by the number of pages in ascending order
db.sampleCollection.find({}).sort({pages: 1})`;
export const findAndSortCommandForDisplay = `// Query to find all books and sort them by the number of pages in ascending order
db.sampleCollection.find({}).sort({pages: 1})
`;

View File

@@ -9,15 +9,17 @@ import {
Text,
TextField,
} from "@fluentui/react";
import { sendMessage } from "Common/MessageHandler";
import { MessageTypes } from "Contracts/ExplorerContracts";
import { customPivotHeaderRenderer } from "Explorer/Quickstart/Shared/QuickstartRenderUtilities";
import {
findAndSortCommand,
findAndSortCommandForDisplay,
findByPagesCommand,
findByPagesCommandForDisplay,
findOrwellCommand,
findOrwellCommandForDisplay,
loadDataCommand,
newDbAndCollectionCommand,
newDbAndCollectionCommandForDisplay,
queriesCommand,
queriesCommandForDisplay,
} from "Explorer/Quickstart/VCoreMongoQuickstartCommands";
import { useTerminal } from "hooks/useTerminal";
import React, { useState } from "react";
@@ -63,28 +65,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 +93,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 +144,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,23 +188,23 @@ 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>
<DefaultButton
style={{ marginTop: 16, width: 110 }}
onClick={() => useTerminal.getState().sendMessage(queriesCommand)}
onClick={() => useTerminal.getState().sendMessage(findOrwellCommand)}
>
Load data
Try query
</DefaultButton>
<Stack horizontal style={{ marginTop: 16 }}>
<TextField
id="queriesCommand"
id="findOrwellCommand"
multiline
rows={5}
rows={2}
readOnly
defaultValue={queriesCommandForDisplay}
defaultValue={findOrwellCommandForDisplay}
styles={{
root: { width: "90%" },
field: {
@@ -227,13 +218,71 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
iconProps={{
iconName: "Copy",
}}
onClick={() => onCopyBtnClicked("#queriesCommand")}
onClick={() => onCopyBtnClicked("#findOrwellCommand")}
/>
</Stack>
<DefaultButton
style={{ marginTop: 32, width: 110 }}
onClick={() => useTerminal.getState().sendMessage(findByPagesCommand)}
>
Try query
</DefaultButton>
<Stack horizontal style={{ marginTop: 16 }}>
<TextField
id="findByPagesCommand"
multiline
rows={2}
readOnly
defaultValue={findByPagesCommandForDisplay}
styles={{
root: { width: "90%" },
field: {
backgroundColor: "#EEEEEE",
fontFamily:
"Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New",
},
}}
/>
<IconButton
iconProps={{
iconName: "Copy",
}}
onClick={() => onCopyBtnClicked("#findByPagesCommand")}
/>
</Stack>
<DefaultButton
style={{ marginTop: 32, width: 110 }}
onClick={() => useTerminal.getState().sendMessage(findAndSortCommand)}
>
Try query
</DefaultButton>
<Stack horizontal style={{ marginTop: 16 }}>
<TextField
id="findAndSortCommand"
multiline
rows={2}
readOnly
defaultValue={findAndSortCommandForDisplay}
styles={{
root: { width: "90%" },
field: {
backgroundColor: "#EEEEEE",
fontFamily:
"Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New",
},
}}
/>
<IconButton
iconProps={{
iconName: "Copy",
}}
onClick={() => onCopyBtnClicked("#findAndSortCommand")}
/>
</Stack>
</Stack>
</PivotItem>
<PivotItem
headerText="Integrations"
headerText="Next steps"
onRenderItemLink={(props, defaultRenderer) =>
customPivotHeaderRenderer(props, defaultRenderer, currentStep, 4)
}
@@ -242,46 +291,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/cosmos-db/mongodb/vcore/migration-options"
>
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

@@ -19,6 +19,7 @@ import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
import { useCarousel } from "hooks/useCarousel";
import { usePostgres } from "hooks/usePostgres";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import { ReactTabKind, useTabs } from "hooks/useTabs";
import * as React from "react";
import ConnectIcon from "../../../images/Connect_color.svg";
@@ -104,6 +105,12 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
(state) => state.sampleDataResourceTokenCollection,
),
},
{
dispose: useQueryCopilot.subscribe(
() => this.setState({}),
(state) => state.copilotEnabled,
),
},
);
}
@@ -114,9 +121,9 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
private getSplashScreenButtons = (): JSX.Element => {
if (
useDatabases.getState().sampleDataResourceTokenCollection &&
userContext.features.enableCopilot &&
userContext.apiType === "SQL"
userContext.apiType === "SQL" &&
useQueryCopilot.getState().copilotEnabled &&
useDatabases.getState().sampleDataResourceTokenCollection
) {
return (
<Stack style={{ width: "66%", cursor: "pointer", margin: "40px auto" }} tokens={{ childrenGap: 16 }}>

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

@@ -1,10 +1,10 @@
import { ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { QueryConstants } from "Shared/Constants";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
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 NewDocumentIcon from "../../../images/NewDocument.svg";
import UploadIcon from "../../../images/Upload_16x16.svg";
@@ -331,6 +331,7 @@ export default class DocumentsTab extends TabsBase {
this.showPartitionKey = this._shouldShowPartitionKey();
this._isQueryCopilotSampleContainer =
this.collection?.isSampleCollection &&
this.collection?.databaseId === QueryCopilotSampleDatabaseId &&
this.collection?.id() === QueryCopilotSampleContainerId;
}

View File

@@ -1,11 +1,16 @@
import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext";
import { userContext } from "UserContext";
import React from "react";
import * as DataModels from "../../../Contracts/DataModels";
import type { QueryTabOptions } from "../../../Contracts/ViewModels";
import { useTabs } from "../../../hooks/useTabs";
import Explorer from "../../Explorer";
import { IQueryTabComponentProps, ITabAccessor } from "../../Tabs/QueryTab/QueryTabComponent";
import QueryTabComponent, {
IQueryTabComponentProps,
ITabAccessor,
QueryTabFunctionComponent,
} from "../../Tabs/QueryTab/QueryTabComponent";
import TabsBase from "../TabsBase";
import QueryTabComponent from "./QueryTabComponent";
export interface IQueryTabProps {
container: Explorer;
@@ -40,7 +45,13 @@ export class NewQueryTab extends TabsBase {
}
public render(): JSX.Element {
return <QueryTabComponent {...this.iQueryTabComponentProps} />;
return userContext.apiType === "SQL" ? (
<CopilotProvider>
<QueryTabFunctionComponent {...this.iQueryTabComponentProps} />
</CopilotProvider>
) : (
<QueryTabComponent {...this.iQueryTabComponentProps} />
);
}
public onTabClick(): void {

View File

@@ -1,6 +1,16 @@
import { fireEvent, render } from "@testing-library/react";
import QueryTabComponent, { IQueryTabComponentProps } from "Explorer/Tabs/QueryTab/QueryTabComponent";
import { CollectionTabKind } from "Contracts/ViewModels";
import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext";
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
import QueryTabComponent, {
IQueryTabComponentProps,
QueryTabFunctionComponent,
} from "Explorer/Tabs/QueryTab/QueryTabComponent";
import TabsBase from "Explorer/Tabs/TabsBase";
import { updateUserContext, userContext } from "UserContext";
import { mount } from "enzyme";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import { useTabs } from "hooks/useTabs";
import React from "react";
jest.mock("Explorer/Controls/Editor/EditorReact");
@@ -11,9 +21,15 @@ describe("QueryTabComponent", () => {
mockStore.showCopilotSidebar = false;
mockStore.setShowCopilotSidebar = jest.fn();
});
beforeEach(() => jest.clearAllMocks());
afterEach(() => jest.clearAllMocks());
it("should launch Copilot when ALT+C is pressed", () => {
it("should launch conversational Copilot when ALT+C is pressed and when copilot version is 3", () => {
updateUserContext({
features: {
...userContext.features,
copilotVersion: "v3.0",
},
});
const propsMock: Readonly<IQueryTabComponentProps> = {
collection: { databaseId: "CopilotSampleDb" },
onTabAccessor: () => jest.fn(),
@@ -31,4 +47,32 @@ describe("QueryTabComponent", () => {
expect(mockStore.setShowCopilotSidebar).toHaveBeenCalledWith(true);
});
it("copilot should be enabled by default when tab is active", () => {
useQueryCopilot.getState().setCopilotEnabled(true);
useQueryCopilot.getState().setCopilotUserDBEnabled(true);
const activeTab = new TabsBase({
tabKind: CollectionTabKind.Query,
title: "Query",
tabPath: "",
});
activeTab.tabId = "mockTabId";
useTabs.getState().activeTab = activeTab;
const propsMock: Readonly<IQueryTabComponentProps> = {
collection: { databaseId: "CopilotUserDb", id: () => "CopilotUserContainer" },
onTabAccessor: () => jest.fn(),
isExecutionError: false,
tabId: "mockTabId",
tabsBaseInstance: {
updateNavbarWithTabsButtons: () => jest.fn(),
},
} as unknown as IQueryTabComponentProps;
const container = mount(
<CopilotProvider>
<QueryTabFunctionComponent {...propsMock} />
</CopilotProvider>,
);
expect(container.find(QueryCopilotPromptbar).exists()).toBe(true);
});
});

View File

@@ -1,21 +1,29 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-console */
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 { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { QueryConstants } from "Shared/Constants";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
import { TabsState, useTabs } from "hooks/useTabs";
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 QueryCommandIcon from "../../../../images/CopilotCommand.svg";
import LaunchCopilot from "../../../../images/CopilotTabIcon.svg";
import CancelQueryIcon from "../../../../images/Entity_cancel.svg";
import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg";
import SaveQueryIcon from "../../../../images/save-cosmos.svg";
import { NormalizedEventKey, QueryCopilotSampleDatabaseId } from "../../../Common/Constants";
import { NormalizedEventKey } from "../../../Common/Constants";
import { getErrorMessage } from "../../../Common/ErrorHandlingUtils";
import * as HeadersUtility from "../../../Common/HeadersUtility";
import { MinimalQueryIterator } from "../../../Common/IteratorUtilities";
@@ -24,6 +32,8 @@ import { queryDocuments } from "../../../Common/dataAccess/queryDocuments";
import { queryDocumentsPage } from "../../../Common/dataAccess/queryDocumentsPage";
import * as DataModels from "../../../Contracts/DataModels";
import * as ViewModels from "../../../Contracts/ViewModels";
import * as StringUtility from "../../../Shared/StringUtility";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../../UserContext";
import * as QueryUtils from "../../../Utils/QueryUtils";
import { useSidePanel } from "../../../hooks/useSidePanel";
@@ -72,6 +82,9 @@ export interface IQueryTabComponentProps {
isPreferredApiMongoDB?: boolean;
monacoEditorSetting?: string;
viewModelcollection?: ViewModels.Collection;
copilotEnabled?: boolean;
isSampleCopilotActive?: boolean;
copilotStore?: Partial<QueryCopilotState>;
}
interface IQueryTabStates {
@@ -85,8 +98,25 @@ interface IQueryTabStates {
showCopilotSidebar: boolean;
queryCopilotGeneratedQuery: string;
cancelQueryTimeoutID: NodeJS.Timeout;
copilotActive: boolean;
currentTabActive: boolean;
}
export const QueryTabFunctionComponent = (props: IQueryTabComponentProps): any => {
const copilotStore = useCopilotStore();
const copilotGlobalStore = useQueryCopilot();
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
const queryTabProps = {
...props,
copilotEnabled:
copilotGlobalStore.copilotEnabled &&
(copilotGlobalStore.copilotUserDBEnabled || (isSampleCopilotActive && !!userContext.sampleDataConnectionInfo)),
isSampleCopilotActive: isSampleCopilotActive,
copilotStore: copilotStore,
};
return <QueryTabComponent {...queryTabProps}></QueryTabComponent>;
};
export default class QueryTabComponent extends React.Component<IQueryTabComponentProps, IQueryTabStates> {
public queryEditorId: string;
public executeQueryButton: Button;
@@ -113,12 +143,14 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
showCopilotSidebar: useQueryCopilot.getState().showCopilotSidebar,
queryCopilotGeneratedQuery: useQueryCopilot.getState().query,
cancelQueryTimeoutID: undefined,
copilotActive: this._queryCopilotActive(),
currentTabActive: true,
};
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.isCopilotTabActive = userContext.features.copilotVersion === "v3.0";
this.executeQueryButton = {
enabled: !!this.state.sqlQueryEditorContent && this.state.sqlQueryEditorContent.length > 0,
visible: true,
@@ -143,6 +175,19 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
});
}
private _queryCopilotActive(): boolean {
if (this.props.copilotEnabled) {
const cachedCopilotToggleStatus: string = localStorage.getItem(
`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`,
);
const copilotInitialActive: boolean = cachedCopilotToggleStatus
? StringUtility.toBoolean(cachedCopilotToggleStatus)
: true;
return copilotInitialActive;
}
return false;
}
public onCloseClick(isClicked: boolean): void {
this.isCloseClicked = isClicked;
if (useQueryCopilot.getState().wasCopilotUsed && this.isCopilotTabActive) {
@@ -167,6 +212,16 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
setTimeout(async () => {
await this._executeQueryDocumentsPage(0);
}, 100);
if (this.state.copilotActive) {
const query = this.state.sqlQueryEditorContent.split("\r\n")?.pop();
const isqueryEdited = this.props.copilotStore.generatedQuery && this.props.copilotStore.generatedQuery !== query;
if (isqueryEdited) {
TelemetryProcessor.traceMark(Action.QueryEdited, {
databaseName: this.props.collection.databaseId,
collectionId: this.props.collection.id(),
});
}
}
};
public onSaveQueryClick = (): void => {
@@ -326,7 +381,9 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
buttons.push({
iconSrc: ExecuteQueryIcon,
iconAlt: label,
onCommandClick: this.isCopilotTabActive ? () => OnExecuteQueryClick() : this.onExecuteQueryClick,
onCommandClick: this.props.isSampleCopilotActive
? () => OnExecuteQueryClick(this.props.copilotStore)
: this.onExecuteQueryClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
@@ -380,6 +437,20 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
buttons.push(launchCopilotButton);
}
if (this.props.copilotEnabled) {
const toggleCopilotButton = {
iconSrc: QueryCommandIcon,
iconAlt: "Copilot",
onCommandClick: () => {
this._toggleCopilot(!this.state.copilotActive);
},
commandButtonLabel: this.state.copilotActive ? "Disable Copilot" : "Enable Copilot",
ariaLabel: "Copilot",
hasPopup: false,
};
buttons.push(toggleCopilotButton);
}
if (!this.props.isPreferredApiMongoDB && this.state.isExecuting) {
const label = "Cancel query";
buttons.push({
@@ -395,11 +466,31 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
return buttons;
}
private _toggleCopilot = (active: boolean) => {
this.setState({ copilotActive: active });
useQueryCopilot.getState().setCopilotEnabledforExecution(active);
localStorage.setItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, active.toString());
TelemetryProcessor.traceSuccess(active ? Action.ActivateQueryCopilot : Action.DeactivateQueryCopilot, {
databaseName: this.props.collection.databaseId,
collectionId: this.props.collection.id(),
});
};
componentDidUpdate = (_prevProps: IQueryTabComponentProps, prevState: IQueryTabStates): void => {
if (prevState.copilotActive !== this.state.copilotActive) {
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}
};
public onChangeContent(newContent: string): void {
this.setState({
sqlQueryEditorContent: newContent,
queryCopilotGeneratedQuery: "",
});
if (this.state.copilotActive) {
this.props.copilotStore?.setQuery(newContent);
}
if (this.isPreferredApiMongoDB) {
if (newContent.length > 0) {
this.executeQueryButton = {
@@ -434,6 +525,10 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
: useQueryCopilot.getState().setSelectedQuery("");
}
if (this.state.copilotActive) {
this.props.copilotStore?.setSelectedQuery(selectedContent);
}
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}
@@ -442,6 +537,10 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
return this.state.queryCopilotGeneratedQuery;
}
if (this.state.copilotActive) {
return this.props.copilotStore?.query;
}
return this.state.sqlQueryEditorContent;
}
@@ -452,12 +551,15 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
private unsubscribeCopilotSidebar: () => void;
componentDidMount(): void {
this.unsubscribeCopilotSidebar = useQueryCopilot.subscribe((state: QueryCopilotState) => {
if (this.state.showCopilotSidebar !== state.showCopilotSidebar) {
this.setState({ showCopilotSidebar: state.showCopilotSidebar });
}
if (this.state.queryCopilotGeneratedQuery !== state.query) {
this.setState({ queryCopilotGeneratedQuery: state.query });
useTabs.subscribe((state: TabsState) => {
if (this.state.currentTabActive && state.activeTab?.tabId !== this.props.tabId) {
this.setState({
currentTabActive: false,
});
} else if (!this.state.currentTabActive && state.activeTab?.tabId === this.props.tabId) {
this.setState({
currentTabActive: true,
});
}
});
@@ -466,7 +568,6 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
}
componentWillUnmount(): void {
this.unsubscribeCopilotSidebar();
document.removeEventListener("keydown", this.handleCopilotKeyDown);
}
@@ -474,6 +575,14 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
return (
<Fragment>
<div className="tab-pane" id={this.props.tabId} role="tabpanel">
{this.props.copilotEnabled && this.state.currentTabActive && this.state.copilotActive && (
<QueryCopilotPromptbar
explorer={this.props.collection.container}
toggleCopilot={this._toggleCopilot}
databaseId={this.props.collection.databaseId}
containerId={this.props.collection.id()}
></QueryCopilotPromptbar>
)}
<div className="tabPaneContentContainer">
<SplitterLayout vertical={true} primaryIndex={0} primaryMinSize={100} secondaryMinSize={200}>
<Fragment>
@@ -482,6 +591,7 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
language={"sql"}
content={this.setEditorContent()}
isReadOnly={false}
wordWrap={"on"}
ariaLabel={"Editing Query"}
lineNumbers={"on"}
onContentChanged={(newContent: string) => this.onChangeContent(newContent)}
@@ -489,8 +599,21 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
/>
</div>
</Fragment>
{this.isCopilotTabActive ? (
<QueryCopilotResults />
{this.props.isSampleCopilotActive ? (
<QueryResultSection
isMongoDB={this.props.isPreferredApiMongoDB}
queryEditorContent={this.state.sqlQueryEditorContent}
error={this.props.copilotStore?.errorMessage}
queryResults={this.props.copilotStore?.queryResults}
isExecuting={this.props.copilotStore?.isExecuting}
executeQueryDocumentsPage={(firstItemIndex: number) =>
QueryDocumentsPerPage(
firstItemIndex,
this.props.copilotStore.queryIterator,
this.props.copilotStore,
)
}
/>
) : (
<QueryResultSection
isMongoDB={this.props.isPreferredApiMongoDB}
@@ -506,6 +629,14 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
</SplitterLayout>
</div>
</div>
{this.props.copilotEnabled && this.props.copilotStore?.showFeedbackModal && (
<QueryCopilotFeedbackModal
explorer={this.props.collection.container}
databaseId={this.props.collection.databaseId}
containerId={this.props.collection.id()}
mode={this.props.isSampleCopilotActive ? "Sample" : "User"}
/>
)}
</Fragment>
);
}

View File

@@ -11,7 +11,6 @@ import { QuickstartTab } from "Explorer/Tabs/QuickstartTab";
import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab";
import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab";
import { userContext } from "UserContext";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import { useTeachingBubble } from "hooks/useTeachingBubble";
import ko from "knockout";
import React, { MutableRefObject, useEffect, useRef, useState } from "react";
@@ -170,7 +169,7 @@ const CloseButton = ({
onClick={(event: React.MouseEvent<HTMLSpanElement, MouseEvent>) => {
event.stopPropagation();
tab ? tab.onCloseTabButtonClick() : useTabs.getState().closeReactTab(tabKind);
tabKind === ReactTabKind.QueryCopilot && useQueryCopilot.getState().resetQueryCopilotStates();
// tabKind === ReactTabKind.QueryCopilot && useQueryCopilot.getState().resetQueryCopilotStates();
}}
tabIndex={active ? 0 : undefined}
onKeyPress={({ nativeEvent: e }) => tab.onKeyPressClose(undefined, e)}
@@ -256,6 +255,7 @@ const isQueryErrorThrown = (tab?: Tab, tabKind?: ReactTabKind): boolean => {
};
const getReactTabContent = (activeReactTab: ReactTabKind, explorer: Explorer): JSX.Element => {
// eslint-disable-next-line no-console
switch (activeReactTab) {
case ReactTabKind.Connect:
return userContext.apiType === "VCoreMongo" ? (

View File

@@ -3,15 +3,15 @@ import * as Constants from "../../Common/Constants";
import * as ThemeUtility from "../../Common/ThemeUtility";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { useNotificationConsole } from "../../hooks/useNotificationConsole";
import { useTabs } from "../../hooks/useTabs";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { useNotificationConsole } from "../../hooks/useNotificationConsole";
import { useTabs } from "../../hooks/useTabs";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { useSelectedNode } from "../useSelectedNode";
import { WaitsForTemplateViewModel } from "../WaitsForTemplateViewModel";
import { useSelectedNode } from "../useSelectedNode";
// TODO: Use specific actions for logging telemetry data
export default class TabsBase extends WaitsForTemplateViewModel {
private static id = 0;

View File

@@ -177,8 +177,8 @@ export default class Collection implements ViewModels.Collection {
this.children.subscribe(() => {
// update the database in zustand store
const database = this.getDatabase();
database.collections(
database.collections()?.map((collection) => {
database?.collections(
database?.collections()?.map((collection) => {
if (collection.id() === this.id()) {
return this;
}

View File

@@ -156,6 +156,6 @@ export default class ResourceTokenCollection implements ViewModels.CollectionBas
}
public getDatabase(): ViewModels.Database {
return useDatabases.getState().findDatabaseWithId(this.databaseId);
return useDatabases.getState().findDatabaseWithId(this.databaseId, this.isSampleCollection);
}
}

View File

@@ -1,6 +1,7 @@
import { Callout, DirectionalHint, ICalloutProps, ILinkProps, Link, Stack, Text } from "@fluentui/react";
import { SampleDataTree } from "Explorer/Tree/SampleDataTree";
import { getItemName } from "Utils/APITypeUtils";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import * as React from "react";
import shallow from "zustand/shallow";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
@@ -767,7 +768,8 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ container }: Resourc
};
const dataRootNode = buildDataTree();
const isSampleDataEnabled = userContext.sampleDataConnectionInfo && userContext.apiType === "SQL";
const isSampleDataEnabled =
useQueryCopilot().copilotEnabled && userContext.sampleDataConnectionInfo && userContext.apiType === "SQL";
const sampleDataResourceTokenCollection = useDatabases((state) => state.sampleDataResourceTokenCollection);
return (

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

@@ -14,7 +14,7 @@ interface DatabasesState {
deleteDatabase: (database: ViewModels.Database) => void;
clearDatabases: () => void;
isSaveQueryEnabled: () => boolean;
findDatabaseWithId: (databaseId: string) => ViewModels.Database;
findDatabaseWithId: (databaseId: string, isSampleDatabase?: boolean) => ViewModels.Database;
isLastNonEmptyDatabase: () => boolean;
findCollection: (databaseId: string, collectionId: string) => ViewModels.Collection;
isLastCollection: () => boolean;
@@ -33,7 +33,7 @@ export const useDatabases: UseStore<DatabasesState> = create((set, get) => ({
updateDatabase: (updatedDatabase: ViewModels.Database) =>
set((state) => {
const updatedDatabases = state.databases.map((database: ViewModels.Database) => {
if (database.id() === updatedDatabase.id()) {
if (database?.id() === updatedDatabase?.id()) {
return updatedDatabase;
}
@@ -67,7 +67,9 @@ export const useDatabases: UseStore<DatabasesState> = create((set, get) => ({
}
return true;
},
findDatabaseWithId: (databaseId: string) => get().databases.find((db) => databaseId === db.id()),
findDatabaseWithId: (databaseId: string, isSampleDatabase?: boolean) => {
return get().databases.find((db) => databaseId === db.id() && db.isSampleDB === isSampleDatabase);
},
isLastNonEmptyDatabase: () => {
const databases = get().databases;
return databases.length === 1 && (databases[0].collections()?.length > 0 || !!databases[0].offer());

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();

View File

@@ -16,9 +16,8 @@ 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";
@@ -26,8 +25,6 @@ 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 "../less/documentDB.less";
import "../less/forms.less";
import "../less/infobox.less";
@@ -37,6 +34,7 @@ import "../less/resourceTree.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";
@@ -56,10 +54,10 @@ import { SidePanel } from "./Explorer/Panes/PanelContainerComponent";
import "./Explorer/SplashScreen/SplashScreen.less";
import { Tabs } from "./Explorer/Tabs/Tabs";
import "./Libs/jquery";
import { appThemeFabric } from "./Platform/Fabric/FabricTheme";
import "./Shared/appInsights";
import { useConfig } from "./hooks/useConfig";
import { useKnockoutExplorer } from "./hooks/useKnockoutExplorer";
import { appThemeFabric } from "./Platform/Fabric/FabricTheme";
initializeIcons();
@@ -67,11 +65,11 @@ const App: React.FunctionComponent = () => {
const [isLeftPaneExpanded, setIsLeftPaneExpanded] = useState<boolean>(true);
const isCarouselOpen = useCarousel((state) => state.shouldOpen);
const isCopilotCarouselOpen = useCarousel((state) => state.showCopilotCarousel);
const shouldShowModal = useQueryCopilot((state) => state.showFeedbackModal);
const config = useConfig();
if (config?.platform === Platform.Fabric) {
loadTheme(appThemeFabric);
import("../less/documentDBFabric.less");
}
StyleConstants.updateStyles();
const explorer = useKnockoutExplorer(config?.platform);
@@ -91,7 +89,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 */}
@@ -136,25 +133,12 @@ const App: React.FunctionComponent = () => {
{<SQLQuickstartTutorial />}
{<MongoQuickstartTutorial />}
{<QueryCopilotCarousel isOpen={isCopilotCarouselOpen} explorer={explorer} />}
{shouldShowModal && <QueryCopilotFeedbackModal explorer={explorer} />}
</div>
);
};
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

@@ -30,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,
@@ -38,6 +50,7 @@ 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 (
@@ -53,6 +66,14 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
id="connectWithConnectionString"
onSubmit={async (event) => {
event.preventDefault();
setErrorMessage("");
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
setErrorMessage(
"This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.",
);
return;
}
if (isResourceTokenConnectionString(connectionString)) {
setAuthType(AuthType.ResourceToken);
@@ -76,10 +97,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" />

View File

@@ -66,7 +66,7 @@
}
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails {
bottom: 24px;
width: 145px;
width: 165px;
visibility: hidden;
background-color: #393939;
color: #ffffff;

View File

@@ -14,6 +14,7 @@ export type Features = {
readonly enableTtl: boolean;
readonly executeSproc: boolean;
readonly enableAadDataPlane: boolean;
readonly enableResourceGraph: boolean;
readonly enableKoResourceTree: boolean;
readonly hostedDataExplorer: boolean;
readonly junoEndpoint?: string;
@@ -73,6 +74,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
canExceedMaximumValue: "true" === get("canexceedmaximumvalue"),
cosmosdb: "true" === get("cosmosdb"),
enableAadDataPlane: "true" === get("enableaaddataplane"),
enableResourceGraph: "true" === get("enableresourcegraph"),
enableChangeFeedPolicy: "true" === get("enablechangefeedpolicy"),
enableFixedCollectionWithSharedThroughput: "true" === get("enablefixedcollectionwithsharedthroughput"),
enableKOPanel: "true" === get("enablekopanel"),
@@ -110,7 +112,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
enableLegacyMongoShellV2Debug: "true" === get("enablelegacymongoshellv2debug"),
loadLegacyMongoShellFromBE: "true" === get("loadlegacymongoshellfrombe"),
enableCopilot: "true" === get("enablecopilot", "true"),
copilotVersion: get("copilotversion") ?? "v1.0",
copilotVersion: get("copilotversion") ?? "v2.0",
disableCopilotPhoenixGateaway: "true" === get("disablecopilotphoenixgateaway"),
enableCopilotFullSchema: "true" === get("enablecopilotfullschema", "true"),
copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"),

View File

@@ -133,6 +133,10 @@ export enum Action {
CompleteUITour,
OpenQueryCopilotFromSplashScreen,
OpenQueryCopilotFromNewQuery,
ActivateQueryCopilot,
DeactivateQueryCopilot,
QueryGenerationFromCopilotPrompt,
QueryEdited,
ExecuteQueryGeneratedFromQueryCopilot,
}

View File

@@ -10,9 +10,13 @@ import { userContext } from "UserContext";
export class JupyterLabAppFactory {
private isShellStarted: boolean | undefined;
private checkShellStarted: ((content: string | undefined) => void) | undefined;
private onShellExited: () => void;
private onShellExited: (restartShell: boolean) => void;
private restartShell: boolean;
private isShellExited(content: string | undefined) {
if (userContext.apiType === "VCoreMongo" && content?.includes("MongoServerError: Invalid key")) {
this.restartShell = true;
}
return content?.includes("cosmosuser@");
}
@@ -32,10 +36,11 @@ export class JupyterLabAppFactory {
this.isShellStarted = content?.includes("Enter password");
}
constructor(closeTab: () => void) {
constructor(closeTab: (restartShell: boolean) => void) {
this.onShellExited = closeTab;
this.isShellStarted = false;
this.checkShellStarted = undefined;
this.restartShell = false;
switch (userContext.apiType) {
case "Mongo":
@@ -69,7 +74,7 @@ export class JupyterLabAppFactory {
if (!this.isShellStarted) {
this.checkShellStarted(content);
} else if (this.isShellExited(content)) {
this.onShellExited();
this.onShellExited(this.restartShell);
}
}
}, this);

View File

@@ -11,6 +11,8 @@ import { JupyterLabAppFactory } from "./JupyterLabAppFactory";
import { TerminalProps } from "./TerminalProps";
import "./index.css";
let session: ITerminalConnection | undefined;
const createServerSettings = (props: TerminalProps): ServerConnection.ISettings => {
let body: BodyInit | undefined;
let headers: HeadersInit | undefined;
@@ -49,7 +51,7 @@ const createServerSettings = (props: TerminalProps): ServerConnection.ISettings
return ServerConnection.makeSettings(options);
};
const initTerminal = async (props: TerminalProps): Promise<ITerminalConnection | undefined> => {
const initTerminal = async (props: TerminalProps): Promise<void> => {
// Initialize userContext (only properties which are needed by TelemetryProcessor)
updateUserContext({
subscriptionId: props.subscriptionId,
@@ -59,28 +61,37 @@ const initTerminal = async (props: TerminalProps): Promise<ITerminalConnection |
});
const serverSettings = createServerSettings(props);
createTerminalApp(props, serverSettings);
};
const createTerminalApp = async (props: TerminalProps, serverSettings: ServerConnection.ISettings) => {
const data = { baseUrl: serverSettings.baseUrl };
const startTime = TelemetryProcessor.traceStart(Action.OpenTerminal, data);
try {
const session = await new JupyterLabAppFactory(() => closeTab(props.tabId)).createTerminalApp(serverSettings);
session = await new JupyterLabAppFactory((restartShell: boolean) =>
closeTab(props, serverSettings, restartShell),
).createTerminalApp(serverSettings);
TelemetryProcessor.traceSuccess(Action.OpenTerminal, data, startTime);
return session;
} catch (error) {
TelemetryProcessor.traceFailure(Action.OpenTerminal, data, startTime);
return undefined;
session = undefined;
}
};
const closeTab = (tabId: string): void => {
window.parent.postMessage(
{ type: MessageTypes.CloseTab, data: { tabId: tabId }, signature: "pcIframe" },
window.document.referrer,
);
const closeTab = (props: TerminalProps, serverSettings: ServerConnection.ISettings, restartShell: boolean): void => {
if (restartShell) {
createTerminalApp(props, serverSettings);
} else {
window.parent.postMessage(
{ type: MessageTypes.CloseTab, data: { tabId: props.tabId }, signature: "pcIframe" },
window.document.referrer,
);
}
};
const main = async (): Promise<void> => {
let session: ITerminalConnection | undefined;
postRobot.on(
"props",
{
@@ -91,7 +102,7 @@ const main = async (): Promise<void> => {
// Typescript definition for event is wrong. So read props by casting to <any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const props = (event as any).data as TerminalProps;
session = await initTerminal(props);
await initTerminal(props);
},
);

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;

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,6 +1,9 @@
import { HttpHeaders } from "Common/Constants";
import { QueryRequestOptions, QueryResponse } from "Contracts/AzureResourceGraph";
import useSWR from "swr";
import { configContext } from "../ConfigContext";
import { DatabaseAccount } from "../Contracts/DataModels";
/* eslint-disable @typescript-eslint/no-explicit-any */
interface AccountListResult {
nextLink: string;
@@ -30,10 +33,56 @@ export async function fetchDatabaseAccounts(subscriptionId: string, accessToken:
return accounts.sort((a, b) => a.name.localeCompare(b.name));
}
export async function fetchDatabaseAccountsFromGraph(
subscriptionId: string,
accessToken: string,
): Promise<DatabaseAccount[]> {
const headers = new Headers();
const bearer = `Bearer ${accessToken}`;
headers.append("Authorization", bearer);
headers.append(HttpHeaders.contentType, "application/json");
const databaseAccountsQuery = "resources | where type =~ 'microsoft.documentdb/databaseaccounts'";
const apiVersion = "2021-03-01";
const managementResourceGraphAPIURL = `${configContext.ARM_ENDPOINT}providers/Microsoft.ResourceGraph/resources?api-version=${apiVersion}`;
const databaseAccounts: DatabaseAccount[] = [];
let skipToken: string;
do {
const body = {
query: databaseAccountsQuery,
subscriptions: [subscriptionId],
...(skipToken && {
options: {
$skipToken: skipToken,
} as QueryRequestOptions,
}),
};
const response = await fetch(managementResourceGraphAPIURL, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(await response.text());
}
const queryResponse: QueryResponse = (await response.json()) as QueryResponse;
skipToken = queryResponse.$skipToken;
queryResponse.data?.map((databaseAccount: any) => {
databaseAccounts.push(databaseAccount as DatabaseAccount);
});
} while (skipToken);
return databaseAccounts.sort((a, b) => a.name.localeCompare(b.name));
}
export function useDatabaseAccounts(subscriptionId: string, armToken: string): DatabaseAccount[] | undefined {
const { data } = useSWR(
() => (armToken && subscriptionId ? ["databaseAccounts", subscriptionId, armToken] : undefined),
(_, subscriptionId, armToken) => fetchDatabaseAccounts(subscriptionId, armToken),
(_, subscriptionId, armToken) => fetchDatabaseAccountsFromGraph(subscriptionId, armToken),
);
return data;
}

View File

@@ -1,8 +1,13 @@
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 {
handleRequestDatabaseResourceTokensResponse,
scheduleRefreshDatabaseResourceToken,
} from "Platform/Fabric/FabricUtil";
import { getNetworkSettingsWarningMessage } from "Utils/NetworkUtility";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import { ReactTabKind, useTabs } from "hooks/useTabs";
import { useEffect, useState } from "react";
import { AuthType } from "../AuthType";
@@ -53,19 +58,21 @@ export function useKnockoutExplorer(platform: Platform): Explorer {
userContext.features.phoenixNotebooks = true;
userContext.features.phoenixFeatures = true;
}
let explorer: Explorer;
if (platform === Platform.Hosted) {
const explorer = await configureHosted();
setExplorer(explorer);
explorer = await configureHosted();
} else if (platform === Platform.Emulator) {
const explorer = configureEmulator();
setExplorer(explorer);
explorer = configureEmulator();
} else if (platform === Platform.Portal) {
const explorer = await configurePortal();
setExplorer(explorer);
explorer = await configurePortal();
} else if (platform === Platform.Fabric) {
const explorer = await configureFabric();
setExplorer(explorer);
explorer = await configureFabric();
}
if (explorer && userContext.features.enableCopilot) {
await updateContextForCopilot(explorer);
await updateContextForSampleData(explorer);
}
setExplorer(explorer);
}
};
effect();
@@ -74,9 +81,6 @@ export function useKnockoutExplorer(platform: Platform): Explorer {
useEffect(() => {
if (explorer) {
applyExplorerBindings(explorer);
if (userContext.features.enableCopilot) {
updateContextForSampleData(explorer);
}
}
}, [explorer]);
@@ -98,60 +102,39 @@ async function configureFabric(): Promise<Explorer> {
}
const data: FabricMessage = event.data?.data;
if (!data) {
return;
}
switch (data.type) {
case "initialize": {
explorer = await configureWithFabric(data.message.endpoint);
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 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,
);
}
}
break;
}
case "authorizationToken": {
handleCachedDataMessage(data);
break;
}
case "allResourceTokens": {
// TODO call handleCachedDataMessage when Fabric echoes message id back
handleRequestDatabaseResourceTokensResponse(explorer, data.message as FabricDatabaseConnectionInfo);
break;
}
default:
console.error(`Unknown Fabric message type: ${JSON.stringify(data)}`);
break;
@@ -164,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;
@@ -301,8 +319,9 @@ function configureHostedWithResourceToken(config: ResourceToken): Explorer {
return explorer;
}
function configureWithFabric(documentEndpoint: string): Explorer {
function createExplorerFabric(fabricDatabaseConnectionInfo: FabricDatabaseConnectionInfo): Explorer {
updateUserContext({
fabricDatabaseConnectionInfo,
authType: AuthType.ConnectionString,
databaseAccount: {
id: "",
@@ -311,12 +330,11 @@ function configureWithFabric(documentEndpoint: string): Explorer {
name: "Mounted",
kind: AccountKind.Default,
properties: {
documentEndpoint,
documentEndpoint: fabricDatabaseConnectionInfo.endpoint,
},
},
});
const explorer = new Explorer();
setTimeout(() => explorer.refreshAllDatabases(), 0);
return explorer;
}
@@ -402,9 +420,11 @@ async function configurePortal(): Promise<Explorer> {
updateContextsFromPortalMessage(inputs);
explorer = new Explorer();
resolve(explorer);
if (userContext.apiType === "Postgres") {
explorer.openNPSSurveyDialog();
if (userContext.apiType === "Postgres" || userContext.apiType === "SQL" || userContext.apiType === "Mongo") {
setTimeout(() => explorer.openNPSSurveyDialog(), 3000);
}
if (openAction) {
handleOpenAction(openAction, useDatabases.getState().databases, explorer);
}
@@ -536,12 +556,23 @@ interface PortalMessage {
inputs?: DataExplorerInputsFrame;
}
async function updateContextForCopilot(explorer: Explorer): Promise<void> {
await explorer.configureCopilot();
}
async function updateContextForSampleData(explorer: Explorer): Promise<void> {
if (!userContext.features.enableCopilot) {
const copilotEnabled =
userContext.apiType === "SQL" && userContext.features.enableCopilot && useQueryCopilot.getState().copilotEnabled;
if (!copilotEnabled) {
return;
}
const url = createUri(`${configContext.BACKEND_ENDPOINT}`, `/api/tokens/sampledataconnection`);
const sampleDatabaseEndpoint = useQueryCopilot.getState().copilotUserDBEnabled
? `/api/tokens/sampledataconnection/v2`
: `/api/tokens/sampledataconnection`;
const url = createUri(`${configContext.BACKEND_ENDPOINT}`, sampleDatabaseEndpoint);
const authorizationHeader = getAuthorizationHeader();
const headers = { [authorizationHeader.header]: authorizationHeader.token };

View File

@@ -1,6 +1,6 @@
import { MinimalQueryIterator } from "Common/IteratorUtilities";
import { QueryResults } from "Contracts/ViewModels";
import { CopilotMessage } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { CopilotMessage, CopilotSchemaAllocationInfo } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { guid } from "Explorer/Tables/Utilities";
import { useTabs } from "hooks/useTabs";
import create, { UseStore } from "zustand";
@@ -8,6 +8,8 @@ import * as DataModels from "../Contracts/DataModels";
import { ContainerInfo } from "../Contracts/DataModels";
export interface QueryCopilotState {
copilotEnabled: boolean;
copilotUserDBEnabled: boolean;
generatedQuery: string;
likeQuery: boolean;
userPrompt: string;
@@ -34,14 +36,21 @@ export interface QueryCopilotState {
generatedQueryComments: string;
wasCopilotUsed: boolean;
showWelcomeSidebar: boolean;
showWelcomeModal: boolean;
showCopilotSidebar: boolean;
chatMessages: CopilotMessage[];
shouldIncludeInMessages: boolean;
showExplanationBubble: boolean;
notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo;
containerStatus: ContainerInfo;
schemaAllocationInfo: CopilotSchemaAllocationInfo;
isAllocatingContainer: boolean;
copilotEnabledforExecution: boolean;
getState?: () => QueryCopilotState;
setCopilotEnabled: (copilotEnabled: boolean) => void;
setCopilotUserDBEnabled: (copilotUserDBEnabled: boolean) => void;
openFeedbackModal: (generatedQuery: string, likeQuery: boolean, userPrompt: string) => void;
closeFeedbackModal: () => void;
setHideFeedbackModalForLikedQueries: (hideFeedbackModalForLikedQueries: boolean) => void;
@@ -69,6 +78,7 @@ export interface QueryCopilotState {
setGeneratedQueryComments: (generatedQueryComments: string) => void;
setWasCopilotUsed: (wasCopilotUsed: boolean) => void;
setShowWelcomeSidebar: (showWelcomeSidebar: boolean) => void;
setShowWelcomeModal: (showWelcomeModal: boolean) => void;
setShowCopilotSidebar: (showCopilotSidebar: boolean) => void;
setChatMessages: (chatMessages: CopilotMessage[]) => void;
setShouldIncludeInMessages: (shouldIncludeInMessages: boolean) => void;
@@ -76,6 +86,8 @@ export interface QueryCopilotState {
setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => void;
setContainerStatus: (containerStatus: ContainerInfo) => void;
setIsAllocatingContainer: (isAllocatingContainer: boolean) => void;
setSchemaAllocationInfo: (schemaAllocationInfo: CopilotSchemaAllocationInfo) => void;
setCopilotEnabledforExecution: (copilotEnabledforExecution: boolean) => void;
resetContainerConnection: () => void;
resetQueryCopilotStates: () => void;
@@ -84,6 +96,8 @@ export interface QueryCopilotState {
type QueryCopilotStore = UseStore<QueryCopilotState>;
export const useQueryCopilot: QueryCopilotStore = create((set) => ({
copilotEnabled: false,
copilotUserDBEnabled: false,
generatedQuery: "",
likeQuery: false,
userPrompt: "",
@@ -110,6 +124,7 @@ export const useQueryCopilot: QueryCopilotStore = create((set) => ({
generatedQueryComments: "",
wasCopilotUsed: false,
showWelcomeSidebar: true,
showWelcomeModal: true,
showCopilotSidebar: false,
chatMessages: [],
shouldIncludeInMessages: true,
@@ -124,8 +139,15 @@ export const useQueryCopilot: QueryCopilotStore = create((set) => ({
durationLeftInMinutes: undefined,
phoenixServerInfo: undefined,
},
schemaAllocationInfo: {
databaseId: undefined,
containerId: undefined,
},
isAllocatingContainer: false,
copilotEnabledforExecution: false,
setCopilotEnabled: (copilotEnabled: boolean) => set({ copilotEnabled }),
setCopilotUserDBEnabled: (copilotUserDBEnabled: boolean) => set({ copilotUserDBEnabled }),
openFeedbackModal: (generatedQuery: string, likeQuery: boolean, userPrompt: string) =>
set({ generatedQuery, likeQuery, userPrompt, showFeedbackModal: true }),
closeFeedbackModal: () => set({ showFeedbackModal: false }),
@@ -155,6 +177,7 @@ export const useQueryCopilot: QueryCopilotStore = create((set) => ({
setGeneratedQueryComments: (generatedQueryComments: string) => set({ generatedQueryComments }),
setWasCopilotUsed: (wasCopilotUsed: boolean) => set({ wasCopilotUsed }),
setShowWelcomeSidebar: (showWelcomeSidebar: boolean) => set({ showWelcomeSidebar }),
setShowWelcomeModal: (showWelcomeModal: boolean) => set({ showWelcomeModal }),
setShowCopilotSidebar: (showCopilotSidebar: boolean) => set({ showCopilotSidebar }),
setChatMessages: (chatMessages: CopilotMessage[]) => set({ chatMessages }),
setShouldIncludeInMessages: (shouldIncludeInMessages: boolean) => set({ shouldIncludeInMessages }),
@@ -163,6 +186,8 @@ export const useQueryCopilot: QueryCopilotStore = create((set) => ({
set({ notebookServerInfo }),
setContainerStatus: (containerStatus: ContainerInfo) => set({ containerStatus }),
setIsAllocatingContainer: (isAllocatingContainer: boolean) => set({ isAllocatingContainer }),
setSchemaAllocationInfo: (schemaAllocationInfo: CopilotSchemaAllocationInfo) => set({ schemaAllocationInfo }),
setCopilotEnabledforExecution: (copilotEnabledforExecution: boolean) => set({ copilotEnabledforExecution }),
resetContainerConnection: (): void => {
useTabs.getState().closeAllNotebookTabs(true);
@@ -173,6 +198,10 @@ export const useQueryCopilot: QueryCopilotStore = create((set) => ({
durationLeftInMinutes: undefined,
phoenixServerInfo: undefined,
});
useQueryCopilot.getState().setSchemaAllocationInfo({
databaseId: undefined,
containerId: undefined,
});
},
resetQueryCopilotStates: () => {
@@ -217,6 +246,10 @@ export const useQueryCopilot: QueryCopilotStore = create((set) => ({
durationLeftInMinutes: undefined,
phoenixServerInfo: undefined,
},
schemaAllocationInfo: {
databaseId: undefined,
containerId: undefined,
},
isAllocatingContainer: false,
}));
},

View File

@@ -1,6 +1,9 @@
import { HttpHeaders } from "Common/Constants";
import { QueryRequestOptions, QueryResponse } from "Contracts/AzureResourceGraph";
import useSWR from "swr";
import { configContext } from "../ConfigContext";
import { Subscription } from "../Contracts/DataModels";
/* eslint-disable @typescript-eslint/no-explicit-any */
interface SubscriptionListResult {
nextLink: string;
@@ -32,10 +35,58 @@ export async function fetchSubscriptions(accessToken: string): Promise<Subscript
return subscriptions.sort((a, b) => a.displayName.localeCompare(b.displayName));
}
export async function fetchSubscriptionsFromGraph(accessToken: string): Promise<Subscription[]> {
const headers = new Headers();
const bearer = `Bearer ${accessToken}`;
headers.append("Authorization", bearer);
headers.append(HttpHeaders.contentType, "application/json");
const subscriptionsQuery =
"resources | where type == 'microsoft.documentdb/databaseaccounts' | join kind=inner ( resourcecontainers | where type == 'microsoft.resources/subscriptions' | project subscriptionId, subscriptionName = name, subscriptionState = tostring(parse_json(properties).state) ) on subscriptionId | summarize by subscriptionId, subscriptionName, subscriptionState";
const apiVersion = "2021-03-01";
const managementResourceGraphAPIURL = `${configContext.ARM_ENDPOINT}providers/Microsoft.ResourceGraph/resources?api-version=${apiVersion}`;
const subscriptions: Subscription[] = [];
let skipToken: string;
do {
const body = {
query: subscriptionsQuery,
...(skipToken && {
options: {
$skipToken: skipToken,
} as QueryRequestOptions,
}),
};
const response = await fetch(managementResourceGraphAPIURL, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(await response.text());
}
const queryResponse: QueryResponse = (await response.json()) as QueryResponse;
skipToken = queryResponse.$skipToken;
queryResponse.data?.map((subscription: any) => {
subscriptions.push({
displayName: subscription.subscriptionName,
subscriptionId: subscription.subscriptionId,
state: subscription.subscriptionState,
} as Subscription);
});
} while (skipToken);
return subscriptions.sort((a, b) => a.displayName.localeCompare(b.displayName));
}
export function useSubscriptions(armToken: string): Subscription[] | undefined {
const { data } = useSWR(
() => (armToken ? ["subscriptions", armToken] : undefined),
(_, armToken) => fetchSubscriptions(armToken),
(_, armToken) => fetchSubscriptionsFromGraph(armToken),
);
return data;
}

View File

@@ -5,7 +5,7 @@ import NotebookTabV2 from "../Explorer/Tabs/NotebookV2Tab";
import TabsBase from "../Explorer/Tabs/TabsBase";
import { Platform, configContext } from "./../ConfigContext";
interface TabsState {
export interface TabsState {
openedTabs: TabsBase[];
openedReactTabs: ReactTabKind[];
activeTab: TabsBase | undefined;