From 2cff0fc3ff1206a28c4e9e6c802ab5c8e3861286 Mon Sep 17 00:00:00 2001 From: asier-isayas Date: Fri, 11 Apr 2025 10:39:32 -0400 Subject: [PATCH] Global Secondary Index (#2071) * add Materialized Views feature flag * fetch MV properties from RP API and capture them in our data models * AddMaterializedViewPanel * undefined check * subpartition keys * Partition Key, Throughput, Unique Keys * All views associated with a container (#2063) and Materialized View Target Container (#2065) Identified Source container and Target container Created tabs in Scale and Settings respectively Changed the Icon of target container * Add MV Panel * format * format * styling * add tests * tests * test files (#2074) Co-authored-by: nishthaAhujaa * fix type error * fix tests * merge conflict * Panel Integration (#2075) * integrated panel * edited header text --------- Co-authored-by: nishthaAhujaa Co-authored-by: Asier Isayas * updated tests (#2077) Co-authored-by: nishthaAhujaa * fix tests * update treeNodeUtil test snap * update settings component test snap * fixed source container in global "New Materialized View" * source container check (#2079) Co-authored-by: nishthaAhujaa * renamed Materialized Views to Global Secondary Index * more renaming * fix import * fix typo * disable materialized views for Fabric * updated input validation --------- Co-authored-by: Asier Isayas Co-authored-by: Nishtha Ahuja <45535788+nishthaAhujaa@users.noreply.github.com> Co-authored-by: nishthaAhujaa --- src/Common/Constants.ts | 3 + src/Common/DatabaseAccountUtility.ts | 7 + .../dataAccess/createMaterializedView.ts | 74 +++ src/Common/dataAccess/readCollections.ts | 9 +- src/Contracts/DataModels.ts | 28 +- src/Contracts/ViewModels.ts | 2 + src/Explorer/ContextMenuButtonFactory.tsx | 24 + .../Controls/Settings/SettingsComponent.tsx | 19 + .../GlobalSecondaryIndexComponent.test.tsx | 46 ++ .../GlobalSecondaryIndexComponent.tsx | 41 ++ ...obalSecondaryIndexSourceComponent.test.tsx | 42 ++ .../GlobalSecondaryIndexSourceComponent.tsx | 114 +++++ ...obalSecondaryIndexTargetComponent.test.tsx | 32 ++ .../GlobalSecondaryIndexTargetComponent.tsx | 45 ++ .../Controls/Settings/SettingsUtils.tsx | 3 + src/Explorer/Controls/Settings/TestUtils.tsx | 9 + .../SettingsComponent.test.tsx.snap | 101 ++++ src/Explorer/Explorer.tsx | 2 +- .../AddCollectionPanel.test.tsx | 2 +- .../AddCollectionPanel.tsx | 280 +++--------- .../AddCollectionPanelUtility.tsx | 217 +++++++++ .../AddCollectionPanel.test.tsx.snap | 0 .../AddGlobalSecondaryIndexPanel.test.tsx | 28 ++ .../AddGlobalSecondaryIndexPanel.tsx | 431 ++++++++++++++++++ .../AddGlobalSecondaryIndexPanelStyles.ts | 15 + .../Components/AdvancedComponent.tsx | 54 +++ .../Components/AnalyticalStoreComponent.tsx | 99 ++++ .../Components/FullTextSearchComponent.tsx | 45 ++ .../Components/PartitionKeyComponent.tsx | 132 ++++++ .../Components/ThroughputComponent.tsx | 71 +++ .../Components/UniqueKeysComponent.tsx | 78 ++++ .../Components/VectorSearchComponent.tsx | 58 +++ ...AddGlobalSecondaryIndexPanel.test.tsx.snap | 190 ++++++++ src/Explorer/QueryCopilot/CopilotCarousel.tsx | 2 +- src/Explorer/Sidebar.tsx | 25 + src/Explorer/Tree/Collection.ts | 4 + src/Explorer/Tree/Database.tsx | 2 +- src/Explorer/Tree/ResourceTree.tsx | 3 +- .../__snapshots__/treeNodeUtil.test.ts.snap | 50 +- src/Explorer/Tree/treeNodeUtil.test.ts | 10 + src/Explorer/Tree/treeNodeUtil.tsx | 9 +- src/Shared/Telemetry/TelemetryConstants.ts | 2 + 42 files changed, 2145 insertions(+), 263 deletions(-) create mode 100644 src/Common/dataAccess/createMaterializedView.ts create mode 100644 src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.test.tsx create mode 100644 src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.tsx create mode 100644 src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.test.tsx create mode 100644 src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.tsx create mode 100644 src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.test.tsx create mode 100644 src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.tsx rename src/Explorer/Panes/{ => AddCollectionPanel}/AddCollectionPanel.test.tsx (90%) rename src/Explorer/Panes/{ => AddCollectionPanel}/AddCollectionPanel.tsx (87%) create mode 100644 src/Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility.tsx rename src/Explorer/Panes/{ => AddCollectionPanel}/__snapshots__/AddCollectionPanel.test.tsx.snap (100%) create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.test.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles.ts create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/UniqueKeysComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 94ca16c27..37243de72 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -530,6 +530,9 @@ export class ariaLabelForLearnMoreLink { public static readonly AzureSynapseLink = "Learn more about Azure Synapse Link."; } +export class GlobalSecondaryIndexLabels { + public static readonly NewGlobalSecondaryIndex: string = "New Global Secondary Index"; +} export class FeedbackLabels { public static readonly provideFeedback: string = "Provide feedback"; } diff --git a/src/Common/DatabaseAccountUtility.ts b/src/Common/DatabaseAccountUtility.ts index c72d3baf6..50ec0064a 100644 --- a/src/Common/DatabaseAccountUtility.ts +++ b/src/Common/DatabaseAccountUtility.ts @@ -1,5 +1,6 @@ import { TagNames, WorkloadType } from "Common/Constants"; import { Tags } from "Contracts/DataModels"; +import { isFabric } from "Platform/Fabric/FabricUtil"; import { userContext } from "../UserContext"; function isVirtualNetworkFilterEnabled() { @@ -26,3 +27,9 @@ export function getWorkloadType(): WorkloadType { } return workloadType; } + +export function isGlobalSecondaryIndexEnabled(): boolean { + return ( + !isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews + ); +} diff --git a/src/Common/dataAccess/createMaterializedView.ts b/src/Common/dataAccess/createMaterializedView.ts new file mode 100644 index 000000000..659da9c14 --- /dev/null +++ b/src/Common/dataAccess/createMaterializedView.ts @@ -0,0 +1,74 @@ +import { constructRpOptions } from "Common/dataAccess/createCollection"; +import { handleError } from "Common/ErrorHandlingUtils"; +import { Collection, CreateMaterializedViewsParams as CreateGlobalSecondaryIndexParams } from "Contracts/DataModels"; +import { userContext } from "UserContext"; +import { createUpdateSqlContainer } from "Utils/arm/generatedClients/cosmos/sqlResources"; +import { + CreateUpdateOptions, + SqlContainerResource, + SqlDatabaseCreateUpdateParameters, +} from "Utils/arm/generatedClients/cosmos/types"; +import { logConsoleInfo, logConsoleProgress } from "Utils/NotificationConsoleUtils"; + +export const createGlobalSecondaryIndex = async (params: CreateGlobalSecondaryIndexParams): Promise => { + const clearMessage = logConsoleProgress( + `Creating a new global secondary index ${params.materializedViewId} for database ${params.databaseId}`, + ); + + const options: CreateUpdateOptions = constructRpOptions(params); + + const resource: SqlContainerResource = { + id: params.materializedViewId, + }; + if (params.materializedViewDefinition) { + resource.materializedViewDefinition = params.materializedViewDefinition; + } + if (params.analyticalStorageTtl) { + resource.analyticalStorageTtl = params.analyticalStorageTtl; + } + if (params.indexingPolicy) { + resource.indexingPolicy = params.indexingPolicy; + } + if (params.partitionKey) { + resource.partitionKey = params.partitionKey; + } + if (params.uniqueKeyPolicy) { + resource.uniqueKeyPolicy = params.uniqueKeyPolicy; + } + if (params.vectorEmbeddingPolicy) { + resource.vectorEmbeddingPolicy = params.vectorEmbeddingPolicy; + } + if (params.fullTextPolicy) { + resource.fullTextPolicy = params.fullTextPolicy; + } + + const rpPayload: SqlDatabaseCreateUpdateParameters = { + properties: { + resource, + options, + }, + }; + + try { + const createResponse = await createUpdateSqlContainer( + userContext.subscriptionId, + userContext.resourceGroup, + userContext.databaseAccount.name, + params.databaseId, + params.materializedViewId, + rpPayload, + ); + logConsoleInfo(`Successfully created global secondary index ${params.materializedViewId}`); + + return createResponse && (createResponse.properties.resource as Collection); + } catch (error) { + handleError( + error, + "CreateGlobalSecondaryIndex", + `Error while creating global secondary index ${params.materializedViewId}`, + ); + throw error; + } finally { + clearMessage(); + } +}; diff --git a/src/Common/dataAccess/readCollections.ts b/src/Common/dataAccess/readCollections.ts index ecb67c876..39e241cda 100644 --- a/src/Common/dataAccess/readCollections.ts +++ b/src/Common/dataAccess/readCollections.ts @@ -126,5 +126,12 @@ async function readCollectionsWithARM(databaseId: string): Promise collection.properties?.resource as DataModels.Collection); + // TO DO: Remove when we get RP API Spec with materializedViews + /* eslint-disable @typescript-eslint/no-explicit-any */ + return rpResponse?.value?.map((collection: any) => { + const collectionDataModel: DataModels.Collection = collection.properties?.resource as DataModels.Collection; + collectionDataModel.materializedViews = collection.properties?.resource?.materializedViews; + collectionDataModel.materializedViewDefinition = collection.properties?.resource?.materializedViewDefinition; + return collectionDataModel; + }); } diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index 3b3ab5027..58e412b76 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -32,6 +32,7 @@ export interface DatabaseAccountExtendedProperties { writeLocations?: DatabaseAccountResponseLocation[]; enableFreeTier?: boolean; enableAnalyticalStorage?: boolean; + enableMaterializedViews?: boolean; isVirtualNetworkFilterEnabled?: boolean; ipRules?: IpRule[]; privateEndpointConnections?: unknown[]; @@ -164,6 +165,8 @@ export interface Collection extends Resource { schema?: ISchema; requestSchema?: () => void; computedProperties?: ComputedProperties; + materializedViews?: MaterializedView[]; + materializedViewDefinition?: MaterializedViewDefinition; } export interface CollectionsWithPagination { @@ -223,6 +226,17 @@ export interface ComputedProperty { export type ComputedProperties = ComputedProperty[]; +export interface MaterializedView { + id: string; + _rid: string; +} + +export interface MaterializedViewDefinition { + definition: string; + sourceCollectionId: string; + sourceCollectionRid?: string; +} + export interface PartitionKey { paths: string[]; kind: "Hash" | "Range" | "MultiHash"; @@ -345,9 +359,7 @@ export interface CreateDatabaseParams { offerThroughput?: number; } -export interface CreateCollectionParams { - createNewDatabase: boolean; - collectionId: string; +export interface CreateCollectionParamsBase { databaseId: string; databaseLevelThroughput: boolean; offerThroughput?: number; @@ -361,6 +373,16 @@ export interface CreateCollectionParams { fullTextPolicy?: FullTextPolicy; } +export interface CreateCollectionParams extends CreateCollectionParamsBase { + createNewDatabase: boolean; + collectionId: string; +} + +export interface CreateMaterializedViewsParams extends CreateCollectionParamsBase { + materializedViewId: string; + materializedViewDefinition: MaterializedViewDefinition; +} + export interface VectorEmbeddingPolicy { vectorEmbeddings: VectorEmbedding[]; } diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index 83afa9ddb..a66d83b86 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -143,6 +143,8 @@ export interface Collection extends CollectionBase { geospatialConfig: ko.Observable; documentIds: ko.ObservableArray; computedProperties: ko.Observable; + materializedViews: ko.Observable; + materializedViewDefinition: ko.Observable; cassandraKeys: CassandraTableKeys; cassandraSchema: CassandraTableKey[]; diff --git a/src/Explorer/ContextMenuButtonFactory.tsx b/src/Explorer/ContextMenuButtonFactory.tsx index 8108cbbb2..3cb4c7a80 100644 --- a/src/Explorer/ContextMenuButtonFactory.tsx +++ b/src/Explorer/ContextMenuButtonFactory.tsx @@ -1,5 +1,11 @@ +import { GlobalSecondaryIndexLabels } from "Common/Constants"; +import { isGlobalSecondaryIndexEnabled } from "Common/DatabaseAccountUtility"; import { configContext, Platform } from "ConfigContext"; import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; +import { + AddGlobalSecondaryIndexPanel, + AddGlobalSecondaryIndexPanelProps, +} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel"; import { useDatabases } from "Explorer/useDatabases"; import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil"; import { Action } from "Shared/Telemetry/TelemetryConstants"; @@ -164,6 +170,24 @@ export const createCollectionContextMenuButton = ( }); } + if (isGlobalSecondaryIndexEnabled() && !selectedCollection.materializedViewDefinition()) { + items.push({ + label: GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex, + onClick: () => { + const addMaterializedViewPanelProps: AddGlobalSecondaryIndexPanelProps = { + explorer: container, + sourceContainer: selectedCollection, + }; + useSidePanel + .getState() + .openSidePanel( + GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex, + , + ); + }, + }); + } + return items; }; diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 720bef874..beec14495 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -44,6 +44,10 @@ import { ConflictResolutionComponent, ConflictResolutionComponentProps, } from "./SettingsSubComponents/ConflictResolutionComponent"; +import { + GlobalSecondaryIndexComponent, + GlobalSecondaryIndexComponentProps, +} from "./SettingsSubComponents/GlobalSecondaryIndexComponent"; import { IndexingPolicyComponent, IndexingPolicyComponentProps } from "./SettingsSubComponents/IndexingPolicyComponent"; import { MongoIndexingPolicyComponent, @@ -162,6 +166,7 @@ export class SettingsComponent extends React.Component, + }); + } + const pivotProps: IPivotProps = { onLinkClick: this.onPivotChange, selectedKey: SettingsV2TabTypes[this.state.selectedTab], diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.test.tsx new file mode 100644 index 000000000..ac290f93c --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.test.tsx @@ -0,0 +1,46 @@ +import { shallow } from "enzyme"; +import React from "react"; +import { collection, container } from "../TestUtils"; +import { GlobalSecondaryIndexComponent } from "./GlobalSecondaryIndexComponent"; +import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent"; +import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent"; + +describe("GlobalSecondaryIndexComponent", () => { + let testCollection: typeof collection; + let testExplorer: typeof container; + + beforeEach(() => { + testCollection = { ...collection }; + }); + + it("renders only the source component when materializedViewDefinition is missing", () => { + testCollection.materializedViews([ + { id: "view1", _rid: "rid1" }, + { id: "view2", _rid: "rid2" }, + ]); + testCollection.materializedViewDefinition(null); + const wrapper = shallow(); + expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(true); + expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(false); + }); + + it("renders only the target component when materializedViews is missing", () => { + testCollection.materializedViews(null); + testCollection.materializedViewDefinition({ + definition: "SELECT * FROM c WHERE c.id = 1", + sourceCollectionId: "source1", + sourceCollectionRid: "rid123", + }); + const wrapper = shallow(); + expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(false); + expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(true); + }); + + it("renders neither component when both are missing", () => { + testCollection.materializedViews(null); + testCollection.materializedViewDefinition(null); + const wrapper = shallow(); + expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(false); + expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(false); + }); +}); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.tsx new file mode 100644 index 000000000..66aa3313a --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.tsx @@ -0,0 +1,41 @@ +import { FontIcon, Link, Stack, Text } from "@fluentui/react"; +import Explorer from "Explorer/Explorer"; +import React from "react"; +import * as ViewModels from "../../../../Contracts/ViewModels"; +import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent"; +import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent"; + +export interface GlobalSecondaryIndexComponentProps { + collection: ViewModels.Collection; + explorer: Explorer; +} + +export const GlobalSecondaryIndexComponent: React.FC = ({ + collection, + explorer, +}) => { + const isTargetContainer = !!collection?.materializedViewDefinition(); + const isSourceContainer = !!collection?.materializedViews(); + + return ( + + + {isSourceContainer && ( + This container has the following indexes defined for it. + )} + + + Learn more + + {" "} + about how to define global secondary indexes and how to use them. + + + {isSourceContainer && } + {isTargetContainer && } + + ); +}; diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.test.tsx new file mode 100644 index 000000000..30ac800b9 --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.test.tsx @@ -0,0 +1,42 @@ +import { PrimaryButton } from "@fluentui/react"; +import { shallow } from "enzyme"; +import React from "react"; +import { collection, container } from "../TestUtils"; +import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent"; + +describe("GlobalSecondaryIndexSourceComponent", () => { + let testCollection: typeof collection; + let testExplorer: typeof container; + + beforeEach(() => { + testCollection = { ...collection }; + }); + + it("renders without crashing", () => { + const wrapper = shallow( + , + ); + expect(wrapper.exists()).toBe(true); + }); + + it("renders the PrimaryButton", () => { + const wrapper = shallow( + , + ); + expect(wrapper.find(PrimaryButton).exists()).toBe(true); + }); + + it("updates when new global secondary indexes are provided", () => { + const wrapper = shallow( + , + ); + + // Simulating an update by modifying the observable directly + testCollection.materializedViews([{ id: "view3", _rid: "rid3" }]); + + wrapper.setProps({ collection: testCollection }); + wrapper.update(); + + expect(wrapper.find(PrimaryButton).exists()).toBe(true); + }); +}); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.tsx new file mode 100644 index 000000000..aa0a0edae --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.tsx @@ -0,0 +1,114 @@ +import { PrimaryButton } from "@fluentui/react"; +import { GlobalSecondaryIndexLabels } from "Common/Constants"; +import { MaterializedView } from "Contracts/DataModels"; +import Explorer from "Explorer/Explorer"; +import { loadMonaco } from "Explorer/LazyMonaco"; +import { AddGlobalSecondaryIndexPanel } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel"; +import { useDatabases } from "Explorer/useDatabases"; +import { useSidePanel } from "hooks/useSidePanel"; +import * as monaco from "monaco-editor"; +import React, { useEffect, useRef } from "react"; +import * as ViewModels from "../../../../Contracts/ViewModels"; + +export interface GlobalSecondaryIndexSourceComponentProps { + collection: ViewModels.Collection; + explorer: Explorer; +} + +export const GlobalSecondaryIndexSourceComponent: React.FC = ({ + collection, + explorer, +}) => { + const editorContainerRef = useRef(null); + const editorRef = useRef(null); + + const globalSecondaryIndexes: MaterializedView[] = collection?.materializedViews() ?? []; + + // Helper function to fetch the definition and partition key of targetContainer by traversing through all collections and matching id from MaterializedView[] with collection id. + const getViewDetails = (viewId: string): { definition: string; partitionKey: string[] } => { + let definition = ""; + let partitionKey: string[] = []; + + useDatabases.getState().databases.find((database) => { + const collection = database.collections().find((collection) => collection.id() === viewId); + if (collection) { + const globalSecondaryIndexDefinition = collection.materializedViewDefinition(); + globalSecondaryIndexDefinition && (definition = globalSecondaryIndexDefinition.definition); + collection.partitionKey?.paths && (partitionKey = collection.partitionKey.paths); + } + }); + + return { definition, partitionKey }; + }; + + //JSON value for the editor using the fetched id and definitions. + const jsonValue = JSON.stringify( + globalSecondaryIndexes.map((view) => { + const { definition, partitionKey } = getViewDetails(view.id); + return { + name: view.id, + partitionKey: partitionKey.join(", "), + definition, + }; + }), + null, + 2, + ); + + // Initialize Monaco editor with the computed JSON value. + useEffect(() => { + let disposed = false; + const initMonaco = async () => { + const monacoInstance = await loadMonaco(); + if (disposed || !editorContainerRef.current) { + return; + } + + editorRef.current = monacoInstance.editor.create(editorContainerRef.current, { + value: jsonValue, + language: "json", + ariaLabel: "Global Secondary Index JSON", + readOnly: true, + }); + }; + + initMonaco(); + return () => { + disposed = true; + editorRef.current?.dispose(); + }; + }, [jsonValue]); + + // Update the editor when the jsonValue changes. + useEffect(() => { + if (editorRef.current) { + editorRef.current.setValue(jsonValue); + } + }, [jsonValue]); + + return ( +
+
+ + useSidePanel + .getState() + .openSidePanel( + GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex, + , + ) + } + /> +
+ ); +}; diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.test.tsx new file mode 100644 index 000000000..6296cdab7 --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.test.tsx @@ -0,0 +1,32 @@ +import { Text } from "@fluentui/react"; +import { Collection } from "Contracts/ViewModels"; +import { shallow } from "enzyme"; +import React from "react"; +import { collection } from "../TestUtils"; +import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent"; + +describe("GlobalSecondaryIndexTargetComponent", () => { + let testCollection: Collection; + + beforeEach(() => { + testCollection = { + ...collection, + materializedViewDefinition: collection.materializedViewDefinition, + }; + }); + + it("renders without crashing", () => { + const wrapper = shallow(); + expect(wrapper.exists()).toBe(true); + }); + + it("displays the source container ID", () => { + const wrapper = shallow(); + expect(wrapper.find(Text).at(2).dive().text()).toBe("source1"); + }); + + it("displays the global secondary index definition", () => { + const wrapper = shallow(); + expect(wrapper.find(Text).at(4).dive().text()).toBe("SELECT * FROM c WHERE c.id = 1"); + }); +}); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.tsx new file mode 100644 index 000000000..8fa1171e8 --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.tsx @@ -0,0 +1,45 @@ +import { Stack, Text } from "@fluentui/react"; +import * as React from "react"; +import * as ViewModels from "../../../../Contracts/ViewModels"; + +export interface GlobalSecondaryIndexTargetComponentProps { + collection: ViewModels.Collection; +} + +export const GlobalSecondaryIndexTargetComponent: React.FC = ({ + collection, +}) => { + const globalSecondaryIndexDefinition = collection?.materializedViewDefinition(); + + const textHeadingStyle = { + root: { fontWeight: "600", fontSize: 16 }, + }; + + const valueBoxStyle = { + root: { + backgroundColor: "#f3f3f3", + padding: "5px 10px", + borderRadius: "4px", + }, + }; + + return ( + + Global Secondary Index Settings + + + Source container + + {globalSecondaryIndexDefinition?.sourceCollectionId} + + + + + Global secondary index definition + + {globalSecondaryIndexDefinition?.definition} + + + + ); +}; diff --git a/src/Explorer/Controls/Settings/SettingsUtils.tsx b/src/Explorer/Controls/Settings/SettingsUtils.tsx index 900ad6ab0..448b59370 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.tsx @@ -57,6 +57,7 @@ export enum SettingsV2TabTypes { ComputedPropertiesTab, ContainerVectorPolicyTab, ThroughputBucketsTab, + GlobalSecondaryIndexTab, } export enum ContainerPolicyTabTypes { @@ -171,6 +172,8 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => { return "Container Policies"; case SettingsV2TabTypes.ThroughputBucketsTab: return "Throughput Buckets"; + case SettingsV2TabTypes.GlobalSecondaryIndexTab: + return "Global Secondary Index (Preview)"; default: throw new Error(`Unknown tab ${tab}`); } diff --git a/src/Explorer/Controls/Settings/TestUtils.tsx b/src/Explorer/Controls/Settings/TestUtils.tsx index c158e5cba..71d939584 100644 --- a/src/Explorer/Controls/Settings/TestUtils.tsx +++ b/src/Explorer/Controls/Settings/TestUtils.tsx @@ -48,6 +48,15 @@ export const collection = { ]), vectorEmbeddingPolicy: ko.observable({} as DataModels.VectorEmbeddingPolicy), fullTextPolicy: ko.observable({} as DataModels.FullTextPolicy), + materializedViews: ko.observable([ + { id: "view1", _rid: "rid1" }, + { id: "view2", _rid: "rid2" }, + ]), + materializedViewDefinition: ko.observable({ + definition: "SELECT * FROM c WHERE c.id = 1", + sourceCollectionId: "source1", + sourceCollectionRid: "rid123", + }), readSettings: () => { return; }, diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index ea6fe2864..34f6dec6d 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -60,6 +60,8 @@ exports[`SettingsComponent renders 1`] = ` "getDatabase": [Function], "id": [Function], "indexingPolicy": [Function], + "materializedViewDefinition": [Function], + "materializedViews": [Function], "offer": [Function], "partitionKey": { "kind": "hash", @@ -139,6 +141,8 @@ exports[`SettingsComponent renders 1`] = ` "getDatabase": [Function], "id": [Function], "indexingPolicy": [Function], + "materializedViewDefinition": [Function], + "materializedViews": [Function], "offer": [Function], "partitionKey": { "kind": "hash", @@ -258,6 +262,8 @@ exports[`SettingsComponent renders 1`] = ` "getDatabase": [Function], "id": [Function], "indexingPolicy": [Function], + "materializedViewDefinition": [Function], + "materializedViews": [Function], "offer": [Function], "partitionKey": { "kind": "hash", @@ -336,6 +342,101 @@ exports[`SettingsComponent renders 1`] = ` shouldDiscardComputedProperties={false} /> + + +
diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index ace0aaffe..e62bfe5b3 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -55,7 +55,7 @@ import type NotebookManager from "./Notebook/NotebookManager"; import { NotebookPaneContent } from "./Notebook/NotebookManager"; import { NotebookUtil } from "./Notebook/NotebookUtil"; import { useNotebook } from "./Notebook/useNotebook"; -import { AddCollectionPanel } from "./Panes/AddCollectionPanel"; +import { AddCollectionPanel } from "./Panes/AddCollectionPanel/AddCollectionPanel"; import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane"; import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane"; import { StringInputPane } from "./Panes/StringInputPane/StringInputPane"; diff --git a/src/Explorer/Panes/AddCollectionPanel.test.tsx b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.test.tsx similarity index 90% rename from src/Explorer/Panes/AddCollectionPanel.test.tsx rename to src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.test.tsx index 0e87c61de..f075eb828 100644 --- a/src/Explorer/Panes/AddCollectionPanel.test.tsx +++ b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.test.tsx @@ -1,6 +1,6 @@ import { shallow } from "enzyme"; import React from "react"; -import Explorer from "../Explorer"; +import Explorer from "../../Explorer"; import { AddCollectionPanel } from "./AddCollectionPanel"; const props = { diff --git a/src/Explorer/Panes/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx similarity index 87% rename from src/Explorer/Panes/AddCollectionPanel.tsx rename to src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx index 0256418d4..c92eda06b 100644 --- a/src/Explorer/Panes/AddCollectionPanel.tsx +++ b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx @@ -21,11 +21,25 @@ import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility"; import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; import { configContext, Platform } from "ConfigContext"; import * as DataModels from "Contracts/DataModels"; -import { - FullTextPoliciesComponent, - getFullTextLanguageOptions, -} from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent"; +import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent"; import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent"; +import { + AllPropertiesIndexed, + AnalyticalStorageContent, + ContainerVectorPolicyTooltipContent, + FullTextPolicyDefault, + getPartitionKey, + getPartitionKeyName, + getPartitionKeyPlaceHolder, + getPartitionKeyTooltipText, + isFreeTierAccount, + isSynapseLinkEnabled, + parseUniqueKeys, + scrollToSection, + SharedDatabaseDefault, + shouldShowAnalyticalStoreOptions, + UniqueKeysHeader, +} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; import { useSidePanel } from "hooks/useSidePanel"; import { useTeachingBubble } from "hooks/useTeachingBubble"; import { isFabricNative } from "Platform/Fabric/FabricUtil"; @@ -43,15 +57,14 @@ import { } from "Utils/CapabilityUtils"; import { getUpsellMessage } from "Utils/PricingUtils"; import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils"; -import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent"; -import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput"; -import "../Controls/ThroughputInput/ThroughputInput.less"; -import { ContainerSampleGenerator } from "../DataSamples/ContainerSampleGenerator"; -import Explorer from "../Explorer"; -import { useDatabases } from "../useDatabases"; -import { PanelFooterComponent } from "./PanelFooterComponent"; -import { PanelInfoErrorComponent } from "./PanelInfoErrorComponent"; -import { PanelLoadingScreen } from "./PanelLoadingScreen"; +import { CollapsibleSectionComponent } from "../../Controls/CollapsiblePanel/CollapsibleSectionComponent"; +import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput"; +import { ContainerSampleGenerator } from "../../DataSamples/ContainerSampleGenerator"; +import Explorer from "../../Explorer"; +import { useDatabases } from "../../useDatabases"; +import { PanelFooterComponent } from "../PanelFooterComponent"; +import { PanelInfoErrorComponent } from "../PanelInfoErrorComponent"; +import { PanelLoadingScreen } from "../PanelLoadingScreen"; export interface AddCollectionPanelProps { explorer: Explorer; @@ -59,40 +72,6 @@ export interface AddCollectionPanelProps { isQuickstart?: boolean; } -const SharedDatabaseDefault: DataModels.IndexingPolicy = { - indexingMode: "consistent", - automatic: true, - includedPaths: [], - excludedPaths: [ - { - path: "/*", - }, - ], -}; - -export const AllPropertiesIndexed: DataModels.IndexingPolicy = { - indexingMode: "consistent", - automatic: true, - includedPaths: [ - { - path: "/*", - indexes: [ - { - kind: "Range", - dataType: "Number", - precision: -1, - }, - { - kind: "Range", - dataType: "String", - precision: -1, - }, - ], - }, - ], - excludedPaths: [], -}; - export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = { vectorEmbeddings: [], }; @@ -145,7 +124,7 @@ export class AddCollectionPanel extends React.Component )} - {!this.state.errorMessage && this.isFreeTierAccount() && ( + {!this.state.errorMessage && isFreeTierAccount() && ( (this.newDatabaseThroughput = throughput)} setIsAutoscale={(isAutoscale: boolean) => (this.isNewDatabaseAutoscale = isAutoscale)} @@ -580,17 +559,14 @@ export class AddCollectionPanel extends React.Component - {this.getPartitionKeyName()} + {getPartitionKeyName()} - + @@ -604,8 +580,8 @@ export class AddCollectionPanel extends React.Component 0 ? 1 : 0} className="panelTextField" autoComplete="off" - placeholder={this.getPartitionKeyPlaceHolder(index)} - aria-label={this.getPartitionKeyName()} + placeholder={getPartitionKeyPlaceHolder(index)} + aria-label={getPartitionKeyName()} pattern={".*"} title={""} value={subPartitionKey} @@ -735,10 +711,10 @@ export class AddCollectionPanel extends React.Component (this.collectionThroughput = throughput)} setIsAutoscale={(isAutoscale: boolean) => (this.isCollectionAutoscale = isAutoscale)} @@ -753,27 +729,7 @@ export class AddCollectionPanel extends React.Component - - - Unique keys - - - - - - + {UniqueKeysHeader()} {this.state.uniqueKeys.map((uniqueKey: string, i: number): JSX.Element => { return ( @@ -821,10 +777,10 @@ export class AddCollectionPanel extends React.Component )} - {this.shouldShowAnalyticalStoreOptions() && ( + {shouldShowAnalyticalStoreOptions() && ( - {this.getAnalyticalStorageContent()} + {AnalyticalStorageContent()} @@ -832,7 +788,7 @@ export class AddCollectionPanel extends React.Component - {!this.isSynapseLinkEnabled() && ( + {!isSynapseLinkEnabled() && ( Azure Synapse Link is required for creating an analytical store{" "} @@ -891,9 +847,9 @@ export class AddCollectionPanel extends React.Component { - this.scrollToSection("collapsibleVectorPolicySectionContent"); + scrollToSection("collapsibleVectorPolicySectionContent"); }} - tooltipContent={this.getContainerVectorPolicyTooltipContent()} + tooltipContent={ContainerVectorPolicyTooltipContent()} > @@ -919,7 +875,7 @@ export class AddCollectionPanel extends React.Component { - this.scrollToSection("collapsibleFullTextPolicySectionContent"); + scrollToSection("collapsibleFullTextPolicySectionContent"); }} //TODO: uncomment when learn more text becomes available // tooltipContent={this.getContainerFullTextPolicyTooltipContent()} @@ -947,7 +903,7 @@ export class AddCollectionPanel extends React.Component { TelemetryProcessor.traceOpen(Action.ExpandAddCollectionPaneAdvancedSection); - this.scrollToSection("collapsibleAdvancedSectionContent"); + scrollToSection("collapsibleAdvancedSectionContent"); }} > @@ -1057,31 +1013,6 @@ export class AddCollectionPanel extends React.Component): void { if (event.target.checked && !this.state.createNewDatabase) { this.setState({ @@ -1169,48 +1100,12 @@ export class AddCollectionPanel extends React.Component - Enable analytical store capability to perform near real-time analytics on your operational data, without - impacting the performance of transactional workloads.{" "} - - Learn more - - - ); - } - - private getContainerVectorPolicyTooltipContent(): JSX.Element { - return ( - - Describe any properties in your data that contain vectors, so that they can be made available for similarity - queries.{" "} - - Learn more - - - ); - } - //TODO: uncomment when learn more text becomes available // private getContainerFullTextPolicyTooltipContent(): JSX.Element { // return ( @@ -1280,7 +1147,7 @@ export class AddCollectionPanel extends React.Component capability.name === Constants.CapabilityNames.EnableStorageAnalytics, - ); - } - private shouldShowVectorSearchParameters() { return isVectorSearchEnabled() && (isServerlessAccount() || this.shouldShowCollectionThroughputInput()); } @@ -1402,11 +1236,11 @@ export class AddCollectionPanel extends React.Component + + Unique keys + + + + + + ); +} + +export function shouldShowAnalyticalStoreOptions(): boolean { + if (isFabricNative() || configContext.platform === Platform.Emulator) { + return false; + } + + switch (userContext.apiType) { + case "SQL": + case "Mongo": + return true; + default: + return false; + } +} + +export function AnalyticalStorageContent(): JSX.Element { + return ( + + Enable analytical store capability to perform near real-time analytics on your operational data, without impacting + the performance of transactional workloads.{" "} + + Learn more + + + ); +} + +export function isSynapseLinkEnabled(): boolean { + if (!userContext.databaseAccount) { + return false; + } + + const { properties } = userContext.databaseAccount; + if (!properties) { + return false; + } + + if (properties.enableAnalyticalStorage) { + return true; + } + + return properties.capabilities?.some( + (capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics, + ); +} + +export function scrollToSection(id: string): void { + document.getElementById(id)?.scrollIntoView(); +} + +export function ContainerVectorPolicyTooltipContent(): JSX.Element { + return ( + + Describe any properties in your data that contain vectors, so that they can be made available for similarity + queries.{" "} + + Learn more + + + ); +} + +export function parseUniqueKeys(uniqueKeys: string[]): DataModels.UniqueKeyPolicy { + if (uniqueKeys?.length === 0) { + return undefined; + } + + const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = { uniqueKeys: [] }; + uniqueKeys.forEach((uniqueKey: string) => { + if (uniqueKey) { + const validPaths: string[] = uniqueKey.split(",")?.filter((path) => path?.length > 0); + const trimmedPaths: string[] = validPaths?.map((path) => path.trim()); + if (trimmedPaths?.length > 0) { + if (userContext.apiType === "Mongo") { + trimmedPaths.map((path) => { + const transformedPath = path.split(".").join("/"); + if (transformedPath[0] !== "/") { + return "/" + transformedPath; + } + return transformedPath; + }); + } + uniqueKeyPolicy.uniqueKeys.push({ paths: trimmedPaths }); + } + } + }); + + return uniqueKeyPolicy; +} + +export const SharedDatabaseDefault: DataModels.IndexingPolicy = { + indexingMode: "consistent", + automatic: true, + includedPaths: [], + excludedPaths: [ + { + path: "/*", + }, + ], +}; + +export const FullTextPolicyDefault: DataModels.FullTextPolicy = { + defaultLanguage: getFullTextLanguageOptions()[0].key as never, + fullTextPaths: [], +}; + +export const AllPropertiesIndexed: DataModels.IndexingPolicy = { + indexingMode: "consistent", + automatic: true, + includedPaths: [ + { + path: "/*", + indexes: [ + { + kind: "Range", + dataType: "Number", + precision: -1, + }, + { + kind: "Range", + dataType: "String", + precision: -1, + }, + ], + }, + ], + excludedPaths: [], +}; diff --git a/src/Explorer/Panes/__snapshots__/AddCollectionPanel.test.tsx.snap b/src/Explorer/Panes/AddCollectionPanel/__snapshots__/AddCollectionPanel.test.tsx.snap similarity index 100% rename from src/Explorer/Panes/__snapshots__/AddCollectionPanel.test.tsx.snap rename to src/Explorer/Panes/AddCollectionPanel/__snapshots__/AddCollectionPanel.test.tsx.snap diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.test.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.test.tsx new file mode 100644 index 000000000..05eec133d --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.test.tsx @@ -0,0 +1,28 @@ +import { shallow, ShallowWrapper } from "enzyme"; +import Explorer from "Explorer/Explorer"; +import { + AddGlobalSecondaryIndexPanel, + AddGlobalSecondaryIndexPanelProps, +} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel"; +import React, { Component } from "react"; + +const props: AddGlobalSecondaryIndexPanelProps = { + explorer: new Explorer(), +}; + +describe("AddGlobalSecondaryIndexPanel", () => { + it("render default panel", () => { + const wrapper: ShallowWrapper = shallow( + , + ); + expect(wrapper).toMatchSnapshot(); + }); + + it("should render form", () => { + const wrapper: ShallowWrapper = shallow( + , + ); + const form = wrapper.find("form").first(); + expect(form).toBeDefined(); + }); +}); diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.tsx new file mode 100644 index 000000000..313c72b4c --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.tsx @@ -0,0 +1,431 @@ +import { + DirectionalHint, + Dropdown, + DropdownMenuItemType, + Icon, + IDropdownOption, + Link, + Separator, + Stack, + Text, + TooltipHost, +} from "@fluentui/react"; +import * as Constants from "Common/Constants"; +import { createGlobalSecondaryIndex } from "Common/dataAccess/createMaterializedView"; +import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; +import * as DataModels from "Contracts/DataModels"; +import { FullTextIndex, FullTextPolicy, VectorEmbedding, VectorIndex } from "Contracts/DataModels"; +import { Collection, Database } from "Contracts/ViewModels"; +import Explorer from "Explorer/Explorer"; +import { + AllPropertiesIndexed, + FullTextPolicyDefault, + getPartitionKey, + isSynapseLinkEnabled, + parseUniqueKeys, + scrollToSection, + shouldShowAnalyticalStoreOptions, +} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import { + chooseSourceContainerStyle, + chooseSourceContainerStyles, +} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles"; +import { AdvancedComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent"; +import { AnalyticalStoreComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent"; +import { FullTextSearchComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent"; +import { PartitionKeyComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent"; +import { ThroughputComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent"; +import { UniqueKeysComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/UniqueKeysComponent"; +import { VectorSearchComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent"; +import { PanelFooterComponent } from "Explorer/Panes/PanelFooterComponent"; +import { PanelInfoErrorComponent } from "Explorer/Panes/PanelInfoErrorComponent"; +import { PanelLoadingScreen } from "Explorer/Panes/PanelLoadingScreen"; +import { useDatabases } from "Explorer/useDatabases"; +import { useSidePanel } from "hooks/useSidePanel"; +import React, { useEffect, useState } from "react"; +import { CollectionCreation } from "Shared/Constants"; +import { Action } from "Shared/Telemetry/TelemetryConstants"; +import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor"; +import { userContext } from "UserContext"; +import { isFullTextSearchEnabled, isServerlessAccount, isVectorSearchEnabled } from "Utils/CapabilityUtils"; +import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils"; + +export interface AddGlobalSecondaryIndexPanelProps { + explorer: Explorer; + sourceContainer?: Collection; +} +export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanelProps): JSX.Element => { + const { explorer, sourceContainer } = props; + + const [sourceContainerOptions, setSourceContainerOptions] = useState(); + const [selectedSourceContainer, setSelectedSourceContainer] = useState(sourceContainer); + const [globalSecondaryIndexId, setGlobalSecondaryIndexId] = useState(); + const [definition, setDefinition] = useState(); + const [partitionKey, setPartitionKey] = useState(getPartitionKey()); + const [subPartitionKeys, setSubPartitionKeys] = useState([]); + const [useHashV1, setUseHashV1] = useState(); + const [enableDedicatedThroughput, setEnabledDedicatedThroughput] = useState(); + const [isThroughputCapExceeded, setIsThroughputCapExceeded] = useState(); + const [uniqueKeys, setUniqueKeys] = useState([]); + const [enableAnalyticalStore, setEnableAnalyticalStore] = useState(); + const [vectorEmbeddingPolicy, setVectorEmbeddingPolicy] = useState(); + const [vectorIndexingPolicy, setVectorIndexingPolicy] = useState(); + const [vectorPolicyValidated, setVectorPolicyValidated] = useState(); + const [fullTextPolicy, setFullTextPolicy] = useState(FullTextPolicyDefault); + const [fullTextIndexes, setFullTextIndexes] = useState(); + const [fullTextPolicyValidated, setFullTextPolicyValidated] = useState(); + const [errorMessage, setErrorMessage] = useState(); + const [showErrorDetails, setShowErrorDetails] = useState(); + const [isExecuting, setIsExecuting] = useState(); + + useEffect(() => { + const sourceContainerOptions: IDropdownOption[] = []; + useDatabases.getState().databases.forEach((database: Database) => { + sourceContainerOptions.push({ + key: database.rid, + text: database.id(), + itemType: DropdownMenuItemType.Header, + }); + + database.collections().forEach((collection: Collection) => { + const isGlobalSecondaryIndex: boolean = !!collection.materializedViewDefinition(); + sourceContainerOptions.push({ + key: collection.rid, + text: collection.id(), + disabled: isGlobalSecondaryIndex, + ...(isGlobalSecondaryIndex && { + title: "This is a global secondary index.", + }), + data: collection, + }); + }); + }); + + setSourceContainerOptions(sourceContainerOptions); + }, []); + + useEffect(() => { + scrollToSection("panelContainer"); + }, [errorMessage]); + + let globalSecondaryIndexThroughput: number; + let isGlobalSecondaryIndexAutoscale: boolean; + let isCostAcknowledged: boolean; + + const globalSecondaryIndexThroughputOnChange = (globalSecondaryIndexThroughputValue: number): void => { + globalSecondaryIndexThroughput = globalSecondaryIndexThroughputValue; + }; + + const isGlobalSecondaryIndexAutoscaleOnChange = (isGlobalSecondaryIndexAutoscaleValue: boolean): void => { + isGlobalSecondaryIndexAutoscale = isGlobalSecondaryIndexAutoscaleValue; + }; + + const isCostAknowledgedOnChange = (isCostAcknowledgedValue: boolean): void => { + isCostAcknowledged = isCostAcknowledgedValue; + }; + + const isSelectedSourceContainerSharedThroughput = (): boolean => { + if (!selectedSourceContainer) { + return false; + } + + return !!selectedSourceContainer.getDatabase().offer(); + }; + + const showCollectionThroughputInput = (): boolean => { + if (isServerlessAccount()) { + return false; + } + + if (enableDedicatedThroughput) { + return true; + } + + return !!selectedSourceContainer && !isSelectedSourceContainerSharedThroughput(); + }; + + const showVectorSearchParameters = (): boolean => { + return isVectorSearchEnabled() && (isServerlessAccount() || showCollectionThroughputInput()); + }; + + const showFullTextSearchParameters = (): boolean => { + return isFullTextSearchEnabled() && (isServerlessAccount() || showCollectionThroughputInput()); + }; + + const getAnalyticalStorageTtl = (): number => { + if (!isSynapseLinkEnabled()) { + return undefined; + } + + if (!shouldShowAnalyticalStoreOptions()) { + return undefined; + } + + if (enableAnalyticalStore) { + // TODO: always default to 90 days once the backend hotfix is deployed + return userContext.features.ttl90Days + ? Constants.AnalyticalStorageTtl.Days90 + : Constants.AnalyticalStorageTtl.Infinite; + } + + return Constants.AnalyticalStorageTtl.Disabled; + }; + + const validateInputs = (): boolean => { + if (!selectedSourceContainer) { + setErrorMessage("Please select a source container"); + return false; + } + + if (globalSecondaryIndexThroughput > CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) { + const errorMessage = isGlobalSecondaryIndexAutoscale + ? "Please acknowledge the estimated monthly spend." + : "Please acknowledge the estimated daily spend."; + setErrorMessage(errorMessage); + return false; + } + + if (globalSecondaryIndexThroughput > CollectionCreation.MaxRUPerPartition) { + setErrorMessage("Unsharded collections support up to 10,000 RUs"); + return false; + } + + if (showVectorSearchParameters()) { + if (!vectorPolicyValidated) { + setErrorMessage("Please fix errors in container vector policy"); + return false; + } + + if (!fullTextPolicyValidated) { + setErrorMessage("Please fix errors in container full text search policy"); + return false; + } + } + + return true; + }; + + const submit = async (event?: React.FormEvent): Promise => { + event?.preventDefault(); + + if (!validateInputs()) { + return; + } + + const globalSecondaryIdTrimmed: string = globalSecondaryIndexId.trim(); + + const globalSecondaryIndexDefinition: DataModels.MaterializedViewDefinition = { + sourceCollectionId: selectedSourceContainer.id(), + definition: definition, + }; + + const partitionKeyTrimmed: string = partitionKey.trim(); + + const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = parseUniqueKeys(uniqueKeys); + const partitionKeyVersion = useHashV1 ? undefined : 2; + const partitionKeyPaths: DataModels.PartitionKey = partitionKeyTrimmed + ? { + paths: [ + partitionKeyTrimmed, + ...(userContext.apiType === "SQL" && subPartitionKeys.length > 0 ? subPartitionKeys : []), + ], + kind: userContext.apiType === "SQL" && subPartitionKeys.length > 0 ? "MultiHash" : "Hash", + version: partitionKeyVersion, + } + : undefined; + + const indexingPolicy: DataModels.IndexingPolicy = AllPropertiesIndexed; + let vectorEmbeddingPolicyFinal: DataModels.VectorEmbeddingPolicy; + + if (showVectorSearchParameters()) { + indexingPolicy.vectorIndexes = vectorIndexingPolicy; + vectorEmbeddingPolicyFinal = { + vectorEmbeddings: vectorEmbeddingPolicy, + }; + } + + if (showFullTextSearchParameters()) { + indexingPolicy.fullTextIndexes = fullTextIndexes; + } + + const telemetryData: TelemetryProcessor.TelemetryData = { + database: { + id: selectedSourceContainer.databaseId, + shared: isSelectedSourceContainerSharedThroughput(), + }, + collection: { + id: globalSecondaryIdTrimmed, + throughput: globalSecondaryIndexThroughput, + isAutoscale: isGlobalSecondaryIndexAutoscale, + partitionKeyPaths, + uniqueKeyPolicy, + collectionWithDedicatedThroughput: enableDedicatedThroughput, + }, + subscriptionQuotaId: userContext.quotaId, + dataExplorerArea: Constants.Areas.ContextualPane, + }; + + const startKey: number = TelemetryProcessor.traceStart(Action.CreateCollection, telemetryData); + const databaseLevelThroughput: boolean = isSelectedSourceContainerSharedThroughput() && !enableDedicatedThroughput; + + let offerThroughput: number; + let autoPilotMaxThroughput: number; + + if (!databaseLevelThroughput) { + if (isGlobalSecondaryIndexAutoscale) { + autoPilotMaxThroughput = globalSecondaryIndexThroughput; + } else { + offerThroughput = globalSecondaryIndexThroughput; + } + } + + const createGlobalSecondaryIndexParams: DataModels.CreateMaterializedViewsParams = { + materializedViewId: globalSecondaryIdTrimmed, + materializedViewDefinition: globalSecondaryIndexDefinition, + databaseId: selectedSourceContainer.databaseId, + databaseLevelThroughput: databaseLevelThroughput, + offerThroughput: offerThroughput, + autoPilotMaxThroughput: autoPilotMaxThroughput, + analyticalStorageTtl: getAnalyticalStorageTtl(), + indexingPolicy: indexingPolicy, + partitionKey: partitionKeyPaths, + uniqueKeyPolicy: uniqueKeyPolicy, + vectorEmbeddingPolicy: vectorEmbeddingPolicyFinal, + fullTextPolicy: fullTextPolicy, + }; + + setIsExecuting(true); + + try { + await createGlobalSecondaryIndex(createGlobalSecondaryIndexParams); + await explorer.refreshAllDatabases(); + TelemetryProcessor.traceSuccess(Action.CreateGlobalSecondaryIndex, telemetryData, startKey); + useSidePanel.getState().closeSidePanel(); + } catch (error) { + const errorMessage: string = getErrorMessage(error); + setErrorMessage(errorMessage); + setShowErrorDetails(true); + const failureTelemetryData = { ...telemetryData, error: errorMessage, errorStack: getErrorStack(error) }; + TelemetryProcessor.traceFailure(Action.CreateGlobalSecondaryIndex, failureTelemetryData, startKey); + } finally { + setIsExecuting(false); + } + }; + + return ( +
+ {errorMessage && ( + + )} +
+ + + + + Source container id + + + setSelectedSourceContainer(options.data as Collection)} + /> + + + + + Global secondary index container id + + + ) => setGlobalSecondaryIndexId(event.target.value)} + /> + + + + Global secondary index definition + + + Learn more about defining global secondary indexes. + + } + > + + + + ) => setDefinition(event.target.value)} + /> + + + + {shouldShowAnalyticalStoreOptions() && ( + + )} + {showVectorSearchParameters() && ( + + )} + {showFullTextSearchParameters() && ( + + )} + + +
+ + {isExecuting && } + + ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles.ts b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles.ts new file mode 100644 index 000000000..cfb6da846 --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles.ts @@ -0,0 +1,15 @@ +import { IDropdownStyleProps, IDropdownStyles, IStyleFunctionOrObject } from "@fluentui/react"; +import { CSSProperties } from "react"; + +export function chooseSourceContainerStyles(): IStyleFunctionOrObject { + return { + title: { height: 27, lineHeight: 27 }, + dropdownItem: { fontSize: 12 }, + dropdownItemDisabled: { fontSize: 12 }, + dropdownItemSelected: { fontSize: 12 }, + }; +} + +export function chooseSourceContainerStyle(): CSSProperties { + return { width: 300, fontSize: 12 }; +} diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent.tsx new file mode 100644 index 000000000..17d1cb303 --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent.tsx @@ -0,0 +1,54 @@ +import { Checkbox, Icon, Link, Stack, Text } from "@fluentui/react"; +import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent"; +import { scrollToSection } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import React from "react"; +import { Action } from "Shared/Telemetry/TelemetryConstants"; +import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor"; + +export interface AdvancedComponentProps { + useHashV1: boolean; + setUseHashV1: React.Dispatch>; + setSubPartitionKeys: React.Dispatch>; +} +export const AdvancedComponent = (props: AdvancedComponentProps): JSX.Element => { + const { useHashV1, setUseHashV1, setSubPartitionKeys } = props; + + const useHashV1CheckboxOnChange = (isChecked: boolean): void => { + setUseHashV1(isChecked); + setSubPartitionKeys([]); + }; + + return ( + { + TelemetryProcessor.traceOpen(Action.ExpandAddGlobalSecondaryIndexPaneAdvancedSection); + scrollToSection("collapsibleAdvancedSectionContent"); + }} + > + + , isChecked: boolean) => { + useHashV1CheckboxOnChange(isChecked); + }} + /> + + To ensure compatibility with older SDKs, the created + container will use a legacy partitioning scheme that supports partition key values of size only up to 101 + bytes. If this is enabled, you will not be able to use hierarchical partition keys.{" "} + + Learn more + + + + + ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent.tsx new file mode 100644 index 000000000..46c28c6d8 --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent.tsx @@ -0,0 +1,99 @@ +import { DefaultButton, Link, Stack, Text } from "@fluentui/react"; +import * as Constants from "Common/Constants"; +import Explorer from "Explorer/Explorer"; +import { + AnalyticalStorageContent, + isSynapseLinkEnabled, +} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import React from "react"; +import { getCollectionName } from "Utils/APITypeUtils"; + +export interface AnalyticalStoreComponentProps { + explorer: Explorer; + enableAnalyticalStore: boolean; + setEnableAnalyticalStore: React.Dispatch>; +} +export const AnalyticalStoreComponent = (props: AnalyticalStoreComponentProps): JSX.Element => { + const { explorer, enableAnalyticalStore, setEnableAnalyticalStore } = props; + + const onEnableAnalyticalStoreRadioButtonChange = (checked: boolean): void => { + if (checked && !enableAnalyticalStore) { + setEnableAnalyticalStore(true); + } + }; + + const onDisableAnalyticalStoreRadioButtonnChange = (checked: boolean): void => { + if (checked && enableAnalyticalStore) { + setEnableAnalyticalStore(false); + } + }; + + return ( + + + {AnalyticalStorageContent()} + + + +
+ ) => { + onEnableAnalyticalStoreRadioButtonChange(event.target.checked); + }} + /> + On + + ) => { + onDisableAnalyticalStoreRadioButtonnChange(event.target.checked); + }} + /> + Off +
+
+ + {!isSynapseLinkEnabled() && ( + + + Azure Synapse Link is required for creating an analytical store {getCollectionName().toLocaleLowerCase()}. + Enable Synapse Link for this Cosmos DB account.{" "} + + Learn more + + + explorer.openEnableSynapseLinkDialog()} + style={{ height: 27, width: 80 }} + styles={{ label: { fontSize: 12 } }} + /> + + )} +
+ ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent.tsx new file mode 100644 index 000000000..e02bce6ab --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent.tsx @@ -0,0 +1,45 @@ +import { Stack } from "@fluentui/react"; +import { FullTextIndex, FullTextPolicy } from "Contracts/DataModels"; +import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent"; +import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent"; +import { scrollToSection } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import React from "react"; + +export interface FullTextSearchComponentProps { + fullTextPolicy: FullTextPolicy; + setFullTextPolicy: React.Dispatch>; + setFullTextIndexes: React.Dispatch>; + setFullTextPolicyValidated: React.Dispatch>; +} +export const FullTextSearchComponent = (props: FullTextSearchComponentProps): JSX.Element => { + const { fullTextPolicy, setFullTextPolicy, setFullTextIndexes, setFullTextPolicyValidated } = props; + + return ( + + { + scrollToSection("collapsibleFullTextPolicySectionContent"); + }} + > + + + { + setFullTextPolicy(fullTextPolicy); + setFullTextIndexes(fullTextIndexes); + setFullTextPolicyValidated(fullTextPolicyValidated); + }} + /> + + + + + ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent.tsx new file mode 100644 index 000000000..f8f01c24b --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent.tsx @@ -0,0 +1,132 @@ +import { DefaultButton, DirectionalHint, Icon, IconButton, Link, Stack, Text, TooltipHost } from "@fluentui/react"; +import * as Constants from "Common/Constants"; +import { + getPartitionKeyName, + getPartitionKeyPlaceHolder, + getPartitionKeyTooltipText, +} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import React from "react"; + +export interface PartitionKeyComponentProps { + partitionKey?: string; + setPartitionKey: React.Dispatch>; + subPartitionKeys: string[]; + setSubPartitionKeys: React.Dispatch>; + useHashV1: boolean; +} + +export const PartitionKeyComponent = (props: PartitionKeyComponentProps): JSX.Element => { + const { partitionKey, setPartitionKey, subPartitionKeys, setSubPartitionKeys, useHashV1 } = props; + + const partitionKeyValueOnChange = (value: string): void => { + if (!partitionKey && !value.startsWith("/")) { + setPartitionKey("/" + value); + } else { + setPartitionKey(value); + } + }; + + const subPartitionKeysValueOnChange = (value: string, index: number): void => { + const updatedSubPartitionKeys: string[] = [...subPartitionKeys]; + if (!updatedSubPartitionKeys[index] && !value.startsWith("/")) { + updatedSubPartitionKeys[index] = "/" + value.trim(); + } else { + updatedSubPartitionKeys[index] = value.trim(); + } + setSubPartitionKeys(updatedSubPartitionKeys); + }; + + return ( + + + + + Partition key + + + + + + + ) => { + partitionKeyValueOnChange(event.target.value); + }} + /> + {subPartitionKeys.map((subPartitionKey: string, subPartitionKeyIndex: number) => { + return ( + +
+ 0 ? 1 : 0} + className="panelTextField" + autoComplete="off" + placeholder={getPartitionKeyPlaceHolder(subPartitionKeyIndex)} + aria-label={getPartitionKeyName()} + pattern={".*"} + title={""} + value={subPartitionKey} + onChange={(event: React.ChangeEvent) => { + subPartitionKeysValueOnChange(event.target.value, subPartitionKeyIndex); + }} + /> + { + const updatedSubPartitionKeys = subPartitionKeys.filter( + (_, subPartitionKeyIndexToRemove) => subPartitionKeyIndex !== subPartitionKeyIndexToRemove, + ); + setSubPartitionKeys(updatedSubPartitionKeys); + }} + /> +
+ ); + })} + + + {subPartitionKeys.length > 0 && ( + + This feature allows you to partition your + data with up to three levels of keys for better data distribution. Requires .NET V3, Java V4 SDK, or preview + JavaScript V3 SDK.{" "} + + Learn more + + + )} + +
+ ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent.tsx new file mode 100644 index 000000000..07669906f --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent.tsx @@ -0,0 +1,71 @@ +import { Checkbox, Stack } from "@fluentui/react"; +import { ThroughputInput } from "Explorer/Controls/ThroughputInput/ThroughputInput"; +import { isFreeTierAccount } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import { useDatabases } from "Explorer/useDatabases"; +import React from "react"; +import { getCollectionName } from "Utils/APITypeUtils"; +import { isServerlessAccount } from "Utils/CapabilityUtils"; + +export interface ThroughputComponentProps { + enableDedicatedThroughput: boolean; + setEnabledDedicatedThroughput: React.Dispatch>; + isSelectedSourceContainerSharedThroughput: () => boolean; + showCollectionThroughputInput: () => boolean; + globalSecondaryIndexThroughputOnChange: (globalSecondaryIndexThroughputValue: number) => void; + isGlobalSecondaryIndexAutoscaleOnChange: (isGlobalSecondaryIndexAutoscaleValue: boolean) => void; + setIsThroughputCapExceeded: React.Dispatch>; + isCostAknowledgedOnChange: (isCostAknowledgedValue: boolean) => void; +} + +export const ThroughputComponent = (props: ThroughputComponentProps): JSX.Element => { + const { + enableDedicatedThroughput, + setEnabledDedicatedThroughput, + isSelectedSourceContainerSharedThroughput, + showCollectionThroughputInput, + globalSecondaryIndexThroughputOnChange, + isGlobalSecondaryIndexAutoscaleOnChange, + setIsThroughputCapExceeded, + isCostAknowledgedOnChange, + } = props; + + return ( + + {!isServerlessAccount() && isSelectedSourceContainerSharedThroughput() && ( + + setEnabledDedicatedThroughput(isChecked)} + /> + + )} + {showCollectionThroughputInput() && ( + { + globalSecondaryIndexThroughputOnChange(throughput); + }} + setIsAutoscale={(isAutoscale: boolean) => { + isGlobalSecondaryIndexAutoscaleOnChange(isAutoscale); + }} + setIsThroughputCapExceeded={(isThroughputCapExceeded: boolean) => { + setIsThroughputCapExceeded(isThroughputCapExceeded); + }} + onCostAcknowledgeChange={(isAcknowledged: boolean) => { + isCostAknowledgedOnChange(isAcknowledged); + }} + /> + )} + + ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/UniqueKeysComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/UniqueKeysComponent.tsx new file mode 100644 index 000000000..ab46c8d41 --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/UniqueKeysComponent.tsx @@ -0,0 +1,78 @@ +import { ActionButton, IconButton, Stack } from "@fluentui/react"; +import { UniqueKeysHeader } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import React from "react"; +import { userContext } from "UserContext"; + +export interface UniqueKeysComponentProps { + uniqueKeys: string[]; + setUniqueKeys: React.Dispatch>; +} + +export const UniqueKeysComponent = (props: UniqueKeysComponentProps): JSX.Element => { + const { uniqueKeys, setUniqueKeys } = props; + + const updateUniqueKeysOnChange = (value: string, uniqueKeyToReplaceIndex: number): void => { + const updatedUniqueKeys = uniqueKeys.map((uniqueKey: string, uniqueKeyIndex: number) => { + if (uniqueKeyToReplaceIndex === uniqueKeyIndex) { + return value; + } + return uniqueKey; + }); + setUniqueKeys(updatedUniqueKeys); + }; + + const deleteUniqueKeyOnClick = (uniqueKeyToDeleteIndex: number): void => { + const updatedUniqueKeys = uniqueKeys.filter((_, uniqueKeyIndex) => uniqueKeyToDeleteIndex !== uniqueKeyIndex); + setUniqueKeys(updatedUniqueKeys); + }; + + const addUniqueKeyOnClick = (): void => { + setUniqueKeys([...uniqueKeys, ""]); + }; + + return ( + + {UniqueKeysHeader()} + + {uniqueKeys.map((uniqueKey: string, uniqueKeyIndex: number): JSX.Element => { + return ( + + ) => { + updateUniqueKeysOnChange(event.target.value, uniqueKeyIndex); + }} + /> + + { + deleteUniqueKeyOnClick(uniqueKeyIndex); + }} + /> + + ); + })} + + { + addUniqueKeyOnClick(); + }} + > + Add unique key + + + ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent.tsx new file mode 100644 index 000000000..440d51ea4 --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent.tsx @@ -0,0 +1,58 @@ +import { Stack } from "@fluentui/react"; +import { VectorEmbedding, VectorIndex } from "Contracts/DataModels"; +import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent"; +import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent"; +import { + ContainerVectorPolicyTooltipContent, + scrollToSection, +} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import React from "react"; + +export interface VectorSearchComponentProps { + vectorEmbeddingPolicy: VectorEmbedding[]; + setVectorEmbeddingPolicy: React.Dispatch>; + vectorIndexingPolicy: VectorIndex[]; + setVectorIndexingPolicy: React.Dispatch>; + setVectorPolicyValidated: React.Dispatch>; +} + +export const VectorSearchComponent = (props: VectorSearchComponentProps): JSX.Element => { + const { + vectorEmbeddingPolicy, + setVectorEmbeddingPolicy, + vectorIndexingPolicy, + setVectorIndexingPolicy, + setVectorPolicyValidated, + } = props; + + return ( + + { + scrollToSection("collapsibleVectorPolicySectionContent"); + }} + tooltipContent={ContainerVectorPolicyTooltipContent()} + > + + + { + setVectorEmbeddingPolicy(vectorEmbeddingPolicy); + setVectorIndexingPolicy(vectorIndexingPolicy); + setVectorPolicyValidated(vectorPolicyValidated); + }} + /> + + + + + ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap new file mode 100644 index 000000000..ddd1648d4 --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap @@ -0,0 +1,190 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddGlobalSecondaryIndexPanel render default panel 1`] = ` +
+
+ + + + *  + + + Source container id + + + + + + + *  + + + Global secondary index container id + + + + + + *  + + + Global secondary index definition + + + Learn more about defining global secondary indexes. + + } + directionalHint={4} + > + + + + + + + + + + +
+ + +`; diff --git a/src/Explorer/QueryCopilot/CopilotCarousel.tsx b/src/Explorer/QueryCopilot/CopilotCarousel.tsx index 4a73cacb8..a1273c910 100644 --- a/src/Explorer/QueryCopilot/CopilotCarousel.tsx +++ b/src/Explorer/QueryCopilot/CopilotCarousel.tsx @@ -18,7 +18,7 @@ import { createCollection } from "Common/dataAccess/createCollection"; import * as DataModels from "Contracts/DataModels"; import { ContainerSampleGenerator } from "Explorer/DataSamples/ContainerSampleGenerator"; import Explorer from "Explorer/Explorer"; -import { AllPropertiesIndexed } from "Explorer/Panes/AddCollectionPanel"; +import { AllPropertiesIndexed } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; import { PromptCard } from "Explorer/QueryCopilot/PromptCard"; import { useDatabases } from "Explorer/useDatabases"; import { useCarousel } from "hooks/useCarousel"; diff --git a/src/Explorer/Sidebar.tsx b/src/Explorer/Sidebar.tsx index 01fd8a6d1..229518f9f 100644 --- a/src/Explorer/Sidebar.tsx +++ b/src/Explorer/Sidebar.tsx @@ -13,9 +13,15 @@ import { SplitButton, } from "@fluentui/react-components"; import { Add16Regular, ArrowSync12Regular, ChevronLeft12Regular, ChevronRight12Regular } from "@fluentui/react-icons"; +import { GlobalSecondaryIndexLabels } from "Common/Constants"; +import { isGlobalSecondaryIndexEnabled } from "Common/DatabaseAccountUtility"; import { configContext, Platform } from "ConfigContext"; import Explorer from "Explorer/Explorer"; import { AddDatabasePanel } from "Explorer/Panes/AddDatabasePanel/AddDatabasePanel"; +import { + AddGlobalSecondaryIndexPanel, + AddGlobalSecondaryIndexPanelProps, +} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel"; import { Tabs } from "Explorer/Tabs/Tabs"; import { CosmosFluentProvider, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil"; import { ResourceTree } from "Explorer/Tree/ResourceTree"; @@ -162,6 +168,25 @@ const GlobalCommands: React.FC = ({ explorer }) => { }); } + if (isGlobalSecondaryIndexEnabled()) { + const addMaterializedViewPanelProps: AddGlobalSecondaryIndexPanelProps = { + explorer, + }; + + actions.push({ + id: "new_materialized_view", + label: GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex, + icon: , + onClick: () => + useSidePanel + .getState() + .openSidePanel( + GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex, + , + ), + }); + } + return actions; }, [explorer]); diff --git a/src/Explorer/Tree/Collection.ts b/src/Explorer/Tree/Collection.ts index c692d0747..bc3af39f1 100644 --- a/src/Explorer/Tree/Collection.ts +++ b/src/Explorer/Tree/Collection.ts @@ -58,6 +58,8 @@ export default class Collection implements ViewModels.Collection { public uniqueKeyPolicy: DataModels.UniqueKeyPolicy; public usageSizeInKB: ko.Observable; public computedProperties: ko.Observable; + public materializedViews: ko.Observable; + public materializedViewDefinition: ko.Observable; public offer: ko.Observable; public conflictResolutionPolicy: ko.Observable; @@ -124,6 +126,8 @@ export default class Collection implements ViewModels.Collection { this.requestSchema = data.requestSchema; this.geospatialConfig = ko.observable(data.geospatialConfig); this.computedProperties = ko.observable(data.computedProperties); + this.materializedViews = ko.observable(data.materializedViews); + this.materializedViewDefinition = ko.observable(data.materializedViewDefinition); this.partitionKeyPropertyHeaders = this.partitionKey?.paths; this.partitionKeyProperties = this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => { diff --git a/src/Explorer/Tree/Database.tsx b/src/Explorer/Tree/Database.tsx index 0ed11cb5d..5ad2769d8 100644 --- a/src/Explorer/Tree/Database.tsx +++ b/src/Explorer/Tree/Database.tsx @@ -19,7 +19,7 @@ import { logConsoleError } from "../../Utils/NotificationConsoleUtils"; import { useSidePanel } from "../../hooks/useSidePanel"; import { useTabs } from "../../hooks/useTabs"; import Explorer from "../Explorer"; -import { AddCollectionPanel } from "../Panes/AddCollectionPanel"; +import { AddCollectionPanel } from "../Panes/AddCollectionPanel/AddCollectionPanel"; import { DatabaseSettingsTabV2 } from "../Tabs/SettingsTabV2"; import { useDatabases } from "../useDatabases"; import { useSelectedNode } from "../useSelectedNode"; diff --git a/src/Explorer/Tree/ResourceTree.tsx b/src/Explorer/Tree/ResourceTree.tsx index 10b8316c0..60e67ecda 100644 --- a/src/Explorer/Tree/ResourceTree.tsx +++ b/src/Explorer/Tree/ResourceTree.tsx @@ -1,6 +1,7 @@ import { Tree, TreeItemValue, TreeOpenChangeData, TreeOpenChangeEvent } from "@fluentui/react-components"; import { Home16Regular } from "@fluentui/react-icons"; import { AuthType } from "AuthType"; +import { Collection } from "Contracts/ViewModels"; import { useTreeStyles } from "Explorer/Controls/TreeComponent/Styles"; import { TreeNode, TreeNodeComponent } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import { @@ -60,7 +61,7 @@ export const ResourceTree: React.FC = ({ explorer }: Resource const databaseTreeNodes = useMemo(() => { return userContext.authType === AuthType.ResourceToken - ? createResourceTokenTreeNodes(resourceTokenCollection) + ? createResourceTokenTreeNodes(resourceTokenCollection as Collection) : createDatabaseTreeNodes(explorer, isNotebookEnabled, databases, refreshActiveTab); }, [resourceTokenCollection, databases, isNotebookEnabled, refreshActiveTab]); diff --git a/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap b/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap index 9814a72fb..3515c1257 100644 --- a/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap +++ b/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap @@ -30,7 +30,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -72,7 +72,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -145,7 +145,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -264,7 +264,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -369,7 +369,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -442,7 +442,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -546,7 +546,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -696,7 +696,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -760,7 +760,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -787,7 +787,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -841,7 +841,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -895,7 +895,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -953,7 +953,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], - "iconSrc": , "isExpanded": true, @@ -974,7 +974,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], - "iconSrc": , "isExpanded": true, @@ -1010,7 +1010,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], - "iconSrc": , "isExpanded": true, @@ -1046,7 +1046,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], - "iconSrc": , "isExpanded": true, @@ -1208,7 +1208,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1311,7 +1311,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1445,7 +1445,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1625,7 +1625,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1799,7 +1799,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1897,7 +1897,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -2031,7 +2031,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -2211,7 +2211,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -2266,7 +2266,7 @@ exports[`createResourceTokenTreeNodes creates the expected tree nodes 1`] = ` }, ], "className": "collectionNode", - "iconSrc": , "isExpanded": true, diff --git a/src/Explorer/Tree/treeNodeUtil.test.ts b/src/Explorer/Tree/treeNodeUtil.test.ts index f9298a428..8172ade3a 100644 --- a/src/Explorer/Tree/treeNodeUtil.test.ts +++ b/src/Explorer/Tree/treeNodeUtil.test.ts @@ -82,6 +82,7 @@ jest.mock("Explorer/Tree/Trigger", () => { jest.mock("Common/DatabaseAccountUtility", () => { return { isPublicInternetAccessAllowed: () => true, + isGlobalSecondaryIndexEnabled: () => false, }; }); @@ -134,6 +135,15 @@ const baseCollection = { kind: "hash", version: 2, }, + materializedViews: ko.observable([ + { id: "view1", _rid: "rid1" }, + { id: "view2", _rid: "rid2" }, + ]), + materializedViewDefinition: ko.observable({ + definition: "SELECT * FROM c WHERE c.id = 1", + sourceCollectionId: "source1", + sourceCollectionRid: "rid123", + }), storedProcedures: ko.observableArray([]), userDefinedFunctions: ko.observableArray([]), triggers: ko.observableArray([]), diff --git a/src/Explorer/Tree/treeNodeUtil.tsx b/src/Explorer/Tree/treeNodeUtil.tsx index 60fe9079b..a9bf32247 100644 --- a/src/Explorer/Tree/treeNodeUtil.tsx +++ b/src/Explorer/Tree/treeNodeUtil.tsx @@ -1,4 +1,4 @@ -import { DatabaseRegular, DocumentMultipleRegular, SettingsRegular } from "@fluentui/react-icons"; +import { DatabaseRegular, DocumentMultipleRegular, EyeRegular, SettingsRegular } from "@fluentui/react-icons"; import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity"; import TabsBase from "Explorer/Tabs/TabsBase"; @@ -29,6 +29,7 @@ export const shouldShowScriptNodes = (): boolean => { const TreeDatabaseIcon = ; const TreeSettingsIcon = ; const TreeCollectionIcon = ; +const GlobalSecondaryIndexCollectionIcon = ; //check icon export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: ViewModels.CollectionBase): TreeNode[] => { const updatedSampleTree: TreeNode = { @@ -80,7 +81,7 @@ export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: Vie return [updatedSampleTree]; }; -export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBase): TreeNode[] => { +export const createResourceTokenTreeNodes = (collection: ViewModels.Collection): TreeNode[] => { if (!collection) { return [ { @@ -110,7 +111,7 @@ export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBa isExpanded: true, children, className: "collectionNode", - iconSrc: TreeCollectionIcon, + iconSrc: collection.materializedViewDefinition() ? GlobalSecondaryIndexCollectionIcon : TreeCollectionIcon, onClick: () => { // Rewritten version of expandCollapseCollection useSelectedNode.getState().setSelectedNode(collection); @@ -228,7 +229,7 @@ export const buildCollectionNode = ( children: children, className: "collectionNode", contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection), - iconSrc: TreeCollectionIcon, + iconSrc: collection.materializedViewDefinition() ? GlobalSecondaryIndexCollectionIcon : TreeCollectionIcon, onClick: () => { useSelectedNode.getState().setSelectedNode(collection); collection.openTab(); diff --git a/src/Shared/Telemetry/TelemetryConstants.ts b/src/Shared/Telemetry/TelemetryConstants.ts index 1fae132ad..e0f1691be 100644 --- a/src/Shared/Telemetry/TelemetryConstants.ts +++ b/src/Shared/Telemetry/TelemetryConstants.ts @@ -2,6 +2,7 @@ export enum Action { CollapseTreeNode, CreateCollection, + CreateGlobalSecondaryIndex, CreateDocument, CreateStoredProcedure, CreateTrigger, @@ -119,6 +120,7 @@ export enum Action { NotebooksGalleryPublishedCount, SelfServe, ExpandAddCollectionPaneAdvancedSection, + ExpandAddGlobalSecondaryIndexPaneAdvancedSection, SchemaAnalyzerClickAnalyze, SelfServeComponent, LaunchQuickstart,