Migrate DocumentsTab to React and add bulk delete and column resize (#1770)

* Document page now loads list of docs and displays selection

* DocumentsTabV2 now properly loads documents, show partition keys and display first doc with proper selection behavior. Move it to its own folder.

* Extract table in a separate component

* Resizable columns on the document table

* Fix selection behavior and some layout issue

* Adding table scrolling

* Fix NaN height issue

* Fix NaN height issue

* Fix column sizing + cell selection

* Improvement in width size. Add Load More

* Add react editor and pass column headers

* Dynamic columns for pk

* Fix initial columns size

* Add nav buttons

* Editing content updates buttons state

* Discard and save buttons working

* Fix save new document. Implement delete.

* Remove debug display

* Fix unexpand filter and reformat

* Fix compil issues

* Add refresh button

* Update column header placeholder style

* Implement delete multiple docs

* Fix multi delete

* Fix show/hide delete button

* Fix selection behavior

* Fix UX with buttons behavior and editor display

* Fix UX issue with not discarding edit changes

* Add some TODO's

* Remove debugging info and reformat

* Add mongo support

* Fix build issues

* Fix table header. Remove debug statement

* Restore broken nosql

* Fix mongo save new document/update document

* Fix bugs with clicking on newly created documents

* Fix comment

* Fix double fetch issue when clicking on an item

* Auto-select last document when saving new document

* Fix resourceTokenPartitionKey code

* Fix format

* Fix isQueryCopilotSampleContainer flag

* Fix unused code

* Call tab when updating error flag

* Destructure props to make useEffect dependencies work

* Fix loadStartKey

* minor update

* Fix format

* Add title to table

* Fix table coming off its container with unwanted horizontal scrollbar

* Increase table width. Fix eslint issue.

* Move refresh documents button from table back to DocumentsTabV2

* Fix load more text centering

* Don't show Load More if nothing to show

* Fix columns min width

* Add keyboard shortcuts

* Add keyboard handlers to load more and refresh button

* Add keyboard support to select table row

* Disable eslint issue from fluent library

* Connect cancel query button

* Add Fluent V9 theme for Fabric (#1821)

* Clean up dependencies and memoize internal functions and object. Move methods and object that don't depend on state outside of component.

* Fix filter disappearing when clicking Apply Filter

* Fix typo and format

* Implement bulk delete for nosql

* Replace filter ui components with fluent ui

* Remove jquery calls

* Migrate unit test to DocumentsTabV2

* Remove DocumentsTab and MongoDocumentsTab. Fix build issues.

* Properly handle activetab

* Remove comments and unused code

* Port keyboard shortcuts from commitId 1f4d0f2

* Port item editor shortcuts to improved Items tab branch (#1831)

* set filter focus on Ctrl+Shift+F

* implement filter enter/esc keybinds

* remove debugging

* Collapse filter when query is executed

* Fix monaco editor not happy when parent is null

* Fix how bulk delete operation gets called when no partition key

* Fix update id list after delete

* Fix deleteDocuments

* Fix build issue

* Fix bug in mongo delete

* Fix mongo delete flow

* Proper error handling in mongo

* Handle >100 bulk delete operations

* Add unit tests for DocumentsTableComponent

* More improvements to table unit tests

* Fix import. Disable selection test for now

* Add more DocumentsTab unit react tests

* Remove selection test

* Add more unit tests. Add lcov coverage report to display in vscode

* Move unit tests to correct file

* Add unit test on command bar

* Fix build issues

* Add more unit tests

* Remove unneeded call

* Add DocumentsTab for Mongo API

* Fix linting errors

* Update fluent ui v9 dependency. Color columns separation. Fix refresh button placement to not interfere with header cell width.

* Revert @fluentui/react-components to a safe version that compiles

* Add confirmation window when documents have been deleted

* Fix mongo unit tests

* Fix format

* Update src/Common/dataAccess/deleteDocument.ts

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>

* Update src/Common/dataAccess/deleteDocument.ts

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>

* Update src/Common/dataAccess/deleteDocument.ts

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>

* Fix bug with markup. Simplify code.

* Protect against creating React editor without parent node

* Replace rendering tests with snapshot match

* Add test screenshot to troubleshoot e2e test

* Revert "Add test screenshot to troubleshoot e2e test"

This reverts commit 1b8138ade0.

* Attempt 2 at troubleshooting failing test

* Revert "Attempt 2 at troubleshooting failing test"

This reverts commit 3e51a593bf.

* Delete button now shows if one or more rows are selected

---------

Co-authored-by: Vsevolod Kukol <sevoku@microsoft.com>
Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>
This commit is contained in:
Laurent Nguyen
2024-05-29 09:09:13 +02:00
committed by GitHub
parent 19d1e0d1df
commit 36736882ee
31 changed files with 5143 additions and 2114 deletions

View File

@@ -0,0 +1,476 @@
import { FeedResponse, ItemDefinition, Resource } from "@azure/cosmos";
import { deleteDocuments } from "Common/dataAccess/deleteDocument";
import { Platform, updateConfigContext } from "ConfigContext";
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import {
ButtonsDependencies,
DELETE_BUTTON_ID,
DISCARD_BUTTON_ID,
DocumentsTabComponent,
IDocumentsTabComponentProps,
NEW_DOCUMENT_BUTTON_ID,
SAVE_BUTTON_ID,
UPDATE_BUTTON_ID,
UPLOAD_BUTTON_ID,
buildQuery,
getDiscardExistingDocumentChangesButtonState,
getDiscardNewDocumentChangesButtonState,
getSaveExistingDocumentButtonState,
getSaveNewDocumentButtonState,
getTabsButtons,
showPartitionKey,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import { ReactWrapper, ShallowWrapper, mount, shallow } from "enzyme";
import * as ko from "knockout";
import React from "react";
import { act } from "react-dom/test-utils";
import { DatabaseAccount, DocumentId } from "../../../Contracts/DataModels";
import * as ViewModels from "../../../Contracts/ViewModels";
import { updateUserContext } from "../../../UserContext";
import Explorer from "../../Explorer";
jest.mock("Common/dataAccess/queryDocuments", () => ({
queryDocuments: jest.fn(() => ({
// Omit headers, because we can't mock a private field and we don't need to test it
fetchNext: (): Promise<Omit<FeedResponse<ItemDefinition & Resource>, "headers">> =>
Promise.resolve({
resources: [{ id: "id", _rid: "rid", _self: "self", _etag: "etag", _ts: 123 }],
hasMoreResults: false,
diagnostics: undefined,
continuation: undefined,
continuationToken: undefined,
queryMetrics: "queryMetrics",
requestCharge: 1,
activityId: "activityId",
indexMetrics: "indexMetrics",
}),
})),
}));
const PROPERTY_VALUE = "__SOME_PROPERTY_VALUE__";
jest.mock("Common/dataAccess/readDocument", () => ({
readDocument: jest.fn(() =>
Promise.resolve({
container: undefined,
id: "id",
property: PROPERTY_VALUE,
}),
),
}));
jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
EditorReact: (props: EditorReactProps) => <>{props.content}</>,
}));
jest.mock("Explorer/Controls/Dialog", () => ({
useDialog: {
getState: jest.fn(() => ({
showOkCancelModalDialog: (title: string, subText: string, okLabel: string, onOk: () => void) => onOk(),
showOkModalDialog: () => {},
})),
},
}));
jest.mock("Common/dataAccess/deleteDocument", () => ({
deleteDocuments: jest.fn((collection: ViewModels.CollectionBase, documentIds: DocumentId[]) =>
Promise.resolve(documentIds),
),
}));
async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | ShallowWrapper<P>, amount = 0) {
let newWrapper;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, amount));
newWrapper = wrapper.update();
});
return newWrapper;
}
describe("Documents tab (noSql API)", () => {
describe("buildQuery", () => {
it("should generate the right select query for SQL API", () => {
expect(buildQuery(false, "")).toContain("select");
});
});
describe("showPartitionKey", () => {
const explorer = new Explorer();
const mongoExplorer = new Explorer();
updateUserContext({
databaseAccount: {
properties: {
capabilities: [{ name: "EnableGremlin" }],
},
} as DatabaseAccount,
});
const collectionWithoutPartitionKey: ViewModels.Collection = {
id: ko.observable<string>("foo"),
databaseId: "foo",
container: explorer,
} as ViewModels.Collection;
const collectionWithSystemPartitionKey: ViewModels.Collection = {
id: ko.observable<string>("foo"),
databaseId: "foo",
partitionKey: {
paths: ["/foo"],
kind: "Hash",
version: 2,
systemKey: true,
},
container: explorer,
} as ViewModels.Collection;
const collectionWithNonSystemPartitionKey: ViewModels.Collection = {
id: ko.observable<string>("foo"),
databaseId: "foo",
partitionKey: {
paths: ["/foo"],
kind: "Hash",
version: 2,
systemKey: false,
},
container: explorer,
} as ViewModels.Collection;
const mongoCollectionWithSystemPartitionKey: ViewModels.Collection = {
id: ko.observable<string>("foo"),
databaseId: "foo",
partitionKey: {
paths: ["/foo"],
kind: "Hash",
version: 2,
systemKey: true,
},
container: mongoExplorer,
} as ViewModels.Collection;
it("should be false for null or undefined collection", () => {
expect(showPartitionKey(undefined, false)).toBe(false);
expect(showPartitionKey(null, false)).toBe(false);
expect(showPartitionKey(undefined, true)).toBe(false);
expect(showPartitionKey(null, true)).toBe(false);
});
it("should be false for null or undefined partitionKey", () => {
expect(showPartitionKey(collectionWithoutPartitionKey, false)).toBe(false);
});
it("should be true for non-Mongo accounts with system partitionKey", () => {
expect(showPartitionKey(collectionWithSystemPartitionKey, false)).toBe(true);
});
it("should be false for Mongo accounts with system partitionKey", () => {
expect(showPartitionKey(mongoCollectionWithSystemPartitionKey, true)).toBe(false);
});
it("should be true for non-system partitionKey", () => {
expect(showPartitionKey(collectionWithNonSystemPartitionKey, false)).toBe(true);
});
});
describe("when getting command bar button state", () => {
describe("should set Save New Document state", () => {
const testCases = new Set<{ state: ViewModels.DocumentExplorerState; enabled: boolean; visible: boolean }>();
testCases.add({ state: ViewModels.DocumentExplorerState.noDocumentSelected, enabled: false, visible: false });
testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentValid, enabled: true, visible: true });
testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentInvalid, enabled: false, visible: true });
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentNoEdits,
enabled: false,
visible: false,
});
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid,
enabled: false,
visible: false,
});
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid,
enabled: false,
visible: false,
});
testCases.forEach((testCase) => {
const state = getSaveNewDocumentButtonState(testCase.state);
it(`enable for ${testCase.state}`, () => {
expect(state.enabled).toBe(testCase.enabled);
});
it(`visible for ${testCase.state}`, () => {
expect(state.visible).toBe(testCase.visible);
});
});
});
describe("should set Discard New Document state", () => {
const testCases = new Set<{ state: ViewModels.DocumentExplorerState; enabled: boolean; visible: boolean }>();
testCases.add({ state: ViewModels.DocumentExplorerState.noDocumentSelected, enabled: false, visible: false });
testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentValid, enabled: true, visible: true });
testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentInvalid, enabled: true, visible: true });
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentNoEdits,
enabled: false,
visible: false,
});
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid,
enabled: false,
visible: false,
});
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid,
enabled: false,
visible: false,
});
testCases.forEach((testCase) => {
const state = getDiscardNewDocumentChangesButtonState(testCase.state);
it(`enable for ${testCase.state}`, () => {
expect(state.enabled).toBe(testCase.enabled);
});
it(`visible for ${testCase.state}`, () => {
expect(state.visible).toBe(testCase.visible);
});
});
});
describe("should set Save Existing Document state", () => {
const testCases = new Set<{ state: ViewModels.DocumentExplorerState; enabled: boolean; visible: boolean }>();
testCases.add({ state: ViewModels.DocumentExplorerState.noDocumentSelected, enabled: false, visible: false });
testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentValid, enabled: false, visible: false });
testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentInvalid, enabled: false, visible: false });
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentNoEdits,
enabled: false,
visible: true,
});
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid,
enabled: true,
visible: true,
});
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid,
enabled: false,
visible: true,
});
testCases.forEach((testCase) => {
const state = getSaveExistingDocumentButtonState(testCase.state);
it(`enable for ${testCase.state}`, () => {
expect(state.enabled).toBe(testCase.enabled);
});
it(`visible for ${testCase.state}`, () => {
expect(state.visible).toBe(testCase.visible);
});
});
});
describe("should set Discard Existing Document state", () => {
const testCases = new Set<{ state: ViewModels.DocumentExplorerState; enabled: boolean; visible: boolean }>();
testCases.add({ state: ViewModels.DocumentExplorerState.noDocumentSelected, enabled: false, visible: false });
testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentValid, enabled: false, visible: false });
testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentInvalid, enabled: false, visible: false });
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentNoEdits,
enabled: false,
visible: true,
});
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid,
enabled: true,
visible: true,
});
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid,
enabled: true,
visible: true,
});
testCases.forEach((testCase) => {
const state = getDiscardExistingDocumentChangesButtonState(testCase.state);
it(`enable for ${testCase.state}`, () => {
expect(state.enabled).toBe(testCase.enabled);
});
it(`visible for ${testCase.state}`, () => {
expect(state.visible).toBe(testCase.visible);
});
});
});
describe("should set Delete Existing Document state", () => {
const testCases = new Set<{ state: ViewModels.DocumentExplorerState; enabled: boolean; visible: boolean }>();
testCases.add({ state: ViewModels.DocumentExplorerState.noDocumentSelected, enabled: false, visible: false });
testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentValid, enabled: false, visible: false });
testCases.add({ state: ViewModels.DocumentExplorerState.newDocumentInvalid, enabled: false, visible: false });
testCases.add({ state: ViewModels.DocumentExplorerState.exisitingDocumentNoEdits, enabled: true, visible: true });
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid,
enabled: true,
visible: true,
});
testCases.add({
state: ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid,
enabled: true,
visible: true,
});
});
});
it("Do not get tabs button for Fabric readonly", () => {
updateConfigContext({ platform: Platform.Fabric });
updateUserContext({
fabricContext: {
connectionId: "test",
databaseConnectionInfo: undefined,
isReadOnly: true,
isVisible: true,
},
});
const buttons = getTabsButtons({} as ButtonsDependencies);
expect(buttons.length).toBe(0);
});
describe("when rendered", () => {
const createMockProps = (): IDocumentsTabComponentProps => ({
isPreferredApiMongoDB: false,
documentIds: [],
collection: undefined,
partitionKey: undefined,
onLoadStartKey: 0,
tabTitle: "",
onExecutionErrorChange: (isExecutionError: boolean): void => {
isExecutionError;
},
onIsExecutingChange: (isExecuting: boolean): void => {
isExecuting;
},
isTabActive: true,
});
let wrapper: ShallowWrapper;
beforeEach(async () => {
const props: IDocumentsTabComponentProps = createMockProps();
wrapper = shallow(<DocumentsTabComponent {...props} />);
});
afterEach(() => {
wrapper.unmount();
});
it("should render the page", () => {
expect(wrapper).toMatchSnapshot();
});
it("clicking on Edit filter should render the Apply Filter button", () => {
wrapper
.findWhere((node) => node.text() === "Edit Filter")
.at(0)
.simulate("click");
expect(wrapper.findWhere((node) => node.text() === "Apply Filter").exists()).toBeTruthy();
});
it("clicking on Edit filter should render input for filter", () => {
wrapper
.findWhere((node) => node.text() === "Edit Filter")
.at(0)
.simulate("click");
expect(wrapper.find("#filterInput").exists()).toBeTruthy();
});
});
describe("Command bar buttons", () => {
const createMockProps = (): IDocumentsTabComponentProps => ({
isPreferredApiMongoDB: false,
documentIds: [],
collection: {
id: ko.observable<string>("foo"),
container: new Explorer(),
partitionKey: {
kind: "MultiHash",
paths: ["/pkey1", "/pkey2", "/pkey3"],
version: 2,
},
partitionKeyProperties: ["pkey1", "pkey2", "pkey3"],
partitionKeyPropertyHeaders: ["/pkey1", "/pkey2", "/pkey3"],
} as ViewModels.CollectionBase,
partitionKey: undefined,
onLoadStartKey: 0,
tabTitle: "",
onExecutionErrorChange: (isExecutionError: boolean): void => {
isExecutionError;
},
onIsExecutingChange: (isExecuting: boolean): void => {
isExecuting;
},
isTabActive: true,
});
let wrapper: ReactWrapper;
beforeEach(async () => {
updateConfigContext({ platform: Platform.Hosted });
const props: IDocumentsTabComponentProps = createMockProps();
wrapper = mount(<DocumentsTabComponent {...props} />);
wrapper = await waitForComponentToPaint(wrapper);
});
afterEach(() => {
wrapper.unmount();
});
it("renders by default the first document", async () => {
expect(wrapper.findWhere((node) => node.text().includes(PROPERTY_VALUE)).exists()).toBeTruthy();
});
it("default buttons", async () => {
expect(useCommandBar.getState().contextButtons.find((button) => button.id === UPDATE_BUTTON_ID)).toBeDefined();
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined();
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DELETE_BUTTON_ID)).toBeDefined();
expect(useCommandBar.getState().contextButtons.find((button) => button.id === UPLOAD_BUTTON_ID)).toBeDefined();
});
it("clicking on New Document should show editor with new document", () => {
act(() => {
useCommandBar
.getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID)
.onCommandClick(undefined);
});
expect(wrapper.findWhere((node) => node.text().includes("replace_with_new_document_id")).exists()).toBeTruthy();
});
it("clicking on New Document should show Save and Discard buttons", () => {
act(() => {
useCommandBar
.getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID)
.onCommandClick(undefined);
});
expect(useCommandBar.getState().contextButtons.find((button) => button.id === SAVE_BUTTON_ID)).toBeDefined();
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined();
});
it("clicking Delete Document asks for confirmation", () => {
const mockDeleteDocuments = deleteDocuments as jest.Mock;
mockDeleteDocuments.mockClear();
act(() => {
useCommandBar
.getState()
.contextButtons.find((button) => button.id === DELETE_BUTTON_ID)
.onCommandClick(undefined);
});
expect(mockDeleteDocuments).toHaveBeenCalled();
});
});
});