Compare commits

..

11 Commits

Author SHA1 Message Date
Ashley Stanton-Nurse
e03287a89e fix layout of sidebar panels in fabric 2024-09-04 09:20:16 -07:00
Asier Isayas
4b207f3fa6 Show portal networking banner for new backend (#1952)
* show portal networking banner for new backend

* fixed valid endpoints

* format

* fixed tests

* Fixed tests

* fix tests

* fixed tests

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-08-29 18:42:13 -04:00
sindhuba
c5b7f599b3 Add AAD Endpoints for Data Explorer in Portal (#1943)
* Fix API endpoint for CassandraProxy query API

* activate Mongo Proxy and Cassandra Proxy in Prod

* Add CP Prod endpoint

* Run npm format and tests

* Revert code

* fix bug that blocked local mongo proxy and cassandra proxy development

* Add prod endpoint

* fix pr check tests

* Remove prod

* Remove prod endpoint

* Remove dev endpoint

* Support data plane RBAC

* Support data plane RBAC

* Add additional changes for Portal RBAC functionality

* Remove unnecessary code

* Remove unnecessary code

* Add code to fix VCoreMongo/PG bug

* Address feedback

* Add more logs for RBAC feature

* Add more logs for RBAC features

* Add AAD endpoints for all environments

* Add AAD endpoints

* Run npm format

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-08-28 09:11:21 -07:00
Asier Isayas
6aeac542b1 Runtime Proxy API (#1950)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-08-28 09:04:49 -04:00
Ashley Stanton-Nurse
0d22d4ab4d change default splitter orientation when the setting has not been set (#1946) 2024-08-27 14:20:34 -07:00
vchske
0658448b54 Reinstating partition key fix with added check for nested partitions (#1947)
* Reinstating empty hiearchical partition key value fix

* Added use case for nested partitions

* Fix lint issue
2024-08-26 10:00:33 -07:00
Laurent Nguyen
833d677d20 Change persistence format for column width (#1944) 2024-08-22 17:00:49 +02:00
Laurent Nguyen
038142c180 Save and restore DocumentsTab state to local storage (#1919)
* Infrastructure to save app state

* Save filters

* Replace read/save methods with more generic ones

* Make datalist for filter unique per database/container combination

* Disable saving middle split position for now

* Fix unit tests

* Turn off confusing auto-complete from input box

* Disable tabStateData for now

* Save and restore split position

* Fix replace autocomplete="off" by removing id on Input tag

* Properly set allotment width

* Fix saved percentage

* Save splitter per collection

* Add error handling and telemetry

* Fix compiling issue

* Add ability to delete filter history. Bug fix when hitting Enter on filter input box.

* Replace delete filter modal with dropdown menu

* Add code to remove oldest record if max limit is reached in app state persistence

* Only save new splitter position on drag end (not onchange)

* Add unit tests

* Add Clear all in settings. Update snapshots

* Fix format

* Remove filter delete and keep filter history to a max. Reword clear button and message in settings pane.

* Fix setting button label

* Update test snapshots

* Reword Clear history button text

* Update unit test snapshot

* Enable Settings pane for Fabric, but turn off Rbac dial for Fabric.

* Change union type to enum

* Update src/Shared/AppStatePersistenceUtility.ts

Assert that path does not include slash char.

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>

* Update src/Shared/AppStatePersistenceUtility.ts

Assert that path does not contain slash.

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>

* Fix format

---------

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>
2024-08-22 07:37:15 +02:00
Ashley Stanton-Nurse
94d3fcb30f disable query error tests due to backend issue (#1942) 2024-08-21 11:30:43 -07:00
Ashley Stanton-Nurse
d3722f2c99 Improve sidebar UI layout when narrow (#1938)
* improve how the sidebar reacts to being a smol lil' guy

* fix snapshots

* shrink minimum sizes to allow small screens to work in some way
2024-08-21 09:55:57 -07:00
Laurent Nguyen
5a5e155205 Implement bulk delete documents for Mongo (#1859)
* Implement bulk delete documents for Mongo

* Fix unit test

* Adding bulkdelete to new mongo apis

* Fix error message

* Fix typo

* Improve error message wording

* Fix format

* Fix format

* Put back old delete for older container with system partition key
2024-08-21 16:59:52 +02:00
37 changed files with 1146 additions and 401 deletions

67
.vscode/launch.json vendored
View File

@@ -4,37 +4,6 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"command": "npm start",
"name": "Run Host (npm start)",
"request": "launch",
"type": "node-terminal",
"presentation": {
"hidden": true
}
},
{
"type": "chrome",
"name": "Debug in Portal (Chrome)",
"request": "launch",
"smartStep": true,
"url": "https://ms.portal.azure.com/?dataExplorerSource=https%3A%2F%2Flocalhost%3A1234%2Fexplorer.html",
"presentation": {
"group": "debug"
//"order": 5
}
},
{
"type": "chrome",
"name": "Debug Standalone (Chrome)",
"request": "launch",
"smartStep": true,
"url": "https://localhost:1234/hostedExplorer.html",
"presentation": {
"group": "debug"
//"order": 5
}
},
{ {
"name": "Debug Jest Tests", "name": "Debug Jest Tests",
"type": "node", "type": "node",
@@ -48,11 +17,7 @@
], ],
"console": "integratedTerminal", "console": "integratedTerminal",
"internalConsoleOptions": "neverOpen", "internalConsoleOptions": "neverOpen",
"port": 9229, "port": 9229
"presentation": {
"group": "tests"
//"order": 5
}
}, },
{ {
"name": "Debug Jest Current Test", "name": "Debug Jest Current Test",
@@ -63,7 +28,7 @@
"${workspaceRoot}/node_modules/jest/bin/jest.js", "${workspaceRoot}/node_modules/jest/bin/jest.js",
"${fileBasenameNoExtension}", "${fileBasenameNoExtension}",
"--coverage", "--coverage",
"false" "false",
// "--watch", // "--watch",
// // --no-cache only used to make --watch work. Otherwise jest ignores the breakpoints. // // --no-cache only used to make --watch work. Otherwise jest ignores the breakpoints.
// // https://github.com/facebook/jest/issues/6683 // // https://github.com/facebook/jest/issues/6683
@@ -71,31 +36,7 @@
], ],
"console": "integratedTerminal", "console": "integratedTerminal",
"internalConsoleOptions": "neverOpen", "internalConsoleOptions": "neverOpen",
"port": 9229, "port": 9229
"presentation": {
"group": "tests"
//"order": 5
}
}
],
"compounds": [
{
"name": "Run Host and Debug in Portal (Chrome)",
"configurations": ["Run Host (npm start)", "Debug in Portal (Chrome)"],
"stopAll": true,
"presentation": {
"group": "debug",
"order": 0
}
},
{
"name": "Run Host and Debug Standalone (Chrome)",
"configurations": ["Run Host (npm start)", "Debug Standalone (Chrome)"],
"stopAll": true,
"presentation": {
"group": "debug",
"order": 1
}
} }
] ]
} }

16
.vscode/tasks.json vendored
View File

@@ -1,16 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "build",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": [],
"label": "npm: build",
"detail": "npm run format:check && npm run lint && npm run compile && npm run compile:strict && npm run pack:prod && npm run copyToConsumers"
}
]
}

View File

@@ -31,6 +31,7 @@ a:focus {
margin-top: 0px; margin-top: 0px;
margin-bottom: 0px; margin-bottom: 0px;
background-color: #ffffff; background-color: #ffffff;
width: auto; // Override width: 100% coming from Allotment
} }
.tabsManagerContainer { .tabsManagerContainer {

View File

@@ -134,6 +134,8 @@ export class BackendApi {
public static readonly GenerateToken: string = "GenerateToken"; public static readonly GenerateToken: string = "GenerateToken";
public static readonly PortalSettings: string = "PortalSettings"; public static readonly PortalSettings: string = "PortalSettings";
public static readonly AccountRestrictions: string = "AccountRestrictions"; public static readonly AccountRestrictions: string = "AccountRestrictions";
public static readonly RuntimeProxy: string = "RuntimeProxy";
public static readonly DisallowedLocations: string = "DisallowedLocations";
} }
export class PortalBackendEndpoints { export class PortalBackendEndpoints {
@@ -183,6 +185,12 @@ export class CassandraProxyAPIs {
public static readonly connectionStringSchemaApi: string = "api/connectionstring/cassandra/schema"; public static readonly connectionStringSchemaApi: string = "api/connectionstring/cassandra/schema";
} }
export class AadEndpoints {
public static readonly Prod: string = "https://login.microsoftonline.com/";
public static readonly Fairfax: string = "https://login.microsoftonline.us/";
public static readonly Mooncake: string = "https://login.partner.microsoftonline.cn/";
}
export class Queries { export class Queries {
public static CustomPageOption: string = "custom"; public static CustomPageOption: string = "custom";
public static UnlimitedPageOption: string = "unlimited"; public static UnlimitedPageOption: string = "unlimited";

View File

@@ -3,15 +3,16 @@ import { getAuthorizationTokenUsingResourceTokens } from "Common/getAuthorizatio
import { AuthorizationToken } from "Contracts/FabricMessageTypes"; import { AuthorizationToken } from "Contracts/FabricMessageTypes";
import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil"; import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { AuthType } from "../AuthType"; import { AuthType } from "../AuthType";
import { PriorityLevel } from "../Common/Constants"; import { BackendApi, PriorityLevel } from "../Common/Constants";
import * as Logger from "../Common/Logger";
import { Platform, configContext } from "../ConfigContext"; import { Platform, configContext } from "../ConfigContext";
import { userContext } from "../UserContext"; import { userContext } from "../UserContext";
import { logConsoleError } from "../Utils/NotificationConsoleUtils"; import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils"; import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
import { EmulatorMasterKey, HttpHeaders } from "./Constants"; import { EmulatorMasterKey, HttpHeaders } from "./Constants";
import { getErrorMessage } from "./ErrorHandlingUtils"; import { getErrorMessage } from "./ErrorHandlingUtils";
import * as Logger from "../Common/Logger";
const _global = typeof self === "undefined" ? window : self; const _global = typeof self === "undefined" ? window : self;
@@ -123,6 +124,37 @@ export async function getTokenFromAuthService(
verb: string, verb: string,
resourceType: string, resourceType: string,
resourceId?: string, resourceId?: string,
): Promise<AuthorizationToken> {
if (!useNewPortalBackendEndpoint(BackendApi.RuntimeProxy)) {
return getTokenFromAuthService_ToBeDeprecated(verb, resourceType, resourceId);
}
try {
const host: string = configContext.PORTAL_BACKEND_ENDPOINT;
const response: Response = await _global.fetch(host + "/api/connectionstring/runtimeproxy/authorizationtokens", {
method: "POST",
headers: {
"content-type": "application/json",
"x-ms-encrypted-auth-token": userContext.accessToken,
},
body: JSON.stringify({
verb,
resourceType,
resourceId,
}),
});
const result: AuthorizationToken = await response.json();
return result;
} catch (error) {
logConsoleError(`Failed to get authorization headers for ${resourceType}: ${getErrorMessage(error)}`);
return Promise.reject(error);
}
}
export async function getTokenFromAuthService_ToBeDeprecated(
verb: string,
resourceType: string,
resourceId?: string,
): Promise<AuthorizationToken> { ): Promise<AuthorizationToken> {
try { try {
const host = configContext.BACKEND_ENDPOINT; const host = configContext.BACKEND_ENDPOINT;

View File

@@ -550,6 +550,49 @@ export function deleteDocument_ToBeDeprecated(
}); });
} }
export function deleteDocuments(
databaseId: string,
collection: Collection,
documentIds: DocumentId[],
): Promise<{
deletedCount: number;
isAcknowledged: boolean;
}> {
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const rids = documentIds.map((documentId) => documentId.id());
const params = {
databaseID: databaseId,
collectionID: collection.id(),
resourceUrl: `${resourceEndpoint}`,
resourceIDs: rids,
subscriptionID: userContext.subscriptionId,
resourceGroup: userContext.resourceGroup,
databaseAccountName: databaseAccount.name,
};
const endpoint = getFeatureEndpointOrDefault("bulkdelete");
return window
.fetch(`${endpoint}/bulkdelete`, {
method: "DELETE",
body: JSON.stringify(params),
headers: {
...defaultHeaders,
...authHeaders(),
[HttpHeaders.contentType]: ContentType.applicationJson,
},
})
.then(async (response) => {
if (response.ok) {
const result = await response.json();
return result;
}
return await errorHandling(response, "deleting documents", params);
});
}
export function createMongoCollectionWithProxy( export function createMongoCollectionWithProxy(
params: DataModels.CreateCollectionParams, params: DataModels.CreateCollectionParams,
): Promise<DataModels.Collection> { ): Promise<DataModels.Collection> {
@@ -677,7 +720,8 @@ export function useMongoProxyEndpoint(api: string): boolean {
MongoProxyEndpoints.Local, MongoProxyEndpoints.Local,
MongoProxyEndpoints.Mpac, MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod, MongoProxyEndpoints.Prod,
// MongoProxyEndpoints.Fairfax, MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
]; ];
let canAccessMongoProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled"; let canAccessMongoProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
if ( if (

View File

@@ -117,6 +117,7 @@ let configContext: Readonly<ConfigContext> = {
"deleteDocument", "deleteDocument",
"createCollectionWithProxy", "createCollectionWithProxy",
"legacyMongoShell", "legacyMongoShell",
"bulkdelete",
], ],
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false, MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod, CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,

View File

@@ -25,7 +25,9 @@ export const useTreeStyles = makeStyles({
height: `var(${treeIconWidth})`, height: `var(${treeIconWidth})`,
}, },
treeItem: {}, treeItem: {},
nodeLabel: {}, nodeLabel: {
whiteSpace: "nowrap", // Don't wrap text, there will be a scrollbar.
},
treeItemLayout: { treeItemLayout: {
fontSize: tokens.fontSizeBase300, fontSize: tokens.fontSizeBase300,
height: tokens.layoutRowHeight, height: tokens.layoutRowHeight,

View File

@@ -19,7 +19,7 @@ exports[`TreeNodeComponent does not render children if the node is loading 1`] =
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -183,7 +183,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -235,7 +235,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -283,7 +283,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child1Label child1Label
</span> </span>
@@ -327,7 +327,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child2LoadingLabel child2LoadingLabel
</span> </span>
@@ -367,7 +367,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child3ExpandingLabel child3ExpandingLabel
</span> </span>
@@ -423,7 +423,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
className="fui-TreeItemLayout__main rklbe47" className="fui-TreeItemLayout__main rklbe47"
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -614,7 +614,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child1Label child1Label
</span> </span>
@@ -666,7 +666,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child1Label child1Label
</span> </span>
@@ -720,7 +720,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
className="fui-TreeItemLayout__main rklbe47" className="fui-TreeItemLayout__main rklbe47"
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
child1Label child1Label
</span> </span>
@@ -848,7 +848,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child2LoadingLabel child2LoadingLabel
</span> </span>
@@ -900,7 +900,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child2LoadingLabel child2LoadingLabel
</span> </span>
@@ -954,7 +954,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
className="fui-TreeItemLayout__main rklbe47" className="fui-TreeItemLayout__main rklbe47"
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
child2LoadingLabel child2LoadingLabel
</span> </span>
@@ -1063,7 +1063,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child3ExpandingLabel child3ExpandingLabel
</span> </span>
@@ -1111,7 +1111,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child3ExpandingLabel child3ExpandingLabel
</span> </span>
@@ -1153,7 +1153,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
className="fui-TreeItemLayout__main rklbe47" className="fui-TreeItemLayout__main rklbe47"
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
child3ExpandingLabel child3ExpandingLabel
</span> </span>
@@ -1195,7 +1195,7 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1222,7 +1222,7 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1249,7 +1249,7 @@ exports[`TreeNodeComponent renders a node as expandable if it has empty, but def
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1324,7 +1324,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1374,7 +1374,7 @@ exports[`TreeNodeComponent renders a single node 1`] = `
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1403,7 +1403,7 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1430,7 +1430,7 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1506,7 +1506,7 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1585,7 +1585,7 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>

View File

@@ -167,22 +167,18 @@ export function createContextCommandBarButtons(
} }
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] { export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
const buttons: CommandButtonComponentProps[] = const buttons: CommandButtonComponentProps[] = [
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly {
? [] iconSrc: SettingsIcon,
: [ iconAlt: "Settings",
{ onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
iconSrc: SettingsIcon, commandButtonLabel: undefined,
iconAlt: "Settings", ariaLabel: "Settings",
onCommandClick: () => tooltipText: "Settings",
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />), hasPopup: true,
commandButtonLabel: undefined, disabled: false,
ariaLabel: "Settings", },
tooltipText: "Settings", ];
hasPopup: true,
disabled: false,
},
];
const showOpenFullScreen = const showOpenFullScreen =
configContext.platform === Platform.Portal && !isRunningOnNationalCloud() && userContext.apiType !== "Gremlin"; configContext.platform === Platform.Portal && !isRunningOnNationalCloud() && userContext.apiType !== "Gremlin";

View File

@@ -1,5 +1,6 @@
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility"; import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { PhoenixClient } from "Phoenix/PhoenixClient"; import { PhoenixClient } from "Phoenix/PhoenixClient";
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import create, { UseStore } from "zustand"; import create, { UseStore } from "zustand";
import { AuthType } from "../../AuthType"; import { AuthType } from "../../AuthType";
@@ -127,7 +128,9 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo" userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo"
? databaseAccount?.location ? databaseAccount?.location
: databaseAccount?.properties?.writeLocations?.[0]?.locationName.toLowerCase(); : databaseAccount?.properties?.writeLocations?.[0]?.locationName.toLowerCase();
const disallowedLocationsUri = `${configContext.BACKEND_ENDPOINT}/api/disallowedLocations`; const disallowedLocationsUri: string = useNewPortalBackendEndpoint(Constants.BackendApi.DisallowedLocations)
? `${configContext.PORTAL_BACKEND_ENDPOINT}/api/disallowedlocations`
: `${configContext.BACKEND_ENDPOINT}/api/disallowedLocations`;
const authorizationHeader = getAuthorizationHeader(); const authorizationHeader = getAuthorizationHeader();
try { try {
const response = await fetch(disallowedLocationsUri, { const response = await fetch(disallowedLocationsUri, {

View File

@@ -1,6 +1,7 @@
import { import {
Checkbox, Checkbox,
ChoiceGroup, ChoiceGroup,
DefaultButton,
IChoiceGroupOption, IChoiceGroupOption,
ISpinButtonStyles, ISpinButtonStyles,
IToggleStyles, IToggleStyles,
@@ -12,11 +13,15 @@ import {
Toggle, Toggle,
TooltipHost, TooltipHost,
} from "@fluentui/react"; } from "@fluentui/react";
import { makeStyles } from "@fluentui/react-components";
import { AuthType } from "AuthType";
import * as Constants from "Common/Constants"; import * as Constants from "Common/Constants";
import { SplitterDirection } from "Common/Splitter"; import { SplitterDirection } from "Common/Splitter";
import { InfoTooltip } from "Common/Tooltip/InfoTooltip"; import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
import { Platform, configContext } from "ConfigContext"; import { Platform, configContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import { deleteAllStates } from "Shared/AppStatePersistenceUtility";
import { import {
DefaultRUThreshold, DefaultRUThreshold,
LocalStorageUtility, LocalStorageUtility,
@@ -29,14 +34,13 @@ import * as StringUtility from "Shared/StringUtility";
import { updateUserContext, userContext } from "UserContext"; import { updateUserContext, userContext } from "UserContext";
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils"; import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useQueryCopilot } from "hooks/useQueryCopilot";
import { useSidePanel } from "hooks/useSidePanel"; import { useSidePanel } from "hooks/useSidePanel";
import React, { FunctionComponent, useState } from "react"; import React, { FunctionComponent, useState } from "react";
import create, { UseStore } from "zustand";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
import { AuthType } from "AuthType";
import create, { UseStore } from "zustand";
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
export interface DataPlaneRbacState { export interface DataPlaneRbacState {
dataPlaneRbacEnabled: boolean; dataPlaneRbacEnabled: boolean;
@@ -50,6 +54,13 @@ export interface DataPlaneRbacState {
type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>; type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>;
const useStyles = makeStyles({
bulletList: {
listStyleType: "disc",
paddingLeft: "20px",
},
});
export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({ export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({
dataPlaneRbacEnabled: false, dataPlaneRbacEnabled: false,
})); }));
@@ -133,6 +144,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState<boolean>( const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState<boolean>(
LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true", LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true",
); );
const styles = useStyles();
const explorerVersion = configContext.gitSha; const explorerVersion = configContext.gitSha;
const shouldShowQueryPageOptions = userContext.apiType === "SQL"; const shouldShowQueryPageOptions = userContext.apiType === "SQL";
const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin"; const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin";
@@ -153,43 +167,45 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage); LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage);
LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption); if (configContext.platform !== Platform.Fabric) {
if ( LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption);
enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption || if (
(enableDataPlaneRBACOption === Constants.RBACOptions.setAutomaticRBACOption && enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption ||
userContext.databaseAccount.properties.disableLocalAuth) (enableDataPlaneRBACOption === Constants.RBACOptions.setAutomaticRBACOption &&
) { userContext.databaseAccount.properties.disableLocalAuth)
updateUserContext({ ) {
dataPlaneRbacEnabled: true, updateUserContext({
hasDataPlaneRbacSettingChanged: true, dataPlaneRbacEnabled: true,
}); hasDataPlaneRbacSettingChanged: true,
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true }); });
} else { useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true });
updateUserContext({ } else {
dataPlaneRbacEnabled: false, updateUserContext({
hasDataPlaneRbacSettingChanged: true, dataPlaneRbacEnabled: false,
}); hasDataPlaneRbacSettingChanged: true,
const { databaseAccount: account, subscriptionId, resourceGroup } = userContext; });
if (!userContext.features.enableAadDataPlane && !userContext.masterKey) { const { databaseAccount: account, subscriptionId, resourceGroup } = userContext;
let keys; if (!userContext.features.enableAadDataPlane && !userContext.masterKey) {
try { let keys;
keys = await listKeys(subscriptionId, resourceGroup, account.name); try {
updateUserContext({ keys = await listKeys(subscriptionId, resourceGroup, account.name);
masterKey: keys.primaryMasterKey,
});
} catch (error) {
// if listKeys fail because of permissions issue, then make call to get ReadOnlyKeys
if (error.code === "AuthorizationFailed") {
keys = await getReadOnlyKeys(subscriptionId, resourceGroup, account.name);
updateUserContext({ updateUserContext({
masterKey: keys.primaryReadonlyMasterKey, masterKey: keys.primaryMasterKey,
}); });
} else { } catch (error) {
logConsoleError(`Error occurred fetching keys for the account." ${error.message}`); // if listKeys fail because of permissions issue, then make call to get ReadOnlyKeys
throw error; if (error.code === "AuthorizationFailed") {
keys = await getReadOnlyKeys(subscriptionId, resourceGroup, account.name);
updateUserContext({
masterKey: keys.primaryReadonlyMasterKey,
});
} else {
logConsoleError(`Error occurred fetching keys for the account." ${error.message}`);
throw error;
}
} }
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
} }
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
} }
} }
@@ -476,55 +492,57 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</div> </div>
</div> </div>
)} )}
{userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && ( {userContext.apiType === "SQL" &&
<> userContext.authType === AuthType.AAD &&
<div className="settingsSection"> configContext.platform !== Platform.Fabric && (
<div className="settingsSectionPart"> <>
<fieldset> <div className="settingsSection">
<legend id="enableDataPlaneRBACOptions" className="settingsSectionLabel legendLabel"> <div className="settingsSectionPart">
Enable Entra ID RBAC <fieldset>
</legend> <legend id="enableDataPlaneRBACOptions" className="settingsSectionLabel legendLabel">
<TooltipHost Enable Entra ID RBAC
content={ </legend>
<> <TooltipHost
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra content={
ID RBAC. <>
<a Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer" Entra ID RBAC.
target="_blank" <a
rel="noopener noreferrer" href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
> target="_blank"
{" "} rel="noopener noreferrer"
Learn more{" "} >
</a> {" "}
</> Learn more{" "}
} </a>
> </>
<Icon iconName="Info" ariaLabel="Info tooltip" className="panelInfoIcon" tabIndex={0} /> }
</TooltipHost>
{showDataPlaneRBACWarning && configContext.platform === Platform.Portal && (
<MessageBar
messageBarType={MessageBarType.warning}
isMultiline={true}
onDismiss={() => setShowDataPlaneRBACWarning(false)}
dismissButtonAriaLabel="Close"
> >
Please click on &quot;Login for Entra ID RBAC&quot; button prior to performing Entra ID RBAC <Icon iconName="Info" ariaLabel="Info tooltip" className="panelInfoIcon" tabIndex={0} />
operations </TooltipHost>
</MessageBar> {showDataPlaneRBACWarning && configContext.platform === Platform.Portal && (
)} <MessageBar
<ChoiceGroup messageBarType={MessageBarType.warning}
ariaLabelledBy="enableDataPlaneRBACOptions" isMultiline={true}
options={dataPlaneRBACOptionsList} onDismiss={() => setShowDataPlaneRBACWarning(false)}
styles={choiceButtonStyles} dismissButtonAriaLabel="Close"
selectedKey={enableDataPlaneRBACOption} >
onChange={handleOnDataPlaneRBACOptionChange} Please click on &quot;Login for Entra ID RBAC&quot; button prior to performing Entra ID RBAC
/> operations
</fieldset> </MessageBar>
)}
<ChoiceGroup
ariaLabelledBy="enableDataPlaneRBACOptions"
options={dataPlaneRBACOptionsList}
styles={choiceButtonStyles}
selectedKey={enableDataPlaneRBACOption}
onChange={handleOnDataPlaneRBACOptionChange}
/>
</fieldset>
</div>
</div> </div>
</div> </>
</> )}
)}
{userContext.apiType === "SQL" && ( {userContext.apiType === "SQL" && (
<> <>
<div className="settingsSection"> <div className="settingsSection">
@@ -830,6 +848,34 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</div> </div>
</div> </div>
)} )}
<div className="settingsSection">
<div className="settingsSectionPart">
<DefaultButton
onClick={() => {
useDialog.getState().showOkCancelModalDialog(
"Clear History",
undefined,
"Are you sure you want to proceed?",
() => deleteAllStates(),
"Cancel",
undefined,
<>
<span>
This action will clear the all customizations for this account in this browser, including:
</span>
<ul className={styles.bulletList}>
<li>Reset your customized tab layout, including the splitter positions</li>
<li>Erase your table column preferences, including any custom columns</li>
<li>Clear your filter history</li>
</ul>
</>,
);
}}
>
Clear History
</DefaultButton>
</div>
</div>
<div className="settingsSection"> <div className="settingsSection">
<div className="settingsSectionPart"> <div className="settingsSectionPart">
<div className="settingsSectionLabel">Explorer Version</div> <div className="settingsSectionLabel">Explorer Version</div>

View File

@@ -238,7 +238,7 @@ exports[`Settings Pane should render Default properly 1`] = `
}, },
] ]
} }
selectedKey="vertical" selectedKey="horizontal"
styles={ styles={
{ {
"flexContainer": [ "flexContainer": [
@@ -485,6 +485,19 @@ exports[`Settings Pane should render Default properly 1`] = `
/> />
</div> </div>
</div> </div>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<CustomizedDefaultButton
onClick={[Function]}
>
Clear History
</CustomizedDefaultButton>
</div>
</div>
<div <div
className="settingsSection" className="settingsSection"
> >
@@ -708,6 +721,19 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
/> />
</div> </div>
</div> </div>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<CustomizedDefaultButton
onClick={[Function]}
>
Clear History
</CustomizedDefaultButton>
</div>
</div>
<div <div
className="settingsSection" className="settingsSection"
> >

View File

@@ -1,6 +1,7 @@
import { import {
Button, Button,
Menu, Menu,
MenuButton,
MenuButtonProps, MenuButtonProps,
MenuItem, MenuItem,
MenuList, MenuList,
@@ -60,6 +61,7 @@ const useSidebarStyles = makeStyles({
alignItems: "center", alignItems: "center",
justifyItems: "center", justifyItems: "center",
width: "100%", width: "100%",
containerType: "size", // Use this container for "@container" queries below this.
...cosmosShorthands.borderBottom(), ...cosmosShorthands.borderBottom(),
}, },
loadingProgressBar: { loadingProgressBar: {
@@ -83,6 +85,18 @@ const useSidebarStyles = makeStyles({
}, },
}, },
}, },
globalCommandsMenuButton: {
display: "initial",
"@container (min-width: 250px)": {
display: "none",
},
},
globalCommandsSplitButton: {
display: "none",
"@container (min-width: 250px)": {
display: "flex",
},
},
}); });
interface GlobalCommandsProps { interface GlobalCommandsProps {
@@ -171,13 +185,19 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
<Menu positioning="below-end"> <Menu positioning="below-end">
<MenuTrigger disableButtonEnhancement> <MenuTrigger disableButtonEnhancement>
{(triggerProps: MenuButtonProps) => ( {(triggerProps: MenuButtonProps) => (
<SplitButton <>
menuButton={{ ...triggerProps, "aria-label": "More commands" }} <SplitButton
primaryActionButton={{ onClick: onPrimaryActionClick }} menuButton={{ ...triggerProps, "aria-label": "More commands" }}
icon={primaryAction.icon} primaryActionButton={{ onClick: onPrimaryActionClick }}
> className={styles.globalCommandsSplitButton}
{primaryAction.label} icon={primaryAction.icon}
</SplitButton> >
{primaryAction.label}
</SplitButton>
<MenuButton {...triggerProps} icon={primaryAction.icon} className={styles.globalCommandsMenuButton}>
New...
</MenuButton>
</>
)} )}
</MenuTrigger> </MenuTrigger>
<MenuPopover> <MenuPopover>
@@ -199,7 +219,7 @@ interface SidebarProps {
explorer: Explorer; explorer: Explorer;
} }
const CollapseThreshold = 50; const CollapseThreshold = 140;
export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => { export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
const styles = useSidebarStyles(); const styles = useSidebarStyles();
@@ -314,7 +334,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
</CosmosFluentProvider> </CosmosFluentProvider>
</Allotment.Pane> </Allotment.Pane>
)} )}
<Allotment.Pane minSize={800}> <Allotment.Pane minSize={200}>
<Tabs explorer={explorer} /> <Tabs explorer={explorer} />
</Allotment.Pane> </Allotment.Pane>
</Allotment> </Allotment>

View File

@@ -0,0 +1,100 @@
// Definitions of State data
import { deleteState, loadState, saveState, saveStateDebounced } from "Shared/AppStatePersistenceUtility";
import { userContext } from "UserContext";
import * as ViewModels from "../../../Contracts/ViewModels";
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
const componentName = "DocumentsTab";
export enum SubComponentName {
ColumnSizes = "ColumnSizes",
FilterHistory = "FilterHistory",
MainTabDivider = "MainTabDivider",
}
export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
export type WidthDefinition = { widthPx: number };
export type TabDivider = { leftPaneWidthPercent: number };
/**
*
* @param subComponentName
* @param collection
* @param defaultValue Will be returned if persisted state is not found
* @returns
*/
export const readSubComponentState = <T>(
subComponentName: SubComponentName,
collection: ViewModels.CollectionBase,
defaultValue: T,
): T => {
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.ReadPersistedTabState, { message, componentName });
return defaultValue;
}
const state = loadState({
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
}) as T;
return state || defaultValue;
};
/**
*
* @param subComponentName
* @param collection
* @param state State to save
* @param debounce true for high-frequency calls (e.g mouse drag events)
*/
export const saveSubComponentState = <T>(
subComponentName: SubComponentName,
collection: ViewModels.CollectionBase,
state: T,
debounce?: boolean,
): void => {
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.SavePersistedTabState, { message, componentName });
return;
}
(debounce ? saveStateDebounced : saveState)(
{
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
},
state,
);
};
export const deleteSubComponentState = (subComponentName: SubComponentName, collection: ViewModels.CollectionBase) => {
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.DeletePersistedTabState, { message, componentName });
return;
}
deleteState({
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
});
};

View File

@@ -13,6 +13,7 @@ import {
SAVE_BUTTON_ID, SAVE_BUTTON_ID,
UPDATE_BUTTON_ID, UPDATE_BUTTON_ID,
UPLOAD_BUTTON_ID, UPLOAD_BUTTON_ID,
addStringsNoDuplicate,
buildQuery, buildQuery,
getDiscardExistingDocumentChangesButtonState, getDiscardExistingDocumentChangesButtonState,
getDiscardNewDocumentChangesButtonState, getDiscardNewDocumentChangesButtonState,
@@ -339,7 +340,10 @@ describe("Documents tab (noSql API)", () => {
const createMockProps = (): IDocumentsTabComponentProps => ({ const createMockProps = (): IDocumentsTabComponentProps => ({
isPreferredApiMongoDB: false, isPreferredApiMongoDB: false,
documentIds: [], documentIds: [],
collection: undefined, collection: {
id: ko.observable<string>("collectionId"),
databaseId: "databaseId",
} as ViewModels.CollectionBase,
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 }, partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
onLoadStartKey: 0, onLoadStartKey: 0,
tabTitle: "", tabTitle: "",
@@ -380,7 +384,7 @@ describe("Documents tab (noSql API)", () => {
.findWhere((node) => node.text() === "Edit Filter") .findWhere((node) => node.text() === "Edit Filter")
.at(0) .at(0)
.simulate("click"); .simulate("click");
expect(wrapper.find("#filterInput").exists()).toBeTruthy(); expect(wrapper.find("Input.filterInput").exists()).toBeTruthy();
}); });
}); });
@@ -474,3 +478,13 @@ describe("Documents tab (noSql API)", () => {
}); });
}); });
}); });
describe("Documents tab", () => {
it("should add strings to array without duplicate", () => {
const array1 = ["a", "b", "c"];
const array2 = ["b", "c", "d"];
const array3 = addStringsNoDuplicate(array1, array2);
expect(array3).toEqual(["a", "b", "c", "d"]);
});
});

View File

@@ -20,6 +20,12 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import Explorer from "Explorer/Explorer"; import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities"; import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import {
SubComponentName,
TabDivider,
readSubComponentState,
saveSubComponentState,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil"; import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode"; import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
@@ -42,6 +48,7 @@ import * as Logger from "../../../Common/Logger";
import * as MongoProxyClient from "../../../Common/MongoProxyClient"; import * as MongoProxyClient from "../../../Common/MongoProxyClient";
import * as DataModels from "../../../Contracts/DataModels"; import * as DataModels from "../../../Contracts/DataModels";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
import { CollectionBase } from "../../../Contracts/ViewModels";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import * as QueryUtils from "../../../Utils/QueryUtils"; import * as QueryUtils from "../../../Utils/QueryUtils";
import { extractPartitionKeyValues } from "../../../Utils/QueryUtils"; import { extractPartitionKeyValues } from "../../../Utils/QueryUtils";
@@ -50,6 +57,8 @@ import ObjectId from "../../Tree/ObjectId";
import TabsBase from "../TabsBase"; import TabsBase from "../TabsBase";
import { DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent"; import { DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent";
const MAX_FILTER_HISTORY_COUNT = 100; // Datalist will become scrollable, so we can afford to keep more items than fit on the screen
const loadMoreHeight = LayoutConstants.rowHeight; const loadMoreHeight = LayoutConstants.rowHeight;
export const useDocumentsTabStyles = makeStyles({ export const useDocumentsTabStyles = makeStyles({
container: { container: {
@@ -473,6 +482,24 @@ export const buildQuery = (
return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey); return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey);
}; };
/**
* Export to expose to unit tests
*
* Add array2 to array1 without duplicates
* @param array1
* @param array2
* @return array1 with array2 added without duplicates
*/
export const addStringsNoDuplicate = (array1: string[], array2: string[]): string[] => {
const result = [...array1];
array2.forEach((item) => {
if (!result.includes(item)) {
result.push(item);
}
});
return result;
};
// Export to expose to unit tests // Export to expose to unit tests
export interface IDocumentsTabComponentProps { export interface IDocumentsTabComponentProps {
isPreferredApiMongoDB: boolean; isPreferredApiMongoDB: boolean;
@@ -487,6 +514,11 @@ export interface IDocumentsTabComponentProps {
isTabActive: boolean; isTabActive: boolean;
} }
const getUniqueId = (collection: ViewModels.CollectionBase): string => `${collection.databaseId}-${collection.id()}`;
const defaultSqlFilters = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
// Export to expose to unit tests // Export to expose to unit tests
export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
isPreferredApiMongoDB, isPreferredApiMongoDB,
@@ -534,6 +566,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
ViewModels.DocumentExplorerState.noDocumentSelected, ViewModels.DocumentExplorerState.noDocumentSelected,
); );
// State
const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
readSubComponentState(SubComponentName.MainTabDivider, _collection, {
leftPaneWidthPercent: 35,
}),
);
const isQueryCopilotSampleContainer = const isQueryCopilotSampleContainer =
_collection?.isSampleCollection && _collection?.isSampleCollection &&
_collection?.databaseId === QueryCopilotSampleDatabaseId && _collection?.databaseId === QueryCopilotSampleDatabaseId &&
@@ -542,6 +581,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// For Mongo only // For Mongo only
const [continuationToken, setContinuationToken] = useState<string>(undefined); const [continuationToken, setContinuationToken] = useState<string>(undefined);
// User's filter history
const [lastFilterContents, setLastFilterContents] = useState<string[]>(() =>
readSubComponentState(SubComponentName.FilterHistory, _collection, []),
);
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB); const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
useEffect(() => { useEffect(() => {
@@ -567,8 +611,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
} }
}, [documentIds, clickedRowIndex, editorState]); }, [documentIds, clickedRowIndex, editorState]);
let lastFilterContents = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
const applyFilterButton = { const applyFilterButton = {
enabled: true, enabled: true,
visible: true, visible: true,
@@ -883,7 +925,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
/** /**
* Implementation using bulk delete NoSQL API * Implementation using bulk delete NoSQL API
*/ */
let _deleteDocuments = useCallback( const _deleteDocuments = useCallback(
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => { async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
onExecutionErrorChange(false); onExecutionErrorChange(false);
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocuments, { const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocuments, {
@@ -894,11 +936,29 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released: // TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released:
// Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should always be called. // Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should always be called.
return ( const _deleteNoSqlDocuments = async (
partitionKey.systemKey collection: CollectionBase,
? deleteNoSqlDocument(_collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]]) toDeleteDocumentIds: DocumentId[],
: deleteNoSqlDocuments(_collection, toDeleteDocumentIds) ): Promise<DocumentId[]> => {
) return partitionKey.systemKey
? deleteNoSqlDocument(collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]])
: deleteNoSqlDocuments(collection, toDeleteDocumentIds);
};
const deletePromise = !isPreferredApiMongoDB
? _deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
: MongoProxyClient.deleteDocuments(
_collection.databaseId,
_collection as ViewModels.Collection,
toDeleteDocumentIds,
).then(({ deletedCount, isAcknowledged }) => {
if (deletedCount === toDeleteDocumentIds.length && isAcknowledged) {
return toDeleteDocumentIds;
}
throw new Error(`Delete failed with deletedCount: ${deletedCount} and isAcknowledged: ${isAcknowledged}`);
});
return deletePromise
.then( .then(
(deletedIds) => { (deletedIds) => {
TelemetryProcessor.traceSuccess( TelemetryProcessor.traceSuccess(
@@ -929,7 +989,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
) )
.finally(() => setIsExecuting(false)); .finally(() => setIsExecuting(false));
}, },
[_collection, onExecutionErrorChange, tabTitle], [_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle],
); );
const deleteDocuments = useCallback( const deleteDocuments = useCallback(
@@ -954,7 +1014,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(error: Error) => (error: Error) =>
useDialog useDialog
.getState() .getState()
.showOkModalDialog("Delete documents", `Document(s) deleted failed (${JSON.stringify(error)})`), .showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`),
) )
.finally(() => setIsExecuting(false)); .finally(() => setIsExecuting(false));
}, },
@@ -1220,7 +1280,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => { const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") { if (e.key === "Enter") {
refreshDocumentsGrid(true); onApplyFilterClick();
// Suppress the default behavior of the key // Suppress the default behavior of the key
e.preventDefault(); e.preventDefault();
@@ -1423,7 +1483,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return partitionKey; return partitionKey;
}; };
lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => { partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) { if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, ""); partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
@@ -1438,62 +1497,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return partitionKeyProperty; return partitionKeyProperty;
}); });
/**
* Mongo implementation
* TODO: update proxy to use mongo driver deleteMany
*/
_deleteDocuments = (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
const promises = toDeleteDocumentIds.map((documentId) => _deleteDocument(documentId));
return Promise.all(promises);
};
const __deleteDocument = async (documentId: DocumentId): Promise<DocumentId> => {
await MongoProxyClient.deleteDocument(_collection.databaseId, _collection as ViewModels.Collection, documentId);
return documentId;
};
const _deleteDocument = useCallback(
(documentId: DocumentId): Promise<DocumentId> => {
onExecutionErrorChange(false);
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocument, {
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
});
setIsExecuting(true);
return __deleteDocument(documentId)
.then(
(deletedDocumentId) => {
TelemetryProcessor.traceSuccess(
Action.DeleteDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
},
startKey,
);
return deletedDocumentId;
},
(error) => {
onExecutionErrorChange(true);
console.error(error);
TelemetryProcessor.traceFailure(
Action.DeleteDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey,
);
return undefined;
},
)
.finally(() => setIsExecuting(false));
},
[__deleteDocument, onExecutionErrorChange, tabTitle],
);
onSaveNewDocumentClick = useCallback((): Promise<unknown> => { onSaveNewDocumentClick = useCallback((): Promise<unknown> => {
const documentContent = JSON.parse(selectedDocumentContent); const documentContent = JSON.parse(selectedDocumentContent);
const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, { const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, {
@@ -1700,6 +1703,24 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
} }
// ***************** Mongo *************************** // ***************** Mongo ***************************
const onApplyFilterClick = (): void => {
refreshDocumentsGrid(true);
// Remove duplicates, but keep order
if (lastFilterContents.includes(filterContent)) {
lastFilterContents.splice(lastFilterContents.indexOf(filterContent), 1);
}
// Save filter content to local storage
lastFilterContents.unshift(filterContent);
// Keep the list size under MAX_FILTER_HISTORY_COUNT. Drop last element if needed.
const limitedLastFilterContents = lastFilterContents.slice(0, MAX_FILTER_HISTORY_COUNT);
setLastFilterContents(limitedLastFilterContents);
saveSubComponentState(SubComponentName.FilterHistory, _collection, lastFilterContents);
};
const refreshDocumentsGrid = useCallback( const refreshDocumentsGrid = useCallback(
(applyFilterButtonPressed: boolean): void => { (applyFilterButtonPressed: boolean): void => {
// clear documents grid // clear documents grid
@@ -1758,12 +1779,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<div className={styles.filterRow}> <div className={styles.filterRow}>
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>} {!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
<Input <Input
id="filterInput"
ref={filterInput} ref={filterInput}
type="text" type="text"
size="small" size="small"
list="filtersList" list={`filtersList-${getUniqueId(_collection)}`}
className={styles.filterInput} className={`filterInput ${styles.filterInput}`}
title="Type a query predicate or choose one from the list." title="Type a query predicate or choose one from the list."
placeholder={ placeholder={
isPreferredApiMongoDB isPreferredApiMongoDB
@@ -1777,8 +1797,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
onBlur={() => setIsFilterFocused(false)} onBlur={() => setIsFilterFocused(false)}
/> />
<datalist id="filtersList"> <datalist id={`filtersList-${getUniqueId(_collection)}`}>
{lastFilterContents.map((filter) => ( {addStringsNoDuplicate(
lastFilterContents,
isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters,
).map((filter) => (
<option key={filter} value={filter} /> <option key={filter} value={filter} />
))} ))}
</datalist> </datalist>
@@ -1786,7 +1809,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<Button <Button
appearance="primary" appearance="primary"
size="small" size="small"
onClick={() => refreshDocumentsGrid(true)} onClick={onApplyFilterClick}
disabled={!applyFilterButton.enabled} disabled={!applyFilterButton.enabled}
aria-label="Apply filter" aria-label="Apply filter"
tabIndex={0} tabIndex={0}
@@ -1817,11 +1840,16 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)} )}
</> </>
)} )}
{/* <Split> doesn't like to be a flex child */} {/* <Split> doesn't like to be a flex child */}
<div style={{ overflow: "hidden", height: "100%" }}> <div style={{ overflow: "hidden", height: "100%" }}>
<Allotment> <Allotment
<Allotment.Pane preferredSize="35%" minSize={175}> onDragEnd={(sizes: number[]) => {
tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
saveSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData);
setTabStateData(tabStateData);
}}
>
<Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}>
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}> <div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
<div className={styles.floatingControlsContainer}> <div className={styles.floatingControlsContainer}>
<div className={styles.floatingControls}> <div className={styles.floatingControls}>
@@ -1850,6 +1878,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(partitionKey.systemKey && !isPreferredApiMongoDB) || (partitionKey.systemKey && !isPreferredApiMongoDB) ||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
} }
collection={_collection}
/> />
</div> </div>
{tableItems.length > 0 && ( {tableItems.length > 0 && (
@@ -1865,7 +1894,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)} )}
</div> </div>
</Allotment.Pane> </Allotment.Pane>
<Allotment.Pane preferredSize="65%" minSize={300}> <Allotment.Pane minSize={30}>
<div style={{ height: "100%", width: "100%" }}> <div style={{ height: "100%", width: "100%" }}>
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && ( {isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
<EditorReact <EditorReact

View File

@@ -1,4 +1,4 @@
import { deleteDocument } from "Common/MongoProxyClient"; import { deleteDocuments } from "Common/MongoProxyClient";
import { Platform, updateConfigContext } from "ConfigContext"; import { Platform, updateConfigContext } from "ConfigContext";
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact"; import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
@@ -49,7 +49,7 @@ jest.mock("Common/MongoProxyClient", () => ({
id: "id1", id: "id1",
}), }),
), ),
deleteDocument: jest.fn(() => Promise.resolve()), deleteDocuments: jest.fn(() => Promise.resolve()),
})); }));
jest.mock("Explorer/Controls/Editor/EditorReact", () => ({ jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
@@ -179,8 +179,8 @@ describe("Documents tab (Mongo API)", () => {
}); });
it("clicking Delete Document asks for confirmation", () => { it("clicking Delete Document asks for confirmation", () => {
const mockDeleteDocument = deleteDocument as jest.Mock; const mockDeleteDocuments = deleteDocuments as jest.Mock;
mockDeleteDocument.mockClear(); mockDeleteDocuments.mockClear();
act(() => { act(() => {
useCommandBar useCommandBar
@@ -189,7 +189,7 @@ describe("Documents tab (Mongo API)", () => {
.onCommandClick(undefined); .onCommandClick(undefined);
}); });
expect(mockDeleteDocument).toHaveBeenCalled(); expect(mockDeleteDocuments).toHaveBeenCalled();
}); });
}); });
}); });

View File

@@ -1,6 +1,7 @@
import { TableRowId } from "@fluentui/react-components"; import { TableRowId } from "@fluentui/react-components";
import { mount } from "enzyme"; import { mount } from "enzyme";
import React from "react"; import React from "react";
import * as ViewModels from "../../../Contracts/ViewModels";
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent"; import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
const PARTITION_KEY_HEADER = "partitionKey"; const PARTITION_KEY_HEADER = "partitionKey";
@@ -25,6 +26,10 @@ describe("DocumentsTableComponent", () => {
partitionKeyHeaders: [PARTITION_KEY_HEADER], partitionKeyHeaders: [PARTITION_KEY_HEADER],
}, },
isSelectionDisabled: false, isSelectionDisabled: false,
collection: {
databaseId: "db",
id: ((): string => "coll") as ko.Observable<string>,
} as ViewModels.CollectionBase,
}); });
it("should render documents and partition keys in header", () => { it("should render documents and partition keys in header", () => {

View File

@@ -1,4 +1,5 @@
import { import {
createTableColumn,
Menu, Menu,
MenuItem, MenuItem,
MenuList, MenuList,
@@ -16,19 +17,25 @@ import {
TableRow, TableRow,
TableRowId, TableRowId,
TableSelectionCell, TableSelectionCell,
createTableColumn,
useArrowNavigationGroup, useArrowNavigationGroup,
useTableColumnSizing_unstable, useTableColumnSizing_unstable,
useTableFeatures, useTableFeatures,
useTableSelection, useTableSelection,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { NormalizedEventKey } from "Common/Constants"; import { NormalizedEventKey } from "Common/Constants";
import {
ColumnSizesMap,
readSubComponentState,
saveSubComponentState,
SubComponentName,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper"; import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { LayoutConstants } from "Explorer/Theme/ThemeUtil"; import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils"; import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { FixedSizeList as List, ListChildComponentProps } from "react-window"; import { FixedSizeList as List, ListChildComponentProps } from "react-window";
import * as ViewModels from "../../../Contracts/ViewModels";
export type DocumentsTableComponentItem = { export type DocumentsTableComponentItem = {
id: string; id: string;
@@ -47,6 +54,7 @@ export interface IDocumentsTableComponentProps {
columnHeaders: ColumnHeaders; columnHeaders: ColumnHeaders;
style?: React.CSSProperties; style?: React.CSSProperties;
isSelectionDisabled?: boolean; isSelectionDisabled?: boolean;
collection: ViewModels.CollectionBase;
} }
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> { interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
@@ -59,6 +67,10 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
data: TableRowData[]; data: TableRowData[];
} }
const defaultSize = {
idealWidth: 200,
minWidth: 50,
};
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
items, items,
onSelectedRowsChange, onSelectedRowsChange,
@@ -67,32 +79,53 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
size, size,
columnHeaders, columnHeaders,
isSelectionDisabled, isSelectionDisabled,
collection,
}: IDocumentsTableComponentProps) => { }: IDocumentsTableComponentProps) => {
const styles = useDocumentsTabStyles(); const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
const columnIds = ["id"].concat(columnHeaders.partitionKeyHeaders);
const initialSizingOptions: TableColumnSizingOptions = { const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {});
id: { const columnSizesPx: TableColumnSizingOptions = {};
idealWidth: 280, columnIds.forEach((columnId) => {
minWidth: 50, if (
}, !columnSizesMap ||
}; !columnSizesMap[columnId] ||
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => { columnSizesMap[columnId].widthPx === undefined ||
initialSizingOptions[pkHeader] = { isNaN(columnSizesMap[columnId].widthPx)
idealWidth: 200, ) {
minWidth: 50, columnSizesPx[columnId] = defaultSize;
}; } else {
columnSizesPx[columnId] = {
idealWidth: columnSizesMap[columnId].widthPx,
minWidth: 50,
};
}
});
return columnSizesPx;
}); });
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions); const styles = useDocumentsTabStyles();
const onColumnResize = React.useCallback((_, { columnId, width }) => { const onColumnResize = React.useCallback((_, { columnId, width }: { columnId: string; width: number }) => {
setColumnSizingOptions((state) => ({ setColumnSizingOptions((state) => {
...state, const newSizingOptions = {
[columnId]: { ...state,
...state[columnId], [columnId]: {
idealWidth: width, ...state[columnId],
}, idealWidth: width,
})); },
};
const persistentSizes = Object.keys(newSizingOptions).reduce((acc, key) => {
acc[key] = {
widthPx: newSizingOptions[key].idealWidth,
};
return acc;
}, {} as ColumnSizesMap);
saveSubComponentState(SubComponentName.ColumnSizes, collection, persistentSizes, true);
return newSizingOptions;
});
}, []); }, []);
// Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes // Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes

View File

@@ -38,9 +38,11 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
} }
} }
> >
<Allotment> <Allotment
onDragEnd={[Function]}
>
<Allotment.Pane <Allotment.Pane
minSize={175} minSize={55}
preferredSize="35%" preferredSize="35%"
> >
<div <div
@@ -77,6 +79,12 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
className="___9o87uj0_0000000 ffefeo0" className="___9o87uj0_0000000 ffefeo0"
> >
<DocumentsTableComponent <DocumentsTableComponent
collection={
{
"databaseId": "databaseId",
"id": [Function],
}
}
columnHeaders={ columnHeaders={
{ {
"idHeader": "id", "idHeader": "id",
@@ -97,8 +105,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
</div> </div>
</Allotment.Pane> </Allotment.Pane>
<Allotment.Pane <Allotment.Pane
minSize={300} minSize={30}
preferredSize="65%"
> >
<div <div
style={ style={

View File

@@ -2,6 +2,12 @@
exports[`DocumentsTableComponent should not render selection column when isSelectionDisabled is true 1`] = ` exports[`DocumentsTableComponent should not render selection column when isSelectionDisabled is true 1`] = `
<DocumentsTableComponent <DocumentsTableComponent
collection={
{
"databaseId": "db",
"id": [Function],
}
}
columnHeaders={ columnHeaders={
{ {
"idHeader": "id", "idHeader": "id",
@@ -995,6 +1001,12 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = ` exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = `
<DocumentsTableComponent <DocumentsTableComponent
collection={
{
"databaseId": "db",
"id": [Function],
}
}
columnHeaders={ columnHeaders={
{ {
"idHeader": "id", "idHeader": "id",

View File

@@ -52,7 +52,7 @@ export const isAccountRestrictedForConnectionStringLogin = async (connectionStri
const headers = new Headers(); const headers = new Headers();
headers.append(HttpHeaders.connectionString, connectionString); headers.append(HttpHeaders.connectionString, connectionString);
const backendEndpoint: string = useNewPortalBackendEndpoint(BackendApi.PortalSettings) const backendEndpoint: string = useNewPortalBackendEndpoint(BackendApi.AccountRestrictions)
? configContext.PORTAL_BACKEND_ENDPOINT ? configContext.PORTAL_BACKEND_ENDPOINT
: configContext.BACKEND_ENDPOINT; : configContext.BACKEND_ENDPOINT;

View File

@@ -0,0 +1,170 @@
import { createKeyFromPath, deleteState, loadState, MAX_ENTRY_NB, saveState } from "Shared/AppStatePersistenceUtility";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
jest.mock("Shared/StorageUtility", () => ({
LocalStorageUtility: {
getEntryObject: jest.fn(),
setEntryObject: jest.fn(),
},
StorageKey: {
AppState: "AppState",
},
}));
describe("AppStatePersistenceUtility", () => {
const storePath = {
componentName: "a",
subComponentName: "b",
globalAccountName: "c",
databaseName: "d",
containerName: "e",
};
const key = createKeyFromPath(storePath);
beforeEach(() => {
jest.clearAllMocks();
});
beforeEach(() => {
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
key0: {
schemaVersion: 1,
timestamp: 0,
data: {},
},
});
});
describe("saveState()", () => {
const testState = { aa: 1, bb: "2", cc: [3, 4] };
it("should save state", () => {
saveState(storePath, testState);
expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledTimes(1);
expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledWith(StorageKey.AppState, expect.any(Object));
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(passedState[key].data).toHaveProperty("aa", 1);
});
it("should save state with timestamp", () => {
saveState(storePath, testState);
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(passedState[key]).toHaveProperty("timestamp");
expect(passedState[key].timestamp).toBeGreaterThan(0);
});
it("should add state to existing state", () => {
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
key0: {
schemaVersion: 1,
timestamp: 0,
data: { dd: 5 },
},
});
saveState(storePath, testState);
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(passedState["key0"].data).toHaveProperty("dd", 5);
});
it("should remove the oldest entry when the number of entries exceeds the limit", () => {
// Fill up storage with MAX entries
const currentAppState = {};
for (let i = 0; i < MAX_ENTRY_NB; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(currentAppState as any)[`key${i}`] = {
schemaVersion: 1,
timestamp: i,
data: {},
};
}
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(currentAppState);
saveState(storePath, testState);
// Verify that the new entry is saved
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(passedState[key].data).toHaveProperty("aa", 1);
// Verify that the oldest entry is removed (smallest timestamp)
const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(Object.keys(passedAppState).length).toBe(MAX_ENTRY_NB);
expect(passedAppState).not.toHaveProperty("key0");
});
it("should not remove the oldest entry when the number of entries does not exceed the limit", () => {
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
key0: {
schemaVersion: 1,
timestamp: 0,
data: {},
},
key1: {
schemaVersion: 1,
timestamp: 1,
data: {},
},
});
saveState(storePath, testState);
const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(Object.keys(passedAppState).length).toBe(3);
});
});
describe("loadState()", () => {
it("should load state", () => {
const data = { aa: 1, bb: "2", cc: [3, 4] };
const testState = {
[key]: {
schemaVersion: 1,
timestamp: 0,
data,
},
};
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(testState);
const state = loadState(storePath);
expect(state).toEqual(data);
});
it("should return undefined if the state is not found", () => {
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(null);
const state = loadState(storePath);
expect(state).toBeUndefined();
});
});
describe("deleteState()", () => {
it("should delete state", () => {
const key = createKeyFromPath(storePath);
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
[key]: {
schemaVersion: 1,
timestamp: 0,
data: {},
},
otherKey: {
schemaVersion: 2,
timestamp: 0,
data: {},
},
});
deleteState(storePath);
expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledTimes(1);
const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(passedAppState).not.toHaveProperty(key);
expect(passedAppState).toHaveProperty("otherKey");
});
});
describe("createKeyFromPath()", () => {
it("should create path that contains all components", () => {
const key = createKeyFromPath(storePath);
expect(key).toContain(storePath.componentName);
expect(key).toContain(storePath.subComponentName);
expect(key).toContain(storePath.globalAccountName);
expect(key).toContain(storePath.databaseName);
expect(key).toContain(storePath.containerName);
});
});
});

View File

@@ -0,0 +1,109 @@
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
// The component name whose state is being saved. Component name must not include special characters.
export type ComponentName = "DocumentsTab";
const SCHEMA_VERSION = 1;
// Export for testing purposes
export const MAX_ENTRY_NB = 100_000; // Limit number of entries to 100k
export interface StateData {
schemaVersion: number;
timestamp: number;
data: unknown;
}
type StorePath = {
componentName: string;
subComponentName?: string;
globalAccountName?: string;
databaseName?: string;
containerName?: string;
};
// Load and save state data
export const loadState = (path: StorePath): unknown => {
const appState =
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
const key = createKeyFromPath(path);
return appState[key]?.data;
};
export const saveState = (path: StorePath, state: unknown): void => {
// Retrieve state object
const appState =
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
const key = createKeyFromPath(path);
appState[key] = {
schemaVersion: SCHEMA_VERSION,
timestamp: Date.now(),
data: state,
};
if (Object.keys(appState).length > MAX_ENTRY_NB) {
// Remove the oldest entry
const oldestKey = Object.keys(appState).reduce((oldest, current) =>
appState[current].timestamp < appState[oldest].timestamp ? current : oldest,
);
delete appState[oldestKey];
}
LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
};
export const deleteState = (path: StorePath): void => {
// Retrieve state object
const appState =
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
const key = createKeyFromPath(path);
delete appState[key];
LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
};
// This is for high-frequency state changes
let timeoutId: NodeJS.Timeout | undefined;
export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => saveState(path, state), debounceDelayMs);
};
interface ApplicationState {
[statePath: string]: StateData;
}
const orderedPathSegments: (keyof StorePath)[] = [
"subComponentName",
"globalAccountName",
"databaseName",
"containerName",
];
/**
* /componentName/subComponentName/globalAccountName/databaseName/containerName/
* Any of the path segments can be "" except componentName
* Export for testing purposes
* @param path
*/
export const createKeyFromPath = (path: StorePath): string => {
if (path.componentName.includes("/")) {
throw new Error(`Invalid component name: ${path.componentName}`);
}
let key = `/${path.componentName}`; // ComponentName is always there
orderedPathSegments.forEach((segment) => {
const segmentValue = path[segment as keyof StorePath];
if (segmentValue.includes("/")) {
throw new Error(`Invalid setting path segment: ${segment}`);
}
key += `/${segmentValue !== undefined ? segmentValue : ""}`;
});
return key;
};
/**
* Remove the entire app state key from local storage
*/
export const deleteAllStates = (): void => {
LocalStorageUtility.removeEntry(StorageKey.AppState);
};

View File

@@ -20,3 +20,14 @@ export const setEntryNumber = (key: StorageKey, value: number): void =>
export const setEntryBoolean = (key: StorageKey, value: boolean): void => export const setEntryBoolean = (key: StorageKey, value: boolean): void =>
localStorage.setItem(StorageKey[key], value.toString()); localStorage.setItem(StorageKey[key], value.toString());
export const setEntryObject = (key: StorageKey, value: unknown): void => {
localStorage.setItem(StorageKey[key], JSON.stringify(value));
};
export const getEntryObject = <T>(key: StorageKey): T | null => {
const item = localStorage.getItem(StorageKey[key]);
if (item) {
return JSON.parse(item) as T;
}
return null;
};

View File

@@ -30,6 +30,7 @@ export enum StorageKey {
VisitedAccounts, VisitedAccounts,
PriorityLevel, PriorityLevel,
DefaultQueryResultsView, DefaultQueryResultsView,
AppState,
} }
export const hasRUThresholdBeenConfigured = (): boolean => { export const hasRUThresholdBeenConfigured = (): boolean => {
@@ -56,10 +57,10 @@ export const getRUThreshold = (): number => {
export const getDefaultQueryResultsView = (): SplitterDirection => { export const getDefaultQueryResultsView = (): SplitterDirection => {
const defaultQueryResultsViewRaw = LocalStorageUtility.getEntryString(StorageKey.DefaultQueryResultsView); const defaultQueryResultsViewRaw = LocalStorageUtility.getEntryString(StorageKey.DefaultQueryResultsView);
if (defaultQueryResultsViewRaw === SplitterDirection.Horizontal) { if (defaultQueryResultsViewRaw === SplitterDirection.Vertical) {
return SplitterDirection.Horizontal; return SplitterDirection.Vertical;
} }
return SplitterDirection.Vertical; return SplitterDirection.Horizontal;
}; };
export const DefaultRUThreshold = 5000; export const DefaultRUThreshold = 5000;

View File

@@ -139,6 +139,9 @@ export enum Action {
QueryEdited, QueryEdited,
ExecuteQueryGeneratedFromQueryCopilot, ExecuteQueryGeneratedFromQueryCopilot,
DeleteDocuments, DeleteDocuments,
ReadPersistedTabState,
SavePersistedTabState,
DeletePersistedTabState,
} }
export const ActionModifiers = { export const ActionModifiers = {

View File

@@ -52,7 +52,11 @@ export const defaultAllowedArmEndpoints: ReadonlyArray<string> = [
"https://management.chinacloudapi.cn", "https://management.chinacloudapi.cn",
]; ];
export const allowedAadEndpoints: ReadonlyArray<string> = ["https://login.microsoftonline.com/"]; export const allowedAadEndpoints: ReadonlyArray<string> = [
"https://login.microsoftonline.com/",
"https://login.microsoftonline.us/",
"https://login.partner.microsoftonline.cn/",
];
export const defaultAllowedBackendEndpoints: ReadonlyArray<string> = [ export const defaultAllowedBackendEndpoints: ReadonlyArray<string> = [
"https://main.documentdb.ext.azure.com", "https://main.documentdb.ext.azure.com",
@@ -74,6 +78,13 @@ export const PortalBackendIPs: { [key: string]: string[] } = {
//usnat: ["7.28.202.68"], //usnat: ["7.28.202.68"],
}; };
export const PortalBackendOutboundIPs: { [key: string]: string[] } = {
[PortalBackendEndpoints.Mpac]: ["13.91.105.215", "4.210.172.107"],
[PortalBackendEndpoints.Prod]: ["13.88.56.148", "40.91.218.243"],
[PortalBackendEndpoints.Fairfax]: ["52.247.163.6", "52.244.134.181"],
[PortalBackendEndpoints.Mooncake]: ["163.228.137.6", "143.64.170.142"],
};
export const MongoProxyOutboundIPs: { [key: string]: string[] } = { export const MongoProxyOutboundIPs: { [key: string]: string[] } = {
[MongoProxyEndpoints.Mpac]: ["20.245.81.54", "40.118.23.126"], [MongoProxyEndpoints.Mpac]: ["20.245.81.54", "40.118.23.126"],
[MongoProxyEndpoints.Prod]: ["40.80.152.199", "13.95.130.121"], [MongoProxyEndpoints.Prod]: ["40.80.152.199", "13.95.130.121"],
@@ -164,7 +175,23 @@ export function useNewPortalBackendEndpoint(backendApi: string): boolean {
PortalBackendEndpoints.Mpac, PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod, PortalBackendEndpoints.Prod,
], ],
[BackendApi.AccountRestrictions]: [PortalBackendEndpoints.Development, PortalBackendEndpoints.Mpac], [BackendApi.AccountRestrictions]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
],
[BackendApi.RuntimeProxy]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
],
[BackendApi.DisallowedLocations]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
PortalBackendEndpoints.Fairfax,
PortalBackendEndpoints.Mooncake,
],
}; };
if (!newBackendApiEnvironmentMap[backendApi] || !configContext.PORTAL_BACKEND_ENDPOINT) { if (!newBackendApiEnvironmentMap[backendApi] || !configContext.PORTAL_BACKEND_ENDPOINT) {

View File

@@ -1,20 +1,15 @@
import { MongoProxyEndpoints, PortalBackendEndpoints } from "Common/Constants";
import { resetConfigContext, updateConfigContext } from "ConfigContext"; import { resetConfigContext, updateConfigContext } from "ConfigContext";
import { DatabaseAccount, IpRule } from "Contracts/DataModels"; import { DatabaseAccount, IpRule } from "Contracts/DataModels";
import { updateUserContext } from "UserContext"; import { updateUserContext } from "UserContext";
import { PortalBackendIPs } from "Utils/EndpointUtils"; import { MongoProxyOutboundIPs, PortalBackendIPs, PortalBackendOutboundIPs } from "Utils/EndpointUtils";
import { getNetworkSettingsWarningMessage } from "./NetworkUtility"; import { getNetworkSettingsWarningMessage } from "./NetworkUtility";
describe("NetworkUtility tests", () => { describe("NetworkUtility tests", () => {
describe("getNetworkSettingsWarningMessage", () => { describe("getNetworkSettingsWarningMessage", () => {
const legacyBackendEndpoint: string = "https://main.documentdb.ext.azure.com";
const publicAccessMessagePart = "Please enable public access to proceed"; const publicAccessMessagePart = "Please enable public access to proceed";
const accessMessagePart = "Please allow access from Azure Portal to proceed"; const accessMessagePart = "Please allow access from Azure Portal to proceed";
// validEnpoints are a subset of those from Utils/EndpointValidation/PortalBackendIPs
const validEndpoints = [
"https://main.documentdb.ext.azure.com",
"https://main.documentdb.ext.azure.cn",
"https://main.documentdb.ext.azure.us",
];
let warningMessageResult: string; let warningMessageResult: string;
const warningMessageFunc = (msg: string) => (warningMessageResult = msg); const warningMessageFunc = (msg: string) => (warningMessageResult = msg);
@@ -52,52 +47,59 @@ describe("NetworkUtility tests", () => {
expect(warningMessageResult).toContain(publicAccessMessagePart); expect(warningMessageResult).toContain(publicAccessMessagePart);
}); });
it(`should return no message when the appropriate ip rules are added to mongo/cassandra account per endpoint`, () => { it(`should return no message when the appropriate ip rules are added to mongo/cassandra account per endpoint`, async () => {
validEndpoints.forEach(async (endpoint) => { const portalBackendOutboundIPsWithLegacyIPs: string[] = [
updateUserContext({ ...PortalBackendOutboundIPs[PortalBackendEndpoints.Mpac],
databaseAccount: { ...PortalBackendOutboundIPs[PortalBackendEndpoints.Prod],
kind: "MongoDB", ...MongoProxyOutboundIPs[MongoProxyEndpoints.Mpac],
properties: { ...MongoProxyOutboundIPs[MongoProxyEndpoints.Prod],
ipRules: PortalBackendIPs[endpoint].map((ip: string) => ({ ipAddressOrRange: ip }) as IpRule), ...PortalBackendIPs["https://main.documentdb.ext.azure.com"],
publicNetworkAccess: "Enabled", ];
}, updateUserContext({
} as DatabaseAccount, databaseAccount: {
}); kind: "MongoDB",
properties: {
updateConfigContext({ ipRules: portalBackendOutboundIPsWithLegacyIPs.map((ip: string) => ({ ipAddressOrRange: ip }) as IpRule),
BACKEND_ENDPOINT: endpoint, publicNetworkAccess: "Enabled",
}); },
} as DatabaseAccount,
let asyncWarningMessageResult: string;
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
expect(asyncWarningMessageResult).toBeUndefined();
}); });
updateConfigContext({
BACKEND_ENDPOINT: legacyBackendEndpoint,
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac,
});
let asyncWarningMessageResult: string;
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
expect(asyncWarningMessageResult).toBeUndefined();
}); });
it("should return accessMessage when incorrent ip rule is added to mongo/cassandra account per endpoint", () => { it("should return accessMessage when incorrent ip rule is added to mongo/cassandra account per endpoint", async () => {
validEndpoints.forEach(async (endpoint) => { updateUserContext({
updateUserContext({ databaseAccount: {
databaseAccount: { kind: "MongoDB",
kind: "MongoDB", properties: {
properties: { ipRules: [{ ipAddressOrRange: "1.1.1.1" }],
ipRules: [{ ipAddressOrRange: "1.1.1.1" }], publicNetworkAccess: "Enabled",
publicNetworkAccess: "Enabled", },
}, } as DatabaseAccount,
} as DatabaseAccount,
});
updateConfigContext({
BACKEND_ENDPOINT: endpoint,
});
let asyncWarningMessageResult: string;
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
expect(asyncWarningMessageResult).toContain(accessMessagePart);
}); });
updateConfigContext({
BACKEND_ENDPOINT: legacyBackendEndpoint,
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac,
});
let asyncWarningMessageResult: string;
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
expect(asyncWarningMessageResult).toContain(accessMessagePart);
}); });
// Postgres and vcore mongo account checks basically pass through to CheckFirewallRules so those // Postgres and vcore mongo account checks basically pass through to CheckFirewallRules so those

View File

@@ -1,7 +1,13 @@
import { CassandraProxyEndpoints, MongoProxyEndpoints, PortalBackendEndpoints } from "Common/Constants";
import { configContext } from "ConfigContext"; import { configContext } from "ConfigContext";
import { checkFirewallRules } from "Explorer/Tabs/Shared/CheckFirewallRules"; import { checkFirewallRules } from "Explorer/Tabs/Shared/CheckFirewallRules";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import { PortalBackendIPs } from "Utils/EndpointUtils"; import {
CassandraProxyOutboundIPs,
MongoProxyOutboundIPs,
PortalBackendIPs,
PortalBackendOutboundIPs,
} from "Utils/EndpointUtils";
export const getNetworkSettingsWarningMessage = async ( export const getNetworkSettingsWarningMessage = async (
setStateFunc: (warningMessage: string) => void, setStateFunc: (warningMessage: string) => void,
@@ -45,18 +51,53 @@ export const getNetworkSettingsWarningMessage = async (
const ipRules = accountProperties.ipRules; const ipRules = accountProperties.ipRules;
// public network access is NOT set to "All networks" // public network access is NOT set to "All networks"
if (ipRules?.length > 0) { if (ipRules?.length > 0) {
if (userContext.apiType === "Cassandra" || userContext.apiType === "Mongo") { const isProdOrMpacPortalBackendEndpoint: boolean = [
const portalIPs = PortalBackendIPs[configContext.BACKEND_ENDPOINT]; PortalBackendEndpoints.Mpac,
let numberOfMatches = 0; PortalBackendEndpoints.Prod,
ipRules.forEach((ipRule) => { ].includes(configContext.PORTAL_BACKEND_ENDPOINT);
if (portalIPs.indexOf(ipRule.ipAddressOrRange) !== -1) { const portalBackendOutboundIPs: string[] = isProdOrMpacPortalBackendEndpoint
numberOfMatches++; ? [
} ...PortalBackendOutboundIPs[PortalBackendEndpoints.Mpac],
}); ...PortalBackendOutboundIPs[PortalBackendEndpoints.Prod],
]
: PortalBackendOutboundIPs[configContext.PORTAL_BACKEND_ENDPOINT];
let portalIPs: string[] = [...portalBackendOutboundIPs, ...PortalBackendIPs[configContext.BACKEND_ENDPOINT]];
if (numberOfMatches !== portalIPs.length) { if (userContext.apiType === "Mongo") {
setStateFunc(accessMessage); const isProdOrMpacMongoProxyEndpoint: boolean = [MongoProxyEndpoints.Mpac, MongoProxyEndpoints.Prod].includes(
configContext.MONGO_PROXY_ENDPOINT,
);
const mongoProxyOutboundIPs: string[] = isProdOrMpacMongoProxyEndpoint
? [...MongoProxyOutboundIPs[MongoProxyEndpoints.Mpac], ...MongoProxyOutboundIPs[MongoProxyEndpoints.Prod]]
: MongoProxyOutboundIPs[configContext.MONGO_PROXY_ENDPOINT];
portalIPs = [...portalIPs, ...mongoProxyOutboundIPs];
} else if (userContext.apiType === "Cassandra") {
const isProdOrMpacCassandraProxyEndpoint: boolean = [
CassandraProxyEndpoints.Mpac,
CassandraProxyEndpoints.Prod,
].includes(configContext.CASSANDRA_PROXY_ENDPOINT);
const cassandraProxyOutboundIPs: string[] = isProdOrMpacCassandraProxyEndpoint
? [
...CassandraProxyOutboundIPs[CassandraProxyEndpoints.Mpac],
...CassandraProxyOutboundIPs[CassandraProxyEndpoints.Prod],
]
: CassandraProxyOutboundIPs[configContext.CASSANDRA_PROXY_ENDPOINT];
portalIPs = [...portalIPs, ...cassandraProxyOutboundIPs];
}
let numberOfMatches = 0;
ipRules.forEach((ipRule) => {
if (portalIPs.indexOf(ipRule.ipAddressOrRange) !== -1) {
numberOfMatches++;
} }
});
if (numberOfMatches !== portalIPs.length) {
setStateFunc(accessMessage);
} }
} }
} }

View File

@@ -147,5 +147,33 @@ describe("Query Utils", () => {
expect(expectedPartitionKeyValues).toContain(documentContent["Type"]); expect(expectedPartitionKeyValues).toContain(documentContent["Type"]);
expect(expectedPartitionKeyValues).toContain(documentContent["Status"]); expect(expectedPartitionKeyValues).toContain(documentContent["Status"]);
}); });
it("should extract three partition key values even if one is empty", () => {
const multiPartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.MultiHash,
paths: ["/Country", "/Region", "/Category"],
};
const expectedPartitionKeyValues: string[] = ["United States", "US-Washington", ""];
const partitioinKeyValues: PartitionKey[] = extractPartitionKeyValues(
documentContent,
multiPartitionKeyDefinition,
);
expect(partitioinKeyValues.length).toBe(3);
expect(expectedPartitionKeyValues).toContain(documentContent["Country"]);
expect(expectedPartitionKeyValues).toContain(documentContent["Region"]);
expect(expectedPartitionKeyValues).toContain(documentContent["Category"]);
});
it("should extract no partition key values in the case nested partition key", () => {
const singlePartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.Hash,
paths: ["/Location.type"],
};
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(
documentContent,
singlePartitionKeyDefinition,
);
expect(partitionKeyValues.length).toBe(0);
});
}); });
}); });

View File

@@ -96,7 +96,7 @@ export const extractPartitionKeyValues = (
const partitionKeyValues: PartitionKey[] = []; const partitionKeyValues: PartitionKey[] = [];
partitionKeyDefinition.paths.forEach((partitionKeyPath: string) => { partitionKeyDefinition.paths.forEach((partitionKeyPath: string) => {
const partitionKeyPathWithoutSlash: string = partitionKeyPath.substring(1); const partitionKeyPathWithoutSlash: string = partitionKeyPath.substring(1);
if (documentContent[partitionKeyPathWithoutSlash]) { if (documentContent[partitionKeyPathWithoutSlash] !== undefined) {
partitionKeyValues.push(documentContent[partitionKeyPathWithoutSlash]); partitionKeyValues.push(documentContent[partitionKeyPathWithoutSlash]);
} }
}); });

View File

@@ -619,6 +619,31 @@ function shouldForwardMessage(message: PortalMessage, messageOrigin: string) {
return messageOrigin === window.document.location.origin && message.type === MessageTypes.TelemetryInfo; return messageOrigin === window.document.location.origin && message.type === MessageTypes.TelemetryInfo;
} }
function updateAADEndpoints(portalEnv: PortalEnv) {
switch (portalEnv) {
case "prod1":
case "prod":
updateConfigContext({
AAD_ENDPOINT: Constants.AadEndpoints.Prod,
});
break;
case "fairfax":
updateConfigContext({
AAD_ENDPOINT: Constants.AadEndpoints.Fairfax,
});
break;
case "mooncake":
updateConfigContext({
AAD_ENDPOINT: Constants.AadEndpoints.Mooncake,
});
break;
default:
console.warn(`Unknown portal environment: ${portalEnv}`);
break;
}
}
function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) { function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
if ( if (
configContext.BACKEND_ENDPOINT && configContext.BACKEND_ENDPOINT &&
@@ -639,6 +664,8 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
PORTAL_BACKEND_ENDPOINT: inputs.portalBackendEndpoint, PORTAL_BACKEND_ENDPOINT: inputs.portalBackendEndpoint,
}); });
updateAADEndpoints(inputs.serverId as PortalEnv);
updateUserContext({ updateUserContext({
authorizationToken, authorizationToken,
databaseAccount, databaseAccount,

View File

@@ -1,14 +1,34 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ApiEndpoints } from "../Common/Constants"; import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { ApiEndpoints, BackendApi, HttpHeaders } from "../Common/Constants";
import { configContext } from "../ConfigContext"; import { configContext } from "../ConfigContext";
import { AccessInputMetadata } from "../Contracts/DataModels"; import { AccessInputMetadata } from "../Contracts/DataModels";
const url = `${configContext.BACKEND_ENDPOINT}${ApiEndpoints.guestRuntimeProxy}/accessinputmetadata?_=1609359229955`; const url = `${configContext.BACKEND_ENDPOINT}${ApiEndpoints.guestRuntimeProxy}/accessinputmetadata?_=1609359229955`;
export async function fetchAccessData(portalToken: string): Promise<AccessInputMetadata> { export async function fetchAccessData(portalToken: string): Promise<AccessInputMetadata> {
if (!useNewPortalBackendEndpoint(BackendApi.RuntimeProxy)) {
return fetchAccessData_ToBeDeprecated(portalToken);
}
const headers = new Headers(); const headers = new Headers();
// Portal encrypted token API quirk: The token header must be URL encoded // Portal encrypted token API quirk: The token header must be URL encoded
headers.append("x-ms-encrypted-auth-token", encodeURIComponent(portalToken)); headers.append(HttpHeaders.guestAccessToken, encodeURIComponent(portalToken));
const url: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/accessinputmetadata`;
const options = {
method: "GET",
headers: headers,
};
return fetch(url, options)
.then((response) => response.json())
.catch((error) => console.error(error));
}
export async function fetchAccessData_ToBeDeprecated(portalToken: string): Promise<AccessInputMetadata> {
const headers = new Headers();
// Portal encrypted token API quirk: The token header must be URL encoded
headers.append(HttpHeaders.guestAccessToken, encodeURIComponent(portalToken));
const options = { const options = {
method: "GET", method: "GET",

View File

@@ -71,6 +71,8 @@ test("Query stats", async () => {
}); });
test("Query errors", async () => { test("Query errors", async () => {
test.skip(true, "Disabled due to an issue with error reporting in the backend.");
await queryEditor.locator.click(); await queryEditor.locator.click();
await queryEditor.setText("SELECT\n glarb(c.id),\n blarg(c.id)\nFROM c"); await queryEditor.setText("SELECT\n glarb(c.id),\n blarg(c.id)\nFROM c");

View File

@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowJs": true,
"sourceMap": true, "sourceMap": false,
"noImplicitAny": true, "noImplicitAny": true,
"noImplicitThis": true, "noImplicitThis": true,
"noImplicitReturns": true, "noImplicitReturns": true,