diff --git a/package.json b/package.json index 8af052a64..d3cca1f9c 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@types/lodash": "4.14.171", "@types/mkdirp": "1.0.1", "@types/node-fetch": "2.5.7", + "@uiw/react-split": "5.9.3", "applicationinsights": "1.8.0", "bootstrap": "3.4.1", "canvas": "file:./canvas", diff --git a/src/Explorer/Tabs/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2.tsx new file mode 100644 index 000000000..3a3c00fe6 --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2.tsx @@ -0,0 +1,679 @@ +import { ItemDefinition, QueryIterator, Resource } from '@azure/cosmos'; +import { FluentProvider, Table, TableBody, TableCell, TableCellLayout, TableColumnDefinition, TableHeader, TableHeaderCell, TableRow, TableRowId, TableSelectionCell, createTableColumn, useTableFeatures, useTableSelection } from '@fluentui/react-components'; +import Split from '@uiw/react-split'; +import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants"; +import { getErrorMessage, getErrorStack } from 'Common/ErrorHandlingUtils'; +import { queryDocuments } from 'Common/dataAccess/queryDocuments'; +import { readDocument } from 'Common/dataAccess/readDocument'; +import { useDialog } from 'Explorer/Controls/Dialog'; +import { querySampleDocuments, readSampleDocument } from 'Explorer/QueryCopilot/QueryCopilotUtilities'; +import { dataExplorerLightTheme } from 'Explorer/Theme/ThemeUtil'; +import { QueryConstants } from 'Shared/Constants'; +import { LocalStorageUtility, StorageKey } from 'Shared/StorageUtility'; +import { Action } from 'Shared/Telemetry/TelemetryConstants'; +import { userContext } from "UserContext"; +import { logConsoleError } from 'Utils/NotificationConsoleUtils'; +import React, { KeyboardEventHandler, useEffect, useMemo, useState } from "react"; +import { format } from "react-string-format"; +import CloseIcon from "../../../images/close-black.svg"; +import * as Constants from "../../Common/Constants"; +import * as HeadersUtility from "../../Common/HeadersUtility"; +import * as DataModels from "../../Contracts/DataModels"; +import * as ViewModels from "../../Contracts/ViewModels"; +import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; +import * as QueryUtils from "../../Utils/QueryUtils"; +import DocumentId from "../Tree/DocumentId"; +import TabsBase from "./TabsBase"; + +type Item = { + id: string; + type: string; +}; + +export class DocumentsTabV2 extends TabsBase { + public partitionKey: DataModels.PartitionKey; + + private documentIds: DocumentId[]; + private title: string; + + constructor(options: ViewModels.DocumentsTabOptions) { + super(options); + + this.documentIds = options.documentIds(); + this.title = options.title; + } + + public render(): JSX.Element { + return ; + } + + public onActivate(): void { + super.onActivate(); + this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Documents); + } +} + +const DocumentsTabComponent: React.FunctionComponent<{ + isPreferredApiMongoDB: boolean; + documentIds: DocumentId[]; // TODO: this contains ko observables. We need to convert them to React state. + tabId: string; + collection: ViewModels.CollectionBase; + partitionKey: DataModels.PartitionKey; + onLoadStartKey: number; + tabTitle: string; +}> = (props) => { + const [isFilterCreated, setIsFilterCreated] = useState(true); + const [isFilterExpanded, setIsFilterExpanded] = useState(false); + const [appliedFilter, setAppliedFilter] = useState(""); + const [filterContent, setFilterContent] = useState(""); + const [lastFilterContents, setLastFilterContents] = useState([ + 'WHERE c.id = "foo"', + "ORDER BY c._ts DESC", + 'WHERE c.id = "foo" ORDER BY c._ts DESC', + ]); + const [documentIds, setDocumentIds] = useState([]); + const [isExecuting, setIsExecuting] = useState(false); // TODO isExecuting is a member of TabsBase. We may need to update this field. + const [dataContentsGridScrollHeight, setDataContentsGridScrollHeight] = useState(undefined); + const [shouldShowEditor, setShouldShowEditor] = useState(false); + + // Query + const [documentsIterator, setDocumentsIterator] = useState<{ + iterator: QueryIterator, + applyFilterButtonPressed: boolean + }>(undefined); + const [queryAbortController, setQueryAbortController] = useState(undefined); + const [resourceTokenPartitionKey, setResourceTokenPartitionKey] = useState(undefined); + const [isQueryCopilotSampleContainer, setIsQueryCopilotSampleContainer] = useState(false); + const [cancelQueryTimeoutID, setCancelQueryTimeoutID] = useState(undefined); + + const [isExecutionError, setIsExecutionError] = useState(false); + const [onLoadStartKey, setOnLoadStartKey] = useState(props.onLoadStartKey); + + // TODO remove this? + const applyFilterButton = { + enabled: true, + }; + + const documentContentsContainerId = `documentContentsContainer${props.tabId}`; + const documentContentsGridId = `documentContentsGrid${props.tabId}`; + + const partitionKey: DataModels.PartitionKey = props.partitionKey || (props.collection && props.collection.partitionKey); + const partitionKeyPropertyHeaders: string[] = props.collection?.partitionKeyPropertyHeaders || partitionKey?.paths; + const partitionKeyProperties = partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) => + partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""), + ); + + const isPreferredApiMongoDB = useMemo(() => userContext.apiType === "Mongo" || props.isPreferredApiMongoDB, + [props.isPreferredApiMongoDB]); + + useEffect(() => { + setDocumentIds(props.documentIds); + }, [props.documentIds]); + + // TODO: this is executed in onActivate() in the original code. + useEffect(() => { + if (!documentsIterator) { + try { + refreshDocumentsGrid(); + + + // // Select first document and load content + // if (documentIds.length > 0) { + // documentIds[0].click(); + // } + + } catch (error) { + if (onLoadStartKey !== null && onLoadStartKey !== undefined) { + TelemetryProcessor.traceFailure( + Action.Tab, + { + databaseName: props.collection.databaseId, + collectionName: props.collection.id(), + + dataExplorerArea: Constants.Areas.Tab, + tabTitle: props.tabTitle, + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + onLoadStartKey, + ); + setOnLoadStartKey(null); + } + } + } + }, []); + + useEffect(() => { + if (documentsIterator) { + loadNextPage(documentsIterator.applyFilterButtonPressed); + } + }, [documentsIterator]); + + const onShowFilterClick = () => { + setIsFilterCreated(true); + setIsFilterExpanded(true); + + // TODO convert this + $(".filterDocExpanded").addClass("active"); + $("#content").addClass("active"); + $(".querydropdown").focus(); + }; + + const queryTimeoutEnabled = (): boolean => + !isPreferredApiMongoDB && LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled); + + const buildQuery = (filter: string): string => { + return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey); + }; + + const createIterator = (): QueryIterator => { + const _queryAbortController = new AbortController(); + setQueryAbortController(_queryAbortController); + const filter: string = filterContent.trim(); + const query: string = buildQuery(filter); + const options: any = {}; + options.enableCrossPartitionQuery = HeadersUtility.shouldEnableCrossPartitionKey(); + + if (resourceTokenPartitionKey) { + options.partitionKey = resourceTokenPartitionKey; + } + options.abortSignal = _queryAbortController.signal; + return isQueryCopilotSampleContainer + ? querySampleDocuments(query, options) + : queryDocuments(props.collection.databaseId, props.collection.id(), query, options); + } + + /** + * Query first page of documents + * Select and query first document and display content + */ + // const autoPopulateContent = async (applyFilterButtonPressed?: boolean) => { + // // reset iterator + // setDocumentsIterator({ + // iterator: createIterator(), + // applyFilterButtonPressed, + // }); + // // load documents + // await loadNextPage(applyFilterButtonPressed); + + // // // Select first document and load content + // // if (documentIds.length > 0) { + // // documentIds[0].click(); + // // } + // }; + + const refreshDocumentsGrid = async (applyFilterButtonPressed?: boolean): Promise => { + // clear documents grid + setDocumentIds([]); + try { + // reset iterator + // setDocumentsIterator(createIterator()); + // load documents + // await autoPopulateContent(applyFilterButtonPressed); + setDocumentsIterator({ + iterator: createIterator(), + applyFilterButtonPressed, + }); + + + // collapse filter + setAppliedFilter(filterContent); + setIsFilterExpanded(false); + document.getElementById("errorStatusIcon")?.focus(); + } catch (error) { + useDialog.getState().showOkModalDialog("Refresh documents grid failed", getErrorMessage(error)); + } + }; + + const onHideFilterClick = (): Q.Promise => { + // this.isFilterExpanded(false); + + $(".filterDocExpanded").removeClass("active"); + $("#content").removeClass("active"); + $(".queryButton").focus(); + return Q(); + }; + + const onCloseButtonKeyDown: KeyboardEventHandler = (event) => { + if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) { + onHideFilterClick(); + event.stopPropagation(); + return false; + } + return true; + }; + + // const accessibleDocumentList = new AccessibleVerticalList(documentIds); + // accessibleDocumentList.setOnSelect( + // (selectedDocument: DocumentId) => selectedDocument && selectedDocument.click(), + // ); + // this.selectedDocumentId.subscribe((newSelectedDocumentId: DocumentId) => + // accessibleDocumentList.updateCurrentItem(newSelectedDocumentId), + // ); + // this.documentIds.subscribe((newDocuments: DocumentId[]) => { + // accessibleDocumentList.updateItemList(newDocuments); + // if (newDocuments.length > 0) { + // this.dataContentsGridScrollHeight( + // newDocuments.length * DocumentsGridMetrics.IndividualRowHeight + DocumentsGridMetrics.BufferHeight + "px", + // ); + // } else { + // this.dataContentsGridScrollHeight( + // DocumentsGridMetrics.IndividualRowHeight + DocumentsGridMetrics.BufferHeight + "px", + // ); + // } + // }); + + const onRefreshButtonKeyDown: KeyboardEventHandler = (event) => { + if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) { + refreshDocumentsGrid(); + event.stopPropagation(); + return false; + } + return true; + }; + + const loadNextPage = (applyFilterButtonClicked?: boolean): Promise => { + setIsExecuting(true); + setIsExecutionError(false); + let automaticallyCancelQueryAfterTimeout: boolean; + if (applyFilterButtonClicked && queryTimeoutEnabled()) { + const queryTimeout: number = LocalStorageUtility.getEntryNumber(StorageKey.QueryTimeout); + automaticallyCancelQueryAfterTimeout = LocalStorageUtility.getEntryBoolean( + StorageKey.AutomaticallyCancelQueryAfterTimeout, + ); + const cancelQueryTimeoutID: NodeJS.Timeout = setTimeout(() => { + if (isExecuting) { + if (automaticallyCancelQueryAfterTimeout) { + queryAbortController.abort(); + } else { + useDialog + .getState() + .showOkCancelModalDialog( + QueryConstants.CancelQueryTitle, + format(QueryConstants.CancelQuerySubTextTemplate, QueryConstants.CancelQueryTimeoutThresholdReached), + "Yes", + () => queryAbortController.abort(), + "No", + undefined, + ); + } + } + }, queryTimeout); + setCancelQueryTimeoutID(cancelQueryTimeoutID); + } + return _loadNextPageInternal() + .then( + (documentsIdsResponse = []) => { + const currentDocuments = 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); + setDocumentIds(merged); + if (onLoadStartKey !== null && onLoadStartKey !== undefined) { + TelemetryProcessor.traceSuccess( + Action.Tab, + { + databaseName: props.collection.databaseId, + collectionName: props.collection.id(), + + dataExplorerArea: Constants.Areas.Tab, + tabTitle: props.tabTitle, //tabTitle(), + }, + onLoadStartKey, + ); + setOnLoadStartKey(null); + } + }, + (error) => { + setIsExecutionError(true); + const errorMessage = getErrorMessage(error); + logConsoleError(errorMessage); + if (onLoadStartKey !== null && onLoadStartKey !== undefined) { + TelemetryProcessor.traceFailure( + Action.Tab, + { + databaseName: props.collection.databaseId, + collectionName: props.collection.id(), + + dataExplorerArea: Constants.Areas.Tab, + tabTitle: props.tabTitle, // tabTitle(), + error: errorMessage, + errorStack: getErrorStack(error), + }, + onLoadStartKey, + ); + setOnLoadStartKey(null); + } + }, + ) + .finally(() => { + setIsExecuting(false); + if (applyFilterButtonClicked && queryTimeoutEnabled()) { + clearTimeout(cancelQueryTimeoutID); + if (!automaticallyCancelQueryAfterTimeout) { + useDialog.getState().closeDialog(); + } + } + }); + }; + + const onLoadMoreKeyInput: KeyboardEventHandler = (event): void => { + if (event.key === " " || event.key === "Enter") { + const focusElement = document.getElementById(this.documentContentsGridId); + this.loadNextPage(); + focusElement && focusElement.focus(); + event.stopPropagation(); + event.preventDefault(); + } + }; + + const _loadNextPageInternal = (): Promise => { + return documentsIterator.iterator.fetchNext().then((response) => response.resources); + }; + + const showPartitionKey = (() => { + if (!props.collection) { + return false; + } + + if (!props.collection.partitionKey) { + return false; + } + + if (props.collection.partitionKey.systemKey && props.isPreferredApiMongoDB) { + return false; + } + + return true; + })(); + + const _isQueryCopilotSampleContainer = + props.collection?.isSampleCollection && + props.collection?.databaseId === QueryCopilotSampleDatabaseId && + props.collection?.id() === QueryCopilotSampleContainerId; + + /* Below is for the table config */ + const items = documentIds.map((documentId) => ({ + id: documentId.id(), + type: JSON.stringify(documentId.partitionKeyValue), + })); + + const columns: TableColumnDefinition[] = [ + createTableColumn({ + columnId: "id", + compare: (a, b) => { + return a.id.localeCompare(b.id); + }, + renderHeaderCell: () => { + return "id"; + }, + renderCell: (item) => { + return ( + {item.id} + ); + }, + }), + createTableColumn({ + columnId: "type", + compare: (a, b) => { + return a.type.localeCompare(b.type); + }, + renderHeaderCell: () => { + return "/type"; + }, + renderCell: (item) => { + return ( + {item.type} + ); + }, + }), + ]; + const [selectedRows, setSelectedRows] = React.useState>( + () => new Set([0]) + ); + + const { + getRows, + selection: { + allRowsSelected, + someRowsSelected, + toggleAllRows, + toggleRow, + isRowSelected, + }, + } = useTableFeatures( + { + columns, + items, + }, + [ + useTableSelection({ + selectionMode: "multiselect", + selectedItems: selectedRows, + onSelectionChange: (e, data) => setSelectedRows(data.selectedItems), + }), + ] + ); + + const rows = getRows((row) => { + const selected = isRowSelected(row.rowId); + return { + ...row, + onClick: (e: React.MouseEvent) => toggleRow(e, row.rowId), + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === " ") { + e.preventDefault(); + toggleRow(e, row.rowId); + } + }, + selected, + appearance: selected ? ("brand" as const) : ("none" as const), + }; + }); + + const toggleAllKeydown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === " ") { + toggleAllRows(e); + e.preventDefault(); + } + }, + [toggleAllRows] + ); + + const [currentDocument, setCurrentDocument] = useState(undefined); + + // Load document depending on selection + useEffect(() => { + if (selectedRows.size === 1 && documentIds.length > 0) { + const documentId = documentIds[selectedRows.values().next().value]; + + // TODO: replicate logic of selectedDocument.click(); + // TODO: Check if editor is dirty + + (_isQueryCopilotSampleContainer + ? readSampleDocument(documentId) + : readDocument(props.collection, documentId)).then((content) => { + // this.initDocumentEditor(documentId, content); + setCurrentDocument(content); + }); + } + }, [selectedRows, documentIds]); + + /* End of table config */ + + return +
+ {/* */} + {isFilterCreated && +
+ {/* */} + {!isFilterExpanded && !isPreferredApiMongoDB && +
+ SELECT * FROM c + {appliedFilter} + +
+ } + {!isFilterExpanded && isPreferredApiMongoDB && +
+ {appliedFilter.length > 0 && + Filter : + } + {!(appliedFilter.length > 0) && + No filter applied + } + {appliedFilter} + +
+ } + {/* */} + + {/* */} + {isFilterExpanded && +
+
+
+ {!isPreferredApiMongoDB && + SELECT * FROM c + } + setFilterContent(e.target.value)} + /* + data-bind=" + W attr:{ + placeholder:isPreferredApiMongoDB?'Type a query predicate (e.g., {´a´:´foo´}), or choose one from the drop down list, or leave empty to query all documents.':'Type a query predicate (e.g., WHERE c.id=´1´), or choose one from the drop down list, or leave empty to query all documents.' + }, + css: { placeholderVisible: filterContent().length === 0 }, + textInput: filterContent" + */ + /> + + + {lastFilterContents.map((filter) => + + + + + + + {!isPreferredApiMongoDB && isExecuting && + + } + + onHideFilterClick()} onKeyDown={onCloseButtonKeyDown} + /*data-bind="click: onHideFilterClick, event: { keydown: onCloseButtonKeyDown }"*/> + Hide filter + +
+
+
+ } + {/* */} +
+ } + {/* */} + + +
+ + + + + id + /type + + + + {rows.map(({ item, selected, onClick, onKeyDown, appearance }, index: number) => ( + + + setSelectedRows(new Set([index]))} onKeyDown={onKeyDown}> + {item.id} + + + {item.type} + + + ))} + +
+ +
+
{JSON.stringify(currentDocument, undefined, " ")}
+
+ +
+
; +} + diff --git a/src/Explorer/Theme/ThemeUtil.ts b/src/Explorer/Theme/ThemeUtil.ts new file mode 100644 index 000000000..32b11a0f2 --- /dev/null +++ b/src/Explorer/Theme/ThemeUtil.ts @@ -0,0 +1,25 @@ +import { BrandVariants, Theme, createLightTheme } from "@fluentui/react-components"; + +// These are the theme colors for Fluent UI 9 React components +const cosmosdb: BrandVariants = { + 10: "#020305", + 20: "#111723", + 30: "#16263D", + 40: "#193253", + 50: "#1B3F6A", + 60: "#1B4C82", + 70: "#18599B", + 80: "#1267B4", + 90: "#3174C2", + 100: "#4F82C8", + 110: "#6790CF", + 120: "#7D9ED5", + 130: "#92ACDC", + 140: "#A6BAE2", + 150: "#BAC9E9", + 160: "#CDD8EF", +}; + +export const dataExplorerLightTheme: Theme = { + ...createLightTheme(cosmosdb), +}; diff --git a/src/Explorer/Tree/Collection.ts b/src/Explorer/Tree/Collection.ts index 4b5411d09..e7a54047f 100644 --- a/src/Explorer/Tree/Collection.ts +++ b/src/Explorer/Tree/Collection.ts @@ -1,5 +1,6 @@ import { Resource, StoredProcedureDefinition, TriggerDefinition, UserDefinedFunctionDefinition } from "@azure/cosmos"; import { useNotebook } from "Explorer/Notebook/useNotebook"; +import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2"; import * as ko from "knockout"; import * as _ from "underscore"; import * as Constants from "../../Common/Constants"; @@ -27,7 +28,6 @@ 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 GraphTab from "../Tabs/GraphTab"; import MongoDocumentsTab from "../Tabs/MongoDocumentsTab"; import { NewMongoQueryTab } from "../Tabs/MongoQueryTab/MongoQueryTab"; @@ -290,13 +290,13 @@ export default class Collection implements ViewModels.Collection { dataExplorerArea: Constants.Areas.ResourceTree, }); - const documentsTabs: DocumentsTab[] = useTabs + const documentsTabs: DocumentsTabV2[] = useTabs .getState() .getTabs( ViewModels.CollectionTabKind.Documents, (tab) => tab.collection && tab.collection.databaseId === this.databaseId && tab.collection.id() === this.id(), - ) as DocumentsTab[]; - let documentsTab: DocumentsTab = documentsTabs && documentsTabs[0]; + ) as DocumentsTabV2[]; + let documentsTab: DocumentsTabV2 = documentsTabs && documentsTabs[0]; if (documentsTab) { useTabs.getState().activateTab(documentsTab); @@ -310,7 +310,7 @@ export default class Collection implements ViewModels.Collection { }); this.documentIds([]); - documentsTab = new DocumentsTab({ + documentsTab = new DocumentsTabV2({ partitionKey: this.partitionKey, documentIds: ko.observableArray([]), tabKind: ViewModels.CollectionTabKind.Documents, diff --git a/src/Explorer/Tree2/ResourceTree.tsx b/src/Explorer/Tree2/ResourceTree.tsx index 761cda2c6..71b7973d8 100644 --- a/src/Explorer/Tree2/ResourceTree.tsx +++ b/src/Explorer/Tree2/ResourceTree.tsx @@ -1,14 +1,12 @@ import { - BrandVariants, FluentProvider, - Theme, Tree, TreeItemValue, TreeOpenChangeData, TreeOpenChangeEvent, - createLightTheme, } from "@fluentui/react-components"; import { TreeNode2, TreeNode2Component } from "Explorer/Controls/TreeComponent2/TreeNode2Component"; +import { dataExplorerLightTheme } from "Explorer/Theme/ThemeUtil"; import { useDatabaseTreeNodes } from "Explorer/Tree2/useDatabaseTreeNodes"; import * as React from "react"; import shallow from "zustand/shallow"; @@ -22,29 +20,6 @@ interface ResourceTreeProps { container: Explorer; } -const cosmosdb: BrandVariants = { - 10: "#020305", - 20: "#111723", - 30: "#16263D", - 40: "#193253", - 50: "#1B3F6A", - 60: "#1B4C82", - 70: "#18599B", - 80: "#1267B4", - 90: "#3174C2", - 100: "#4F82C8", - 110: "#6790CF", - 120: "#7D9ED5", - 130: "#92ACDC", - 140: "#A6BAE2", - 150: "#BAC9E9", - 160: "#CDD8EF", -}; - -const lightTheme: Theme = { - ...createLightTheme(cosmosdb), -}; - export const DATA_TREE_LABEL = "DATA"; /** @@ -113,7 +88,7 @@ export const ResourceTree2: React.FC = ({ container }: Resour return ( <> - +