From 94d3fcb30fb6116ec7703c2a8869e52494495838 Mon Sep 17 00:00:00 2001 From: Ashley Stanton-Nurse Date: Wed, 21 Aug 2024 11:30:43 -0700 Subject: [PATCH 01/28] disable query error tests due to backend issue (#1942) --- test/sql/query.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/sql/query.spec.ts b/test/sql/query.spec.ts index 8d1d54eac..5872346bb 100644 --- a/test/sql/query.spec.ts +++ b/test/sql/query.spec.ts @@ -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"); From 038142c1801fb08ad48a9c765a85cdb213c610e8 Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Thu, 22 Aug 2024 07:37:15 +0200 Subject: [PATCH 02/28] 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 * Update src/Shared/AppStatePersistenceUtility.ts Assert that path does not contain slash. Co-authored-by: Ashley Stanton-Nurse * Fix format --------- Co-authored-by: Ashley Stanton-Nurse --- .../CommandBarComponentButtonFactory.tsx | 28 +-- .../Panes/SettingsPane/SettingsPane.tsx | 212 +++++++++++------- .../__snapshots__/SettingsPane.test.tsx.snap | 26 +++ .../DocumentsTabV2/DocumentsTabStateUtil.ts | 100 +++++++++ .../DocumentsTabV2/DocumentsTabV2.test.tsx | 18 +- .../Tabs/DocumentsTabV2/DocumentsTabV2.tsx | 94 ++++++-- .../DocumentsTableComponent.test.tsx | 5 + .../DocumentsTableComponent.tsx | 60 +++-- .../DocumentsTabV2.test.tsx.snap | 15 +- .../DocumentsTableComponent.test.tsx.snap | 12 + src/Shared/AppStatePersistenceUtility.test.ts | 170 ++++++++++++++ src/Shared/AppStatePersistenceUtility.ts | 109 +++++++++ src/Shared/LocalStorageUtility.ts | 11 + src/Shared/StorageUtility.ts | 1 + src/Shared/Telemetry/TelemetryConstants.ts | 3 + 15 files changed, 723 insertions(+), 141 deletions(-) create mode 100644 src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts create mode 100644 src/Shared/AppStatePersistenceUtility.test.ts create mode 100644 src/Shared/AppStatePersistenceUtility.ts diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx index a55b0ca68..8c374a4c1 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx @@ -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", ), - commandButtonLabel: undefined, - ariaLabel: "Settings", - tooltipText: "Settings", - hasPopup: true, - disabled: false, - }, - ]; + const buttons: CommandButtonComponentProps[] = [ + { + iconSrc: SettingsIcon, + iconAlt: "Settings", + onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", ), + commandButtonLabel: undefined, + ariaLabel: "Settings", + tooltipText: "Settings", + hasPopup: true, + disabled: false, + }, + ]; const showOpenFullScreen = configContext.platform === Platform.Portal && !isRunningOnNationalCloud() && userContext.apiType !== "Gremlin"; diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index 8d07fdad7..a8e6c17f5 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -1,6 +1,7 @@ import { Checkbox, ChoiceGroup, + DefaultButton, IChoiceGroupOption, ISpinButtonStyles, IToggleStyles, @@ -12,11 +13,15 @@ import { Toggle, TooltipHost, } from "@fluentui/react"; +import { makeStyles } from "@fluentui/react-components"; +import { AuthType } from "AuthType"; import * as Constants from "Common/Constants"; import { SplitterDirection } from "Common/Splitter"; import { InfoTooltip } from "Common/Tooltip/InfoTooltip"; import { Platform, configContext } from "ConfigContext"; +import { useDialog } from "Explorer/Controls/Dialog"; import { useDatabases } from "Explorer/useDatabases"; +import { deleteAllStates } from "Shared/AppStatePersistenceUtility"; import { DefaultRUThreshold, LocalStorageUtility, @@ -29,14 +34,13 @@ import * as StringUtility from "Shared/StringUtility"; import { updateUserContext, userContext } from "UserContext"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils"; +import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useSidePanel } from "hooks/useSidePanel"; import React, { FunctionComponent, useState } from "react"; +import create, { UseStore } from "zustand"; import Explorer from "../../Explorer"; import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; -import { AuthType } from "AuthType"; -import create, { UseStore } from "zustand"; -import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; export interface DataPlaneRbacState { dataPlaneRbacEnabled: boolean; @@ -50,6 +54,13 @@ export interface DataPlaneRbacState { type DataPlaneRbacStore = UseStore>; +const useStyles = makeStyles({ + bulletList: { + listStyleType: "disc", + paddingLeft: "20px", + }, +}); + export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({ dataPlaneRbacEnabled: false, })); @@ -133,6 +144,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState( LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true", ); + + const styles = useStyles(); + const explorerVersion = configContext.gitSha; const shouldShowQueryPageOptions = userContext.apiType === "SQL"; const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin"; @@ -153,43 +167,45 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage); - LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption); - if ( - enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption || - (enableDataPlaneRBACOption === Constants.RBACOptions.setAutomaticRBACOption && - userContext.databaseAccount.properties.disableLocalAuth) - ) { - updateUserContext({ - dataPlaneRbacEnabled: true, - hasDataPlaneRbacSettingChanged: true, - }); - useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true }); - } else { - updateUserContext({ - dataPlaneRbacEnabled: false, - hasDataPlaneRbacSettingChanged: true, - }); - const { databaseAccount: account, subscriptionId, resourceGroup } = userContext; - if (!userContext.features.enableAadDataPlane && !userContext.masterKey) { - let keys; - try { - keys = await listKeys(subscriptionId, resourceGroup, account.name); - updateUserContext({ - masterKey: keys.primaryMasterKey, - }); - } catch (error) { - // if listKeys fail because of permissions issue, then make call to get ReadOnlyKeys - if (error.code === "AuthorizationFailed") { - keys = await getReadOnlyKeys(subscriptionId, resourceGroup, account.name); + if (configContext.platform !== Platform.Fabric) { + LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption); + if ( + enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption || + (enableDataPlaneRBACOption === Constants.RBACOptions.setAutomaticRBACOption && + userContext.databaseAccount.properties.disableLocalAuth) + ) { + updateUserContext({ + dataPlaneRbacEnabled: true, + hasDataPlaneRbacSettingChanged: true, + }); + useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true }); + } else { + updateUserContext({ + dataPlaneRbacEnabled: false, + hasDataPlaneRbacSettingChanged: true, + }); + const { databaseAccount: account, subscriptionId, resourceGroup } = userContext; + if (!userContext.features.enableAadDataPlane && !userContext.masterKey) { + let keys; + try { + keys = await listKeys(subscriptionId, resourceGroup, account.name); updateUserContext({ - masterKey: keys.primaryReadonlyMasterKey, + masterKey: keys.primaryMasterKey, }); - } else { - logConsoleError(`Error occurred fetching keys for the account." ${error.message}`); - throw error; + } catch (error) { + // if listKeys fail because of permissions issue, then make call to get ReadOnlyKeys + if (error.code === "AuthorizationFailed") { + keys = await getReadOnlyKeys(subscriptionId, resourceGroup, account.name); + updateUserContext({ + masterKey: keys.primaryReadonlyMasterKey, + }); + } else { + logConsoleError(`Error occurred fetching keys for the account." ${error.message}`); + throw error; + } } + useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false }); } - useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false }); } } @@ -476,55 +492,57 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ )} - {userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && ( - <> -
-
-
- - Enable Entra ID RBAC - - - Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra - ID RBAC. - - {" "} - Learn more{" "} - - - } - > - - - {showDataPlaneRBACWarning && configContext.platform === Platform.Portal && ( - setShowDataPlaneRBACWarning(false)} - dismissButtonAriaLabel="Close" + {userContext.apiType === "SQL" && + userContext.authType === AuthType.AAD && + configContext.platform !== Platform.Fabric && ( + <> +
+
+
+ + Enable Entra ID RBAC + + + Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable + Entra ID RBAC. + + {" "} + Learn more{" "} + + + } > - Please click on "Login for Entra ID RBAC" button prior to performing Entra ID RBAC - operations - - )} - -
+ + + {showDataPlaneRBACWarning && configContext.platform === Platform.Portal && ( + setShowDataPlaneRBACWarning(false)} + dismissButtonAriaLabel="Close" + > + Please click on "Login for Entra ID RBAC" button prior to performing Entra ID RBAC + operations + + )} + +
+
- - - )} + + )} {userContext.apiType === "SQL" && ( <>
@@ -830,6 +848,34 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
)} +
+
+ { + useDialog.getState().showOkCancelModalDialog( + "Clear History", + undefined, + "Are you sure you want to proceed?", + () => deleteAllStates(), + "Cancel", + undefined, + <> + + This action will clear the all customizations for this account in this browser, including: + +
    +
  • Reset your customized tab layout, including the splitter positions
  • +
  • Erase your table column preferences, including any custom columns
  • +
  • Clear your filter history
  • +
+ , + ); + }} + > + Clear History +
+
+
Explorer Version
diff --git a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap index 6e2f9e001..633afd20e 100644 --- a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap +++ b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap @@ -485,6 +485,19 @@ exports[`Settings Pane should render Default properly 1`] = ` />
+
+
+ + Clear History + +
+
@@ -708,6 +721,19 @@ exports[`Settings Pane should render Gremlin properly 1`] = ` />
+
+
+ + Clear History + +
+
diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts new file mode 100644 index 000000000..fb01bdc54 --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts @@ -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 = { idealWidth?: number; minWidth?: 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 = ( + 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 = ( + 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(), + }); +}; diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx index 267db89b6..59ae9052a 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx @@ -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("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"]); + }); +}); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index 25e52bd2c..7f20dbce4 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -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"; @@ -51,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: { @@ -474,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; @@ -488,6 +514,11 @@ export interface IDocumentsTabComponentProps { isTabActive: boolean; } +const getUniqueId = (collection: ViewModels.CollectionBase): string => `${collection.databaseId}-${collection.id()}`; + +const defaultSqlFilters = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC']; +const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"]; + // Export to expose to unit tests export const DocumentsTabComponent: React.FunctionComponent = ({ isPreferredApiMongoDB, @@ -535,6 +566,13 @@ export const DocumentsTabComponent: React.FunctionComponent(() => + readSubComponentState(SubComponentName.MainTabDivider, _collection, { + leftPaneWidthPercent: 35, + }), + ); + const isQueryCopilotSampleContainer = _collection?.isSampleCollection && _collection?.databaseId === QueryCopilotSampleDatabaseId && @@ -543,6 +581,11 @@ export const DocumentsTabComponent: React.FunctionComponent(undefined); + // User's filter history + const [lastFilterContents, setLastFilterContents] = useState(() => + readSubComponentState(SubComponentName.FilterHistory, _collection, []), + ); + const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB); useEffect(() => { @@ -568,8 +611,6 @@ export const DocumentsTabComponent: React.FunctionComponent): void => { if (e.key === "Enter") { - refreshDocumentsGrid(true); + onApplyFilterClick(); // Suppress the default behavior of the key e.preventDefault(); @@ -1442,7 +1483,6 @@ export const DocumentsTabComponent: React.FunctionComponent { if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) { partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, ""); @@ -1663,6 +1703,24 @@ export const DocumentsTabComponent: React.FunctionComponent { + 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 @@ -1721,12 +1779,11 @@ export const DocumentsTabComponent: React.FunctionComponent {!isPreferredApiMongoDB && SELECT * FROM c } setIsFilterFocused(false)} /> - - {lastFilterContents.map((filter) => ( + + {addStringsNoDuplicate( + lastFilterContents, + isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters, + ).map((filter) => ( @@ -1749,7 +1809,7 @@ export const DocumentsTabComponent: React.FunctionComponent refreshDocumentsGrid(true)} + onClick={onApplyFilterClick} disabled={!applyFilterButton.enabled} aria-label="Apply filter" tabIndex={0} @@ -1780,11 +1840,16 @@ export const DocumentsTabComponent: React.FunctionComponent )} - {/* doesn't like to be a flex child */}
- - + { + tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]); + saveSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData); + setTabStateData(tabStateData); + }} + > +
@@ -1813,6 +1878,7 @@ export const DocumentsTabComponent: React.FunctionComponent
{tableItems.length > 0 && ( @@ -1828,7 +1894,7 @@ export const DocumentsTabComponent: React.FunctionComponent - +
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && ( { partitionKeyHeaders: [PARTITION_KEY_HEADER], }, isSelectionDisabled: false, + collection: { + databaseId: "db", + id: ((): string => "coll") as ko.Observable, + } as ViewModels.CollectionBase, }); it("should render documents and partition keys in header", () => { diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx index b6cc25355..7d785f6b1 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx @@ -1,4 +1,5 @@ import { + createTableColumn, Menu, MenuItem, MenuList, @@ -16,19 +17,26 @@ import { TableRow, TableRowId, TableSelectionCell, - createTableColumn, useArrowNavigationGroup, useTableColumnSizing_unstable, useTableFeatures, useTableSelection, } from "@fluentui/react-components"; import { NormalizedEventKey } from "Common/Constants"; +import { + ColumnSizesMap, + readSubComponentState, + saveSubComponentState, + SubComponentName, + WidthDefinition, +} 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 +55,7 @@ export interface IDocumentsTableComponentProps { columnHeaders: ColumnHeaders; style?: React.CSSProperties; isSelectionDisabled?: boolean; + collection: ViewModels.CollectionBase; } interface TableRowData extends RowStateBase { @@ -59,6 +68,11 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps { data: TableRowData[]; } +const defaultSize: WidthDefinition = { + idealWidth: 200, + minWidth: 50, +}; + export const DocumentsTableComponent: React.FC = ({ items, onSelectedRowsChange, @@ -67,32 +81,34 @@ export const DocumentsTableComponent: React.FC = 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(() => { + const columnIds = ["id"].concat(columnHeaders.partitionKeyHeaders); + const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {}); + const columnSizesPx: ColumnSizesMap = {}; + columnIds.forEach((columnId) => { + columnSizesPx[columnId] = (columnSizesMap && columnSizesMap[columnId]) || defaultSize; + }); + return columnSizesPx; }); - const [columnSizingOptions, setColumnSizingOptions] = React.useState(initialSizingOptions); + const styles = useDocumentsTabStyles(); const onColumnResize = React.useCallback((_, { columnId, width }) => { - setColumnSizingOptions((state) => ({ - ...state, - [columnId]: { - ...state[columnId], - idealWidth: width, - }, - })); + setColumnSizingOptions((state) => { + const newSizingOptions = { + ...state, + [columnId]: { + ...state[columnId], + idealWidth: width, + }, + }; + + saveSubComponentState(SubComponentName.ColumnSizes, collection, newSizingOptions, true); + + return newSizingOptions; + }); }, []); // Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes diff --git a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap index 7b9bae63c..93719e55c 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap +++ b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap @@ -38,9 +38,11 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = ` } } > - +
({ + 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); + }); + }); +}); diff --git a/src/Shared/AppStatePersistenceUtility.ts b/src/Shared/AppStatePersistenceUtility.ts new file mode 100644 index 000000000..bcf5ad7f3 --- /dev/null +++ b/src/Shared/AppStatePersistenceUtility.ts @@ -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(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(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(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); +}; diff --git a/src/Shared/LocalStorageUtility.ts b/src/Shared/LocalStorageUtility.ts index 9fc2f4f7c..097f45877 100644 --- a/src/Shared/LocalStorageUtility.ts +++ b/src/Shared/LocalStorageUtility.ts @@ -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 = (key: StorageKey): T | null => { + const item = localStorage.getItem(StorageKey[key]); + if (item) { + return JSON.parse(item) as T; + } + return null; +}; diff --git a/src/Shared/StorageUtility.ts b/src/Shared/StorageUtility.ts index f2ca1f20b..952bcd9ac 100644 --- a/src/Shared/StorageUtility.ts +++ b/src/Shared/StorageUtility.ts @@ -30,6 +30,7 @@ export enum StorageKey { VisitedAccounts, PriorityLevel, DefaultQueryResultsView, + AppState, } export const hasRUThresholdBeenConfigured = (): boolean => { diff --git a/src/Shared/Telemetry/TelemetryConstants.ts b/src/Shared/Telemetry/TelemetryConstants.ts index 3b4892990..1fae132ad 100644 --- a/src/Shared/Telemetry/TelemetryConstants.ts +++ b/src/Shared/Telemetry/TelemetryConstants.ts @@ -139,6 +139,9 @@ export enum Action { QueryEdited, ExecuteQueryGeneratedFromQueryCopilot, DeleteDocuments, + ReadPersistedTabState, + SavePersistedTabState, + DeletePersistedTabState, } export const ActionModifiers = { From 833d677d20984a29d56f482894283f3cdb15deae Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Thu, 22 Aug 2024 17:00:49 +0200 Subject: [PATCH 03/28] Change persistence format for column width (#1944) --- .../DocumentsTabV2/DocumentsTabStateUtil.ts | 2 +- .../DocumentsTableComponent.tsx | 31 ++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts index fb01bdc54..aaccfa3ac 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts @@ -14,7 +14,7 @@ export enum SubComponentName { } export type ColumnSizesMap = { [columnId: string]: WidthDefinition }; -export type WidthDefinition = { idealWidth?: number; minWidth?: number }; +export type WidthDefinition = { widthPx: number }; export type TabDivider = { leftPaneWidthPercent: number }; /** diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx index 7d785f6b1..127c5d6d9 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx @@ -28,7 +28,6 @@ import { readSubComponentState, saveSubComponentState, SubComponentName, - WidthDefinition, } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil"; import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper"; @@ -68,11 +67,10 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps { data: TableRowData[]; } -const defaultSize: WidthDefinition = { +const defaultSize = { idealWidth: 200, minWidth: 50, }; - export const DocumentsTableComponent: React.FC = ({ items, onSelectedRowsChange, @@ -86,16 +84,28 @@ export const DocumentsTableComponent: React.FC = const [columnSizingOptions, setColumnSizingOptions] = React.useState(() => { const columnIds = ["id"].concat(columnHeaders.partitionKeyHeaders); const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {}); - const columnSizesPx: ColumnSizesMap = {}; + const columnSizesPx: TableColumnSizingOptions = {}; columnIds.forEach((columnId) => { - columnSizesPx[columnId] = (columnSizesMap && columnSizesMap[columnId]) || defaultSize; + 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 styles = useDocumentsTabStyles(); - const onColumnResize = React.useCallback((_, { columnId, width }) => { + const onColumnResize = React.useCallback((_, { columnId, width }: { columnId: string; width: number }) => { setColumnSizingOptions((state) => { const newSizingOptions = { ...state, @@ -105,7 +115,14 @@ export const DocumentsTableComponent: React.FC = }, }; - saveSubComponentState(SubComponentName.ColumnSizes, collection, newSizingOptions, true); + 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; }); From 0658448b54710ee9ff0e186db750e5cc9100288d Mon Sep 17 00:00:00 2001 From: vchske Date: Mon, 26 Aug 2024 10:00:33 -0700 Subject: [PATCH 04/28] 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 --- src/Utils/QueryUtils.test.ts | 28 ++++++++++++++++++++++++++++ src/Utils/QueryUtils.ts | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/Utils/QueryUtils.test.ts b/src/Utils/QueryUtils.test.ts index 5a0c519eb..e6b413a98 100644 --- a/src/Utils/QueryUtils.test.ts +++ b/src/Utils/QueryUtils.test.ts @@ -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); + }); }); }); diff --git a/src/Utils/QueryUtils.ts b/src/Utils/QueryUtils.ts index 5440c2dda..5c621b2fb 100644 --- a/src/Utils/QueryUtils.ts +++ b/src/Utils/QueryUtils.ts @@ -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]); } }); From 0d22d4ab4dd4ecfa859e014cdde2d7582c74c4f0 Mon Sep 17 00:00:00 2001 From: Ashley Stanton-Nurse Date: Tue, 27 Aug 2024 14:20:34 -0700 Subject: [PATCH 05/28] change default splitter orientation when the setting has not been set (#1946) --- .../SettingsPane/__snapshots__/SettingsPane.test.tsx.snap | 2 +- src/Shared/StorageUtility.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap index 633afd20e..ed67f2665 100644 --- a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap +++ b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap @@ -238,7 +238,7 @@ exports[`Settings Pane should render Default properly 1`] = ` }, ] } - selectedKey="vertical" + selectedKey="horizontal" styles={ { "flexContainer": [ diff --git a/src/Shared/StorageUtility.ts b/src/Shared/StorageUtility.ts index 952bcd9ac..1cea30145 100644 --- a/src/Shared/StorageUtility.ts +++ b/src/Shared/StorageUtility.ts @@ -57,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; From 6aeac542b1b78a0b54b8067fc9a4d65fdc342a2b Mon Sep 17 00:00:00 2001 From: Asier Isayas Date: Wed, 28 Aug 2024 09:04:49 -0400 Subject: [PATCH 06/28] Runtime Proxy API (#1950) Co-authored-by: Asier Isayas --- src/Common/Constants.ts | 1 + src/Common/CosmosClient.ts | 36 +++++++++++++++++-- src/Common/MongoProxyClient.ts | 3 +- .../Hosted/Components/ConnectExplorer.tsx | 2 +- src/Utils/EndpointUtils.ts | 11 +++++- src/hooks/usePortalAccessToken.tsx | 24 +++++++++++-- 6 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index cde5e9462..548a92c76 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -134,6 +134,7 @@ 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"; } export class PortalBackendEndpoints { diff --git a/src/Common/CosmosClient.ts b/src/Common/CosmosClient.ts index 2216e2448..f286f3fbc 100644 --- a/src/Common/CosmosClient.ts +++ b/src/Common/CosmosClient.ts @@ -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 { + 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 { try { const host = configContext.BACKEND_ENDPOINT; diff --git a/src/Common/MongoProxyClient.ts b/src/Common/MongoProxyClient.ts index 64c42d875..97cc849d8 100644 --- a/src/Common/MongoProxyClient.ts +++ b/src/Common/MongoProxyClient.ts @@ -720,7 +720,8 @@ 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 ( diff --git a/src/Platform/Hosted/Components/ConnectExplorer.tsx b/src/Platform/Hosted/Components/ConnectExplorer.tsx index 64f8540b7..93bdc51e8 100644 --- a/src/Platform/Hosted/Components/ConnectExplorer.tsx +++ b/src/Platform/Hosted/Components/ConnectExplorer.tsx @@ -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; diff --git a/src/Utils/EndpointUtils.ts b/src/Utils/EndpointUtils.ts index b685dc71a..11968c639 100644 --- a/src/Utils/EndpointUtils.ts +++ b/src/Utils/EndpointUtils.ts @@ -164,7 +164,16 @@ 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, + ], }; if (!newBackendApiEnvironmentMap[backendApi] || !configContext.PORTAL_BACKEND_ENDPOINT) { diff --git a/src/hooks/usePortalAccessToken.tsx b/src/hooks/usePortalAccessToken.tsx index fdccc84a7..5de77e08d 100644 --- a/src/hooks/usePortalAccessToken.tsx +++ b/src/hooks/usePortalAccessToken.tsx @@ -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 { + 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 { + 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", From c5b7f599b3ea778565aa634d09aaa829d3d7638c Mon Sep 17 00:00:00 2001 From: sindhuba <122321535+sindhuba@users.noreply.github.com> Date: Wed, 28 Aug 2024 09:11:21 -0700 Subject: [PATCH 07/28] 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 --- src/Common/Constants.ts | 6 ++++++ src/Utils/EndpointUtils.ts | 6 +++++- src/hooks/useKnockoutExplorer.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 548a92c76..51f9de284 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -184,6 +184,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"; diff --git a/src/Utils/EndpointUtils.ts b/src/Utils/EndpointUtils.ts index 11968c639..e543e7cb9 100644 --- a/src/Utils/EndpointUtils.ts +++ b/src/Utils/EndpointUtils.ts @@ -52,7 +52,11 @@ export const defaultAllowedArmEndpoints: ReadonlyArray = [ "https://management.chinacloudapi.cn", ]; -export const allowedAadEndpoints: ReadonlyArray = ["https://login.microsoftonline.com/"]; +export const allowedAadEndpoints: ReadonlyArray = [ + "https://login.microsoftonline.com/", + "https://login.microsoftonline.us/", + "https://login.partner.microsoftonline.cn/", +]; export const defaultAllowedBackendEndpoints: ReadonlyArray = [ "https://main.documentdb.ext.azure.com", diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index ed7b762bc..028925d7d 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -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, From 4b207f3fa6746df37353dd123a3de7f0e35e1cb9 Mon Sep 17 00:00:00 2001 From: Asier Isayas Date: Thu, 29 Aug 2024 18:42:13 -0400 Subject: [PATCH 08/28] 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 --- src/Common/Constants.ts | 1 + src/Explorer/Notebook/useNotebook.ts | 5 +- src/Utils/EndpointUtils.ts | 14 ++++ src/Utils/NetworkUtility.test.ts | 102 ++++++++++++++------------- src/Utils/NetworkUtility.ts | 63 ++++++++++++++--- 5 files changed, 123 insertions(+), 62 deletions(-) diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 51f9de284..23bcfc23f 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -135,6 +135,7 @@ export class BackendApi { 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 { diff --git a/src/Explorer/Notebook/useNotebook.ts b/src/Explorer/Notebook/useNotebook.ts index 734d1cd79..a0f6efbf0 100644 --- a/src/Explorer/Notebook/useNotebook.ts +++ b/src/Explorer/Notebook/useNotebook.ts @@ -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 = 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, { diff --git a/src/Utils/EndpointUtils.ts b/src/Utils/EndpointUtils.ts index e543e7cb9..cf0b66971 100644 --- a/src/Utils/EndpointUtils.ts +++ b/src/Utils/EndpointUtils.ts @@ -78,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"], @@ -178,6 +185,13 @@ export function useNewPortalBackendEndpoint(backendApi: string): boolean { PortalBackendEndpoints.Mpac, PortalBackendEndpoints.Prod, ], + [BackendApi.DisallowedLocations]: [ + PortalBackendEndpoints.Development, + PortalBackendEndpoints.Mpac, + PortalBackendEndpoints.Prod, + PortalBackendEndpoints.Fairfax, + PortalBackendEndpoints.Mooncake, + ], }; if (!newBackendApiEnvironmentMap[backendApi] || !configContext.PORTAL_BACKEND_ENDPOINT) { diff --git a/src/Utils/NetworkUtility.test.ts b/src/Utils/NetworkUtility.test.ts index 4ee4b5cd2..ba2eb2c67 100644 --- a/src/Utils/NetworkUtility.test.ts +++ b/src/Utils/NetworkUtility.test.ts @@ -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 diff --git a/src/Utils/NetworkUtility.ts b/src/Utils/NetworkUtility.ts index 96f3ae124..40f663624 100644 --- a/src/Utils/NetworkUtility.ts +++ b/src/Utils/NetworkUtility.ts @@ -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); } } } From b4973e83675e9e6837795509d4ba45b3d0653ba2 Mon Sep 17 00:00:00 2001 From: vchske Date: Wed, 4 Sep 2024 11:35:32 -0700 Subject: [PATCH 09/28] Fixing regex on allowedParentFrameOrigins to address XSS (#1956) --- src/ConfigContext.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ConfigContext.ts b/src/ConfigContext.ts index 6d15a8a0f..f202602e9 100644 --- a/src/ConfigContext.ts +++ b/src/ConfigContext.ts @@ -87,7 +87,7 @@ let configContext: Readonly = { `^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/", From e8a565879993518d1547a7c876c0dc03b28b7486 Mon Sep 17 00:00:00 2001 From: Ashley Stanton-Nurse Date: Wed, 4 Sep 2024 13:07:27 -0700 Subject: [PATCH 10/28] 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 --- less/documentDB.less | 8 +- src/Explorer/Controls/TreeComponent/Styles.ts | 3 +- .../TreeComponent/TreeNodeComponent.tsx | 15 +- .../TreeNodeComponent.test.tsx.snap | 280 +++++++++++++++--- src/Explorer/Sidebar.tsx | 4 +- .../DocumentsTableComponent.test.tsx.snap | 76 ++--- src/Explorer/Tabs/Tabs.tsx | 3 +- src/Explorer/Theme/ThemeUtil.tsx | 30 +- .../__snapshots__/treeNodeUtil.test.ts.snap | 123 ++++++++ .../{treeNodeUtil.ts => treeNodeUtil.tsx} | 11 + 10 files changed, 452 insertions(+), 101 deletions(-) rename src/Explorer/Tree/{treeNodeUtil.ts => treeNodeUtil.tsx} (97%) diff --git a/less/documentDB.less b/less/documentDB.less index ee4dba4b6..1abbc9b30 100644 --- a/less/documentDB.less +++ b/less/documentDB.less @@ -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 { diff --git a/src/Explorer/Controls/TreeComponent/Styles.ts b/src/Explorer/Controls/TreeComponent/Styles.ts index 14247e657..a81923f35 100644 --- a/src/Explorer/Controls/TreeComponent/Styles.ts +++ b/src/Explorer/Controls/TreeComponent/Styles.ts @@ -17,7 +17,7 @@ export const useTreeStyles = makeStyles({ minWidth: "100%", rowGap: "0px", paddingTop: "0px", - [treeIconWidth]: "20px", + [treeIconWidth]: "16px", [leafNodeSpacing]: "24px", }, nodeIcon: { @@ -32,7 +32,6 @@ export const useTreeStyles = makeStyles({ 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, diff --git a/src/Explorer/Controls/TreeComponent/TreeNodeComponent.tsx b/src/Explorer/Controls/TreeComponent/TreeNodeComponent.tsx index 1a4a5dbae..39dbf3c0f 100644 --- a/src/Explorer/Controls/TreeComponent/TreeNodeComponent.tsx +++ b/src/Explorer/Controls/TreeComponent/TreeNodeComponent.tsx @@ -149,15 +149,16 @@ export const TreeNodeComponent: React.FC = ({ // 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 ? ( - - ) : !isBranch ? ( - typeof node.iconSrc === "string" ? ( + const treeIcon = + node.iconSrc === undefined ? undefined : typeof node.iconSrc === "string" ? ( ) : ( node.iconSrc - ) - ) : openItems.includes(treeNodeId) ? ( + ); + + const expandIcon = isLoading ? ( + + ) : !isBranch ? undefined : openItems.includes(treeNodeId) ? ( ) : ( @@ -174,7 +175,6 @@ export const TreeNodeComponent: React.FC = ({ = ({ ), } } + iconBefore={treeIcon} expandIcon={expandIcon} > {node.label} diff --git a/src/Explorer/Controls/TreeComponent/__snapshots__/TreeNodeComponent.test.tsx.snap b/src/Explorer/Controls/TreeComponent/__snapshots__/TreeNodeComponent.test.tsx.snap index f031061af..80e4d1f19 100644 --- a/src/Explorer/Controls/TreeComponent/__snapshots__/TreeNodeComponent.test.tsx.snap +++ b/src/Explorer/Controls/TreeComponent/__snapshots__/TreeNodeComponent.test.tsx.snap @@ -10,13 +10,20 @@ exports[`TreeNodeComponent does not render children if the node is loading 1`] = > } + iconBefore={ + + } >
+
@@ -208,7 +225,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="-1" >
+
@@ -242,7 +269,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
@@ -256,7 +283,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="0" >
+
@@ -300,7 +337,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="-1" >
+
@@ -343,7 +390,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="-1" >
+
@@ -383,16 +440,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` > } + iconBefore={ + + } >
+
+ +
@@ -431,7 +505,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
@@ -587,7 +661,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` "itemType": "branch", "layoutRef": { "current":
+
@@ -639,7 +723,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="0" >
+
@@ -680,16 +774,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` > } + iconBefore={ + + } >
+
+ +
@@ -728,7 +839,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
+
@@ -873,7 +994,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="-1" >
+
@@ -914,16 +1045,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` > } + iconBefore={ + + } >
+
+ +
@@ -1039,7 +1187,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` "itemType": "leaf", "layoutRef": { "current":
+
@@ -1087,7 +1245,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="-1" >
+
@@ -1125,9 +1293,9 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` >
} + iconBefore={ + + } > } + iconBefore={ + + } > } + iconBefore={ + + } > } + iconBefore={ + + } > = ({ explorer }) => { {/* Collections Tree - Start */} {hasSidebar && ( // When collapsed, we force the pane to 24 pixels wide and make it non-resizable. - +
{loading && ( diff --git a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap index 4acf5348f..de8c56663 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap +++ b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap @@ -348,7 +348,7 @@ exports[`DocumentsTableComponent should not render selection column when isSelec > { }, }} > - {`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".`} & 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. }; } diff --git a/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap b/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap index 42029b374..787443041 100644 --- a/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap +++ b/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap @@ -30,6 +30,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "standardCollection", @@ -69,6 +72,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "conflictsCollection", @@ -92,6 +98,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "standardDb", @@ -102,6 +111,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca { "children": [ { + "iconSrc": , "id": "", "isSelected": [Function], "label": "Scale", @@ -133,6 +145,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "sampleItemsCollection", @@ -156,6 +171,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "sharedDatabase", @@ -246,6 +264,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "schemaCollection", @@ -274,6 +295,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "giganticDatabase", @@ -345,6 +369,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "standardCollection", @@ -415,6 +442,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "conflictsCollection", @@ -438,6 +468,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "standardDb", @@ -448,6 +481,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo { "children": [ { + "iconSrc": , "id": "", "isSelected": [Function], "label": "Scale", @@ -510,6 +546,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "sampleItemsCollection", @@ -533,6 +572,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "sharedDatabase", @@ -654,6 +696,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "schemaCollection", @@ -682,6 +727,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "giganticDatabase", @@ -706,6 +754,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "standardCollection", @@ -724,6 +775,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "conflictsCollection", @@ -747,6 +801,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "standardDb", @@ -766,6 +823,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "sampleItemsCollection", @@ -789,6 +849,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "sharedDatabase", @@ -808,6 +871,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "schemaCollection", @@ -836,6 +902,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "giganticDatabase", @@ -976,6 +1045,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "standardCollection", @@ -1076,6 +1148,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "conflictsCollection", @@ -1099,6 +1174,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "standardDb", @@ -1109,6 +1187,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ { "children": [ { + "iconSrc": , "id": "", "isSelected": [Function], "label": "Scale", @@ -1201,6 +1282,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "sampleItemsCollection", @@ -1224,6 +1308,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "sharedDatabase", @@ -1375,6 +1462,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "schemaCollection", @@ -1403,6 +1493,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "giganticDatabase", @@ -1543,6 +1636,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "standardCollection", @@ -1638,6 +1734,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "conflictsCollection", @@ -1661,6 +1760,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "standardDb", @@ -1671,6 +1773,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe { "children": [ { + "iconSrc": , "id": "", "isSelected": [Function], "label": "Scale", @@ -1763,6 +1868,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "sampleItemsCollection", @@ -1786,6 +1894,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "sharedDatabase", @@ -1937,6 +2048,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "schemaCollection", @@ -1965,6 +2079,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteDatabaseMenuItem", }, ], + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "giganticDatabase", @@ -1986,6 +2103,9 @@ exports[`createResourceTokenTreeNodes creates the expected tree nodes 1`] = ` }, ], "className": "collectionNode", + "iconSrc": , "isExpanded": true, "isSelected": [Function], "label": "testCollection", @@ -2021,6 +2141,9 @@ exports[`createSampleDataTreeNodes creates the expected tree nodes 1`] = ` "onClick": [Function], }, ], + "iconSrc": , "isExpanded": false, "isSelected": [Function], "label": "testCollection", diff --git a/src/Explorer/Tree/treeNodeUtil.ts b/src/Explorer/Tree/treeNodeUtil.tsx similarity index 97% rename from src/Explorer/Tree/treeNodeUtil.ts rename to src/Explorer/Tree/treeNodeUtil.tsx index e2cc769a6..b6ec04e01 100644 --- a/src/Explorer/Tree/treeNodeUtil.ts +++ b/src/Explorer/Tree/treeNodeUtil.tsx @@ -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 = ; +const TreeSettingsIcon = ; +const TreeCollectionIcon = ; + 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(); From 4296b5ae024577860b2e4482c2136498b9957053 Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Thu, 5 Sep 2024 07:16:48 +0200 Subject: [PATCH 11/28] Add more default filters (#1955) --- src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index 7f20dbce4..b0b76b377 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -516,7 +516,10 @@ export interface IDocumentsTabComponentProps { const getUniqueId = (collection: ViewModels.CollectionBase): string => `${collection.databaseId}-${collection.id()}`; -const defaultSqlFilters = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC']; +const 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 @@ -1800,7 +1803,7 @@ export const DocumentsTabComponent: React.FunctionComponent {addStringsNoDuplicate( lastFilterContents, - isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters, + isPreferredApiMongoDB ? defaultMongoFilters : getDefaultSqlFilters(partitionKeyProperties), ).map((filter) => (