diff --git a/less/forms.less b/less/forms.less index ba771a108..e453a82b7 100644 --- a/less/forms.less +++ b/less/forms.less @@ -200,4 +200,7 @@ .migration:disabled { background-color: #ccc; +} +.react-editor { + height: 400px; } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4c95dc071..7ecf322c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5650,6 +5650,15 @@ "redux": "^4.0.0" } }, + "@types/react-splitter-layout": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/react-splitter-layout/-/react-splitter-layout-3.0.1.tgz", + "integrity": "sha512-NsKq32LdG11G/Uj+xo2QmC9S8YSe8JRtxkBhsBE7ODFs0zcnzNEqFAQirP0H7rPe2WFGiu+d/44xbHsew7QAJw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-table": { "version": "6.8.7", "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-6.8.7.tgz", @@ -21635,6 +21644,11 @@ "react-is": "^16.9.0" } }, + "react-splitter-layout": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/react-splitter-layout/-/react-splitter-layout-4.0.0.tgz", + "integrity": "sha512-SLqOjBOxRuizWUa83w6q5/u9cDWa9/yj9Iko9V9JFN8x+cqIXiDlUFWSx+icz3IIgvsN/oRIw3za5/32RjIwrA==" + }, "react-syntax-highlighter": { "version": "12.2.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-12.2.1.tgz", diff --git a/package.json b/package.json index 811475c19..afaaed826 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "react-i18next": "11.8.5", "react-notification-system": "0.2.17", "react-redux": "7.1.3", + "react-splitter-layout": "4.0.0", "redux": "4.0.4", "reflect-metadata": "0.1.13", "rx-jupyter": "5.5.12", @@ -123,6 +124,7 @@ "@types/react-dom": "17.0.3", "@types/react-notification-system": "0.2.39", "@types/react-redux": "7.1.7", + "@types/react-splitter-layout": "3.0.1", "@types/sanitize-html": "1.27.2", "@types/sinon": "2.3.3", "@types/styled-components": "5.1.1", diff --git a/src/Common/QueriesClient.ts b/src/Common/QueriesClient.ts index dfb31a2f6..63ecd8dc1 100644 --- a/src/Common/QueriesClient.ts +++ b/src/Common/QueriesClient.ts @@ -2,7 +2,7 @@ import * as _ from "underscore"; import * as DataModels from "../Contracts/DataModels"; import * as ViewModels from "../Contracts/ViewModels"; import Explorer from "../Explorer/Explorer"; -import DocumentsTab from "../Explorer/Tabs/DocumentsTab"; +import DocumentsTab from "../Explorer/Tabs/DocumentsTab1"; import DocumentId from "../Explorer/Tree/DocumentId"; import { userContext } from "../UserContext"; import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils"; diff --git a/src/Explorer/Tabs/DocumentsTab.test.ts b/src/Explorer/Tabs/DocumentsTab.test.ts index bd7018000..0c331494f 100644 --- a/src/Explorer/Tabs/DocumentsTab.test.ts +++ b/src/Explorer/Tabs/DocumentsTab.test.ts @@ -4,7 +4,7 @@ import * as ViewModels from "../../Contracts/ViewModels"; import { updateUserContext } from "../../UserContext"; import Explorer from "../Explorer"; import DocumentId from "../Tree/DocumentId"; -import DocumentsTab from "./DocumentsTab"; +import DocumentsTab from "./DocumentsTab1"; describe("Documents tab", () => { describe("buildQuery", () => { diff --git a/src/Explorer/Tabs/DocumentsTab1.tsx b/src/Explorer/Tabs/DocumentsTab1.tsx new file mode 100644 index 000000000..4de8424e1 --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTab1.tsx @@ -0,0 +1,932 @@ +import { extractPartitionKey, ItemDefinition, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos"; +import * as ko from "knockout"; +import Q from "q"; +import React from "react"; +import 'react-splitter-layout/lib/index.css'; +import DeleteDocumentIcon from "../../../images/DeleteDocument.svg"; +import DiscardIcon from "../../../images/discard.svg"; +import NewDocumentIcon from "../../../images/NewDocument.svg"; +import SaveIcon from "../../../images/save-cosmos.svg"; +import UploadIcon from "../../../images/Upload_16x16.svg"; +import * as Constants from "../../Common/Constants"; +import { DocumentsGridMetrics, KeyCodes } from "../../Common/Constants"; +import { createDocument } from "../../Common/dataAccess/createDocument"; +import { deleteDocument } from "../../Common/dataAccess/deleteDocument"; +import { queryDocuments } from "../../Common/dataAccess/queryDocuments"; +import { readDocument } from "../../Common/dataAccess/readDocument"; +import { updateDocument } from "../../Common/dataAccess/updateDocument"; +import editable from "../../Common/EditableUtility"; +import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; +import * as HeadersUtility from "../../Common/HeadersUtility"; +import { Splitter, SplitterBounds, SplitterDirection } from "../../Common/Splitter"; +import * as DataModels from "../../Contracts/DataModels"; +import * as ViewModels from "../../Contracts/ViewModels"; +import { Action } from "../../Shared/Telemetry/TelemetryConstants"; +import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; +import { userContext } from "../../UserContext"; +import { logConsoleError } from "../../Utils/NotificationConsoleUtils"; +import * as QueryUtils from "../../Utils/QueryUtils"; +import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent"; +import Explorer from "../Explorer"; +import { AccessibleVerticalList } from "../Tree/AccessibleVerticalList"; +import DocumentId from "../Tree/DocumentId"; +import DocumentsTabContent from "./DocumentsTabContent"; +// import template from "./DocumentsTab.html"; +import TabsBase from "./TabsBase"; + +export default class DocumentsTab extends TabsBase { + // public readonly html = template; + public selectedDocumentId: ko.Observable; + public selectedDocumentContent: ViewModels.Editable; + public initialDocumentContent: ko.Observable; + public documentContentsGridId: string; + public documentContentsContainerId: string; + public filterContent: ko.Observable; + public appliedFilter: ko.Observable; + public lastFilterContents: ko.ObservableArray; + public isFilterExpanded: ko.Observable; + public isFilterCreated: ko.Observable; + public applyFilterButton: ViewModels.Button; + public isEditorDirty: ko.Computed; + public editorState: ko.Observable; + public newDocumentButton: ViewModels.Button; + public saveNewDocumentButton: ViewModels.Button; + public saveExisitingDocumentButton: ViewModels.Button; + public discardNewDocumentChangesButton: ViewModels.Button; + public discardExisitingDocumentChangesButton: ViewModels.Button; + public deleteExisitingDocumentButton: ViewModels.Button; + public displayedError: ko.Observable; + public accessibleDocumentList: AccessibleVerticalList; + public dataContentsGridScrollHeight: ko.Observable; + public isPreferredApiMongoDB: boolean; + public shouldShowEditor: ko.Computed; + public splitter: Splitter; + public showPartitionKey: boolean; + public idHeader: string; + + // TODO need to refactor + public partitionKey: DataModels.PartitionKey; + public partitionKeyPropertyHeader: string; + public partitionKeyProperty: string; + public documentIds: ko.ObservableArray; + + private _documentsIterator: QueryIterator; + private _resourceTokenPartitionKey: string; + + constructor(options: ViewModels.DocumentsTabOptions) { + super(options); + this.isPreferredApiMongoDB = userContext.apiType === "Mongo" || options.isPreferredApiMongoDB; + + this.idHeader = this.isPreferredApiMongoDB ? "_id" : "id"; + + this.documentContentsGridId = `documentContentsGrid${this.tabId}`; + this.documentContentsContainerId = `documentContentsContainer${this.tabId}`; + this.editorState = ko.observable( + ViewModels.DocumentExplorerState.noDocumentSelected + ); + this.selectedDocumentId = ko.observable(); + this.selectedDocumentContent = editable.observable(""); + this.initialDocumentContent = ko.observable(""); + this.partitionKey = options.partitionKey || (this.collection && this.collection.partitionKey); + this._resourceTokenPartitionKey = options.resourceTokenPartitionKey; + this.documentIds = options.documentIds; + + this.partitionKeyPropertyHeader = + (this.collection && this.collection.partitionKeyPropertyHeader) || this._getPartitionKeyPropertyHeader(); + this.partitionKeyProperty = this.partitionKeyPropertyHeader + ? this.partitionKeyPropertyHeader.replace(/[/]+/g, ".").substr(1).replace(/[']+/g, "") + : null; + + this.isFilterExpanded = ko.observable(false); + this.isFilterCreated = ko.observable(true); + this.filterContent = ko.observable(""); + this.appliedFilter = ko.observable(""); + this.displayedError = ko.observable(""); + this.lastFilterContents = ko.observableArray([ + 'WHERE c.id = "foo"', + "ORDER BY c._ts DESC", + 'WHERE c.id = "foo" ORDER BY c._ts DESC', + ]); + + this.dataContentsGridScrollHeight = ko.observable(null); + + // initialize splitter only after template has been loaded so dom elements are accessible + super.onTemplateReady((isTemplateReady: boolean) => { + if (isTemplateReady) { + const tabContainer: HTMLElement = document.getElementById("content"); + const splitterBounds: SplitterBounds = { + min: Constants.DocumentsGridMetrics.DocumentEditorMinWidthRatio * tabContainer.clientWidth, + max: Constants.DocumentsGridMetrics.DocumentEditorMaxWidthRatio * tabContainer.clientWidth, + }; + this.splitter = new Splitter({ + splitterId: "h_splitter2", + leftId: this.documentContentsContainerId, + bounds: splitterBounds, + direction: SplitterDirection.Vertical, + }); + } + }); + + this.accessibleDocumentList = new AccessibleVerticalList(this.documentIds()); + this.accessibleDocumentList.setOnSelect( + (selectedDocument: DocumentId) => selectedDocument && selectedDocument.click() + ); + this.selectedDocumentId.subscribe((newSelectedDocumentId: DocumentId) => + this.accessibleDocumentList.updateCurrentItem(newSelectedDocumentId) + ); + this.documentIds.subscribe((newDocuments: DocumentId[]) => { + this.accessibleDocumentList.updateItemList(newDocuments); + if (newDocuments.length > 0) { + this.dataContentsGridScrollHeight( + newDocuments.length * DocumentsGridMetrics.IndividualRowHeight + DocumentsGridMetrics.BufferHeight + "px" + ); + } else { + this.dataContentsGridScrollHeight( + DocumentsGridMetrics.IndividualRowHeight + DocumentsGridMetrics.BufferHeight + "px" + ); + } + }); + + this.isEditorDirty = ko.computed(() => { + switch (this.editorState()) { + case ViewModels.DocumentExplorerState.noDocumentSelected: + case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: + return false; + + case ViewModels.DocumentExplorerState.newDocumentValid: + case ViewModels.DocumentExplorerState.newDocumentInvalid: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: + return true; + + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: + return ( + this.selectedDocumentContent.getEditableOriginalValue() !== + this.selectedDocumentContent.getEditableCurrentValue() + ); + + default: + return false; + } + }); + + this.newDocumentButton = { + enabled: ko.computed(() => { + switch (this.editorState()) { + case ViewModels.DocumentExplorerState.noDocumentSelected: + case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: + return true; + } + + return false; + }), + + visible: ko.computed(() => { + return true; + }), + }; + + this.saveNewDocumentButton = { + enabled: ko.computed(() => { + switch (this.editorState()) { + case ViewModels.DocumentExplorerState.newDocumentValid: + return true; + } + + return false; + }), + + visible: ko.computed(() => { + switch (this.editorState()) { + case ViewModels.DocumentExplorerState.newDocumentValid: + case ViewModels.DocumentExplorerState.newDocumentInvalid: + return true; + } + + return false; + }), + }; + + this.discardNewDocumentChangesButton = { + enabled: ko.computed(() => { + switch (this.editorState()) { + case ViewModels.DocumentExplorerState.newDocumentValid: + case ViewModels.DocumentExplorerState.newDocumentInvalid: + return true; + } + + return false; + }), + + visible: ko.computed(() => { + switch (this.editorState()) { + case ViewModels.DocumentExplorerState.newDocumentValid: + case ViewModels.DocumentExplorerState.newDocumentInvalid: + return true; + } + + return false; + }), + }; + + this.saveExisitingDocumentButton = { + enabled: ko.computed(() => { + switch (this.editorState()) { + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: + return true; + } + + return false; + }), + + visible: ko.computed(() => { + switch (this.editorState()) { + case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: + return true; + } + + return false; + }), + }; + + this.discardExisitingDocumentChangesButton = { + enabled: ko.computed(() => { + switch (this.editorState()) { + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: + return true; + } + + return false; + }), + + visible: ko.computed(() => { + switch (this.editorState()) { + case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: + return true; + } + + return false; + }), + }; + + this.deleteExisitingDocumentButton = { + enabled: ko.computed(() => { + switch (this.editorState()) { + case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: + return true; + } + + return false; + }), + + visible: ko.computed(() => { + switch (this.editorState()) { + case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: + case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: + return true; + } + + return false; + }), + }; + + this.applyFilterButton = { + enabled: ko.computed(() => { + return true; + }), + + visible: ko.computed(() => { + return true; + }), + }; + this.buildCommandBarOptions(); + this.shouldShowEditor = ko.computed(() => { + const documentHasContent: boolean = + this.selectedDocumentContent() != null && this.selectedDocumentContent().length > 0; + const isNewDocument: boolean = + this.editorState() === ViewModels.DocumentExplorerState.newDocumentValid || + this.editorState() === ViewModels.DocumentExplorerState.newDocumentInvalid; + + return documentHasContent || isNewDocument; + }); + this.selectedDocumentContent.subscribe((newContent: string) => this._onEditorContentChange(newContent)); + + this.showPartitionKey = this._shouldShowPartitionKey(); + } + + private _shouldShowPartitionKey(): boolean { + if (!this.collection) { + return false; + } + + if (!this.collection.partitionKey) { + return false; + } + + if (this.collection.partitionKey.systemKey && this.isPreferredApiMongoDB) { + return false; + } + + return true; + } + + public onShowFilterClick(): Q.Promise { + this.isFilterCreated(true); + this.isFilterExpanded(true); + + $(".filterDocExpanded").addClass("active"); + $("#content").addClass("active"); + $(".querydropdown").focus(); + + return Q(); + } + + public onHideFilterClick(): Q.Promise { + this.isFilterExpanded(false); + + $(".filterDocExpanded").removeClass("active"); + $("#content").removeClass("active"); + $(".queryButton").focus(); + return Q(); + } + + public onCloseButtonKeyDown = (source: any, event: KeyboardEvent): boolean => { + if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) { + this.onHideFilterClick(); + event.stopPropagation(); + return false; + } + return true; + }; + + public async refreshDocumentsGrid(): Promise { + // clear documents grid + this.documentIds([]); + + try { + // reset iterator + this._documentsIterator = this.createIterator(); + // load documents + await this.loadNextPage(); + // collapse filter + this.appliedFilter(this.filterContent()); + this.isFilterExpanded(false); + document.getElementById("errorStatusIcon")?.focus(); + } catch (error) { + window.alert(getErrorMessage(error)); + } + } + + public onRefreshButtonKeyDown = (source: any, event: KeyboardEvent): boolean => { + if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) { + this.refreshDocumentsGrid(); + event.stopPropagation(); + return false; + } + return true; + }; + + public onDocumentIdClick(clickedDocumentId: DocumentId): Q.Promise { + if (this.editorState() !== ViewModels.DocumentExplorerState.noDocumentSelected) { + return Q(); + } + + this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); + + return Q(); + } + + public onNewDocumentClick = (): Q.Promise => { + if (this.isEditorDirty() && !this._isIgnoreDirtyEditor()) { + return Q(); + } + this.selectedDocumentId(null); + + const defaultDocument: string = this.renderObjectForEditor({ id: "replace_with_new_document_id" }, null, 4); + this.initialDocumentContent(defaultDocument); + this.selectedDocumentContent.setBaseline(defaultDocument); + this.editorState(ViewModels.DocumentExplorerState.newDocumentValid); + + return Q(); + }; + + public onSaveNewDocumentClick = (): Promise => { + this.isExecutionError(false); + const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, { + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.tabTitle(), + }); + const document = JSON.parse(this.selectedDocumentContent()); + this.isExecuting(true); + return createDocument(this.collection, document) + .then( + (savedDocument: any) => { + const value: string = this.renderObjectForEditor(savedDocument || {}, null, 4); + this.selectedDocumentContent.setBaseline(value); + this.initialDocumentContent(value); + const partitionKeyValueArray = extractPartitionKey( + savedDocument, + this.partitionKey as PartitionKeyDefinition + ); + const partitionKeyValue = partitionKeyValueArray && partitionKeyValueArray[0]; + const id = new DocumentId(this, savedDocument, partitionKeyValue); + const ids = this.documentIds(); + ids.push(id); + + this.selectedDocumentId(id); + this.documentIds(ids); + this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); + TelemetryProcessor.traceSuccess( + Action.CreateDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.tabTitle(), + }, + startKey + ); + }, + (error) => { + this.isExecutionError(true); + const errorMessage = getErrorMessage(error); + window.alert(errorMessage); + TelemetryProcessor.traceFailure( + Action.CreateDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.tabTitle(), + error: errorMessage, + errorStack: getErrorStack(error), + }, + startKey + ); + } + ) + .finally(() => this.isExecuting(false)); + }; + + public onRevertNewDocumentClick = (): Q.Promise => { + this.initialDocumentContent(""); + this.selectedDocumentContent(""); + this.editorState(ViewModels.DocumentExplorerState.noDocumentSelected); + + return Q(); + }; + + public onSaveExisitingDocumentClick = (): Promise => { + const selectedDocumentId = this.selectedDocumentId(); + const documentContent = JSON.parse(this.selectedDocumentContent()); + + const partitionKeyValueArray = extractPartitionKey(documentContent, this.partitionKey as PartitionKeyDefinition); + const partitionKeyValue = partitionKeyValueArray && partitionKeyValueArray[0]; + + selectedDocumentId.partitionKeyValue = partitionKeyValue; + + this.isExecutionError(false); + const startKey: number = TelemetryProcessor.traceStart(Action.UpdateDocument, { + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.tabTitle(), + }); + this.isExecuting(true); + return updateDocument(this.collection, selectedDocumentId, documentContent) + .then( + (updatedDocument: any) => { + const value: string = this.renderObjectForEditor(updatedDocument || {}, null, 4); + this.selectedDocumentContent.setBaseline(value); + this.initialDocumentContent(value); + this.documentIds().forEach((documentId: DocumentId) => { + if (documentId.rid === updatedDocument._rid) { + documentId.id(updatedDocument.id); + } + }); + this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); + TelemetryProcessor.traceSuccess( + Action.UpdateDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.tabTitle(), + }, + startKey + ); + }, + (error) => { + this.isExecutionError(true); + const errorMessage = getErrorMessage(error); + window.alert(errorMessage); + TelemetryProcessor.traceFailure( + Action.UpdateDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.tabTitle(), + error: errorMessage, + errorStack: getErrorStack(error), + }, + startKey + ); + } + ) + .finally(() => this.isExecuting(false)); + }; + + public onRevertExisitingDocumentClick = (): Q.Promise => { + this.selectedDocumentContent.setBaseline(this.initialDocumentContent()); + this.initialDocumentContent.valueHasMutated(); + this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); + + return Q(); + }; + + public onDeleteExisitingDocumentClick = async (): Promise => { + const selectedDocumentId = this.selectedDocumentId(); + const msg = !this.isPreferredApiMongoDB + ? "Are you sure you want to delete the selected item ?" + : "Are you sure you want to delete the selected document ?"; + + if (window.confirm(msg)) { + await this._deleteDocument(selectedDocumentId); + } + }; + + public onValidDocumentEdit(): Q.Promise { + if ( + this.editorState() === ViewModels.DocumentExplorerState.newDocumentInvalid || + this.editorState() === ViewModels.DocumentExplorerState.newDocumentValid + ) { + this.editorState(ViewModels.DocumentExplorerState.newDocumentValid); + return Q(); + } + + this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid); + return Q(); + } + + public onInvalidDocumentEdit(): Q.Promise { + if ( + this.editorState() === ViewModels.DocumentExplorerState.newDocumentInvalid || + this.editorState() === ViewModels.DocumentExplorerState.newDocumentValid + ) { + this.editorState(ViewModels.DocumentExplorerState.newDocumentInvalid); + return Q(); + } + + if ( + this.editorState() === ViewModels.DocumentExplorerState.exisitingDocumentNoEdits || + this.editorState() === ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid + ) { + this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid); + return Q(); + } + + return Q(); + } + + public onTabClick(): void { + super.onTabClick(); + this.collection && this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Documents); + } + + public async onActivate(): Promise { + super.onActivate(); + + if (!this._documentsIterator) { + try { + this._documentsIterator = this.createIterator(); + await this.loadNextPage(); + } catch (error) { + if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) { + TelemetryProcessor.traceFailure( + Action.Tab, + { + databaseName: this.collection.databaseId, + collectionName: this.collection.id(), + + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.tabTitle(), + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + this.onLoadStartKey + ); + this.onLoadStartKey = null; + } + } + } + } + + private _isIgnoreDirtyEditor = (): boolean => { + const msg = "Changes will be lost. Do you want to continue?"; + return window.confirm(msg); + }; + + protected __deleteDocument(documentId: DocumentId): Promise { + return deleteDocument(this.collection, documentId); + } + + private _deleteDocument(selectedDocumentId: DocumentId): Promise { + this.isExecutionError(false); + const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocument, { + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.tabTitle(), + }); + this.isExecuting(true); + return this.__deleteDocument(selectedDocumentId) + .then( + () => { + this.documentIds.remove((documentId: DocumentId) => documentId.rid === selectedDocumentId.rid); + this.selectedDocumentContent(""); + this.selectedDocumentId(null); + this.editorState(ViewModels.DocumentExplorerState.noDocumentSelected); + TelemetryProcessor.traceSuccess( + Action.DeleteDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.tabTitle(), + }, + startKey + ); + }, + (error) => { + this.isExecutionError(true); + console.error(error); + TelemetryProcessor.traceFailure( + Action.DeleteDocument, + { + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.tabTitle(), + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey + ); + } + ) + .finally(() => this.isExecuting(false)); + } + + public createIterator(): QueryIterator { + const filters = this.lastFilterContents(); + const filter: string = this.filterContent().trim(); + const query: string = this.buildQuery(filter); + const options: any = {}; + options.enableCrossPartitionQuery = HeadersUtility.shouldEnableCrossPartitionKey(); + + if (this._resourceTokenPartitionKey) { + options.partitionKey = this._resourceTokenPartitionKey; + } + + return queryDocuments(this.collection.databaseId, this.collection.id(), query, options); + } + + public async selectDocument(documentId: DocumentId): Promise { + this.selectedDocumentId(documentId); + const content = await readDocument(this.collection, documentId); + this.initDocumentEditor(documentId, content); + } + + public loadNextPage(): Q.Promise { + this.isExecuting(true); + this.isExecutionError(false); + return this._loadNextPageInternal() + .then( + (documentsIdsResponse = []) => { + const currentDocuments = this.documentIds(); + const currentDocumentsRids = currentDocuments.map((currentDocument) => currentDocument.rid); + const nextDocumentIds = documentsIdsResponse + // filter documents already loaded in observable + .filter((d: any) => { + return currentDocumentsRids.indexOf(d._rid) < 0; + }) + // map raw response to view model + .map((rawDocument: any) => { + const partitionKeyValue = rawDocument._partitionKeyValue; + return new DocumentId(this, rawDocument, partitionKeyValue); + }); + + const merged = currentDocuments.concat(nextDocumentIds); + this.documentIds(merged); + if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) { + TelemetryProcessor.traceSuccess( + Action.Tab, + { + databaseName: this.collection.databaseId, + collectionName: this.collection.id(), + + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.tabTitle(), + }, + this.onLoadStartKey + ); + this.onLoadStartKey = null; + } + }, + (error) => { + this.isExecutionError(true); + const errorMessage = getErrorMessage(error); + logConsoleError(errorMessage); + if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) { + TelemetryProcessor.traceFailure( + Action.Tab, + { + databaseName: this.collection.databaseId, + collectionName: this.collection.id(), + + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.tabTitle(), + error: errorMessage, + errorStack: getErrorStack(error), + }, + this.onLoadStartKey + ); + this.onLoadStartKey = null; + } + } + ) + .finally(() => this.isExecuting(false)); + } + + public onLoadMoreKeyInput = (source: any, event: KeyboardEvent): void => { + if (event.key === " " || event.key === "Enter") { + const focusElement = document.getElementById(this.documentContentsGridId); + this.loadNextPage(); + focusElement && focusElement.focus(); + event.stopPropagation(); + event.preventDefault(); + } + }; + + protected _loadNextPageInternal(): Q.Promise { + return Q(this._documentsIterator.fetchNext().then((response) => response.resources)); + } + + protected _onEditorContentChange(newContent: string) { + try { + const parsed: any = JSON.parse(newContent); + this.onValidDocumentEdit(); + } catch (e) { + this.onInvalidDocumentEdit(); + } + } + + public initDocumentEditor(documentId: DocumentId, documentContent: any): Q.Promise { + if (documentId) { + const content: string = this.renderObjectForEditor(documentContent, null, 4); + this.selectedDocumentContent.setBaseline(content); + this.initialDocumentContent(content); + const newState = documentId + ? ViewModels.DocumentExplorerState.exisitingDocumentNoEdits + : ViewModels.DocumentExplorerState.newDocumentValid; + this.editorState(newState); + } + + return Q(); + } + + public buildQuery(filter: string): string { + return QueryUtils.buildDocumentsQuery(filter, this.partitionKeyProperty, this.partitionKey); + } + + protected getTabsButtons(): CommandButtonComponentProps[] { + const buttons: CommandButtonComponentProps[] = []; + const label = !this.isPreferredApiMongoDB ? "New Item" : "New Document"; + if (this.newDocumentButton.visible()) { + buttons.push({ + iconSrc: NewDocumentIcon, + iconAlt: label, + onCommandClick: this.onNewDocumentClick, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: false, + disabled: !this.newDocumentButton.enabled(), + }); + } + + if (this.saveNewDocumentButton.visible()) { + const label = "Save"; + buttons.push({ + iconSrc: SaveIcon, + iconAlt: label, + onCommandClick: this.onSaveNewDocumentClick, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: false, + disabled: !this.saveNewDocumentButton.enabled(), + }); + } + + if (this.discardNewDocumentChangesButton.visible()) { + const label = "Discard"; + buttons.push({ + iconSrc: DiscardIcon, + iconAlt: label, + onCommandClick: this.onRevertNewDocumentClick, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: false, + disabled: !this.discardNewDocumentChangesButton.enabled(), + }); + } + + if (this.saveExisitingDocumentButton.visible()) { + const label = "Update"; + buttons.push({ + iconSrc: SaveIcon, + iconAlt: label, + onCommandClick: this.onSaveExisitingDocumentClick, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: false, + disabled: !this.saveExisitingDocumentButton.enabled(), + }); + } + + if (this.discardExisitingDocumentChangesButton.visible()) { + const label = "Discard"; + buttons.push({ + iconSrc: DiscardIcon, + iconAlt: label, + onCommandClick: this.onRevertExisitingDocumentClick, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: false, + disabled: !this.discardExisitingDocumentChangesButton.enabled(), + }); + } + + if (this.deleteExisitingDocumentButton.visible()) { + const label = "Delete"; + buttons.push({ + iconSrc: DeleteDocumentIcon, + iconAlt: label, + onCommandClick: this.onDeleteExisitingDocumentClick, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: false, + disabled: !this.deleteExisitingDocumentButton.enabled(), + }); + } + + if (!this.isPreferredApiMongoDB) { + buttons.push(DocumentsTab._createUploadButton(this.collection.container)); + } + + return buttons; + } + + protected buildCommandBarOptions(): void { + ko.computed(() => + ko.toJSON([ + this.newDocumentButton.visible, + this.newDocumentButton.enabled, + this.saveNewDocumentButton.visible, + this.saveNewDocumentButton.enabled, + this.discardNewDocumentChangesButton.visible, + this.discardNewDocumentChangesButton.enabled, + this.saveExisitingDocumentButton.visible, + this.saveExisitingDocumentButton.enabled, + this.discardExisitingDocumentChangesButton.visible, + this.discardExisitingDocumentChangesButton.enabled, + this.deleteExisitingDocumentButton.visible, + this.deleteExisitingDocumentButton.enabled, + ]) + ).subscribe(() => this.updateNavbarWithTabsButtons()); + this.updateNavbarWithTabsButtons(); + } + + private _getPartitionKeyPropertyHeader(): string { + return ( + (this.partitionKey && + this.partitionKey.paths && + this.partitionKey.paths.length > 0 && + this.partitionKey.paths[0]) || + null + ); + } + + public static _createUploadButton(container: Explorer): CommandButtonComponentProps { + const label = "Upload Item"; + return { + iconSrc: UploadIcon, + iconAlt: label, + onCommandClick: () => { + const selectedCollection: ViewModels.Collection = container.findSelectedCollection(); + selectedCollection && container.openUploadItemsPanePane(); + }, + commandButtonLabel: label, + ariaLabel: label, + hasPopup: true, + disabled: container.isDatabaseNodeOrNoneSelected(), + }; + } + + render(): JSX.Element { + return ( + + ) + } +} diff --git a/src/Explorer/Tabs/DocumentsTabContent.tsx b/src/Explorer/Tabs/DocumentsTabContent.tsx new file mode 100644 index 000000000..7613a036b --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabContent.tsx @@ -0,0 +1,155 @@ +import { DetailsList, DetailsListLayoutMode, IColumn, Selection, SelectionMode } from '@fluentui/react/lib/DetailsList'; +import * as React from 'react'; +import SplitterLayout from 'react-splitter-layout'; +import { EditorReact } from "../Controls/Editor/EditorReact"; + + +export interface IDetailsListDocumentsExampleState { + columns: IColumn[]; + items: any; + selectionDetails: string; + isModalSelection: boolean; + isCompactMode: boolean; + announcedMessage?: string; +} + +export interface IDocument { + key: string; + name: string; + value: string; + iconName: string; + fileType: string; + modifiedBy: string; + dateModified: string; + dateModifiedValue: number; + fileSize: string; + fileSizeRaw: number; +} + +export default class DocumentsTabContent extends React.Component<{}, IDetailsListDocumentsExampleState> { + private _selection: Selection; + + constructor(props: {}) { + super(props); + + const columns: IColumn[] = [ + { + key: 'column4', + name: 'Modified By', + fieldName: 'modifiedBy', + minWidth: 70, + maxWidth: 90, + isResizable: true, + isCollapsible: true, + data: 'string', + onColumnClick: this._onColumnClick, + onRender: (item: IDocument) => { + return {item.modifiedBy}; + }, + isPadded: true, + }, + { + key: 'column5', + name: 'File Size', + fieldName: 'fileSizeRaw', + minWidth: 70, + maxWidth: 90, + isResizable: true, + isCollapsible: true, + data: 'number', + onColumnClick: this._onColumnClick, + onRender: (item: IDocument) => { + return {item.fileSize}; + }, + }, + ]; + + this._selection = new Selection({ + onSelectionChanged: () => { + this.setState({ + selectionDetails: this._getSelectionDetails(), + }); + }, + }); + + const items = [ + { + fileSize: "44 KB", + modifiedBy: "Dolor Sit", + }, + { + fileSize: "44 KB", + modifiedBy: "Dolor Sit", + } + ] + + this.state = { + items: items, + columns: columns, + selectionDetails: this._getSelectionDetails(), + isModalSelection: false, + isCompactMode: false, + announcedMessage: undefined, + }; + } + + public render(): JSX.Element { + const { columns, isCompactMode, items } = this.state; + + return ( +
+ + +
+ +
+
+
+ + ); + } + + private _getKey(item: any, index?: number): string { + return item.key; + } + + + private _onItemInvoked(item: any): void { + alert(`Item invoked: ${item.name}`); + } + + private _getSelectionDetails(): string { + const selectionCount = this._selection.getSelectedCount(); + + switch (selectionCount) { + case 0: + return 'No items selected'; + case 1: + return '1 item selected: ' + (this._selection.getSelection()[0] as IDocument).name; + default: + return `${selectionCount} items selected`; + } + } + + private _onColumnClick = (ev: React.MouseEvent, column: IColumn): void => { + console.log("====>", column) + } +} diff --git a/src/Explorer/Tabs/MongoDocumentsTab.ts b/src/Explorer/Tabs/MongoDocumentsTab.ts index f5b2f2959..477173d4b 100644 --- a/src/Explorer/Tabs/MongoDocumentsTab.ts +++ b/src/Explorer/Tabs/MongoDocumentsTab.ts @@ -9,7 +9,7 @@ import { deleteDocument, queryDocuments, readDocument, - updateDocument, + updateDocument } from "../../Common/MongoProxyClient"; import MongoUtility from "../../Common/MongoUtility"; import * as DataModels from "../../Contracts/DataModels"; @@ -18,7 +18,7 @@ import { Action } from "../../Shared/Telemetry/TelemetryConstants"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import DocumentId from "../Tree/DocumentId"; import ObjectId from "../Tree/ObjectId"; -import DocumentsTab from "./DocumentsTab"; +import DocumentsTab from "./DocumentsTab1"; export default class MongoDocumentsTab extends DocumentsTab { public collection: ViewModels.Collection; diff --git a/src/Explorer/Tabs/TabsManager.test.ts b/src/Explorer/Tabs/TabsManager.test.ts index 7f4509a2f..b959af8be 100644 --- a/src/Explorer/Tabs/TabsManager.test.ts +++ b/src/Explorer/Tabs/TabsManager.test.ts @@ -3,7 +3,7 @@ import * as ViewModels from "../../Contracts/ViewModels"; import { updateUserContext } from "../../UserContext"; import Explorer from "../Explorer"; import DocumentId from "../Tree/DocumentId"; -import DocumentsTab from "./DocumentsTab"; +import DocumentsTab from "./DocumentsTab1"; import QueryTab from "./QueryTab"; import { TabsManager } from "./TabsManager"; diff --git a/src/Explorer/Tree/Collection.ts b/src/Explorer/Tree/Collection.ts index 1731e18c6..c7aada5b8 100644 --- a/src/Explorer/Tree/Collection.ts +++ b/src/Explorer/Tree/Collection.ts @@ -25,7 +25,7 @@ import Explorer from "../Explorer"; import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter"; import { CassandraAPIDataClient, CassandraTableKey, CassandraTableKeys } from "../Tables/TableDataClient"; import ConflictsTab from "../Tabs/ConflictsTab"; -import DocumentsTab from "../Tabs/DocumentsTab"; +import DocumentsTab from "../Tabs/DocumentsTab1"; import GraphTab from "../Tabs/GraphTab"; import MongoDocumentsTab from "../Tabs/MongoDocumentsTab"; import MongoQueryTab from "../Tabs/MongoQueryTab"; diff --git a/src/Explorer/Tree/DocumentId.ts b/src/Explorer/Tree/DocumentId.ts index a30c0b534..6ba31447a 100644 --- a/src/Explorer/Tree/DocumentId.ts +++ b/src/Explorer/Tree/DocumentId.ts @@ -1,6 +1,6 @@ import * as ko from "knockout"; import * as DataModels from "../../Contracts/DataModels"; -import DocumentsTab from "../Tabs/DocumentsTab"; +import DocumentsTab from "../Tabs/DocumentsTab1"; export default class DocumentId { public container: DocumentsTab; diff --git a/src/Explorer/Tree/ObjectId.ts b/src/Explorer/Tree/ObjectId.ts index fc53a6a37..de2c9a2d6 100644 --- a/src/Explorer/Tree/ObjectId.ts +++ b/src/Explorer/Tree/ObjectId.ts @@ -1,6 +1,6 @@ import * as ko from "knockout"; +import DocumentsTab from "../Tabs/DocumentsTab1"; import DocumentId from "./DocumentId"; -import DocumentsTab from "../Tabs/DocumentsTab"; export default class ObjectId extends DocumentId { constructor(container: DocumentsTab, data: any, partitionKeyValue: any) { diff --git a/src/Explorer/Tree/ResourceTokenCollection.ts b/src/Explorer/Tree/ResourceTokenCollection.ts index 45155fe0e..64a888ae0 100644 --- a/src/Explorer/Tree/ResourceTokenCollection.ts +++ b/src/Explorer/Tree/ResourceTokenCollection.ts @@ -6,7 +6,7 @@ import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstan import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import { userContext } from "../../UserContext"; import Explorer from "../Explorer"; -import DocumentsTab from "../Tabs/DocumentsTab"; +import DocumentsTab from "../Tabs/DocumentsTab1"; import QueryTab from "../Tabs/QueryTab"; import TabsBase from "../Tabs/TabsBase"; import DocumentId from "./DocumentId";