mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-19 08:51:24 +00:00
DDM in DE for NOSQL (#2224)
* ddm for DE for noSQL * ddm for DE for noSQL * ddm for DE for noSQL * ddm fix for the default case and test fix * formatting issue * updated the text change * added validation errors --------- Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
This commit is contained in:
@@ -89,6 +89,9 @@ export class CapabilityNames {
|
|||||||
public static readonly EnableMongo: string = "EnableMongo";
|
public static readonly EnableMongo: string = "EnableMongo";
|
||||||
public static readonly EnableServerless: string = "EnableServerless";
|
public static readonly EnableServerless: string = "EnableServerless";
|
||||||
public static readonly EnableNoSQLVectorSearch: string = "EnableNoSQLVectorSearch";
|
public static readonly EnableNoSQLVectorSearch: string = "EnableNoSQLVectorSearch";
|
||||||
|
public static readonly EnableNoSQLFullTextSearch: string = "EnableNoSQLFullTextSearch";
|
||||||
|
public static readonly EnableDataMasking: string = "EnableDataMasking";
|
||||||
|
public static readonly EnableDynamicDataMasking: string = "EnableDynamicDataMasking";
|
||||||
public static readonly EnableNoSQLFullTextSearchPreviewFeatures: string = "EnableNoSQLFullTextSearchPreviewFeatures";
|
public static readonly EnableNoSQLFullTextSearchPreviewFeatures: string = "EnableNoSQLFullTextSearchPreviewFeatures";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export interface Collection extends Resource {
|
|||||||
geospatialConfig?: GeospatialConfig;
|
geospatialConfig?: GeospatialConfig;
|
||||||
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
|
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
|
||||||
fullTextPolicy?: FullTextPolicy;
|
fullTextPolicy?: FullTextPolicy;
|
||||||
|
dataMaskingPolicy?: DataMaskingPolicy;
|
||||||
schema?: ISchema;
|
schema?: ISchema;
|
||||||
requestSchema?: () => void;
|
requestSchema?: () => void;
|
||||||
computedProperties?: ComputedProperties;
|
computedProperties?: ComputedProperties;
|
||||||
@@ -227,6 +228,18 @@ export interface ComputedProperty {
|
|||||||
|
|
||||||
export type ComputedProperties = ComputedProperty[];
|
export type ComputedProperties = ComputedProperty[];
|
||||||
|
|
||||||
|
export interface DataMaskingPolicy {
|
||||||
|
includedPaths: Array<{
|
||||||
|
path: string;
|
||||||
|
strategy: string;
|
||||||
|
startPosition: number;
|
||||||
|
length: number;
|
||||||
|
}>;
|
||||||
|
excludedPaths: string[];
|
||||||
|
policyFormatVersion: number;
|
||||||
|
isPolicyEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MaterializedView {
|
export interface MaterializedView {
|
||||||
id: string;
|
id: string;
|
||||||
_rid: string;
|
_rid: string;
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export interface Collection extends CollectionBase {
|
|||||||
requestSchema?: () => void;
|
requestSchema?: () => void;
|
||||||
vectorEmbeddingPolicy: ko.Observable<DataModels.VectorEmbeddingPolicy>;
|
vectorEmbeddingPolicy: ko.Observable<DataModels.VectorEmbeddingPolicy>;
|
||||||
fullTextPolicy: ko.Observable<DataModels.FullTextPolicy>;
|
fullTextPolicy: ko.Observable<DataModels.FullTextPolicy>;
|
||||||
|
dataMaskingPolicy: ko.Observable<DataModels.DataMaskingPolicy>;
|
||||||
indexingPolicy: ko.Observable<DataModels.IndexingPolicy>;
|
indexingPolicy: ko.Observable<DataModels.IndexingPolicy>;
|
||||||
uniqueKeyPolicy: DataModels.UniqueKeyPolicy;
|
uniqueKeyPolicy: DataModels.UniqueKeyPolicy;
|
||||||
usageSizeInKB: ko.Observable<number>;
|
usageSizeInKB: ko.Observable<number>;
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({
|
|||||||
changeFeedPolicy: undefined,
|
changeFeedPolicy: undefined,
|
||||||
analyticalStorageTtl: undefined,
|
analyticalStorageTtl: undefined,
|
||||||
geospatialConfig: undefined,
|
geospatialConfig: undefined,
|
||||||
|
dataMaskingPolicy: {
|
||||||
|
includedPaths: [],
|
||||||
|
excludedPaths: ["/excludedPath"],
|
||||||
|
policyFormatVersion: 2,
|
||||||
|
isPolicyEnabled: true,
|
||||||
|
},
|
||||||
indexes: [],
|
indexes: [],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -95,7 +101,6 @@ describe("SettingsComponent", () => {
|
|||||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||||
expect(settingsComponentInstance.hasProvisioningTypeChanged()).toEqual(false);
|
expect(settingsComponentInstance.hasProvisioningTypeChanged()).toEqual(false);
|
||||||
wrapper.setState({
|
wrapper.setState({
|
||||||
userCanChangeProvisioningTypes: true,
|
|
||||||
isAutoPilotSelected: true,
|
isAutoPilotSelected: true,
|
||||||
wasAutopilotOriginallySet: false,
|
wasAutopilotOriginallySet: false,
|
||||||
autoPilotThroughput: 1000,
|
autoPilotThroughput: 1000,
|
||||||
@@ -289,6 +294,167 @@ describe("SettingsComponent", () => {
|
|||||||
|
|
||||||
expect(wrapper.state("isThroughputBucketsSaveable")).toBe(false);
|
expect(wrapper.state("isThroughputBucketsSaveable")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should handle data masking policy updates correctly", async () => {
|
||||||
|
updateUserContext({
|
||||||
|
apiType: "SQL",
|
||||||
|
authType: AuthType.AAD,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||||
|
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||||
|
|
||||||
|
wrapper.setState({
|
||||||
|
dataMaskingContent: {
|
||||||
|
includedPaths: [],
|
||||||
|
excludedPaths: ["/excludedPath"],
|
||||||
|
policyFormatVersion: 2,
|
||||||
|
isPolicyEnabled: true,
|
||||||
|
},
|
||||||
|
dataMaskingContentBaseline: {
|
||||||
|
includedPaths: [],
|
||||||
|
excludedPaths: [],
|
||||||
|
policyFormatVersion: 2,
|
||||||
|
isPolicyEnabled: false,
|
||||||
|
},
|
||||||
|
isDataMaskingDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await settingsComponentInstance.onSaveClick();
|
||||||
|
|
||||||
|
// The test needs to match what onDataMaskingContentChange returns
|
||||||
|
expect(updateCollection).toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(wrapper.state("isDataMaskingDirty")).toBe(false);
|
||||||
|
expect(wrapper.state("dataMaskingContentBaseline")).toEqual({
|
||||||
|
includedPaths: [],
|
||||||
|
excludedPaths: ["/excludedPath"],
|
||||||
|
policyFormatVersion: 2,
|
||||||
|
isPolicyEnabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate data masking policy content", () => {
|
||||||
|
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||||
|
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||||
|
|
||||||
|
// Test with invalid data structure
|
||||||
|
// Use invalid data type for testing validation
|
||||||
|
type InvalidPolicy = Omit<DataModels.DataMaskingPolicy, "includedPaths"> & { includedPaths: string };
|
||||||
|
const invalidPolicy: InvalidPolicy = {
|
||||||
|
includedPaths: "invalid",
|
||||||
|
excludedPaths: [],
|
||||||
|
policyFormatVersion: 2,
|
||||||
|
isPolicyEnabled: false,
|
||||||
|
};
|
||||||
|
// Use type assertion since we're deliberately testing with invalid data
|
||||||
|
settingsComponentInstance["onDataMaskingContentChange"](invalidPolicy as unknown as DataModels.DataMaskingPolicy);
|
||||||
|
|
||||||
|
// State should update with the content but also set validation errors
|
||||||
|
expect(wrapper.state("dataMaskingContent")).toEqual({
|
||||||
|
includedPaths: "invalid",
|
||||||
|
excludedPaths: [],
|
||||||
|
policyFormatVersion: 2,
|
||||||
|
isPolicyEnabled: false,
|
||||||
|
});
|
||||||
|
expect(wrapper.state("dataMaskingValidationErrors")).toEqual(["includedPaths must be an array"]);
|
||||||
|
|
||||||
|
// Test with valid data
|
||||||
|
const validPolicy = {
|
||||||
|
includedPaths: [
|
||||||
|
{
|
||||||
|
path: "/path1",
|
||||||
|
strategy: "mask",
|
||||||
|
startPosition: 0,
|
||||||
|
length: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
excludedPaths: ["/excludedPath"],
|
||||||
|
policyFormatVersion: 2,
|
||||||
|
isPolicyEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
settingsComponentInstance["onDataMaskingContentChange"](validPolicy);
|
||||||
|
|
||||||
|
// State should update with valid data and no validation errors
|
||||||
|
expect(wrapper.state("dataMaskingContent")).toEqual(validPolicy);
|
||||||
|
expect(wrapper.state("dataMaskingValidationErrors")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle data masking discard correctly", () => {
|
||||||
|
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||||
|
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||||
|
|
||||||
|
const baselinePolicy = {
|
||||||
|
includedPaths: [
|
||||||
|
{
|
||||||
|
path: "/basePath",
|
||||||
|
strategy: "mask",
|
||||||
|
startPosition: 0,
|
||||||
|
length: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
excludedPaths: ["/excludedPath1"],
|
||||||
|
policyFormatVersion: 2,
|
||||||
|
isPolicyEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modifiedPolicy = {
|
||||||
|
includedPaths: [
|
||||||
|
{
|
||||||
|
path: "/newPath",
|
||||||
|
strategy: "mask",
|
||||||
|
startPosition: 1,
|
||||||
|
length: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
excludedPaths: ["/excludedPath2"],
|
||||||
|
policyFormatVersion: 2,
|
||||||
|
isPolicyEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial state
|
||||||
|
wrapper.setState({
|
||||||
|
dataMaskingContent: modifiedPolicy,
|
||||||
|
dataMaskingContentBaseline: baselinePolicy,
|
||||||
|
isDataMaskingDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call revert
|
||||||
|
settingsComponentInstance.onRevertClick();
|
||||||
|
|
||||||
|
// Verify state is reset
|
||||||
|
expect(wrapper.state("dataMaskingContent")).toEqual(baselinePolicy);
|
||||||
|
expect(wrapper.state("isDataMaskingDirty")).toBe(false);
|
||||||
|
expect(wrapper.state("shouldDiscardDataMasking")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disable save button when data masking has validation errors", () => {
|
||||||
|
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||||
|
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||||
|
|
||||||
|
// Initially, save button should be disabled
|
||||||
|
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(false);
|
||||||
|
|
||||||
|
// Make data masking dirty with valid data
|
||||||
|
wrapper.setState({
|
||||||
|
isDataMaskingDirty: true,
|
||||||
|
dataMaskingValidationErrors: [],
|
||||||
|
});
|
||||||
|
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
|
||||||
|
|
||||||
|
// Add validation errors - save should be disabled
|
||||||
|
wrapper.setState({
|
||||||
|
dataMaskingValidationErrors: ["includedPaths must be an array"],
|
||||||
|
});
|
||||||
|
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(false);
|
||||||
|
|
||||||
|
// Clear validation errors - save should be enabled again
|
||||||
|
wrapper.setState({
|
||||||
|
dataMaskingValidationErrors: [],
|
||||||
|
});
|
||||||
|
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SettingsComponent - indexing policy subscription", () => {
|
describe("SettingsComponent - indexing policy subscription", () => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
|
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||||
import { useDatabases } from "Explorer/useDatabases";
|
import { useDatabases } from "Explorer/useDatabases";
|
||||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||||
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||||
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
|
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import DiscardIcon from "../../../../images/discard.svg";
|
import DiscardIcon from "../../../../images/discard.svg";
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
ConflictResolutionComponent,
|
ConflictResolutionComponent,
|
||||||
ConflictResolutionComponentProps,
|
ConflictResolutionComponentProps,
|
||||||
} from "./SettingsSubComponents/ConflictResolutionComponent";
|
} from "./SettingsSubComponents/ConflictResolutionComponent";
|
||||||
|
import { DataMaskingComponent, DataMaskingComponentProps } from "./SettingsSubComponents/DataMaskingComponent";
|
||||||
import {
|
import {
|
||||||
GlobalSecondaryIndexComponent,
|
GlobalSecondaryIndexComponent,
|
||||||
GlobalSecondaryIndexComponentProps,
|
GlobalSecondaryIndexComponentProps,
|
||||||
@@ -151,6 +152,12 @@ export interface SettingsComponentState {
|
|||||||
conflictResolutionPolicyProcedureBaseline: string;
|
conflictResolutionPolicyProcedureBaseline: string;
|
||||||
isConflictResolutionDirty: boolean;
|
isConflictResolutionDirty: boolean;
|
||||||
|
|
||||||
|
dataMaskingContent: DataModels.DataMaskingPolicy;
|
||||||
|
dataMaskingContentBaseline: DataModels.DataMaskingPolicy;
|
||||||
|
shouldDiscardDataMasking: boolean;
|
||||||
|
isDataMaskingDirty: boolean;
|
||||||
|
dataMaskingValidationErrors: string[];
|
||||||
|
|
||||||
selectedTab: SettingsV2TabTypes;
|
selectedTab: SettingsV2TabTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +265,12 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
shouldDiscardComputedProperties: false,
|
shouldDiscardComputedProperties: false,
|
||||||
isComputedPropertiesDirty: false,
|
isComputedPropertiesDirty: false,
|
||||||
|
|
||||||
|
dataMaskingContent: undefined,
|
||||||
|
dataMaskingContentBaseline: undefined,
|
||||||
|
shouldDiscardDataMasking: false,
|
||||||
|
isDataMaskingDirty: false,
|
||||||
|
dataMaskingValidationErrors: [],
|
||||||
|
|
||||||
conflictResolutionPolicyMode: undefined,
|
conflictResolutionPolicyMode: undefined,
|
||||||
conflictResolutionPolicyModeBaseline: undefined,
|
conflictResolutionPolicyModeBaseline: undefined,
|
||||||
conflictResolutionPolicyPath: undefined,
|
conflictResolutionPolicyPath: undefined,
|
||||||
@@ -345,7 +358,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
};
|
};
|
||||||
|
|
||||||
public isSaveSettingsButtonEnabled = (): boolean => {
|
public isSaveSettingsButtonEnabled = (): boolean => {
|
||||||
if (this.isOfferReplacePending()) {
|
if (this.isOfferReplacePending() || this.props.settingsTab.isExecuting()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +366,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.state.dataMaskingValidationErrors.length > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
this.state.isScaleSaveable ||
|
this.state.isScaleSaveable ||
|
||||||
this.state.isSubSettingsSaveable ||
|
this.state.isSubSettingsSaveable ||
|
||||||
@@ -360,12 +377,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
this.state.isIndexingPolicyDirty ||
|
this.state.isIndexingPolicyDirty ||
|
||||||
this.state.isConflictResolutionDirty ||
|
this.state.isConflictResolutionDirty ||
|
||||||
this.state.isComputedPropertiesDirty ||
|
this.state.isComputedPropertiesDirty ||
|
||||||
|
this.state.isDataMaskingDirty ||
|
||||||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicySaveable) ||
|
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicySaveable) ||
|
||||||
this.state.isThroughputBucketsSaveable
|
this.state.isThroughputBucketsSaveable
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
public isDiscardSettingsButtonEnabled = (): boolean => {
|
public isDiscardSettingsButtonEnabled = (): boolean => {
|
||||||
|
if (this.props.settingsTab.isExecuting()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
this.state.isScaleDiscardable ||
|
this.state.isScaleDiscardable ||
|
||||||
this.state.isSubSettingsDiscardable ||
|
this.state.isSubSettingsDiscardable ||
|
||||||
@@ -373,6 +394,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
this.state.isIndexingPolicyDirty ||
|
this.state.isIndexingPolicyDirty ||
|
||||||
this.state.isConflictResolutionDirty ||
|
this.state.isConflictResolutionDirty ||
|
||||||
this.state.isComputedPropertiesDirty ||
|
this.state.isComputedPropertiesDirty ||
|
||||||
|
this.state.isDataMaskingDirty ||
|
||||||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicyDiscardable) ||
|
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicyDiscardable) ||
|
||||||
this.state.isThroughputBucketsSaveable
|
this.state.isThroughputBucketsSaveable
|
||||||
);
|
);
|
||||||
@@ -428,7 +450,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
: this.saveDatabaseSettings(startKey));
|
: this.saveDatabaseSettings(startKey));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.props.settingsTab.isExecutionError(true);
|
this.props.settingsTab.isExecutionError(true);
|
||||||
console.error(error);
|
|
||||||
traceFailure(
|
traceFailure(
|
||||||
Action.SettingsV2Updated,
|
Action.SettingsV2Updated,
|
||||||
{
|
{
|
||||||
@@ -445,8 +466,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
} finally {
|
} finally {
|
||||||
this.props.settingsTab.isExecuting(false);
|
this.props.settingsTab.isExecuting(false);
|
||||||
|
|
||||||
// Send message to Fabric no matter success or failure.
|
|
||||||
// In case of failure, saveCollectionSettings might have partially succeeded and Fabric needs to refresh
|
|
||||||
if (isFabricNative() && this.isCollectionSettingsTab) {
|
if (isFabricNative() && this.isCollectionSettingsTab) {
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: FabricMessageTypes.ContainerUpdated,
|
type: FabricMessageTypes.ContainerUpdated,
|
||||||
@@ -457,6 +476,9 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
};
|
};
|
||||||
|
|
||||||
public onRevertClick = (): void => {
|
public onRevertClick = (): void => {
|
||||||
|
if (this.props.settingsTab.isExecuting()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
trace(Action.SettingsV2Discarded, ActionModifiers.Mark, {
|
trace(Action.SettingsV2Discarded, ActionModifiers.Mark, {
|
||||||
message: "Settings Discarded",
|
message: "Settings Discarded",
|
||||||
});
|
});
|
||||||
@@ -497,6 +519,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
computedPropertiesContent: this.state.computedPropertiesContentBaseline,
|
computedPropertiesContent: this.state.computedPropertiesContentBaseline,
|
||||||
shouldDiscardComputedProperties: true,
|
shouldDiscardComputedProperties: true,
|
||||||
isComputedPropertiesDirty: false,
|
isComputedPropertiesDirty: false,
|
||||||
|
dataMaskingContent: this.state.dataMaskingContentBaseline,
|
||||||
|
shouldDiscardDataMasking: true,
|
||||||
|
isDataMaskingDirty: false,
|
||||||
|
dataMaskingValidationErrors: [],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -659,6 +685,39 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
private onComputedPropertiesDirtyChange = (isComputedPropertiesDirty: boolean): void =>
|
private onComputedPropertiesDirtyChange = (isComputedPropertiesDirty: boolean): void =>
|
||||||
this.setState({ isComputedPropertiesDirty: isComputedPropertiesDirty });
|
this.setState({ isComputedPropertiesDirty: isComputedPropertiesDirty });
|
||||||
|
|
||||||
|
private onDataMaskingContentChange = (newDataMasking: DataModels.DataMaskingPolicy): void => {
|
||||||
|
if (!newDataMasking.excludedPaths) {
|
||||||
|
newDataMasking.excludedPaths = [];
|
||||||
|
}
|
||||||
|
if (!newDataMasking.includedPaths) {
|
||||||
|
newDataMasking.includedPaths = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationErrors = [];
|
||||||
|
if (!Array.isArray(newDataMasking.includedPaths)) {
|
||||||
|
validationErrors.push("includedPaths must be an array");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(newDataMasking.excludedPaths)) {
|
||||||
|
validationErrors.push("excludedPaths must be an array");
|
||||||
|
}
|
||||||
|
if (typeof newDataMasking.policyFormatVersion !== "number") {
|
||||||
|
validationErrors.push("policyFormatVersion must be a number");
|
||||||
|
}
|
||||||
|
if (typeof newDataMasking.isPolicyEnabled !== "boolean") {
|
||||||
|
validationErrors.push("isPolicyEnabled must be a boolean");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
dataMaskingContent: newDataMasking,
|
||||||
|
dataMaskingValidationErrors: validationErrors,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private resetShouldDiscardDataMasking = (): void => this.setState({ shouldDiscardDataMasking: false });
|
||||||
|
|
||||||
|
private onDataMaskingDirtyChange = (isDataMaskingDirty: boolean): void =>
|
||||||
|
this.setState({ isDataMaskingDirty: isDataMaskingDirty });
|
||||||
|
|
||||||
private calculateTotalThroughputUsed = (): void => {
|
private calculateTotalThroughputUsed = (): void => {
|
||||||
this.totalThroughputUsed = 0;
|
this.totalThroughputUsed = 0;
|
||||||
(useDatabases.getState().databases || []).forEach(async (database) => {
|
(useDatabases.getState().databases || []).forEach(async (database) => {
|
||||||
@@ -783,6 +842,12 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
const fullTextPolicy: DataModels.FullTextPolicy =
|
const fullTextPolicy: DataModels.FullTextPolicy =
|
||||||
this.collection.fullTextPolicy && this.collection.fullTextPolicy();
|
this.collection.fullTextPolicy && this.collection.fullTextPolicy();
|
||||||
const indexingPolicyContent = this.collection.indexingPolicy();
|
const indexingPolicyContent = this.collection.indexingPolicy();
|
||||||
|
const dataMaskingContent: DataModels.DataMaskingPolicy = {
|
||||||
|
includedPaths: this.collection.dataMaskingPolicy?.()?.includedPaths || [],
|
||||||
|
excludedPaths: this.collection.dataMaskingPolicy?.()?.excludedPaths || [],
|
||||||
|
policyFormatVersion: this.collection.dataMaskingPolicy?.()?.policyFormatVersion || 2,
|
||||||
|
isPolicyEnabled: this.collection.dataMaskingPolicy?.()?.isPolicyEnabled || false,
|
||||||
|
};
|
||||||
const conflictResolutionPolicy: DataModels.ConflictResolutionPolicy =
|
const conflictResolutionPolicy: DataModels.ConflictResolutionPolicy =
|
||||||
this.collection.conflictResolutionPolicy && this.collection.conflictResolutionPolicy();
|
this.collection.conflictResolutionPolicy && this.collection.conflictResolutionPolicy();
|
||||||
const conflictResolutionPolicyMode = parseConflictResolutionMode(conflictResolutionPolicy?.mode);
|
const conflictResolutionPolicyMode = parseConflictResolutionMode(conflictResolutionPolicy?.mode);
|
||||||
@@ -834,11 +899,14 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
geospatialConfigTypeBaseline: geoSpatialConfigType,
|
geospatialConfigTypeBaseline: geoSpatialConfigType,
|
||||||
computedPropertiesContent: computedPropertiesContent,
|
computedPropertiesContent: computedPropertiesContent,
|
||||||
computedPropertiesContentBaseline: computedPropertiesContent,
|
computedPropertiesContentBaseline: computedPropertiesContent,
|
||||||
|
dataMaskingContent: dataMaskingContent,
|
||||||
|
dataMaskingContentBaseline: dataMaskingContent,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
private getTabsButtons = (): CommandButtonComponentProps[] => {
|
private getTabsButtons = (): CommandButtonComponentProps[] => {
|
||||||
const buttons: CommandButtonComponentProps[] = [];
|
const buttons: CommandButtonComponentProps[] = [];
|
||||||
|
const isExecuting = this.props.settingsTab.isExecuting();
|
||||||
if (this.saveSettingsButton.isVisible()) {
|
if (this.saveSettingsButton.isVisible()) {
|
||||||
const label = "Save";
|
const label = "Save";
|
||||||
buttons.push({
|
buttons.push({
|
||||||
@@ -848,7 +916,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
commandButtonLabel: label,
|
commandButtonLabel: label,
|
||||||
ariaLabel: label,
|
ariaLabel: label,
|
||||||
hasPopup: false,
|
hasPopup: false,
|
||||||
disabled: !this.saveSettingsButton.isEnabled(),
|
disabled: isExecuting || !this.saveSettingsButton.isEnabled(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,11 +925,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
buttons.push({
|
buttons.push({
|
||||||
iconSrc: DiscardIcon,
|
iconSrc: DiscardIcon,
|
||||||
iconAlt: label,
|
iconAlt: label,
|
||||||
onCommandClick: this.onRevertClick,
|
onCommandClick: () => {
|
||||||
|
if (isExecuting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.onRevertClick();
|
||||||
|
},
|
||||||
commandButtonLabel: label,
|
commandButtonLabel: label,
|
||||||
ariaLabel: label,
|
ariaLabel: label,
|
||||||
hasPopup: false,
|
hasPopup: false,
|
||||||
disabled: !this.discardSettingsChangesButton.isEnabled(),
|
disabled: isExecuting || !this.discardSettingsChangesButton.isEnabled(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return buttons;
|
return buttons;
|
||||||
@@ -980,7 +1053,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
this.state.isContainerPolicyDirty ||
|
this.state.isContainerPolicyDirty ||
|
||||||
this.state.isIndexingPolicyDirty ||
|
this.state.isIndexingPolicyDirty ||
|
||||||
this.state.isConflictResolutionDirty ||
|
this.state.isConflictResolutionDirty ||
|
||||||
this.state.isComputedPropertiesDirty
|
this.state.isComputedPropertiesDirty ||
|
||||||
|
this.state.isDataMaskingDirty
|
||||||
) {
|
) {
|
||||||
let defaultTtl: number;
|
let defaultTtl: number;
|
||||||
switch (this.state.timeToLive) {
|
switch (this.state.timeToLive) {
|
||||||
@@ -1002,6 +1076,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
newCollection.vectorEmbeddingPolicy = this.state.vectorEmbeddingPolicy;
|
newCollection.vectorEmbeddingPolicy = this.state.vectorEmbeddingPolicy;
|
||||||
|
|
||||||
newCollection.fullTextPolicy = this.state.fullTextPolicy;
|
newCollection.fullTextPolicy = this.state.fullTextPolicy;
|
||||||
|
newCollection.dataMaskingPolicy = this.state.dataMaskingContent;
|
||||||
|
|
||||||
newCollection.indexingPolicy = this.state.indexingPolicyContent;
|
newCollection.indexingPolicy = this.state.indexingPolicyContent;
|
||||||
|
|
||||||
@@ -1048,13 +1123,18 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
await this.refreshIndexTransformationProgress();
|
await this.refreshIndexTransformationProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update collection object with new data
|
||||||
|
this.collection.dataMaskingPolicy(updatedCollection.dataMaskingPolicy);
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
|
dataMaskingContentBaseline: this.state.dataMaskingContent,
|
||||||
isSubSettingsSaveable: false,
|
isSubSettingsSaveable: false,
|
||||||
isSubSettingsDiscardable: false,
|
isSubSettingsDiscardable: false,
|
||||||
isContainerPolicyDirty: false,
|
isContainerPolicyDirty: false,
|
||||||
isIndexingPolicyDirty: false,
|
isIndexingPolicyDirty: false,
|
||||||
isConflictResolutionDirty: false,
|
isConflictResolutionDirty: false,
|
||||||
isComputedPropertiesDirty: false,
|
isComputedPropertiesDirty: false,
|
||||||
|
isDataMaskingDirty: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1383,6 +1463,31 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if DDM should be enabled
|
||||||
|
const shouldEnableDDM = (): boolean => {
|
||||||
|
const hasDataMaskingCapability = isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking);
|
||||||
|
const isSqlAccount = userContext.apiType === "SQL";
|
||||||
|
|
||||||
|
return isSqlAccount && hasDataMaskingCapability; // Only show for SQL accounts with DDM capability
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shouldEnableDDM()) {
|
||||||
|
const dataMaskingComponentProps: DataMaskingComponentProps = {
|
||||||
|
shouldDiscardDataMasking: this.state.shouldDiscardDataMasking,
|
||||||
|
resetShouldDiscardDataMasking: this.resetShouldDiscardDataMasking,
|
||||||
|
dataMaskingContent: this.state.dataMaskingContent,
|
||||||
|
dataMaskingContentBaseline: this.state.dataMaskingContentBaseline,
|
||||||
|
onDataMaskingContentChange: this.onDataMaskingContentChange,
|
||||||
|
onDataMaskingDirtyChange: this.onDataMaskingDirtyChange,
|
||||||
|
validationErrors: this.state.dataMaskingValidationErrors,
|
||||||
|
};
|
||||||
|
|
||||||
|
tabs.push({
|
||||||
|
tab: SettingsV2TabTypes.DataMaskingTab,
|
||||||
|
content: <DataMaskingComponent {...dataMaskingComponentProps} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (this.throughputBucketsEnabled && !hasDatabaseSharedThroughput(this.collection) && this.offer) {
|
if (this.throughputBucketsEnabled && !hasDatabaseSharedThroughput(this.collection) && this.offer) {
|
||||||
tabs.push({
|
tabs.push({
|
||||||
tab: SettingsV2TabTypes.ThroughputBucketsTab,
|
tab: SettingsV2TabTypes.ThroughputBucketsTab,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ describe("SettingsUtils functions", () => {
|
|||||||
expect(wrapper).toMatchSnapshot();
|
expect(wrapper).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getRuPriceBreakdown", () => {
|
||||||
it("should return correct price breakdown for a manual RU setting of 500, 1 region, multimaster disabled", () => {
|
it("should return correct price breakdown for a manual RU setting of 500, 1 region, multimaster disabled", () => {
|
||||||
const prices = getRuPriceBreakdown(500, "", 1, false, false);
|
const prices = getRuPriceBreakdown(500, "", 1, false, false);
|
||||||
expect(prices.hourlyPrice).toBe(0.04);
|
expect(prices.hourlyPrice).toBe(0.04);
|
||||||
@@ -78,4 +79,101 @@ describe("SettingsUtils functions", () => {
|
|||||||
expect(prices.currency).toBe("USD");
|
expect(prices.currency).toBe("USD");
|
||||||
expect(prices.currencySign).toBe("$");
|
expect(prices.currencySign).toBe("$");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return correct price breakdown for autoscale", () => {
|
||||||
|
const prices = getRuPriceBreakdown(1000, "", 1, false, true);
|
||||||
|
// For autoscale, the baseline RU is 10% of max RU
|
||||||
|
expect(prices.hourlyPrice).toBe(0.12); // Higher because autoscale pricing is different
|
||||||
|
expect(prices.dailyPrice).toBe(2.88); // hourlyPrice * 24
|
||||||
|
expect(prices.monthlyPrice).toBe(87.6); // hourlyPrice * 730
|
||||||
|
expect(prices.pricePerRu).toBe(0.00012); // Autoscale price per RU
|
||||||
|
expect(prices.currency).toBe("USD");
|
||||||
|
expect(prices.currencySign).toBe("$");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct price breakdown for multimaster", () => {
|
||||||
|
const prices = getRuPriceBreakdown(500, "", 2, true, false);
|
||||||
|
// For multimaster with 2 regions, price is multiplied by 4
|
||||||
|
expect(prices.hourlyPrice).toBe(0.16); // Base price * 4
|
||||||
|
expect(prices.dailyPrice).toBe(3.84); // hourlyPrice * 24
|
||||||
|
expect(prices.monthlyPrice).toBe(116.8); // hourlyPrice * 730
|
||||||
|
expect(prices.pricePerRu).toBe(0.00016); // Base price per RU * 2 (regions) * 2 (multimaster)
|
||||||
|
expect(prices.currency).toBe("USD");
|
||||||
|
expect(prices.currencySign).toBe("$");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("message formatting", () => {
|
||||||
|
it("should format throughput apply delayed message correctly", () => {
|
||||||
|
const message = getThroughputApplyDelayedMessage(false, 1000, "RU/s", "testDb", "testColl", 2000);
|
||||||
|
const wrapper = shallow(message);
|
||||||
|
const text = wrapper.text();
|
||||||
|
expect(text).toContain("testDb");
|
||||||
|
expect(text).toContain("testColl");
|
||||||
|
expect(text).toContain("Current manual throughput: 1000 RU/s");
|
||||||
|
expect(text).toContain("Target manual throughput: 2000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format autoscale throughput message correctly", () => {
|
||||||
|
const message = getThroughputApplyDelayedMessage(true, 1000, "RU/s", "testDb", "testColl", 2000);
|
||||||
|
const wrapper = shallow(message);
|
||||||
|
const text = wrapper.text();
|
||||||
|
expect(text).toContain("Current autoscale throughput: 100 - 1000 RU/s");
|
||||||
|
expect(text).toContain("Target autoscale throughput: 200 - 2000 RU/s");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("estimated spending element", () => {
|
||||||
|
// Mock Stack component since we're using shallow rendering
|
||||||
|
const mockStack = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mock("@fluentui/react", () => ({
|
||||||
|
...jest.requireActual("@fluentui/react"),
|
||||||
|
Stack: mockStack,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render correct spending info for manual throughput", () => {
|
||||||
|
const costElement = <div>Cost</div>;
|
||||||
|
const priceBreakdown: PriceBreakdown = {
|
||||||
|
hourlyPrice: 1.0,
|
||||||
|
dailyPrice: 24.0,
|
||||||
|
monthlyPrice: 730.0,
|
||||||
|
pricePerRu: 0.0001,
|
||||||
|
currency: "USD",
|
||||||
|
currencySign: "$",
|
||||||
|
};
|
||||||
|
|
||||||
|
const element = getEstimatedSpendingElement(costElement, 1000, 1, priceBreakdown, false);
|
||||||
|
const wrapper = shallow(element);
|
||||||
|
const spendElement = wrapper.find("#throughputSpendElement");
|
||||||
|
|
||||||
|
expect(spendElement.find("span").at(0).text()).toBe("1 region");
|
||||||
|
expect(spendElement.find("span").at(1).text()).toBe("1000 RU/s");
|
||||||
|
expect(spendElement.find("span").at(2).text()).toBe("$0.0001/RU");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render correct spending info for autoscale throughput", () => {
|
||||||
|
const costElement = <div>Cost</div>;
|
||||||
|
const priceBreakdown: PriceBreakdown = {
|
||||||
|
hourlyPrice: 1.0,
|
||||||
|
dailyPrice: 24.0,
|
||||||
|
monthlyPrice: 730.0,
|
||||||
|
pricePerRu: 0.0001,
|
||||||
|
currency: "USD",
|
||||||
|
currencySign: "$",
|
||||||
|
};
|
||||||
|
|
||||||
|
const element = getEstimatedSpendingElement(costElement, 1000, 1, priceBreakdown, true);
|
||||||
|
const wrapper = shallow(element);
|
||||||
|
const spendElement = wrapper.find("#throughputSpendElement");
|
||||||
|
|
||||||
|
expect(spendElement.find("span").at(1).text()).toBe("100 RU/s - 1000 RU/s");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export interface PriceBreakdown {
|
|||||||
currencySign: string;
|
currencySign: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type editorType = "indexPolicy" | "computedProperties";
|
export type editorType = "indexPolicy" | "computedProperties" | "dataMasking";
|
||||||
|
|
||||||
export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14, color: "windowtext" } };
|
export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14, color: "windowtext" } };
|
||||||
|
|
||||||
@@ -170,6 +170,14 @@ export const messageBarStyles: Partial<IMessageBarStyles> = {
|
|||||||
text: { fontSize: 14 },
|
text: { fontSize: 14 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const unsavedEditorMessageBarStyles: Partial<IMessageBarStyles> = {
|
||||||
|
root: {
|
||||||
|
marginTop: "5px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
},
|
||||||
|
text: { fontSize: 14 },
|
||||||
|
};
|
||||||
|
|
||||||
export const throughputUnit = "RU/s";
|
export const throughputUnit = "RU/s";
|
||||||
|
|
||||||
export function onRenderRow(props: IDetailsRowProps): JSX.Element {
|
export function onRenderRow(props: IDetailsRowProps): JSX.Element {
|
||||||
@@ -259,7 +267,12 @@ export const ttlWarning: JSX.Element = (
|
|||||||
export const unsavedEditorWarningMessage = (editor: editorType): JSX.Element => (
|
export const unsavedEditorWarningMessage = (editor: editorType): JSX.Element => (
|
||||||
<Text styles={infoAndToolTipTextStyle}>
|
<Text styles={infoAndToolTipTextStyle}>
|
||||||
You have not saved the latest changes made to your{" "}
|
You have not saved the latest changes made to your{" "}
|
||||||
{editor === "indexPolicy" ? "indexing policy" : "computed properties"}. Please click save to confirm the changes.
|
{editor === "indexPolicy"
|
||||||
|
? "indexing policy"
|
||||||
|
: editor === "dataMasking"
|
||||||
|
? "data masking policy"
|
||||||
|
: "computed properties"}
|
||||||
|
. Please click save to confirm the changes.
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { MessageBar, MessageBarType } from "@fluentui/react";
|
||||||
|
import { mount } from "enzyme";
|
||||||
|
import React from "react";
|
||||||
|
import * as DataModels from "../../../../Contracts/DataModels";
|
||||||
|
import { DataMaskingComponent } from "./DataMaskingComponent";
|
||||||
|
|
||||||
|
const mockGetValue = jest.fn();
|
||||||
|
const mockSetValue = jest.fn();
|
||||||
|
const mockOnDidChangeContent = jest.fn();
|
||||||
|
const mockGetModel = jest.fn(() => ({
|
||||||
|
getValue: mockGetValue,
|
||||||
|
setValue: mockSetValue,
|
||||||
|
onDidChangeContent: mockOnDidChangeContent,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockEditor = {
|
||||||
|
getModel: mockGetModel,
|
||||||
|
dispose: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock("../../../LazyMonaco", () => ({
|
||||||
|
loadMonaco: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
editor: {
|
||||||
|
create: jest.fn(() => mockEditor),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../../Utils/CapabilityUtils", () => ({
|
||||||
|
isCapabilityEnabled: jest.fn().mockReturnValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("DataMaskingComponent", () => {
|
||||||
|
const mockProps = {
|
||||||
|
shouldDiscardDataMasking: false,
|
||||||
|
resetShouldDiscardDataMasking: jest.fn(),
|
||||||
|
dataMaskingContent: undefined as DataModels.DataMaskingPolicy,
|
||||||
|
dataMaskingContentBaseline: undefined as DataModels.DataMaskingPolicy,
|
||||||
|
onDataMaskingContentChange: jest.fn(),
|
||||||
|
onDataMaskingDirtyChange: jest.fn(),
|
||||||
|
validationErrors: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const samplePolicy: DataModels.DataMaskingPolicy = {
|
||||||
|
includedPaths: [
|
||||||
|
{
|
||||||
|
path: "/test",
|
||||||
|
strategy: "Default",
|
||||||
|
startPosition: 0,
|
||||||
|
length: -1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
excludedPaths: [],
|
||||||
|
policyFormatVersion: 2,
|
||||||
|
isPolicyEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let changeContentCallback: () => void;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockGetValue.mockReturnValue(JSON.stringify(samplePolicy));
|
||||||
|
mockOnDidChangeContent.mockImplementation((callback) => {
|
||||||
|
changeContentCallback = callback;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders without crashing", async () => {
|
||||||
|
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.exists()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays warning message when content is dirty", async () => {
|
||||||
|
const wrapper = mount(
|
||||||
|
<DataMaskingComponent
|
||||||
|
{...mockProps}
|
||||||
|
dataMaskingContent={samplePolicy}
|
||||||
|
dataMaskingContentBaseline={{ ...samplePolicy, isPolicyEnabled: true }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
// Verify editor div is rendered
|
||||||
|
const editorDiv = wrapper.find(".settingsV2Editor");
|
||||||
|
expect(editorDiv.exists()).toBeTruthy();
|
||||||
|
|
||||||
|
// Warning message should be visible when content is dirty
|
||||||
|
const messageBar = wrapper.find(MessageBar);
|
||||||
|
expect(messageBar.exists()).toBeTruthy();
|
||||||
|
expect(messageBar.prop("messageBarType")).toBe(MessageBarType.warning);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates content and dirty state on valid JSON input", async () => {
|
||||||
|
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
// Simulate valid JSON input by setting mock return value and triggering callback
|
||||||
|
const validJson = JSON.stringify(samplePolicy);
|
||||||
|
mockGetValue.mockReturnValue(validJson);
|
||||||
|
changeContentCallback();
|
||||||
|
|
||||||
|
expect(mockProps.onDataMaskingContentChange).toHaveBeenCalledWith(samplePolicy);
|
||||||
|
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doesn't update content on invalid JSON input", async () => {
|
||||||
|
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
// Simulate invalid JSON input
|
||||||
|
const invalidJson = "{invalid:json}";
|
||||||
|
mockGetValue.mockReturnValue(invalidJson);
|
||||||
|
changeContentCallback();
|
||||||
|
|
||||||
|
expect(mockProps.onDataMaskingContentChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets content when shouldDiscardDataMasking is true", async () => {
|
||||||
|
const baselinePolicy = { ...samplePolicy, isPolicyEnabled: true };
|
||||||
|
|
||||||
|
const wrapper = mount(
|
||||||
|
<DataMaskingComponent
|
||||||
|
{...mockProps}
|
||||||
|
dataMaskingContent={samplePolicy}
|
||||||
|
dataMaskingContentBaseline={baselinePolicy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
// Now update props to trigger shouldDiscardDataMasking
|
||||||
|
wrapper.setProps({ shouldDiscardDataMasking: true });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
// Check that reset was triggered
|
||||||
|
expect(mockProps.resetShouldDiscardDataMasking).toHaveBeenCalled();
|
||||||
|
expect(mockSetValue).toHaveBeenCalledWith(JSON.stringify(samplePolicy, undefined, 4));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recalculates dirty state when baseline changes", async () => {
|
||||||
|
const wrapper = mount(
|
||||||
|
<DataMaskingComponent
|
||||||
|
{...mockProps}
|
||||||
|
dataMaskingContent={samplePolicy}
|
||||||
|
dataMaskingContentBaseline={samplePolicy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
// Update baseline to trigger componentDidUpdate
|
||||||
|
const newBaseline = { ...samplePolicy, isPolicyEnabled: true };
|
||||||
|
wrapper.setProps({ dataMaskingContentBaseline: newBaseline });
|
||||||
|
|
||||||
|
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates required fields in policy", async () => {
|
||||||
|
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
// Test with missing required fields
|
||||||
|
const invalidPolicy: Record<string, unknown> = {
|
||||||
|
includedPaths: "not an array",
|
||||||
|
excludedPaths: [] as string[],
|
||||||
|
policyFormatVersion: "not a number",
|
||||||
|
isPolicyEnabled: "not a boolean",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetValue.mockReturnValue(JSON.stringify(invalidPolicy));
|
||||||
|
changeContentCallback();
|
||||||
|
|
||||||
|
// Parent callback should be called even with invalid data (parent will validate)
|
||||||
|
expect(mockProps.onDataMaskingContentChange).toHaveBeenCalledWith(invalidPolicy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maintains dirty state after multiple content changes", async () => {
|
||||||
|
const wrapper = mount(
|
||||||
|
<DataMaskingComponent
|
||||||
|
{...mockProps}
|
||||||
|
dataMaskingContent={samplePolicy}
|
||||||
|
dataMaskingContentBaseline={samplePolicy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
// First change
|
||||||
|
const modifiedPolicy1 = { ...samplePolicy, isPolicyEnabled: true };
|
||||||
|
mockGetValue.mockReturnValue(JSON.stringify(modifiedPolicy1));
|
||||||
|
changeContentCallback();
|
||||||
|
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
|
||||||
|
|
||||||
|
// Second change back to baseline
|
||||||
|
mockGetValue.mockReturnValue(JSON.stringify(samplePolicy));
|
||||||
|
changeContentCallback();
|
||||||
|
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
|
||||||
|
import * as monaco from "monaco-editor";
|
||||||
|
import * as React from "react";
|
||||||
|
import * as Constants from "../../../../Common/Constants";
|
||||||
|
import * as DataModels from "../../../../Contracts/DataModels";
|
||||||
|
import { isCapabilityEnabled } from "../../../../Utils/CapabilityUtils";
|
||||||
|
import { loadMonaco } from "../../../LazyMonaco";
|
||||||
|
import { titleAndInputStackProps, unsavedEditorWarningMessage } from "../SettingsRenderUtils";
|
||||||
|
import { isDirty as isContentDirty } from "../SettingsUtils";
|
||||||
|
|
||||||
|
export interface DataMaskingComponentProps {
|
||||||
|
shouldDiscardDataMasking: boolean;
|
||||||
|
resetShouldDiscardDataMasking: () => void;
|
||||||
|
dataMaskingContent: DataModels.DataMaskingPolicy;
|
||||||
|
dataMaskingContentBaseline: DataModels.DataMaskingPolicy;
|
||||||
|
onDataMaskingContentChange: (dataMasking: DataModels.DataMaskingPolicy) => void;
|
||||||
|
onDataMaskingDirtyChange: (isDirty: boolean) => void;
|
||||||
|
validationErrors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataMaskingComponentState {
|
||||||
|
isDirty: boolean;
|
||||||
|
dataMaskingContentIsValid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyDataMaskingPolicy: DataModels.DataMaskingPolicy = {
|
||||||
|
includedPaths: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
strategy: "Default",
|
||||||
|
startPosition: 0,
|
||||||
|
length: -1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
excludedPaths: [],
|
||||||
|
policyFormatVersion: 2,
|
||||||
|
isPolicyEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DataMaskingComponent extends React.Component<DataMaskingComponentProps, DataMaskingComponentState> {
|
||||||
|
private dataMaskingDiv = React.createRef<HTMLDivElement>();
|
||||||
|
private dataMaskingEditor: monaco.editor.IStandaloneCodeEditor;
|
||||||
|
private shouldCheckComponentIsDirty = true;
|
||||||
|
|
||||||
|
constructor(props: DataMaskingComponentProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
isDirty: false,
|
||||||
|
dataMaskingContentIsValid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidUpdate(): void {
|
||||||
|
if (this.props.shouldDiscardDataMasking) {
|
||||||
|
this.resetDataMaskingEditor();
|
||||||
|
this.props.resetShouldDiscardDataMasking();
|
||||||
|
}
|
||||||
|
this.onComponentUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount(): void {
|
||||||
|
this.resetDataMaskingEditor();
|
||||||
|
this.onComponentUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onComponentUpdate = (): void => {
|
||||||
|
if (!this.shouldCheckComponentIsDirty) {
|
||||||
|
this.shouldCheckComponentIsDirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.props.onDataMaskingDirtyChange(this.IsComponentDirty());
|
||||||
|
this.shouldCheckComponentIsDirty = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
public IsComponentDirty = (): boolean => {
|
||||||
|
if (
|
||||||
|
isContentDirty(this.props.dataMaskingContent, this.props.dataMaskingContentBaseline) &&
|
||||||
|
this.state.dataMaskingContentIsValid
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private resetDataMaskingEditor = (): void => {
|
||||||
|
if (!this.dataMaskingEditor) {
|
||||||
|
this.createDataMaskingEditor();
|
||||||
|
} else {
|
||||||
|
const dataMaskingEditorModel = this.dataMaskingEditor.getModel();
|
||||||
|
const value: string = JSON.stringify(this.props.dataMaskingContent || emptyDataMaskingPolicy, undefined, 4);
|
||||||
|
dataMaskingEditorModel.setValue(value);
|
||||||
|
}
|
||||||
|
this.onComponentUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
private async createDataMaskingEditor(): Promise<void> {
|
||||||
|
const value: string = JSON.stringify(this.props.dataMaskingContent || emptyDataMaskingPolicy, undefined, 4);
|
||||||
|
const monaco = await loadMonaco();
|
||||||
|
this.dataMaskingEditor = monaco.editor.create(this.dataMaskingDiv.current, {
|
||||||
|
value: value,
|
||||||
|
language: "json",
|
||||||
|
automaticLayout: true,
|
||||||
|
ariaLabel: "Data Masking Policy",
|
||||||
|
fontSize: 13,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
wordWrap: "off",
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
lineNumbers: "on",
|
||||||
|
});
|
||||||
|
if (this.dataMaskingEditor) {
|
||||||
|
const dataMaskingEditorModel = this.dataMaskingEditor.getModel();
|
||||||
|
dataMaskingEditorModel.onDidChangeContent(this.onEditorContentChange.bind(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEditorContentChange = (): void => {
|
||||||
|
const dataMaskingEditorModel = this.dataMaskingEditor.getModel();
|
||||||
|
try {
|
||||||
|
const newContent = JSON.parse(dataMaskingEditorModel.getValue()) as DataModels.DataMaskingPolicy;
|
||||||
|
|
||||||
|
// Always call parent's validation - it will handle validation and store errors
|
||||||
|
this.props.onDataMaskingContentChange(newContent);
|
||||||
|
|
||||||
|
const isDirty = isContentDirty(newContent, this.props.dataMaskingContentBaseline);
|
||||||
|
this.setState(
|
||||||
|
{
|
||||||
|
dataMaskingContentIsValid: this.props.validationErrors.length === 0,
|
||||||
|
isDirty,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.props.onDataMaskingDirtyChange(isDirty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid JSON - mark as invalid without propagating
|
||||||
|
this.setState({
|
||||||
|
dataMaskingContentIsValid: false,
|
||||||
|
isDirty: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public render(): JSX.Element {
|
||||||
|
if (!isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirty = this.IsComponentDirty();
|
||||||
|
return (
|
||||||
|
<Stack {...titleAndInputStackProps}>
|
||||||
|
{isDirty && (
|
||||||
|
<MessageBar messageBarType={MessageBarType.warning}>{unsavedEditorWarningMessage("dataMasking")}</MessageBar>
|
||||||
|
)}
|
||||||
|
{this.props.validationErrors.length > 0 && (
|
||||||
|
<MessageBar messageBarType={MessageBarType.error}>
|
||||||
|
Validation failed: {this.props.validationErrors.join(", ")}
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
<div className="settingsV2Editor" tabIndex={0} ref={this.dataMaskingDiv}></div>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,12 @@ import {
|
|||||||
getMongoIndexType,
|
getMongoIndexType,
|
||||||
getMongoIndexTypeText,
|
getMongoIndexTypeText,
|
||||||
getMongoNotification,
|
getMongoNotification,
|
||||||
|
getPartitionKeyName,
|
||||||
|
getPartitionKeyPlaceHolder,
|
||||||
|
getPartitionKeySubtext,
|
||||||
|
getPartitionKeyTooltipText,
|
||||||
getSanitizedInputValue,
|
getSanitizedInputValue,
|
||||||
|
getTabTitle,
|
||||||
hasDatabaseSharedThroughput,
|
hasDatabaseSharedThroughput,
|
||||||
isDirty,
|
isDirty,
|
||||||
isIndexTransforming,
|
isIndexTransforming,
|
||||||
@@ -14,6 +19,7 @@ import {
|
|||||||
MongoWildcardPlaceHolder,
|
MongoWildcardPlaceHolder,
|
||||||
parseConflictResolutionMode,
|
parseConflictResolutionMode,
|
||||||
parseConflictResolutionProcedure,
|
parseConflictResolutionProcedure,
|
||||||
|
SettingsV2TabTypes,
|
||||||
SingleFieldText,
|
SingleFieldText,
|
||||||
WildcardText,
|
WildcardText,
|
||||||
} from "./SettingsUtils";
|
} from "./SettingsUtils";
|
||||||
@@ -50,14 +56,46 @@ describe("SettingsUtils", () => {
|
|||||||
expect(hasDatabaseSharedThroughput(newCollection)).toEqual(true);
|
expect(hasDatabaseSharedThroughput(newCollection)).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parseConflictResolutionMode", () => {
|
describe("parseConflictResolutionMode", () => {
|
||||||
|
it("parses valid modes correctly", () => {
|
||||||
expect(parseConflictResolutionMode("custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
expect(parseConflictResolutionMode("custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
||||||
expect(parseConflictResolutionMode("lastwriterwins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
expect(parseConflictResolutionMode("lastwriterwins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
||||||
|
expect(parseConflictResolutionMode("Custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
||||||
|
expect(parseConflictResolutionMode("CUSTOM")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
||||||
|
expect(parseConflictResolutionMode("LastWriterWins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parseConflictResolutionProcedure", () => {
|
it("handles empty/undefined input", () => {
|
||||||
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/sprocs/conflictResSproc")).toEqual("conflictResSproc");
|
expect(parseConflictResolutionMode(undefined)).toBeUndefined();
|
||||||
|
expect(parseConflictResolutionMode(null)).toBeUndefined();
|
||||||
|
expect(parseConflictResolutionMode("")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to LastWriterWins for invalid inputs", () => {
|
||||||
|
expect(parseConflictResolutionMode("invalid")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
||||||
|
expect(parseConflictResolutionMode("123")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseConflictResolutionProcedure", () => {
|
||||||
|
it("extracts procedure name from valid paths", () => {
|
||||||
|
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/sprocs/conflictResSproc")).toEqual(
|
||||||
|
"conflictResSproc",
|
||||||
|
);
|
||||||
expect(parseConflictResolutionProcedure("conflictResSproc")).toEqual("conflictResSproc");
|
expect(parseConflictResolutionProcedure("conflictResSproc")).toEqual("conflictResSproc");
|
||||||
|
expect(parseConflictResolutionProcedure("/dbs/mydb/colls/mycoll/sprocs/myProc")).toEqual("myProc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty/undefined input", () => {
|
||||||
|
expect(parseConflictResolutionProcedure(undefined)).toBeUndefined();
|
||||||
|
expect(parseConflictResolutionProcedure(null)).toBeUndefined();
|
||||||
|
expect(parseConflictResolutionProcedure("")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles invalid path formats", () => {
|
||||||
|
expect(parseConflictResolutionProcedure("/invalid/path")).toBeUndefined();
|
||||||
|
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/wrongtype/name")).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isDirty", () => {
|
describe("isDirty", () => {
|
||||||
@@ -68,68 +106,235 @@ describe("SettingsUtils", () => {
|
|||||||
excludedPaths: [],
|
excludedPaths: [],
|
||||||
} as DataModels.IndexingPolicy;
|
} as DataModels.IndexingPolicy;
|
||||||
|
|
||||||
it("works on all types", () => {
|
describe("primitive types", () => {
|
||||||
expect(isDirty("baseline", "baseline")).toEqual(false);
|
it("handles strings", () => {
|
||||||
expect(isDirty(0, 0)).toEqual(false);
|
expect(isDirty("baseline", "baseline")).toBeFalsy();
|
||||||
expect(isDirty(true, true)).toEqual(false);
|
expect(isDirty("baseline", "current")).toBeTruthy();
|
||||||
expect(isDirty(undefined, undefined)).toEqual(false);
|
expect(isDirty("", "")).toBeFalsy();
|
||||||
expect(isDirty(indexingPolicy, indexingPolicy)).toEqual(false);
|
expect(isDirty("test", "")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
expect(isDirty("baseline", "current")).toEqual(true);
|
it("handles numbers", () => {
|
||||||
expect(isDirty(0, 1)).toEqual(true);
|
expect(isDirty(0, 0)).toBeFalsy();
|
||||||
expect(isDirty(true, false)).toEqual(true);
|
expect(isDirty(1, 1)).toBeFalsy();
|
||||||
expect(isDirty(undefined, indexingPolicy)).toEqual(true);
|
expect(isDirty(0, 1)).toBeTruthy();
|
||||||
expect(isDirty(indexingPolicy, { ...indexingPolicy, automatic: false })).toEqual(true);
|
expect(isDirty(-1, 1)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles booleans", () => {
|
||||||
|
expect(isDirty(true, true)).toBeFalsy();
|
||||||
|
expect(isDirty(false, false)).toBeFalsy();
|
||||||
|
expect(isDirty(true, false)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles undefined and null", () => {
|
||||||
|
expect(isDirty(undefined, undefined)).toBeFalsy();
|
||||||
|
expect(isDirty(null, null)).toBeFalsy();
|
||||||
|
expect(isDirty(undefined, null)).toBeTruthy();
|
||||||
|
expect(isDirty(undefined, "value")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getSanitizedInputValue", () => {
|
describe("complex types", () => {
|
||||||
|
it("handles indexing policy", () => {
|
||||||
|
expect(isDirty(indexingPolicy, indexingPolicy)).toBeFalsy();
|
||||||
|
expect(isDirty(indexingPolicy, { ...indexingPolicy, automatic: false })).toBeTruthy();
|
||||||
|
expect(isDirty(indexingPolicy, { ...indexingPolicy, includedPaths: ["/path"] })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles array type policies", () => {
|
||||||
|
const computedProperties: DataModels.ComputedProperties = [
|
||||||
|
{ name: "prop1", query: "SELECT * FROM c" },
|
||||||
|
{ name: "prop2", query: "SELECT * FROM c" },
|
||||||
|
];
|
||||||
|
const otherProperties: DataModels.ComputedProperties = [{ name: "prop1", query: "SELECT * FROM c" }];
|
||||||
|
expect(isDirty(computedProperties, computedProperties)).toBeFalsy();
|
||||||
|
expect(isDirty(computedProperties, otherProperties)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("type mismatch handling", () => {
|
||||||
|
it("throws error for mismatched types", () => {
|
||||||
|
expect(() => isDirty("string", 123)).toThrow("current and baseline values are not of the same type");
|
||||||
|
expect(() => isDirty(true, "true")).toThrow("current and baseline values are not of the same type");
|
||||||
|
expect(() => isDirty(0, false)).toThrow("current and baseline values are not of the same type");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSanitizedInputValue", () => {
|
||||||
const max = 100;
|
const max = 100;
|
||||||
|
|
||||||
|
it("handles empty or invalid inputs", () => {
|
||||||
expect(getSanitizedInputValue("", max)).toEqual(0);
|
expect(getSanitizedInputValue("", max)).toEqual(0);
|
||||||
expect(getSanitizedInputValue("999", max)).toEqual(100);
|
expect(getSanitizedInputValue("abc", max)).toEqual(0);
|
||||||
|
expect(getSanitizedInputValue("!@#", max)).toEqual(0);
|
||||||
|
expect(getSanitizedInputValue(null, max)).toEqual(0);
|
||||||
|
expect(getSanitizedInputValue(undefined, max)).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles valid inputs within max", () => {
|
||||||
expect(getSanitizedInputValue("10", max)).toEqual(10);
|
expect(getSanitizedInputValue("10", max)).toEqual(10);
|
||||||
|
expect(getSanitizedInputValue("50", max)).toEqual(50);
|
||||||
|
expect(getSanitizedInputValue("100", max)).toEqual(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getMongoIndexType", () => {
|
it("handles inputs exceeding max", () => {
|
||||||
|
expect(getSanitizedInputValue("101", max)).toEqual(100);
|
||||||
|
expect(getSanitizedInputValue("999", max)).toEqual(100);
|
||||||
|
expect(getSanitizedInputValue("1000000", max)).toEqual(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles inputs without max constraint", () => {
|
||||||
|
expect(getSanitizedInputValue("10")).toEqual(10);
|
||||||
|
expect(getSanitizedInputValue("1000")).toEqual(1000);
|
||||||
|
expect(getSanitizedInputValue("999999")).toEqual(999999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles negative numbers", () => {
|
||||||
|
expect(getSanitizedInputValue("-10", max)).toEqual(-10);
|
||||||
|
expect(getSanitizedInputValue("-999", max)).toEqual(-999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMongoIndexType", () => {
|
||||||
|
it("correctly identifies single field indexes", () => {
|
||||||
expect(getMongoIndexType(["Single"])).toEqual(MongoIndexTypes.Single);
|
expect(getMongoIndexType(["Single"])).toEqual(MongoIndexTypes.Single);
|
||||||
expect(getMongoIndexType(["Wildcard.$**"])).toEqual(MongoIndexTypes.Wildcard);
|
expect(getMongoIndexType(["field1"])).toEqual(MongoIndexTypes.Single);
|
||||||
expect(getMongoIndexType(["Key1", "Key2"])).toEqual(undefined);
|
expect(getMongoIndexType(["name"])).toEqual(MongoIndexTypes.Single);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getMongoIndexTypeText", () => {
|
it("correctly identifies wildcard indexes", () => {
|
||||||
|
expect(getMongoIndexType(["Wildcard.$**"])).toEqual(MongoIndexTypes.Wildcard);
|
||||||
|
expect(getMongoIndexType(["field.$**"])).toEqual(MongoIndexTypes.Wildcard);
|
||||||
|
expect(getMongoIndexType(["nested.path.$**"])).toEqual(MongoIndexTypes.Wildcard);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for invalid or compound indexes", () => {
|
||||||
|
expect(getMongoIndexType(["Key1", "Key2"])).toBeUndefined();
|
||||||
|
expect(getMongoIndexType([])).toBeUndefined();
|
||||||
|
expect(getMongoIndexType(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMongoIndexTypeText", () => {
|
||||||
|
it("returns correct text for single field indexes", () => {
|
||||||
expect(getMongoIndexTypeText(MongoIndexTypes.Single)).toEqual(SingleFieldText);
|
expect(getMongoIndexTypeText(MongoIndexTypes.Single)).toEqual(SingleFieldText);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct text for wildcard indexes", () => {
|
||||||
expect(getMongoIndexTypeText(MongoIndexTypes.Wildcard)).toEqual(WildcardText);
|
expect(getMongoIndexTypeText(MongoIndexTypes.Wildcard)).toEqual(WildcardText);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("getMongoNotification", () => {
|
describe("getMongoNotification", () => {
|
||||||
const singleIndexDescription = "sampleKey";
|
const singleIndexDescription = "sampleKey";
|
||||||
const wildcardIndexDescription = "sampleKey.$**";
|
const wildcardIndexDescription = "sampleKey.$**";
|
||||||
|
|
||||||
let notification = getMongoNotification(singleIndexDescription, undefined);
|
describe("type validation", () => {
|
||||||
|
it("returns warning when type is missing", () => {
|
||||||
|
const notification = getMongoNotification(singleIndexDescription, undefined);
|
||||||
expect(notification.message).toEqual("Please select a type for each index.");
|
expect(notification.message).toEqual("Please select a type for each index.");
|
||||||
expect(notification.type).toEqual(MongoNotificationType.Warning);
|
expect(notification.type).toEqual(MongoNotificationType.Warning);
|
||||||
|
});
|
||||||
|
|
||||||
notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Single);
|
it("returns undefined for valid type and description combinations", () => {
|
||||||
expect(notification).toEqual(undefined);
|
expect(getMongoNotification(singleIndexDescription, MongoIndexTypes.Single)).toBeUndefined();
|
||||||
|
expect(getMongoNotification(wildcardIndexDescription, MongoIndexTypes.Wildcard)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
notification = getMongoNotification(wildcardIndexDescription, MongoIndexTypes.Wildcard);
|
describe("field name validation", () => {
|
||||||
expect(notification).toEqual(undefined);
|
it("returns error when field name is empty", () => {
|
||||||
|
const notification = getMongoNotification("", MongoIndexTypes.Single);
|
||||||
notification = getMongoNotification("", MongoIndexTypes.Single);
|
|
||||||
expect(notification.message).toEqual("Please enter a field name.");
|
expect(notification.message).toEqual("Please enter a field name.");
|
||||||
expect(notification.type).toEqual(MongoNotificationType.Error);
|
expect(notification.type).toEqual(MongoNotificationType.Error);
|
||||||
|
|
||||||
notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Wildcard);
|
const whitespaceNotification = getMongoNotification(" ", MongoIndexTypes.Single);
|
||||||
|
expect(whitespaceNotification.message).toEqual("Please enter a field name.");
|
||||||
|
expect(whitespaceNotification.type).toEqual(MongoNotificationType.Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when wildcard index is missing $** pattern", () => {
|
||||||
|
const notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Wildcard);
|
||||||
expect(notification.message).toEqual(
|
expect(notification.message).toEqual(
|
||||||
"Wildcard path is not present in the field name. Use a pattern like " + MongoWildcardPlaceHolder,
|
"Wildcard path is not present in the field name. Use a pattern like " + MongoWildcardPlaceHolder,
|
||||||
);
|
);
|
||||||
expect(notification.type).toEqual(MongoNotificationType.Error);
|
expect(notification.type).toEqual(MongoNotificationType.Error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isIndexingTransforming", () => {
|
it("handles undefined field name", () => {
|
||||||
|
const notification = getMongoNotification(undefined, MongoIndexTypes.Single);
|
||||||
|
expect(notification.message).toEqual("Please enter a field name.");
|
||||||
|
expect(notification.type).toEqual(MongoNotificationType.Error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("isIndexingTransforming", () => {
|
||||||
expect(isIndexTransforming(undefined)).toBeFalsy();
|
expect(isIndexTransforming(undefined)).toBeFalsy();
|
||||||
expect(isIndexTransforming(0)).toBeTruthy();
|
expect(isIndexTransforming(0)).toBeTruthy();
|
||||||
expect(isIndexTransforming(90)).toBeTruthy();
|
expect(isIndexTransforming(90)).toBeTruthy();
|
||||||
expect(isIndexTransforming(100)).toBeFalsy();
|
expect(isIndexTransforming(100)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTabTitle", () => {
|
||||||
|
it("returns correct titles for each tab type", () => {
|
||||||
|
expect(getTabTitle(SettingsV2TabTypes.ScaleTab)).toBe("Scale");
|
||||||
|
expect(getTabTitle(SettingsV2TabTypes.ConflictResolutionTab)).toBe("Conflict Resolution");
|
||||||
|
expect(getTabTitle(SettingsV2TabTypes.SubSettingsTab)).toBe("Settings");
|
||||||
|
expect(getTabTitle(SettingsV2TabTypes.IndexingPolicyTab)).toBe("Indexing Policy");
|
||||||
|
expect(getTabTitle(SettingsV2TabTypes.ComputedPropertiesTab)).toBe("Computed Properties");
|
||||||
|
expect(getTabTitle(SettingsV2TabTypes.ContainerVectorPolicyTab)).toBe("Container Policies");
|
||||||
|
expect(getTabTitle(SettingsV2TabTypes.ThroughputBucketsTab)).toBe("Throughput Buckets");
|
||||||
|
expect(getTabTitle(SettingsV2TabTypes.DataMaskingTab)).toBe("Masking Policy (preview)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles partition key tab title based on fabric native", () => {
|
||||||
|
// Assuming initially not fabric native
|
||||||
|
expect(getTabTitle(SettingsV2TabTypes.PartitionKeyTab)).toBe("Partition Keys (preview)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error for unknown tab type", () => {
|
||||||
|
expect(() => getTabTitle(999 as SettingsV2TabTypes)).toThrow("Unknown tab 999");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("partition key utils", () => {
|
||||||
|
it("getPartitionKeyName returns correct name based on API type", () => {
|
||||||
|
expect(getPartitionKeyName("Mongo")).toBe("Shard key");
|
||||||
|
expect(getPartitionKeyName("SQL")).toBe("Partition key");
|
||||||
|
expect(getPartitionKeyName("Mongo", true)).toBe("shard key");
|
||||||
|
expect(getPartitionKeyName("SQL", true)).toBe("partition key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getPartitionKeyTooltipText returns correct tooltip based on API type", () => {
|
||||||
|
const mongoTooltip = getPartitionKeyTooltipText("Mongo");
|
||||||
|
expect(mongoTooltip).toContain("shard key");
|
||||||
|
expect(mongoTooltip).toContain("replica sets");
|
||||||
|
|
||||||
|
const sqlTooltip = getPartitionKeyTooltipText("SQL");
|
||||||
|
expect(sqlTooltip).toContain("partition key");
|
||||||
|
expect(sqlTooltip).toContain("id is often a good choice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getPartitionKeySubtext returns correct subtext", () => {
|
||||||
|
expect(getPartitionKeySubtext(true, "SQL")).toBe(
|
||||||
|
"For small workloads, the item ID is a suitable choice for the partition key.",
|
||||||
|
);
|
||||||
|
expect(getPartitionKeySubtext(true, "Mongo")).toBe(
|
||||||
|
"For small workloads, the item ID is a suitable choice for the partition key.",
|
||||||
|
);
|
||||||
|
expect(getPartitionKeySubtext(false, "SQL")).toBe("");
|
||||||
|
expect(getPartitionKeySubtext(true, "Other")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getPartitionKeyPlaceHolder returns correct placeholder based on API type", () => {
|
||||||
|
expect(getPartitionKeyPlaceHolder("Mongo")).toBe("e.g., categoryId");
|
||||||
|
expect(getPartitionKeyPlaceHolder("Gremlin")).toBe("e.g., /address");
|
||||||
|
expect(getPartitionKeyPlaceHolder("SQL")).toBe("Required - first partition key e.g., /TenantId");
|
||||||
|
expect(getPartitionKeyPlaceHolder("SQL", 0)).toBe("second partition key e.g., /UserId");
|
||||||
|
expect(getPartitionKeyPlaceHolder("SQL", 1)).toBe("third partition key e.g., /SessionId");
|
||||||
|
expect(getPartitionKeyPlaceHolder("Other")).toBe("e.g., /address/zipCode");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export type isDirtyTypes =
|
|||||||
| DataModels.ComputedProperties
|
| DataModels.ComputedProperties
|
||||||
| DataModels.VectorEmbedding[]
|
| DataModels.VectorEmbedding[]
|
||||||
| DataModels.FullTextPolicy
|
| DataModels.FullTextPolicy
|
||||||
| DataModels.ThroughputBucket[];
|
| DataModels.ThroughputBucket[]
|
||||||
|
| DataModels.DataMaskingPolicy;
|
||||||
export const TtlOff = "off";
|
export const TtlOff = "off";
|
||||||
export const TtlOn = "on";
|
export const TtlOn = "on";
|
||||||
export const TtlOnNoDefault = "on-nodefault";
|
export const TtlOnNoDefault = "on-nodefault";
|
||||||
@@ -59,6 +60,7 @@ export enum SettingsV2TabTypes {
|
|||||||
ContainerVectorPolicyTab,
|
ContainerVectorPolicyTab,
|
||||||
ThroughputBucketsTab,
|
ThroughputBucketsTab,
|
||||||
GlobalSecondaryIndexTab,
|
GlobalSecondaryIndexTab,
|
||||||
|
DataMaskingTab,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ContainerPolicyTabTypes {
|
export enum ContainerPolicyTabTypes {
|
||||||
@@ -175,6 +177,8 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => {
|
|||||||
return "Throughput Buckets";
|
return "Throughput Buckets";
|
||||||
case SettingsV2TabTypes.GlobalSecondaryIndexTab:
|
case SettingsV2TabTypes.GlobalSecondaryIndexTab:
|
||||||
return "Global Secondary Index (Preview)";
|
return "Global Secondary Index (Preview)";
|
||||||
|
case SettingsV2TabTypes.DataMaskingTab:
|
||||||
|
return "Masking Policy (preview)";
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown tab ${tab}`);
|
throw new Error(`Unknown tab ${tab}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export const collection = {
|
|||||||
sourceCollectionId: "source1",
|
sourceCollectionId: "source1",
|
||||||
sourceCollectionRid: "rid123",
|
sourceCollectionRid: "rid123",
|
||||||
}),
|
}),
|
||||||
|
dataMaskingPolicy: ko.observable<DataModels.DataMaskingPolicy>({
|
||||||
|
includedPaths: [],
|
||||||
|
excludedPaths: ["/excludedPath"],
|
||||||
|
policyFormatVersion: 2,
|
||||||
|
isPolicyEnabled: true,
|
||||||
|
}),
|
||||||
readSettings: () => {
|
readSettings: () => {
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ exports[`SettingsComponent renders 1`] = `
|
|||||||
"parameters": [Function],
|
"parameters": [Function],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"dataMaskingPolicy": [Function],
|
||||||
"databaseId": "test",
|
"databaseId": "test",
|
||||||
"defaultTtl": [Function],
|
"defaultTtl": [Function],
|
||||||
"fullTextPolicy": [Function],
|
"fullTextPolicy": [Function],
|
||||||
@@ -155,6 +156,7 @@ exports[`SettingsComponent renders 1`] = `
|
|||||||
"parameters": [Function],
|
"parameters": [Function],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"dataMaskingPolicy": [Function],
|
||||||
"databaseId": "test",
|
"databaseId": "test",
|
||||||
"defaultTtl": [Function],
|
"defaultTtl": [Function],
|
||||||
"fullTextPolicy": [Function],
|
"fullTextPolicy": [Function],
|
||||||
@@ -330,6 +332,7 @@ exports[`SettingsComponent renders 1`] = `
|
|||||||
"parameters": [Function],
|
"parameters": [Function],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"dataMaskingPolicy": [Function],
|
||||||
"databaseId": "test",
|
"databaseId": "test",
|
||||||
"defaultTtl": [Function],
|
"defaultTtl": [Function],
|
||||||
"fullTextPolicy": [Function],
|
"fullTextPolicy": [Function],
|
||||||
@@ -480,6 +483,7 @@ exports[`SettingsComponent renders 1`] = `
|
|||||||
"parameters": [Function],
|
"parameters": [Function],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"dataMaskingPolicy": [Function],
|
||||||
"databaseId": "test",
|
"databaseId": "test",
|
||||||
"defaultTtl": [Function],
|
"defaultTtl": [Function],
|
||||||
"fullTextPolicy": [Function],
|
"fullTextPolicy": [Function],
|
||||||
|
|||||||
@@ -14,17 +14,30 @@ describe("Collection", () => {
|
|||||||
defaultTtl: 1,
|
defaultTtl: 1,
|
||||||
indexingPolicy: {} as DataModels.IndexingPolicy,
|
indexingPolicy: {} as DataModels.IndexingPolicy,
|
||||||
partitionKey,
|
partitionKey,
|
||||||
_rid: "",
|
_rid: "testRid",
|
||||||
_self: "",
|
_self: "testSelf",
|
||||||
_etag: "",
|
_etag: "testEtag",
|
||||||
_ts: 1,
|
_ts: 1,
|
||||||
id: "",
|
id: "testCollection",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateMockCollectionWithDataModel = (data: DataModels.Collection): Collection => {
|
const generateMockCollectionWithDataModel = (data: DataModels.Collection): Collection => {
|
||||||
const mockContainer = {} as Explorer;
|
const mockContainer = {
|
||||||
return generateCollection(mockContainer, "abc", data);
|
isReadOnly: () => false,
|
||||||
|
isFabricCapable: () => true,
|
||||||
|
databaseAccount: () => ({
|
||||||
|
name: () => "testAccount",
|
||||||
|
id: () => "testAccount",
|
||||||
|
properties: {
|
||||||
|
enablePartitionKey: true,
|
||||||
|
partitionKeyDefinitionVersion: 2,
|
||||||
|
capabilities: [] as string[],
|
||||||
|
databaseAccountEndpoint: "test.documents.azure.com",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as unknown as Explorer;
|
||||||
|
return generateCollection(mockContainer, "testDb", data);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Partition key path parsing", () => {
|
describe("Partition key path parsing", () => {
|
||||||
@@ -78,7 +91,7 @@ describe("Collection", () => {
|
|||||||
expect(collection.partitionKeyPropertyHeaders[0]).toBe("/somePartitionKey");
|
expect(collection.partitionKeyPropertyHeaders[0]).toBe("/somePartitionKey");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be null if there is no partition key", () => {
|
it("should be empty if there is no partition key", () => {
|
||||||
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||||
version: 2,
|
version: 2,
|
||||||
paths: [],
|
paths: [],
|
||||||
@@ -88,4 +101,103 @@ describe("Collection", () => {
|
|||||||
expect(collection.partitionKeyPropertyHeaders.length).toBe(0);
|
expect(collection.partitionKeyPropertyHeaders.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Collection properties", () => {
|
||||||
|
let collection: Collection;
|
||||||
|
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||||
|
paths: ["/id"],
|
||||||
|
kind: "Hash",
|
||||||
|
version: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct collection id", () => {
|
||||||
|
expect(collection.id()).toBe("testCollection");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct rid", () => {
|
||||||
|
expect(collection.rid).toBe("testRid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct self", () => {
|
||||||
|
expect(collection.self).toBe("testSelf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct collection type", () => {
|
||||||
|
expect(collection.partitionKey).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Collection type", () => {
|
||||||
|
it("should identify large partitioned collection for v2 partition key", () => {
|
||||||
|
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||||
|
paths: ["/id"],
|
||||||
|
kind: "Hash",
|
||||||
|
version: 2,
|
||||||
|
});
|
||||||
|
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
||||||
|
expect(collection.partitionKey.version).toBe(2);
|
||||||
|
expect(collection.partitionKey.paths).toEqual(["/id"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should identify standard partitioned collection for v1 partition key", () => {
|
||||||
|
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||||
|
paths: ["/id"],
|
||||||
|
kind: "Hash",
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
||||||
|
expect(collection.partitionKey.version).toBe(1);
|
||||||
|
expect(collection.partitionKey.paths).toEqual(["/id"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should identify collection without partition key", () => {
|
||||||
|
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||||
|
paths: [],
|
||||||
|
kind: "Hash",
|
||||||
|
version: 2,
|
||||||
|
});
|
||||||
|
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
||||||
|
expect(collection.partitionKey.paths).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Partition key handling", () => {
|
||||||
|
it("should return correct partition key paths for multiple paths", () => {
|
||||||
|
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||||
|
paths: ["/id", "/pk"],
|
||||||
|
kind: "Hash",
|
||||||
|
version: 2,
|
||||||
|
});
|
||||||
|
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
||||||
|
expect(collection.partitionKey.paths).toEqual(["/id", "/pk"]);
|
||||||
|
expect(collection.partitionKeyProperties).toEqual(["id", "pk"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty partition key paths", () => {
|
||||||
|
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
|
||||||
|
paths: [],
|
||||||
|
kind: "Hash",
|
||||||
|
version: 2,
|
||||||
|
});
|
||||||
|
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
|
||||||
|
expect(collection.partitionKey.paths).toEqual([]);
|
||||||
|
expect(collection.partitionKeyProperties).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle undefined partition key", () => {
|
||||||
|
const collectionData = generateMockCollectionsDataModelWithPartitionKey({
|
||||||
|
paths: ["/id"],
|
||||||
|
kind: "Hash",
|
||||||
|
version: 2,
|
||||||
|
});
|
||||||
|
delete collectionData.partitionKey;
|
||||||
|
const collection = generateMockCollectionWithDataModel(collectionData);
|
||||||
|
expect(collection.partitionKey).toBeUndefined();
|
||||||
|
expect(collection.partitionKeyProperties).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export default class Collection implements ViewModels.Collection {
|
|||||||
public computedProperties: ko.Observable<DataModels.ComputedProperties>;
|
public computedProperties: ko.Observable<DataModels.ComputedProperties>;
|
||||||
public materializedViews: ko.Observable<DataModels.MaterializedView[]>;
|
public materializedViews: ko.Observable<DataModels.MaterializedView[]>;
|
||||||
public materializedViewDefinition: ko.Observable<DataModels.MaterializedViewDefinition>;
|
public materializedViewDefinition: ko.Observable<DataModels.MaterializedViewDefinition>;
|
||||||
|
public dataMaskingPolicy: ko.Observable<DataModels.DataMaskingPolicy>;
|
||||||
|
|
||||||
public offer: ko.Observable<DataModels.Offer>;
|
public offer: ko.Observable<DataModels.Offer>;
|
||||||
public conflictResolutionPolicy: ko.Observable<DataModels.ConflictResolutionPolicy>;
|
public conflictResolutionPolicy: ko.Observable<DataModels.ConflictResolutionPolicy>;
|
||||||
@@ -136,8 +137,19 @@ export default class Collection implements ViewModels.Collection {
|
|||||||
this.materializedViews = ko.observable(data.materializedViews);
|
this.materializedViews = ko.observable(data.materializedViews);
|
||||||
this.materializedViewDefinition = ko.observable(data.materializedViewDefinition);
|
this.materializedViewDefinition = ko.observable(data.materializedViewDefinition);
|
||||||
|
|
||||||
this.partitionKeyPropertyHeaders = this.partitionKey?.paths;
|
// Initialize dataMaskingPolicy with default values if not present
|
||||||
this.partitionKeyProperties = this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => {
|
const defaultDataMaskingPolicy: DataModels.DataMaskingPolicy = {
|
||||||
|
includedPaths: Array<{ path: string; strategy: string; startPosition: number; length: number }>(),
|
||||||
|
excludedPaths: Array<string>(),
|
||||||
|
policyFormatVersion: 2,
|
||||||
|
isPolicyEnabled: false,
|
||||||
|
};
|
||||||
|
const observablePolicy = ko.observable(data.dataMaskingPolicy || defaultDataMaskingPolicy);
|
||||||
|
observablePolicy.subscribe(() => {});
|
||||||
|
this.dataMaskingPolicy = observablePolicy;
|
||||||
|
this.partitionKeyPropertyHeaders = this.partitionKey?.paths || [];
|
||||||
|
this.partitionKeyProperties =
|
||||||
|
this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => {
|
||||||
// TODO fix this to only replace non-excaped single quotes
|
// TODO fix this to only replace non-excaped single quotes
|
||||||
let partitionKeyProperty = partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, "");
|
let partitionKeyProperty = partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, "");
|
||||||
|
|
||||||
@@ -154,7 +166,7 @@ export default class Collection implements ViewModels.Collection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return partitionKeyProperty;
|
return partitionKeyProperty;
|
||||||
});
|
}) || [];
|
||||||
|
|
||||||
this.documentIds = ko.observableArray<DocumentId>([]);
|
this.documentIds = ko.observableArray<DocumentId>([]);
|
||||||
this.isCollectionExpanded = ko.observable<boolean>(false);
|
this.isCollectionExpanded = ko.observable<boolean>(false);
|
||||||
@@ -163,7 +175,6 @@ export default class Collection implements ViewModels.Collection {
|
|||||||
|
|
||||||
this.documentsFocused = ko.observable<boolean>();
|
this.documentsFocused = ko.observable<boolean>();
|
||||||
this.documentsFocused.subscribe((focus) => {
|
this.documentsFocused.subscribe((focus) => {
|
||||||
console.log("Focus set on Documents: " + focus);
|
|
||||||
this.focusedSubnodeKind(ViewModels.CollectionTabKind.Documents);
|
this.focusedSubnodeKind(ViewModels.CollectionTabKind.Documents);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user