diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index d0ca8ef40..e6b545793 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -98,31 +98,6 @@ export class CapabilityNames { public static readonly EnableServerless: string = "EnableServerless"; } -export class Features { - public static readonly cosmosdb = "cosmosdb"; - public static readonly enableChangeFeedPolicy = "enablechangefeedpolicy"; - public static readonly executeSproc = "dataexplorerexecutesproc"; - public static readonly hostedDataExplorer = "hosteddataexplorerenabled"; - public static readonly enableTtl = "enablettl"; - public static readonly enableNotebooks = "enablenotebooks"; - public static readonly enableSpark = "enablespark"; - public static readonly livyEndpoint = "livyendpoint"; - public static readonly notebookServerUrl = "notebookserverurl"; - public static readonly notebookServerToken = "notebookservertoken"; - public static readonly notebookBasePath = "notebookbasepath"; - public static readonly canExceedMaximumValue = "canexceedmaximumvalue"; - public static readonly enableFixedCollectionWithSharedThroughput = "enablefixedcollectionwithsharedthroughput"; - public static readonly ttl90Days = "ttl90days"; - public static readonly enableRightPanelV2 = "enablerightpanelv2"; - public static readonly enableSchema = "enableschema"; - public static readonly enableSDKoperations = "enablesdkoperations"; - public static readonly showMinRUSurvey = "showminrusurvey"; - public static readonly enableDatabaseSettingsTabV1 = "enabledbsettingsv1"; - public static readonly selfServeType = "selfservetype"; - public static readonly enableKOPanel = "enablekopanel"; - public static readonly enableReactPane = "enablereactpane"; -} - // flight names returned from the portal are always lowercase export class Flights { public static readonly SettingsV2 = "settingsv2"; diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index 4f78015fd..2d40a0fed 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -376,7 +376,6 @@ export interface DataExplorerInputsFrame { masterKey?: string; hasWriteAccess?: boolean; authorizationToken?: string; - features: { [key: string]: string }; csmEndpoint?: string; dnsSuffix?: string; serverId?: string; diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 637adc053..63c1c4b79 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -139,9 +139,7 @@ export class SettingsComponent extends React.Component oneTBinKB && diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index b04b2ef06..f9640bb5f 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -895,7 +895,6 @@ exports[`SettingsComponent renders 1`] = ` "validPartitionKeyValue": [Function], "visible": [Function], }, - "features": [Function], "flight": [Function], "graphStylingPane": GraphStylingPane { "container": [Circular], @@ -2092,7 +2091,6 @@ exports[`SettingsComponent renders 1`] = ` "validPartitionKeyValue": [Function], "visible": [Function], }, - "features": [Function], "flight": [Function], "graphStylingPane": GraphStylingPane { "container": [Circular], @@ -3302,7 +3300,6 @@ exports[`SettingsComponent renders 1`] = ` "validPartitionKeyValue": [Function], "visible": [Function], }, - "features": [Function], "flight": [Function], "graphStylingPane": GraphStylingPane { "container": [Circular], @@ -4499,7 +4496,6 @@ exports[`SettingsComponent renders 1`] = ` "validPartitionKeyValue": [Function], "visible": [Function], }, - "features": [Function], "flight": [Function], "graphStylingPane": GraphStylingPane { "container": [Circular], diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index 44cf575b3..2bd45e796 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -329,7 +329,7 @@ export default class Explorer { this.isNotebookEnabled( userContext.authType !== AuthType.ResourceToken && ((await this._containsDefaultNotebookWorkspace(this.databaseAccount())) || - this.isFeatureEnabled(Constants.Features.enableNotebooks)) + userContext.features.enableNotebooks) ); TelemetryProcessor.trace(Action.NotebookEnabled, ActionModifiers.Mark, { @@ -351,7 +351,7 @@ export default class Explorer { this.isSparkEnabledForAccount() && this.arcadiaWorkspaces() && this.arcadiaWorkspaces().length > 0) || - this.isFeatureEnabled(Constants.Features.enableSpark) + userContext.features.enableSpark ); if (this.isSparkEnabled()) { appInsights.trackEvent( @@ -375,7 +375,6 @@ export default class Explorer { }); this.memoryUsageInfo = ko.observable(); - this.features = ko.observable(); this.queriesClient = new QueriesClient(this); this.resourceTokenDatabaseId = ko.observable(); @@ -387,11 +386,9 @@ export default class Explorer { this.isPublishNotebookPaneEnabled = ko.observable(false); this.isCopyNotebookPaneEnabled = ko.observable(false); - this.canExceedMaximumValue = ko.computed(() => - this.isFeatureEnabled(Constants.Features.canExceedMaximumValue) - ); + this.canExceedMaximumValue = ko.computed(() => userContext.features.canExceedMaximumValue); - this.isSchemaEnabled = ko.computed(() => this.isFeatureEnabled(Constants.Features.enableSchema)); + this.isSchemaEnabled = ko.computed(() => userContext.features.enableSchema); this.isAutoscaleDefaultEnabled = ko.observable(false); @@ -471,7 +468,7 @@ export default class Explorer { }); this.isFixedCollectionWithSharedThroughputSupported = ko.computed(() => { - if (this.isFeatureEnabled(Constants.Features.enableFixedCollectionWithSharedThroughput)) { + if (userContext.features.enableFixedCollectionWithSharedThroughput) { return true; } @@ -530,9 +527,7 @@ export default class Explorer { () => configContext.platform === Platform.Portal && !this.isRunningOnNationalCloud() && !this.isPreferredApiGraph() ); - this.isRightPanelV2Enabled = ko.computed(() => - this.isFeatureEnabled(Constants.Features.enableRightPanelV2) - ); + this.isRightPanelV2Enabled = ko.computed(() => userContext.features.enableRightPanelV2); this.defaultExperience.subscribe((defaultExperience: string) => { if ( defaultExperience && @@ -883,42 +878,29 @@ export default class Explorer { }); // Override notebook server parameters from URL parameters - const featureSubcription = this.features.subscribe((features) => { - const serverInfo = this.notebookServerInfo(); - if (this.isFeatureEnabled(Constants.Features.notebookServerUrl)) { - serverInfo.notebookServerEndpoint = features[Constants.Features.notebookServerUrl]; - } + if (userContext.features.notebookServerUrl && userContext.features.notebookServerToken) { + this.notebookServerInfo({ + notebookServerEndpoint: userContext.features.notebookServerUrl, + authToken: userContext.features.notebookServerToken, + }); + } - if (this.isFeatureEnabled(Constants.Features.notebookServerToken)) { - serverInfo.authToken = features[Constants.Features.notebookServerToken]; - } - this.notebookServerInfo(serverInfo); - this.notebookServerInfo.valueHasMutated(); + if (userContext.features.notebookBasePath) { + this.notebookBasePath(userContext.features.notebookBasePath); + } - if (this.isFeatureEnabled(Constants.Features.notebookBasePath)) { - this.notebookBasePath(features[Constants.Features.notebookBasePath]); - } - - if (this.isFeatureEnabled(Constants.Features.livyEndpoint)) { - this.sparkClusterConnectionInfo({ - userName: undefined, - password: undefined, - endpoints: [ - { - endpoint: features[Constants.Features.livyEndpoint], - kind: DataModels.SparkClusterEndpointKind.Livy, - }, - ], - }); - this.sparkClusterConnectionInfo.valueHasMutated(); - } - - if (this.isFeatureEnabled(Constants.Features.enableSDKoperations)) { - updateUserContext({ useSDKOperations: true }); - } - - featureSubcription.dispose(); - }); + if (userContext.features.livyEndpoint) { + this.sparkClusterConnectionInfo({ + userName: undefined, + password: undefined, + endpoints: [ + { + endpoint: userContext.features.livyEndpoint, + kind: DataModels.SparkClusterEndpointKind.Livy, + }, + ], + }); + } } public openEnableSynapseLinkDialog(): void { @@ -1002,20 +984,6 @@ export default class Explorer { return this.selectedNode() == null; } - public isFeatureEnabled(feature: string): boolean { - const features = this.features(); - - if (!features) { - return false; - } - - if (feature in features && features[feature]) { - return true; - } - - return false; - } - public logConsoleData(consoleData: ConsoleData): void { this.setNotificationConsoleData(consoleData); } @@ -1258,12 +1226,12 @@ export default class Explorer { throw error; } finally { // Overwrite with feature flags - if (this.isFeatureEnabled(Constants.Features.notebookServerUrl)) { - connectionInfo.notebookServerEndpoint = this.features()[Constants.Features.notebookServerUrl]; + if (userContext.features.notebookServerUrl) { + connectionInfo.notebookServerEndpoint = userContext.features.notebookServerUrl; } - if (this.isFeatureEnabled(Constants.Features.notebookServerToken)) { - connectionInfo.authToken = this.features()[Constants.Features.notebookServerToken]; + if (userContext.features.notebookServerToken) { + connectionInfo.authToken = userContext.features.notebookServerToken; } this.notebookServerInfo(connectionInfo); @@ -1413,7 +1381,6 @@ export default class Explorer { if (inputs.defaultCollectionThroughput) { this.collectionCreationDefaults = inputs.defaultCollectionThroughput; } - this.features(inputs.features); this.databaseAccount(databaseAccount); this.subscriptionType(inputs.subscriptionType ?? SharedConstants.CollectionCreation.DefaultSubscriptionType); this.hasWriteAccess(inputs.hasWriteAccess ?? true); @@ -2367,7 +2334,7 @@ export default class Explorer { public onNewCollectionClicked(): void { if (this.isPreferredApiCassandra()) { this.cassandraAddCollectionPane.open(); - } else if (this.isFeatureEnabled(Constants.Features.enableReactPane)) { + } else if (userContext.features.enableReactPane) { this.openAddCollectionPanel(); } else { this.addCollectionPane.open(this.selectedDatabaseId()); @@ -2501,7 +2468,7 @@ export default class Explorer { } public openDeleteCollectionConfirmationPane(): void { - this.isFeatureEnabled(Constants.Features.enableKOPanel) + userContext.features.enableKOPanel ? this.deleteCollectionConfirmationPane.open() : this.openSidePanel( "Delete Collection", diff --git a/src/Explorer/Panes/AddCollectionPane.ts b/src/Explorer/Panes/AddCollectionPane.ts index 67b9ed6ff..74d1071ca 100644 --- a/src/Explorer/Panes/AddCollectionPane.ts +++ b/src/Explorer/Panes/AddCollectionPane.ts @@ -994,7 +994,7 @@ export default class AddCollectionPane extends ContextualPaneBase { this.container.openEnableSynapseLinkDialog(); } - public ttl90DaysEnabled: () => boolean = () => this.container.isFeatureEnabled(Constants.Features.ttl90Days); + public ttl90DaysEnabled: () => boolean = () => userContext.features.ttl90Days; public isValid(): boolean { // TODO add feature flag that disables validation for customers with custom accounts @@ -1202,7 +1202,7 @@ export default class AddCollectionPane extends ContextualPaneBase { if (this.isAnalyticalStorageOn()) { // TODO: always default to 90 days once the backend hotfix is deployed - return this.container.isFeatureEnabled(Constants.Features.ttl90Days) + return userContext.features.ttl90Days ? Constants.AnalyticalStorageTtl.Days90 : Constants.AnalyticalStorageTtl.Infinite; } diff --git a/src/Explorer/Panes/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel.tsx index 548e6c39f..f983b6845 100644 --- a/src/Explorer/Panes/AddCollectionPanel.tsx +++ b/src/Explorer/Panes/AddCollectionPanel.tsx @@ -905,7 +905,7 @@ export class AddCollectionPanel extends React.Component = this.getPendingThroughputSplitNotification(); - const useDatabaseSettingsTabV1: boolean = this.container.isFeatureEnabled( - Constants.Features.enableDatabaseSettingsTabV1 - ); + const useDatabaseSettingsTabV1 = userContext.features.enableDatabaseSettingsTabV1; const tabKind: ViewModels.CollectionTabKind = useDatabaseSettingsTabV1 ? ViewModels.CollectionTabKind.DatabaseSettings : ViewModels.CollectionTabKind.DatabaseSettingsV2; diff --git a/src/Explorer/Tree/StoredProcedure.ts b/src/Explorer/Tree/StoredProcedure.ts index 3ac05f4be..6ed7b3008 100644 --- a/src/Explorer/Tree/StoredProcedure.ts +++ b/src/Explorer/Tree/StoredProcedure.ts @@ -3,13 +3,14 @@ import * as ko from "knockout"; import * as Constants from "../../Common/Constants"; import { deleteStoredProcedure } from "../../Common/dataAccess/deleteStoredProcedure"; import { executeStoredProcedure } from "../../Common/dataAccess/executeStoredProcedure"; +import { getErrorMessage } from "../../Common/ErrorHandlingUtils"; import * as ViewModels from "../../Contracts/ViewModels"; import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; +import { userContext } from "../../UserContext"; import Explorer from "../Explorer"; import StoredProcedureTab from "../Tabs/StoredProcedureTab"; import TabsBase from "../Tabs/TabsBase"; -import { getErrorMessage } from "../../Common/ErrorHandlingUtils"; const sampleStoredProcedureBody: string = `// SAMPLE STORED PROCEDURE function sample(prefix) { @@ -56,7 +57,7 @@ export default class StoredProcedure { this.rid = data._rid; this.id = ko.observable(data.id); this.body = ko.observable(data.body as string); - this.isExecuteEnabled = this.container.isFeatureEnabled(Constants.Features.executeSproc); + this.isExecuteEnabled = userContext.features.executeSproc; } public static create(source: ViewModels.Collection, event: MouseEvent) { diff --git a/src/Platform/Hosted/extractFeatures.test.ts b/src/Platform/Hosted/extractFeatures.test.ts index c0d326fae..a44e81a5d 100644 --- a/src/Platform/Hosted/extractFeatures.test.ts +++ b/src/Platform/Hosted/extractFeatures.test.ts @@ -1,17 +1,22 @@ import { extractFeatures } from "./extractFeatures"; describe("extractFeatures", () => { - it("correctly detects feature flags", () => { - // Search containing non-features, with Camelcase keys and uri encoded values - const params = new URLSearchParams( - "?platform=Hosted&feature.notebookserverurl=https%3A%2F%2Flocalhost%3A10001%2F12345%2Fnotebook&feature.notebookServerToken=token&feature.enablenotebooks=true&key=mykey" - ); + it("correctly detects feature flags in a case insensitive manner", () => { + const url = "https://localhost:10001/12345/notebook"; + const token = "super secret"; + const notebooksEnabled = false; + const params = new URLSearchParams({ + platform: "Hosted", + "feature.NOTEBOOKSERVERURL": url, + "feature.NoTeBooKServerToken": token, + "feature.NotAFeature": "nope", + "feature.ENABLEnotebooks": notebooksEnabled.toString(), + }); + const features = extractFeatures(params); - expect(features).toEqual({ - notebookserverurl: "https://localhost:10001/12345/notebook", - notebookservertoken: "token", - enablenotebooks: "true", - }); + expect(features.notebookServerUrl).toBe(url); + expect(features.notebookServerToken).toBe(token); + expect(features.enableNotebooks).toBe(notebooksEnabled); }); }); diff --git a/src/Platform/Hosted/extractFeatures.ts b/src/Platform/Hosted/extractFeatures.ts index 05581c27b..afa2c8545 100644 --- a/src/Platform/Hosted/extractFeatures.ts +++ b/src/Platform/Hosted/extractFeatures.ts @@ -1,14 +1,56 @@ -export function extractFeatures(params?: URLSearchParams): { [key: string]: string } { +export type Features = { + readonly canExceedMaximumValue: boolean; + readonly cosmosdb: boolean; + readonly enableChangeFeedPolicy: boolean; + readonly enableDatabaseSettingsTabV1: boolean; + readonly enableFixedCollectionWithSharedThroughput: boolean; + readonly enableKOPanel: boolean; + readonly enableNotebooks: boolean; + readonly enableReactPane: boolean; + readonly enableRightPanelV2: boolean; + readonly enableSchema: boolean; + readonly enableSDKoperations: boolean; + readonly enableSpark: boolean; + readonly enableTtl: boolean; + readonly executeSproc: boolean; + readonly hostedDataExplorer: boolean; + readonly livyEndpoint?: string; + readonly notebookBasePath?: string; + readonly notebookServerToken?: string; + readonly notebookServerUrl?: string; + readonly selfServeType?: string; + readonly showMinRUSurvey: boolean; + readonly ttl90Days: boolean; +}; + +export function extractFeatures(params?: URLSearchParams): Features { params = params || new URLSearchParams(window.parent.location.search); - const featureParamRegex = /feature.(.*)/i; - const features: { [key: string]: string } = {}; - params.forEach((value: string, param: string) => { - if (featureParamRegex.test(param)) { - const matches: string[] = param.match(featureParamRegex); - if (matches.length > 0) { - features[matches[1].toLowerCase()] = value; - } - } - }); - return features; + const downcased = new URLSearchParams(); + params.forEach((value, key) => downcased.append(key.toLocaleLowerCase(), value)); + const get = (key: string) => downcased.get("feature." + key.toLocaleLowerCase()) ?? undefined; + + return { + canExceedMaximumValue: "true" === get("canexceedmaximumvalue"), + cosmosdb: "true" === get("cosmosdb"), + enableChangeFeedPolicy: "true" === get("enablechangefeedpolicy"), + enableDatabaseSettingsTabV1: "true" === get("enabledbsettingsv1"), + enableFixedCollectionWithSharedThroughput: "true" === get("enablefixedcollectionwithsharedthroughput"), + enableKOPanel: "true" === get("enablekopanel"), + enableNotebooks: "true" === get("enablenotebooks"), + enableReactPane: "true" === get("enablereactpane"), + enableRightPanelV2: "true" === get("enablerightpanelv2"), + enableSchema: "true" === get("enableschema"), + enableSDKoperations: "true" === get("enablesdkoperations"), + enableSpark: "true" === get("enablespark"), + enableTtl: "true" === get("enablettl"), + executeSproc: "true" === get("dataexplorerexecutesproc"), + hostedDataExplorer: "true" === get("hosteddataexplorerenabled"), + livyEndpoint: get("livyendpoint"), + notebookBasePath: get("notebookbasepath"), + notebookServerToken: get("notebookservertoken"), + notebookServerUrl: get("notebookserverurl"), + selfServeType: get("selfservetype"), + showMinRUSurvey: "true" === get("showminrusurvey"), + ttl90Days: "true" === get("ttl90days"), + }; } diff --git a/src/UserContext.ts b/src/UserContext.ts index 0130392cd..f8d2970e9 100644 --- a/src/UserContext.ts +++ b/src/UserContext.ts @@ -2,34 +2,44 @@ import { AuthType } from "./AuthType"; import { DatabaseAccount } from "./Contracts/DataModels"; import { SubscriptionType } from "./Contracts/SubscriptionType"; import { DefaultAccountExperienceType } from "./DefaultAccountExperienceType"; +import { extractFeatures, Features } from "./Platform/Hosted/extractFeatures"; interface UserContext { - authType?: AuthType; - masterKey?: string; - subscriptionId?: string; - resourceGroup?: string; - databaseAccount?: DatabaseAccount; - endpoint?: string; - accessToken?: string; - authorizationToken?: string; - resourceToken?: string; - defaultExperience?: DefaultAccountExperienceType; - useSDKOperations?: boolean; - subscriptionType?: SubscriptionType; - quotaId?: string; + readonly authType?: AuthType; + readonly masterKey?: string; + readonly subscriptionId?: string; + readonly resourceGroup?: string; + readonly databaseAccount?: DatabaseAccount; + readonly endpoint?: string; + readonly accessToken?: string; + readonly authorizationToken?: string; + readonly resourceToken?: string; + readonly useSDKOperations: boolean; + readonly defaultExperience?: DefaultAccountExperienceType; + readonly subscriptionType?: SubscriptionType; + readonly quotaId?: string; // API Type is not yet provided by ARM. You need to manually inspect all the capabilities+kind so we abstract that logic in userContext // This is coming in a future Cosmos ARM API version as a prperty on databaseAccount - apiType?: ApiType; - isTryCosmosDBSubscription?: boolean; - portalEnv?: PortalEnv; + readonly apiType?: ApiType; + readonly isTryCosmosDBSubscription?: boolean; + readonly portalEnv?: PortalEnv; + readonly features: Features; } type ApiType = "SQL" | "Mongo" | "Gremlin" | "Tables" | "Cassandra"; export type PortalEnv = "localhost" | "blackforest" | "fairfax" | "mooncake" | "prod" | "dev"; -const userContext: UserContext = { isTryCosmosDBSubscription: false, portalEnv: "prod" }; +const features = extractFeatures(); +const { enableSDKoperations: useSDKOperations } = features; -function updateUserContext(newContext: UserContext): void { +const userContext: UserContext = { + isTryCosmosDBSubscription: false, + portalEnv: "prod", + features, + useSDKOperations, +}; + +function updateUserContext(newContext: Partial): void { Object.assign(userContext, newContext); Object.assign(userContext, { apiType: apiType(userContext.databaseAccount) }); } diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index d00b6aa08..495e4c8f3 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -18,7 +18,6 @@ import { ResourceToken, } from "../HostedExplorerChildFrame"; import { emulatorAccount } from "../Platform/Emulator/emulatorAccount"; -import { extractFeatures } from "../Platform/Hosted/extractFeatures"; import { parseResourceTokenConnectionString } from "../Platform/Hosted/Helpers/ResourceTokenUtils"; import { getDatabaseAccountKindFromExperience, @@ -101,7 +100,6 @@ async function configureHostedWithAAD(config: AAD, explorerParams: ExplorerParam resourceGroup, masterKey: keys.primaryMasterKey, authorizationToken: `Bearer ${config.authorizationToken}`, - features: extractFeatures(), }); return explorer; } @@ -128,7 +126,6 @@ function configureHostedWithConnectionString(config: ConnectionString, explorerP explorer.configure({ databaseAccount, masterKey: config.masterKey, - features: extractFeatures(), }); return explorer; } @@ -157,10 +154,7 @@ function configureHostedWithResourceToken(config: ResourceToken, explorerParams: if (parsedResourceToken.partitionKey) { explorer.resourceTokenPartitionKey(parsedResourceToken.partitionKey); } - explorer.configure({ - databaseAccount, - features: extractFeatures(), - }); + explorer.configure({ databaseAccount }); return explorer; } @@ -181,7 +175,6 @@ function configureHostedWithEncryptedToken(config: EncryptedToken, explorerParam properties: getDatabaseAccountPropertiesFromMetadata(config.encryptedTokenMetadata), tags: {}, }, - features: extractFeatures(), }); return explorer; }