Compare commits

...

18 Commits

Author SHA1 Message Date
Vsevolod Kukol
520e530e1f Revert "Enable column selection and sorting in DocumentsTab (with persistence…"
This reverts commit 7e95f5d8c8.
2024-09-05 21:36:11 +02:00
Laurent Nguyen
7e95f5d8c8 Enable column selection and sorting in DocumentsTab (with persistence) (#1881)
* Initial implementation of saving split value to local storage

* Make table columns generic (no more id and partition keys)

* Save column width

* Add column selection from right-click

* Implement new menu for column selection with search.

* Switch icons and search compare with lowercase.

* Search uses string includes instead of startsWith

* Only allow data fields that can be rendered (string and numbers) in column selection

* Accumulate properties rather than replace for column definitions

* Do not allow deselecting all columns

* Move table values under its own property

* Update choices of column when creating new or updating document

* Rework column selection UI

* Fix table size issue with some heuristics

* Fix heuristic for size update

* Don't allow unselecting last column

* Implement column sorting

* Fix format

* Fix format, update snapshots

* Add reset button to column selection and fix naming of openUploadItemsPanePane()

* Fix unit tests

* Fix unit test

* Persist column selection

* Persist column sorting

* Save columns definition (schema) along with selected columns.

* Merge branch 'master' into users/languy/save-documentstab-prefs

* Revert "Merge branch 'master' into users/languy/save-documentstab-prefs"

This reverts commit e5a82fd356.

* Disable column selection for Mongo. Remove extra refresh button

* Update test snapshots

* Remove unused function

* Fix table width

* Add background color to "..." button for column selection

* Label to indicate which field is a partition key in Column Selection Pane

* Update unit test snapshot

* Move column selection and sorting behind feature flag enableDocumentsTableColumnSelection

* Cleanup checkbox styles
2024-09-05 17:43:40 +02:00
Ashley Stanton-Nurse
1be221e106 fix rendering of global commands menu (#1953)
* fix rendering of global commands menu

* refmt
2024-09-05 08:33:39 -07:00
Asier Isayas
8e7a3db67e Point DE to new Mongo and Cassandra Proxies only and activate Cassandra Proxy in FF/MC (#1958)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-09-05 10:57:55 -04:00
Laurent Nguyen
07c0ead523 Improve SettingsPane (#1945)
* Use accordion in settingsPane

* Fix format

* Fix format for retry interval

* Fix unit tests

* Cosmetic changes

* Move info tips into accordion section

* Update snapshot
2024-09-05 11:51:32 +02:00
Laurent Nguyen
4296b5ae02 Add more default filters (#1955) 2024-09-05 07:16:48 +02:00
Ashley Stanton-Nurse
e8a5658799 Reduce extra spacing in the new tree and items tab (#1951)
* reduce layout row size and default font size

* icons for the tree

* refmt and update snapshots

* remove commented out code
2024-09-04 13:07:27 -07:00
vchske
b4973e8367 Fixing regex on allowedParentFrameOrigins to address XSS (#1956) 2024-09-04 11:35:32 -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
41 changed files with 2691 additions and 1427 deletions

View File

@@ -1906,8 +1906,14 @@ input::-webkit-calendar-picker-indicator::after {
}
.nav-tabs-margin {
padding-top: 5px;
height: 32px;
background-color: #f2f2f2;
.nav-tabs {
display: flex;
align-items: flex-end;
height: 100%;
}
}
.navTabHeight {

View File

@@ -134,6 +134,8 @@ export class BackendApi {
public static readonly GenerateToken: string = "GenerateToken";
public static readonly PortalSettings: string = "PortalSettings";
public static readonly AccountRestrictions: string = "AccountRestrictions";
public static readonly RuntimeProxy: string = "RuntimeProxy";
public static readonly DisallowedLocations: string = "DisallowedLocations";
}
export class PortalBackendEndpoints {
@@ -183,6 +185,12 @@ export class CassandraProxyAPIs {
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 {
public static CustomPageOption: string = "custom";
public static UnlimitedPageOption: string = "unlimited";

View File

@@ -3,15 +3,16 @@ import { getAuthorizationTokenUsingResourceTokens } from "Common/getAuthorizatio
import { AuthorizationToken } from "Contracts/FabricMessageTypes";
import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
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 { userContext } from "../UserContext";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
import { getErrorMessage } from "./ErrorHandlingUtils";
import * as Logger from "../Common/Logger";
const _global = typeof self === "undefined" ? window : self;
@@ -123,6 +124,37 @@ export async function getTokenFromAuthService(
verb: string,
resourceType: 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> {
try {
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(
params: DataModels.CreateCollectionParams,
): Promise<DataModels.Collection> {
@@ -677,18 +720,11 @@ export function useMongoProxyEndpoint(api: string): boolean {
MongoProxyEndpoints.Local,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
// MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
];
let canAccessMongoProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
if (
configContext.MONGO_PROXY_ENDPOINT !== MongoProxyEndpoints.Local &&
userContext.databaseAccount.properties.ipRules?.length > 0
) {
canAccessMongoProxy = canAccessMongoProxy && configContext.MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED;
}
return (
canAccessMongoProxy &&
configContext.NEW_MONGO_APIS?.includes(api) &&
activeMongoProxyEndpoints.includes(configContext.MONGO_PROXY_ENDPOINT)
);

View File

@@ -3,11 +3,12 @@ import * as React from "react";
export interface TooltipProps {
children: string;
className?: string;
}
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children }: TooltipProps) => {
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children, className }: TooltipProps) => {
return (
<span>
<span className={className}>
<TooltipHost content={children}>
<Icon iconName="Info" ariaLabel={children} className="panelInfoIcon" tabIndex={0} />
</TooltipHost>

View File

@@ -53,10 +53,8 @@ export interface ConfigContext {
NEW_BACKEND_APIS?: BackendApi[];
MONGO_BACKEND_ENDPOINT?: string;
MONGO_PROXY_ENDPOINT?: string;
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED?: boolean;
NEW_MONGO_APIS?: string[];
CASSANDRA_PROXY_ENDPOINT?: string;
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: boolean;
NEW_CASSANDRA_APIS?: string[];
PROXY_PATH?: string;
JUNO_ENDPOINT: string;
@@ -87,7 +85,7 @@ let configContext: Readonly<ConfigContext> = {
`^https:\\/\\/.*\\.analysis-df\\.net$`,
`^https:\\/\\/.*\\.analysis-df\\.windows\\.net$`,
`^https:\\/\\/.*\\.azure-test\\.net$`,
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net`,
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net$`,
], // Webpack injects this at build time
gitSha: process.env.GIT_SHA,
hostedExplorerURL: "https://cosmos.azure.com/",
@@ -117,11 +115,10 @@ let configContext: Readonly<ConfigContext> = {
"deleteDocument",
"createCollectionWithProxy",
"legacyMongoShell",
"bulkdelete",
],
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
NEW_CASSANDRA_APIS: ["postQuery", "createOrDelete", "getKeys", "getSchema"],
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
isTerminalEnabled: false,
isPhoenixEnabled: false,
};

View File

@@ -17,7 +17,7 @@ export const useTreeStyles = makeStyles({
minWidth: "100%",
rowGap: "0px",
paddingTop: "0px",
[treeIconWidth]: "20px",
[treeIconWidth]: "16px",
[leafNodeSpacing]: "24px",
},
nodeIcon: {
@@ -25,12 +25,13 @@ export const useTreeStyles = makeStyles({
height: `var(${treeIconWidth})`,
},
treeItem: {},
nodeLabel: {},
nodeLabel: {
whiteSpace: "nowrap", // Don't wrap text, there will be a scrollbar.
},
treeItemLayout: {
fontSize: tokens.fontSizeBase300,
height: tokens.layoutRowHeight,
...cosmosShorthands.borderBottom(),
paddingLeft: `calc(var(${treeItemLevelToken}, 1) * ${tokens.spacingHorizontalXXL})`,
// Some sneaky CSS variables stuff to change the background color of the action button on hover.
[actionButtonBackground]: tokens.colorNeutralBackground1,

View File

@@ -149,15 +149,16 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
// We use the expandIcon slot to hold the node icon too.
// We only show a node icon for leaf nodes, even if a branch node has an iconSrc.
const expandIcon = isLoading ? (
<Spinner size="extra-tiny" />
) : !isBranch ? (
typeof node.iconSrc === "string" ? (
const treeIcon =
node.iconSrc === undefined ? undefined : typeof node.iconSrc === "string" ? (
<img src={node.iconSrc} className={treeStyles.nodeIcon} alt="" />
) : (
node.iconSrc
)
) : openItems.includes(treeNodeId) ? (
);
const expandIcon = isLoading ? (
<Spinner size="extra-tiny" />
) : !isBranch ? undefined : openItems.includes(treeNodeId) ? (
<ChevronDown20Regular data-test="TreeNode/CollapseIcon" />
) : (
<ChevronRight20Regular data-text="TreeNode/ExpandIcon" />
@@ -174,7 +175,6 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
<TreeItemLayout
className={mergeClasses(
treeStyles.treeItemLayout,
expandIcon ? undefined : treeStyles.treeItemLayoutNoIcon,
shouldShowAsSelected && treeStyles.selectedItem,
node.className && treeStyles[node.className],
)}
@@ -200,6 +200,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
),
}
}
iconBefore={treeIcon}
expandIcon={expandIcon}
>
<span className={treeStyles.nodeLabel}>{node.label}</span>

View File

@@ -10,16 +10,23 @@ exports[`TreeNodeComponent does not render children if the node is loading 1`] =
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -156,7 +163,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "branch",
"layoutRef": {
"current": <div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
>
<div
@@ -179,11 +186,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -208,7 +225,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
>
<div
@@ -231,18 +248,28 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
</div>
</div>
<div
class="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
class="fui-Tree rnv2ez3 ___17a32do_7zrvj80 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
role="tree"
>
@@ -256,7 +283,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="0"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
>
<div
@@ -279,11 +306,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child1Label
</span>
@@ -300,7 +337,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
>
<div
@@ -323,11 +360,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child2LoadingLabel
</span>
@@ -343,7 +390,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
>
<div
@@ -363,11 +410,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</span>
</div>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child3ExpandingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child3ExpandingLabel
</span>
@@ -383,16 +440,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
className="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
>
<div
@@ -419,11 +483,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</svg>
</ChevronRight20Regular>
</div>
<div
aria-hidden={true}
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
</div>
<div
className="fui-TreeItemLayout__main rklbe47"
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -431,7 +505,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</div>
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
>
<TreeProvider
@@ -499,7 +573,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
}
>
<div
className="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="fui-Tree rnv2ez3 ___17a32do_7zrvj80 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
role="tree"
>
@@ -587,7 +661,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "branch",
"layoutRef": {
"current": <div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
>
<div
@@ -610,11 +684,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child1Label
</span>
@@ -639,7 +723,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="0"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
>
<div
@@ -662,11 +746,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child1Label
</span>
@@ -680,16 +774,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
className="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
>
<div
@@ -716,11 +817,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</svg>
</ChevronRight20Regular>
</div>
<div
aria-hidden={true}
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div
className="fui-TreeItemLayout__main rklbe47"
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
child1Label
</span>
@@ -728,7 +839,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</div>
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root/child1Label"
>
<TreeProvider
@@ -821,7 +932,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "branch",
"layoutRef": {
"current": <div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
>
<div
@@ -844,11 +955,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child2LoadingLabel
</span>
@@ -873,7 +994,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
>
<div
@@ -896,11 +1017,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child2LoadingLabel
</span>
@@ -914,16 +1045,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
className="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
>
<div
@@ -950,11 +1088,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</svg>
</ChevronRight20Regular>
</div>
<div
aria-hidden={true}
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div
className="fui-TreeItemLayout__main rklbe47"
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
child2LoadingLabel
</span>
@@ -1039,7 +1187,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "leaf",
"layoutRef": {
"current": <div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
>
<div
@@ -1059,11 +1207,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</span>
</div>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child3ExpandingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child3ExpandingLabel
</span>
@@ -1087,7 +1245,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
>
<div
@@ -1107,11 +1265,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</span>
</div>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child3ExpandingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child3ExpandingLabel
</span>
@@ -1125,9 +1293,9 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1136,12 +1304,12 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
>
<div
aria-hidden={true}
className="fui-TreeItemLayout__expandIcon rh4pu5o"
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
@@ -1153,7 +1321,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
className="fui-TreeItemLayout__main rklbe47"
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
child3ExpandingLabel
</span>
@@ -1184,9 +1352,9 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1195,7 +1363,7 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1213,16 +1381,23 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
<Spinner
size="extra-tiny"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1240,16 +1415,23 @@ exports[`TreeNodeComponent renders a node as expandable if it has empty, but def
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1313,9 +1495,9 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
"className": "___1r8p62d_0000000 f1xg1ack f1e31b4d",
}
}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1324,7 +1506,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1363,9 +1545,9 @@ exports[`TreeNodeComponent renders a single node 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1374,7 +1556,7 @@ exports[`TreeNodeComponent renders a single node 1`] = `
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1392,9 +1574,9 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1403,7 +1585,7 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1421,22 +1603,29 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
>
<TreeItemLayout
actions={false}
className="___kqkdor0_ihxn0o0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
className="___rq9vxg0_1ykn2d2 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
data-test="TreeNode:root"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
>
<TreeNodeComponent
@@ -1497,22 +1686,29 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
>
<TreeNodeComponent
@@ -1574,9 +1770,9 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
>
<TreeItemLayout
actions={false}
className="___kqkdor0_ihxn0o0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
className="___rq9vxg0_1ykn2d2 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1585,7 +1781,7 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>

View File

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

View File

@@ -1,5 +1,6 @@
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { PhoenixClient } from "Phoenix/PhoenixClient";
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { cloneDeep } from "lodash";
import create, { UseStore } from "zustand";
import { AuthType } from "../../AuthType";
@@ -127,7 +128,9 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo"
? databaseAccount?.location
: 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();
try {
const response = await fetch(disallowedLocationsUri, {

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import {
Button,
Menu,
MenuButton,
MenuButtonProps,
MenuItem,
MenuList,
@@ -25,7 +26,7 @@ import { getCollectionName, getDatabaseName } from "Utils/APITypeUtils";
import { Allotment, AllotmentHandle } from "allotment";
import { useSidePanel } from "hooks/useSidePanel";
import { debounce } from "lodash";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
const useSidebarStyles = makeStyles({
sidebar: {
@@ -60,6 +61,7 @@ const useSidebarStyles = makeStyles({
alignItems: "center",
justifyItems: "center",
width: "100%",
containerType: "size", // Use this container for "@container" queries below this.
...cosmosShorthands.borderBottom(),
},
loadingProgressBar: {
@@ -83,6 +85,18 @@ const useSidebarStyles = makeStyles({
},
},
},
globalCommandsMenuButton: {
display: "inline-flex",
"@container (min-width: 250px)": {
display: "none",
},
},
globalCommandsSplitButton: {
display: "none",
"@container (min-width: 250px)": {
display: "flex",
},
},
});
interface GlobalCommandsProps {
@@ -99,6 +113,12 @@ interface GlobalCommand {
const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
const styles = useSidebarStyles();
// Since we have two buttons in the DOM (one for small screens and one for larger screens), we wrap the entire thing in a div.
// However, that messes with the Menu positioning, so we need to get a reference to the 'div' to pass to the Menu.
// We can't use a ref though, because it would be set after the Menu is rendered, so we use a state value to force a re-render.
const [globalCommandButton, setGlobalCommandButton] = useState<HTMLElement | null>(null);
const actions = useMemo<GlobalCommand[]>(() => {
if (
configContext.platform === Platform.Fabric ||
@@ -168,16 +188,22 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
{primaryAction.label}
</Button>
) : (
<Menu positioning="below-end">
<Menu positioning={{ target: globalCommandButton, position: "below", align: "end" }}>
<MenuTrigger disableButtonEnhancement>
{(triggerProps: MenuButtonProps) => (
<SplitButton
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
primaryActionButton={{ onClick: onPrimaryActionClick }}
icon={primaryAction.icon}
>
{primaryAction.label}
</SplitButton>
<div ref={setGlobalCommandButton}>
<SplitButton
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
primaryActionButton={{ onClick: onPrimaryActionClick }}
className={styles.globalCommandsSplitButton}
icon={primaryAction.icon}
>
{primaryAction.label}
</SplitButton>
<MenuButton {...triggerProps} icon={primaryAction.icon} className={styles.globalCommandsMenuButton}>
New...
</MenuButton>
</div>
)}
</MenuTrigger>
<MenuPopover>
@@ -199,7 +225,7 @@ interface SidebarProps {
explorer: Explorer;
}
const CollapseThreshold = 50;
const CollapseThreshold = 140;
export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
const styles = useSidebarStyles();
@@ -260,7 +286,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
{/* Collections Tree - Start */}
{hasSidebar && (
// When collapsed, we force the pane to 24 pixels wide and make it non-resizable.
<Allotment.Pane minSize={24} preferredSize={300}>
<Allotment.Pane minSize={24} preferredSize={250}>
<CosmosFluentProvider className={mergeClasses(styles.sidebar)}>
<div className={styles.sidebarContainer}>
{loading && (
@@ -314,7 +340,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
</CosmosFluentProvider>
</Allotment.Pane>
)}
<Allotment.Pane minSize={800}>
<Allotment.Pane minSize={200}>
<Tabs explorer={explorer} />
</Allotment.Pane>
</Allotment>

View File

@@ -753,17 +753,11 @@ export class CassandraAPIDataClient extends TableDataClient {
CassandraProxyEndpoints.Development,
CassandraProxyEndpoints.Mpac,
CassandraProxyEndpoints.Prod,
CassandraProxyEndpoints.Fairfax,
CassandraProxyEndpoints.Mooncake,
];
let canAccessCassandraProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
if (
configContext.CASSANDRA_PROXY_ENDPOINT !== CassandraProxyEndpoints.Development &&
userContext.databaseAccount.properties.ipRules?.length > 0
) {
canAccessCassandraProxy = canAccessCassandraProxy && configContext.CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED;
}
return (
canAccessCassandraProxy &&
configContext.NEW_CASSANDRA_APIS?.includes(api) &&
activeCassandraProxyEndpoints.includes(configContext.CASSANDRA_PROXY_ENDPOINT)
);

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,
UPDATE_BUTTON_ID,
UPLOAD_BUTTON_ID,
addStringsNoDuplicate,
buildQuery,
getDiscardExistingDocumentChangesButtonState,
getDiscardNewDocumentChangesButtonState,
@@ -339,7 +340,10 @@ describe("Documents tab (noSql API)", () => {
const createMockProps = (): IDocumentsTabComponentProps => ({
isPreferredApiMongoDB: false,
documentIds: [],
collection: undefined,
collection: {
id: ko.observable<string>("collectionId"),
databaseId: "databaseId",
} as ViewModels.CollectionBase,
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
onLoadStartKey: 0,
tabTitle: "",
@@ -380,7 +384,7 @@ describe("Documents tab (noSql API)", () => {
.findWhere((node) => node.text() === "Edit Filter")
.at(0)
.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 { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
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 { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
@@ -42,6 +48,7 @@ import * as Logger from "../../../Common/Logger";
import * as MongoProxyClient from "../../../Common/MongoProxyClient";
import * as DataModels from "../../../Contracts/DataModels";
import * as ViewModels from "../../../Contracts/ViewModels";
import { CollectionBase } from "../../../Contracts/ViewModels";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import * as QueryUtils from "../../../Utils/QueryUtils";
import { extractPartitionKeyValues } from "../../../Utils/QueryUtils";
@@ -50,6 +57,8 @@ import ObjectId from "../../Tree/ObjectId";
import TabsBase from "../TabsBase";
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;
export const useDocumentsTabStyles = makeStyles({
container: {
@@ -473,6 +482,24 @@ export const buildQuery = (
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 interface IDocumentsTabComponentProps {
isPreferredApiMongoDB: boolean;
@@ -487,6 +514,14 @@ export interface IDocumentsTabComponentProps {
isTabActive: boolean;
}
const getUniqueId = (collection: ViewModels.CollectionBase): string => `${collection.databaseId}-${collection.id()}`;
const getDefaultSqlFilters = (partitionKeys: string[]) =>
['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC', "ORDER BY c._ts ASC"].concat(
partitionKeys.map((partitionKey) => `WHERE c.${partitionKey} = "foo"`),
);
const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
// Export to expose to unit tests
export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
isPreferredApiMongoDB,
@@ -534,6 +569,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
ViewModels.DocumentExplorerState.noDocumentSelected,
);
// State
const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
readSubComponentState(SubComponentName.MainTabDivider, _collection, {
leftPaneWidthPercent: 35,
}),
);
const isQueryCopilotSampleContainer =
_collection?.isSampleCollection &&
_collection?.databaseId === QueryCopilotSampleDatabaseId &&
@@ -542,6 +584,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// For Mongo only
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);
useEffect(() => {
@@ -567,8 +614,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}
}, [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 = {
enabled: true,
visible: true,
@@ -883,7 +928,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
/**
* Implementation using bulk delete NoSQL API
*/
let _deleteDocuments = useCallback(
const _deleteDocuments = useCallback(
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
onExecutionErrorChange(false);
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocuments, {
@@ -894,11 +939,29 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// 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.
return (
partitionKey.systemKey
? deleteNoSqlDocument(_collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]])
: deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
)
const _deleteNoSqlDocuments = async (
collection: CollectionBase,
toDeleteDocumentIds: DocumentId[],
): 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(
(deletedIds) => {
TelemetryProcessor.traceSuccess(
@@ -929,7 +992,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)
.finally(() => setIsExecuting(false));
},
[_collection, onExecutionErrorChange, tabTitle],
[_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle],
);
const deleteDocuments = useCallback(
@@ -954,7 +1017,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(error: Error) =>
useDialog
.getState()
.showOkModalDialog("Delete documents", `Document(s) deleted failed (${JSON.stringify(error)})`),
.showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`),
)
.finally(() => setIsExecuting(false));
},
@@ -1220,7 +1283,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") {
refreshDocumentsGrid(true);
onApplyFilterClick();
// Suppress the default behavior of the key
e.preventDefault();
@@ -1423,7 +1486,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return partitionKey;
};
lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
@@ -1438,62 +1500,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
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> => {
const documentContent = JSON.parse(selectedDocumentContent);
const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, {
@@ -1700,6 +1706,24 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}
// ***************** 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(
(applyFilterButtonPressed: boolean): void => {
// clear documents grid
@@ -1758,12 +1782,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<div className={styles.filterRow}>
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
<Input
id="filterInput"
ref={filterInput}
type="text"
size="small"
list="filtersList"
className={styles.filterInput}
list={`filtersList-${getUniqueId(_collection)}`}
className={`filterInput ${styles.filterInput}`}
title="Type a query predicate or choose one from the list."
placeholder={
isPreferredApiMongoDB
@@ -1777,8 +1800,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
onBlur={() => setIsFilterFocused(false)}
/>
<datalist id="filtersList">
{lastFilterContents.map((filter) => (
<datalist id={`filtersList-${getUniqueId(_collection)}`}>
{addStringsNoDuplicate(
lastFilterContents,
isPreferredApiMongoDB ? defaultMongoFilters : getDefaultSqlFilters(partitionKeyProperties),
).map((filter) => (
<option key={filter} value={filter} />
))}
</datalist>
@@ -1786,7 +1812,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<Button
appearance="primary"
size="small"
onClick={() => refreshDocumentsGrid(true)}
onClick={onApplyFilterClick}
disabled={!applyFilterButton.enabled}
aria-label="Apply filter"
tabIndex={0}
@@ -1817,11 +1843,16 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)}
</>
)}
{/* <Split> doesn't like to be a flex child */}
<div style={{ overflow: "hidden", height: "100%" }}>
<Allotment>
<Allotment.Pane preferredSize="35%" minSize={175}>
<Allotment
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 className={styles.floatingControlsContainer}>
<div className={styles.floatingControls}>
@@ -1850,6 +1881,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(partitionKey.systemKey && !isPreferredApiMongoDB) ||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
}
collection={_collection}
/>
</div>
{tableItems.length > 0 && (
@@ -1865,7 +1897,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)}
</div>
</Allotment.Pane>
<Allotment.Pane preferredSize="65%" minSize={300}>
<Allotment.Pane minSize={30}>
<div style={{ height: "100%", width: "100%" }}>
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
<EditorReact

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import {
createTableColumn,
Menu,
MenuItem,
MenuList,
@@ -16,19 +17,25 @@ import {
TableRow,
TableRowId,
TableSelectionCell,
createTableColumn,
useArrowNavigationGroup,
useTableColumnSizing_unstable,
useTableFeatures,
useTableSelection,
} from "@fluentui/react-components";
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 { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
import React, { useCallback, useMemo } from "react";
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
import * as ViewModels from "../../../Contracts/ViewModels";
export type DocumentsTableComponentItem = {
id: string;
@@ -47,6 +54,7 @@ export interface IDocumentsTableComponentProps {
columnHeaders: ColumnHeaders;
style?: React.CSSProperties;
isSelectionDisabled?: boolean;
collection: ViewModels.CollectionBase;
}
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
@@ -59,6 +67,10 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
data: TableRowData[];
}
const defaultSize = {
idealWidth: 200,
minWidth: 50,
};
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
items,
onSelectedRowsChange,
@@ -67,32 +79,53 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
size,
columnHeaders,
isSelectionDisabled,
collection,
}: IDocumentsTableComponentProps) => {
const styles = useDocumentsTabStyles();
const initialSizingOptions: TableColumnSizingOptions = {
id: {
idealWidth: 280,
minWidth: 50,
},
};
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => {
initialSizingOptions[pkHeader] = {
idealWidth: 200,
minWidth: 50,
};
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
const columnIds = ["id"].concat(columnHeaders.partitionKeyHeaders);
const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {});
const columnSizesPx: TableColumnSizingOptions = {};
columnIds.forEach((columnId) => {
if (
!columnSizesMap ||
!columnSizesMap[columnId] ||
columnSizesMap[columnId].widthPx === undefined ||
isNaN(columnSizesMap[columnId].widthPx)
) {
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 }) => {
setColumnSizingOptions((state) => ({
...state,
[columnId]: {
...state[columnId],
idealWidth: width,
},
}));
const onColumnResize = React.useCallback((_, { columnId, width }: { columnId: string; width: number }) => {
setColumnSizingOptions((state) => {
const newSizingOptions = {
...state,
[columnId]: {
...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

View File

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

View File

@@ -2,6 +2,12 @@
exports[`DocumentsTableComponent should not render selection column when isSelectionDisabled is true 1`] = `
<DocumentsTableComponent
collection={
{
"databaseId": "db",
"id": [Function],
}
}
columnHeaders={
{
"idHeader": "id",
@@ -342,7 +348,7 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
>
<List
direction="ltr"
height={-36}
height={-32}
itemCount={3}
itemData={
[
@@ -381,7 +387,7 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
},
]
}
itemSize={36}
itemSize={32}
layout="vertical"
overscanCount={2}
style={
@@ -398,7 +404,7 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
{
"WebkitOverflowScrolling": "touch",
"direction": "ltr",
"height": -36,
"height": -32,
"overflow": "auto",
"overflowY": "scroll",
"position": "relative",
@@ -410,7 +416,7 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
<div
style={
{
"height": 108,
"height": 96,
"pointerEvents": undefined,
"width": "100%",
}
@@ -458,7 +464,7 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
key="0"
style={
{
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
@@ -476,7 +482,7 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
style={
{
"cursor": "pointer",
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
@@ -494,7 +500,7 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
style={
{
"cursor": "pointer",
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
@@ -647,11 +653,11 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
key="1"
style={
{
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
"top": 36,
"top": 32,
"width": "100%",
}
}
@@ -665,11 +671,11 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
style={
{
"cursor": "pointer",
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
"top": 36,
"top": 32,
"userSelect": "none",
"width": "100%",
}
@@ -683,11 +689,11 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
style={
{
"cursor": "pointer",
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
"top": 36,
"top": 32,
"userSelect": "none",
"width": "100%",
}
@@ -836,11 +842,11 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
key="2"
style={
{
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
"top": 72,
"top": 64,
"width": "100%",
}
}
@@ -854,11 +860,11 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
style={
{
"cursor": "pointer",
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
"top": 72,
"top": 64,
"userSelect": "none",
"width": "100%",
}
@@ -872,11 +878,11 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
style={
{
"cursor": "pointer",
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
"top": 72,
"top": 64,
"userSelect": "none",
"width": "100%",
}
@@ -995,6 +1001,12 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = `
<DocumentsTableComponent
collection={
{
"databaseId": "db",
"id": [Function],
}
}
columnHeaders={
{
"idHeader": "id",
@@ -1375,7 +1387,7 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
>
<List
direction="ltr"
height={-36}
height={-32}
itemCount={3}
itemData={
[
@@ -1414,7 +1426,7 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
},
]
}
itemSize={36}
itemSize={32}
layout="vertical"
overscanCount={2}
style={
@@ -1431,7 +1443,7 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
{
"WebkitOverflowScrolling": "touch",
"direction": "ltr",
"height": -36,
"height": -32,
"overflow": "auto",
"overflowY": "scroll",
"position": "relative",
@@ -1443,7 +1455,7 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
<div
style={
{
"height": 108,
"height": 96,
"pointerEvents": undefined,
"width": "100%",
}
@@ -1491,7 +1503,7 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
key="0"
style={
{
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
@@ -1509,7 +1521,7 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
style={
{
"cursor": "pointer",
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
@@ -1527,7 +1539,7 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
style={
{
"cursor": "pointer",
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
@@ -1720,11 +1732,11 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
key="1"
style={
{
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
"top": 36,
"top": 32,
"width": "100%",
}
}
@@ -1738,11 +1750,11 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
style={
{
"cursor": "pointer",
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
"top": 36,
"top": 32,
"userSelect": "none",
"width": "100%",
}
@@ -1756,11 +1768,11 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
style={
{
"cursor": "pointer",
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
"top": 36,
"top": 32,
"userSelect": "none",
"width": "100%",
}
@@ -1949,11 +1961,11 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
key="2"
style={
{
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
"top": 72,
"top": 64,
"width": "100%",
}
}
@@ -1967,11 +1979,11 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
style={
{
"cursor": "pointer",
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
"top": 72,
"top": 64,
"userSelect": "none",
"width": "100%",
}
@@ -1985,11 +1997,11 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
style={
{
"cursor": "pointer",
"height": 36,
"height": 32,
"left": 0,
"position": "absolute",
"right": undefined,
"top": 72,
"top": 64,
"userSelect": "none",
"width": "100%",
}

View File

@@ -1,7 +1,7 @@
import { IMessageBarStyles, Link, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react";
import { CassandraProxyEndpoints, MongoProxyEndpoints } from "Common/Constants";
import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext, updateConfigContext } from "ConfigContext";
import { Platform, configContext } from "ConfigContext";
import { IpRule } from "Contracts/DataModels";
import { MessageTypes } from "Contracts/ExplorerContracts";
import { CollectionTabKind } from "Contracts/ViewModels";
@@ -100,8 +100,7 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
},
}}
>
{`To prevent queries from using excessive RUs, Data Explorer has a 5,000 RU default limit. To modify or remove
the limit, go to the Settings cog on the right and find "RU Threshold".`}
{`Data Explorer has a 5,000 RU default limit. To adjust the limit, go to the Settings page and find "RU Threshold".`}
<Link
className="underlinedLink"
href="https://review.learn.microsoft.com/en-us/azure/cosmos-db/data-explorer?branch=main#configure-request-unit-threshold"
@@ -119,7 +118,7 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
setShowMongoAndCassandraProxiesNetworkSettingsWarningState(false);
}}
>
{`We are moving our middleware to new infrastructure. To avoid future issues with Data Explorer access, please
{`We have migrated our middleware to new infrastructure. To avoid issues with Data Explorer access, please
re-enable "Allow access from Azure Portal" on the Networking blade for your account.`}
</MessageBar>
)}
@@ -398,12 +397,6 @@ const showMongoAndCassandraProxiesNetworkSettingsWarning = (): boolean => {
ipAddressesFromIPRules.includes(mongoProxyOutboundIP),
);
if (ipRulesIncludeMongoProxy) {
updateConfigContext({
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: true,
});
}
return !ipRulesIncludeMongoProxy;
} else if (userContext.apiType === "Cassandra") {
const isProdOrMpacCassandraProxyEndpoint: boolean = [
@@ -422,12 +415,6 @@ const showMongoAndCassandraProxiesNetworkSettingsWarning = (): boolean => {
(cassandraProxyOutboundIP: string) => ipAddressesFromIPRules.includes(cassandraProxyOutboundIP),
);
if (ipRulesIncludeCassandraProxy) {
updateConfigContext({
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: true,
});
}
return !ipRulesIncludeCassandraProxy;
}
}

View File

@@ -16,7 +16,7 @@ import React from "react";
import { appThemeFabricTealBrandRamp } from "../../Platform/Fabric/FabricTheme";
export const LayoutConstants = {
rowHeight: 36,
rowHeight: 32,
};
// Our CosmosFluentProvider has the same props as a FluentProvider.
@@ -91,15 +91,30 @@ const appThemePortalBrandRamp: BrandVariants = {
160: "#CDD8EF",
};
const cosmosThemeElements = {
layoutRowHeight: `${LayoutConstants.rowHeight}px`,
export enum LayoutSize {
Compact,
// TODO: Cozy and Roomy layouts.
}
interface CosmosThemeElements {
layoutRowHeight: string;
}
export type CosmosTheme = Theme & CosmosThemeElements;
const sizeMappings: Record<LayoutSize, Partial<Theme> & CosmosThemeElements> = {
[LayoutSize.Compact]: {
layoutRowHeight: "32px",
fontSizeBase300: "13px",
},
};
const cosmosTheme = {
sidebarMinimumWidth: "200px",
sidebarInitialWidth: "300px",
};
export type CosmosTheme = Theme & typeof cosmosThemeElements;
export const tokens = themeToTokensObject({ ...webLightTheme, ...cosmosThemeElements });
export const tokens = themeToTokensObject({ ...webLightTheme, ...cosmosTheme, ...sizeMappings[LayoutSize.Compact] });
export const cosmosShorthands = {
border: () => shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
@@ -117,6 +132,7 @@ export function getPlatformTheme(platform: Platform): CosmosTheme {
return {
...baseTheme,
...cosmosThemeElements,
...cosmosTheme,
...sizeMappings[LayoutSize.Compact], // TODO: Allow for different layout sizes.
};
}

View File

@@ -30,6 +30,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardCollection",
@@ -69,6 +72,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "conflictsCollection",
@@ -92,6 +98,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardDb",
@@ -102,6 +111,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
{
"children": [
{
"iconSrc": <SettingsRegular
fontSize={16}
/>,
"id": "",
"isSelected": [Function],
"label": "Scale",
@@ -133,6 +145,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sampleItemsCollection",
@@ -156,6 +171,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sharedDatabase",
@@ -246,6 +264,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "schemaCollection",
@@ -274,6 +295,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "giganticDatabase",
@@ -345,6 +369,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardCollection",
@@ -415,6 +442,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "conflictsCollection",
@@ -438,6 +468,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardDb",
@@ -448,6 +481,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
{
"children": [
{
"iconSrc": <SettingsRegular
fontSize={16}
/>,
"id": "",
"isSelected": [Function],
"label": "Scale",
@@ -510,6 +546,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sampleItemsCollection",
@@ -533,6 +572,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sharedDatabase",
@@ -654,6 +696,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "schemaCollection",
@@ -682,6 +727,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "giganticDatabase",
@@ -706,6 +754,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"onClick": [Function],
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardCollection",
@@ -724,6 +775,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"onClick": [Function],
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "conflictsCollection",
@@ -747,6 +801,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardDb",
@@ -766,6 +823,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"onClick": [Function],
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sampleItemsCollection",
@@ -789,6 +849,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sharedDatabase",
@@ -808,6 +871,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"onClick": [Function],
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "schemaCollection",
@@ -836,6 +902,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "giganticDatabase",
@@ -976,6 +1045,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardCollection",
@@ -1076,6 +1148,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "conflictsCollection",
@@ -1099,6 +1174,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardDb",
@@ -1109,6 +1187,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
{
"children": [
{
"iconSrc": <SettingsRegular
fontSize={16}
/>,
"id": "",
"isSelected": [Function],
"label": "Scale",
@@ -1201,6 +1282,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sampleItemsCollection",
@@ -1224,6 +1308,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sharedDatabase",
@@ -1375,6 +1462,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "schemaCollection",
@@ -1403,6 +1493,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "giganticDatabase",
@@ -1543,6 +1636,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardCollection",
@@ -1638,6 +1734,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "conflictsCollection",
@@ -1661,6 +1760,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardDb",
@@ -1671,6 +1773,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
{
"children": [
{
"iconSrc": <SettingsRegular
fontSize={16}
/>,
"id": "",
"isSelected": [Function],
"label": "Scale",
@@ -1763,6 +1868,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sampleItemsCollection",
@@ -1786,6 +1894,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sharedDatabase",
@@ -1937,6 +2048,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "schemaCollection",
@@ -1965,6 +2079,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "giganticDatabase",
@@ -1986,6 +2103,9 @@ exports[`createResourceTokenTreeNodes creates the expected tree nodes 1`] = `
},
],
"className": "collectionNode",
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "testCollection",
@@ -2021,6 +2141,9 @@ exports[`createSampleDataTreeNodes creates the expected tree nodes 1`] = `
"onClick": [Function],
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": false,
"isSelected": [Function],
"label": "testCollection",

View File

@@ -1,3 +1,4 @@
import { DatabaseRegular, DocumentMultipleRegular, SettingsRegular } from "@fluentui/react-icons";
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import TabsBase from "Explorer/Tabs/TabsBase";
import StoredProcedure from "Explorer/Tree/StoredProcedure";
@@ -7,6 +8,7 @@ import { useDatabases } from "Explorer/useDatabases";
import { getItemName } from "Utils/APITypeUtils";
import { isServerlessAccount } from "Utils/CapabilityUtils";
import { useTabs } from "hooks/useTabs";
import React from "react";
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
import { Platform, configContext } from "../../ConfigContext";
import * as DataModels from "../../Contracts/DataModels";
@@ -25,6 +27,10 @@ export const shouldShowScriptNodes = (): boolean => {
);
};
const TreeDatabaseIcon = <DatabaseRegular fontSize={16} />;
const TreeSettingsIcon = <SettingsRegular fontSize={16} />;
const TreeCollectionIcon = <DocumentMultipleRegular fontSize={16} />;
export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: ViewModels.CollectionBase): TreeNode[] => {
const updatedSampleTree: TreeNode = {
label: sampleDataResourceTokenCollection.databaseId,
@@ -36,6 +42,7 @@ export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: Vie
isExpanded: false,
className: "collectionNode",
contextMenu: ResourceTreeContextMenuButtonFactory.createSampleCollectionContextMenuButton(),
iconSrc: TreeCollectionIcon,
onClick: () => {
useSelectedNode.getState().setSelectedNode(sampleDataResourceTokenCollection);
useCommandBar.getState().setContextButtons([]);
@@ -104,6 +111,7 @@ export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBa
isExpanded: true,
children,
className: "collectionNode",
iconSrc: TreeCollectionIcon,
onClick: () => {
// Rewritten version of expandCollapseCollection
useSelectedNode.getState().setSelectedNode(collection);
@@ -133,6 +141,7 @@ export const createDatabaseTreeNodes = (
databaseNode.children.push({
id: database.isSampleDB ? "sampleScaleSettings" : "",
label: "Scale",
iconSrc: TreeSettingsIcon,
isSelected: () =>
useSelectedNode
.getState()
@@ -169,6 +178,7 @@ export const createDatabaseTreeNodes = (
children: [],
isSelected: () => useSelectedNode.getState().isDataNodeSelected(database.id()),
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
iconSrc: TreeDatabaseIcon,
onExpanded: async () => {
useSelectedNode.getState().setSelectedNode(database);
if (!databaseNode.children || databaseNode.children?.length === 0) {
@@ -219,6 +229,7 @@ export const buildCollectionNode = (
children: children,
className: "collectionNode",
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection),
iconSrc: TreeCollectionIcon,
onClick: () => {
useSelectedNode.getState().setSelectedNode(collection);
collection.openTab();

View File

@@ -52,7 +52,7 @@ export const isAccountRestrictedForConnectionStringLogin = async (connectionStri
const headers = new Headers();
headers.append(HttpHeaders.connectionString, connectionString);
const backendEndpoint: string = useNewPortalBackendEndpoint(BackendApi.PortalSettings)
const backendEndpoint: string = useNewPortalBackendEndpoint(BackendApi.AccountRestrictions)
? configContext.PORTAL_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 =>
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,
PriorityLevel,
DefaultQueryResultsView,
AppState,
}
export const hasRUThresholdBeenConfigured = (): boolean => {
@@ -56,10 +57,10 @@ export const getRUThreshold = (): number => {
export const getDefaultQueryResultsView = (): SplitterDirection => {
const defaultQueryResultsViewRaw = LocalStorageUtility.getEntryString(StorageKey.DefaultQueryResultsView);
if (defaultQueryResultsViewRaw === SplitterDirection.Horizontal) {
return SplitterDirection.Horizontal;
if (defaultQueryResultsViewRaw === SplitterDirection.Vertical) {
return SplitterDirection.Vertical;
}
return SplitterDirection.Vertical;
return SplitterDirection.Horizontal;
};
export const DefaultRUThreshold = 5000;

View File

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

View File

@@ -52,7 +52,11 @@ export const defaultAllowedArmEndpoints: ReadonlyArray<string> = [
"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> = [
"https://main.documentdb.ext.azure.com",
@@ -74,6 +78,13 @@ export const PortalBackendIPs: { [key: string]: string[] } = {
//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[] } = {
[MongoProxyEndpoints.Mpac]: ["20.245.81.54", "40.118.23.126"],
[MongoProxyEndpoints.Prod]: ["40.80.152.199", "13.95.130.121"],
@@ -164,7 +175,23 @@ export function useNewPortalBackendEndpoint(backendApi: string): boolean {
PortalBackendEndpoints.Mpac,
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) {

View File

@@ -1,20 +1,15 @@
import { MongoProxyEndpoints, PortalBackendEndpoints } from "Common/Constants";
import { resetConfigContext, updateConfigContext } from "ConfigContext";
import { DatabaseAccount, IpRule } from "Contracts/DataModels";
import { updateUserContext } from "UserContext";
import { PortalBackendIPs } from "Utils/EndpointUtils";
import { MongoProxyOutboundIPs, PortalBackendIPs, PortalBackendOutboundIPs } from "Utils/EndpointUtils";
import { getNetworkSettingsWarningMessage } from "./NetworkUtility";
describe("NetworkUtility tests", () => {
describe("getNetworkSettingsWarningMessage", () => {
const legacyBackendEndpoint: string = "https://main.documentdb.ext.azure.com";
const publicAccessMessagePart = "Please enable public access 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;
const warningMessageFunc = (msg: string) => (warningMessageResult = msg);
@@ -52,52 +47,59 @@ describe("NetworkUtility tests", () => {
expect(warningMessageResult).toContain(publicAccessMessagePart);
});
it(`should return no message when the appropriate ip rules are added to mongo/cassandra account per endpoint`, () => {
validEndpoints.forEach(async (endpoint) => {
updateUserContext({
databaseAccount: {
kind: "MongoDB",
properties: {
ipRules: PortalBackendIPs[endpoint].map((ip: string) => ({ ipAddressOrRange: ip }) as IpRule),
publicNetworkAccess: "Enabled",
},
} as DatabaseAccount,
});
updateConfigContext({
BACKEND_ENDPOINT: endpoint,
});
let asyncWarningMessageResult: string;
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
expect(asyncWarningMessageResult).toBeUndefined();
it(`should return no message when the appropriate ip rules are added to mongo/cassandra account per endpoint`, async () => {
const portalBackendOutboundIPsWithLegacyIPs: string[] = [
...PortalBackendOutboundIPs[PortalBackendEndpoints.Mpac],
...PortalBackendOutboundIPs[PortalBackendEndpoints.Prod],
...MongoProxyOutboundIPs[MongoProxyEndpoints.Mpac],
...MongoProxyOutboundIPs[MongoProxyEndpoints.Prod],
...PortalBackendIPs["https://main.documentdb.ext.azure.com"],
];
updateUserContext({
databaseAccount: {
kind: "MongoDB",
properties: {
ipRules: portalBackendOutboundIPsWithLegacyIPs.map((ip: string) => ({ ipAddressOrRange: ip }) as IpRule),
publicNetworkAccess: "Enabled",
},
} as DatabaseAccount,
});
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", () => {
validEndpoints.forEach(async (endpoint) => {
updateUserContext({
databaseAccount: {
kind: "MongoDB",
properties: {
ipRules: [{ ipAddressOrRange: "1.1.1.1" }],
publicNetworkAccess: "Enabled",
},
} as DatabaseAccount,
});
updateConfigContext({
BACKEND_ENDPOINT: endpoint,
});
let asyncWarningMessageResult: string;
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
expect(asyncWarningMessageResult).toContain(accessMessagePart);
it("should return accessMessage when incorrent ip rule is added to mongo/cassandra account per endpoint", async () => {
updateUserContext({
databaseAccount: {
kind: "MongoDB",
properties: {
ipRules: [{ ipAddressOrRange: "1.1.1.1" }],
publicNetworkAccess: "Enabled",
},
} as DatabaseAccount,
});
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

View File

@@ -1,7 +1,13 @@
import { CassandraProxyEndpoints, MongoProxyEndpoints, PortalBackendEndpoints } from "Common/Constants";
import { configContext } from "ConfigContext";
import { checkFirewallRules } from "Explorer/Tabs/Shared/CheckFirewallRules";
import { userContext } from "UserContext";
import { PortalBackendIPs } from "Utils/EndpointUtils";
import {
CassandraProxyOutboundIPs,
MongoProxyOutboundIPs,
PortalBackendIPs,
PortalBackendOutboundIPs,
} from "Utils/EndpointUtils";
export const getNetworkSettingsWarningMessage = async (
setStateFunc: (warningMessage: string) => void,
@@ -45,18 +51,53 @@ export const getNetworkSettingsWarningMessage = async (
const ipRules = accountProperties.ipRules;
// public network access is NOT set to "All networks"
if (ipRules?.length > 0) {
if (userContext.apiType === "Cassandra" || userContext.apiType === "Mongo") {
const portalIPs = PortalBackendIPs[configContext.BACKEND_ENDPOINT];
let numberOfMatches = 0;
ipRules.forEach((ipRule) => {
if (portalIPs.indexOf(ipRule.ipAddressOrRange) !== -1) {
numberOfMatches++;
}
});
const isProdOrMpacPortalBackendEndpoint: boolean = [
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
].includes(configContext.PORTAL_BACKEND_ENDPOINT);
const portalBackendOutboundIPs: string[] = isProdOrMpacPortalBackendEndpoint
? [
...PortalBackendOutboundIPs[PortalBackendEndpoints.Mpac],
...PortalBackendOutboundIPs[PortalBackendEndpoints.Prod],
]
: PortalBackendOutboundIPs[configContext.PORTAL_BACKEND_ENDPOINT];
let portalIPs: string[] = [...portalBackendOutboundIPs, ...PortalBackendIPs[configContext.BACKEND_ENDPOINT]];
if (numberOfMatches !== portalIPs.length) {
setStateFunc(accessMessage);
if (userContext.apiType === "Mongo") {
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["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[] = [];
partitionKeyDefinition.paths.forEach((partitionKeyPath: string) => {
const partitionKeyPathWithoutSlash: string = partitionKeyPath.substring(1);
if (documentContent[partitionKeyPathWithoutSlash]) {
if (documentContent[partitionKeyPathWithoutSlash] !== undefined) {
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;
}
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) {
if (
configContext.BACKEND_ENDPOINT &&
@@ -639,6 +664,8 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
PORTAL_BACKEND_ENDPOINT: inputs.portalBackendEndpoint,
});
updateAADEndpoints(inputs.serverId as PortalEnv);
updateUserContext({
authorizationToken,
databaseAccount,

View File

@@ -1,14 +1,34 @@
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 { AccessInputMetadata } from "../Contracts/DataModels";
const url = `${configContext.BACKEND_ENDPOINT}${ApiEndpoints.guestRuntimeProxy}/accessinputmetadata?_=1609359229955`;
export async function fetchAccessData(portalToken: string): Promise<AccessInputMetadata> {
if (!useNewPortalBackendEndpoint(BackendApi.RuntimeProxy)) {
return fetchAccessData_ToBeDeprecated(portalToken);
}
const headers = new Headers();
// 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 = {
method: "GET",

View File

@@ -71,6 +71,8 @@ test("Query stats", 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.setText("SELECT\n glarb(c.id),\n blarg(c.id)\nFROM c");