mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-30 14:22:05 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user