import { FeedOptions, Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource, } from "@azure/cosmos"; import { FluentProvider, TableRowId } from "@fluentui/react-components"; import Split from "@uiw/react-split"; import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants"; import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; import MongoUtility from "Common/MongoUtility"; 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 { Platform, configContext } from "ConfigContext"; import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent"; import { useDialog } from "Explorer/Controls/Dialog"; import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities"; import DocumentsTab from "Explorer/Tabs/DocumentsTab"; import { dataExplorerLightTheme } from "Explorer/Theme/ThemeUtil"; import { useSelectedNode } from "Explorer/useSelectedNode"; 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, useCallback, useEffect, useRef, useState } from "react"; import { format } from "react-string-format"; import DeleteDocumentIcon from "../../../../images/DeleteDocument.svg"; import NewDocumentIcon from "../../../../images/NewDocument.svg"; import CloseIcon from "../../../../images/close-black.svg"; import DiscardIcon from "../../../../images/discard.svg"; import SaveIcon from "../../../../images/save-cosmos.svg"; import * as Constants from "../../../Common/Constants"; import * as Logger from "../../../Common/Logger"; import * as MongoProxyClient from "../../../Common/MongoProxyClient"; 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 { extractPartitionKeyValues } from "../../../Utils/QueryUtils"; import DocumentId from "../../Tree/DocumentId"; import ObjectId from "../../Tree/ObjectId"; import TabsBase from "../TabsBase"; import { DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent"; 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 { if (!this.isActive) { return <>; } return ( ); } public onActivate(): void { super.onActivate(); this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Documents); } } // From TabsBase.renderObjectForEditor() let renderObjectForEditor = ( value: unknown, replacer: (this: unknown, key: string, value: unknown) => unknown, space: string | number, ): string => JSON.stringify(value, replacer, space); 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. // Query const [documentsIterator, setDocumentsIterator] = useState<{ iterator: QueryIterator; applyFilterButtonPressed: boolean; }>(undefined); const [queryAbortController, setQueryAbortController] = useState(undefined); const [resourceTokenPartitionKey, setResourceTokenPartitionKey] = useState(undefined); // TODO: Make this a constant is setter getting called const [isQueryCopilotSampleContainer, setIsQueryCopilotSampleContainer] = useState(false); // TODO: Make this a constant is setter getting called const [cancelQueryTimeoutID, setCancelQueryTimeoutID] = useState(undefined); const [isExecutionError, setIsExecutionError] = useState(false); // TODO: Where is this used? const [onLoadStartKey, setOnLoadStartKey] = useState(props.onLoadStartKey); const [initialDocumentContent, setInitialDocumentContent] = useState(undefined); const [selectedDocumentContent, setSelectedDocumentContent] = useState(undefined); const [selectedDocumentContentBaseline, setSelectedDocumentContentBaseline] = useState(undefined); // Table user clicked on this row const [clickedRow, setClickedRow] = useState(undefined); // Table multiple selection const [selectedRows, setSelectedRows] = React.useState>(() => new Set([0])); // Command buttons const [editorState, setEditorState] = useState( ViewModels.DocumentExplorerState.noDocumentSelected, ); // For Mongo only const [continuationToken, setContinuationToken] = useState(undefined); let lastFilterContents = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC']; const getNewDocumentButtonState = () => ({ enabled: (() => { switch (editorState) { case ViewModels.DocumentExplorerState.noDocumentSelected: case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: return true; default: return false; } })(), visible: true, }); const getSaveNewDocumentButtonState = () => ({ enabled: (() => { switch (editorState) { case ViewModels.DocumentExplorerState.newDocumentValid: return true; default: return false; } })(), visible: (() => { switch (editorState) { case ViewModels.DocumentExplorerState.newDocumentValid: case ViewModels.DocumentExplorerState.newDocumentInvalid: return true; default: return false; } })(), }); const getDiscardNewDocumentChangesButtonState = () => ({ enabled: (() => { switch (editorState) { case ViewModels.DocumentExplorerState.newDocumentValid: case ViewModels.DocumentExplorerState.newDocumentInvalid: return true; default: return false; } })(), visible: (() => { switch (editorState) { case ViewModels.DocumentExplorerState.newDocumentValid: case ViewModels.DocumentExplorerState.newDocumentInvalid: return true; default: return false; } })(), }); const getSaveExistingDocumentButtonState = () => ({ enabled: (() => { switch (editorState) { case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: return true; default: return false; } })(), visible: (() => { switch (editorState) { case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: return true; default: return false; } })(), }); const getDiscardExisitingDocumentChangesButtonState = () => ({ enabled: (() => { switch (editorState) { case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: return true; default: return false; } })(), visible: (() => { switch (editorState) { case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: return true; default: return false; } })(), }); const getDeleteExisitingDocumentButtonState = () => ({ enabled: (() => { switch (editorState) { case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: return true; default: return false; } })(), visible: (() => { switch (editorState) { case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits: case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid: case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid: return selectedRows.size > 0; default: return false; } })(), }); const applyFilterButton = { enabled: true, visible: true, }; // TODO: Where is this used? 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; let partitionKeyProperties = partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) => partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""), ); // const isPreferredApiMongoDB = useMemo( // () => userContext.apiType === "Mongo" || props.isPreferredApiMongoDB, // [props.isPreferredApiMongoDB], // ); const updateNavbarWithTabsButtons = (): void => { // if (this.isActive()) { useCommandBar.getState().setContextButtons(getTabsButtons()); // } }; 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); } } } updateNavbarWithTabsButtons(); }, []); // If editor state changes, update the nav // TODO Put whatever the buttons callback use in the dependency array: find a better way to maintain useEffect( () => updateNavbarWithTabsButtons(), [ editorState, selectedDocumentContent, selectedDocumentContentBaseline, initialDocumentContent, selectedRows, documentIds, clickedRow, ], ); useEffect(() => { if (documentsIterator) { loadNextPage(documentsIterator.applyFilterButtonPressed); } }, [documentsIterator]); const confirmDiscardingChange = (onDiscard: () => void, onCancelDiscard?: () => void): void => { if (isEditorDirty()) { useDialog .getState() .showOkCancelModalDialog( "Unsaved changes", "Your unsaved changes will be lost. Do you want to continue?", "OK", onDiscard, "Cancel", onCancelDiscard, ); } else { onDiscard(); } }; const onNewDocumentClick = useCallback( (): void => confirmDiscardingChange(() => initializeNewDocument()), [editorState /* TODO isEditorDirty depends on more than just editorState */], ); const initializeNewDocument = (): void => { // this.selectedDocumentId(null); const defaultDocument: string = renderObjectForEditor({ id: "replace_with_new_document_id" }, null, 4); setInitialDocumentContent(defaultDocument); setSelectedDocumentContent(defaultDocument); setSelectedDocumentContentBaseline(defaultDocument); setSelectedRows(new Set()); setClickedRow(undefined); setEditorState(ViewModels.DocumentExplorerState.newDocumentValid); }; let onSaveNewDocumentClick = (): Promise => { setIsExecutionError(false); const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, }); const sanitizedContent = selectedDocumentContent.replace("\n", ""); const document = JSON.parse(sanitizedContent); setIsExecuting(true); return createDocument(props.collection, document) .then( (savedDocument: unknown) => { const value: string = renderObjectForEditor(savedDocument || {}, null, 4); setSelectedDocumentContentBaseline(value); setInitialDocumentContent(value); const partitionKeyValueArray: PartitionKey[] = extractPartitionKeyValues( savedDocument, partitionKey as PartitionKeyDefinition, ); const id = new DocumentId(this, savedDocument, partitionKeyValueArray); const ids = documentIds; ids.push(id); // this.selectedDocumentId(id); setDocumentIds(ids); setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); TelemetryProcessor.traceSuccess( Action.CreateDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, }, startKey, ); }, (error) => { setIsExecutionError(true); const errorMessage = getErrorMessage(error); useDialog.getState().showOkModalDialog("Create document failed", errorMessage); TelemetryProcessor.traceFailure( Action.CreateDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, error: errorMessage, errorStack: getErrorStack(error), }, startKey, ); }, ) .finally(() => setIsExecuting(false)); }; const onRevertNewDocumentClick = (): void => { setInitialDocumentContent(""); setSelectedDocumentContent(""); setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected); }; let onSaveExistingDocumentClick = (): Promise => { // const selectedDocumentId = this.selectedDocumentId(); const documentContent = JSON.parse(selectedDocumentContent); const partitionKeyValueArray: PartitionKey[] = extractPartitionKeyValues( documentContent, partitionKey as PartitionKeyDefinition, ); const selectedDocumentId = documentIds[clickedRow as number]; selectedDocumentId.partitionKeyValue = partitionKeyValueArray; setIsExecutionError(false); const startKey: number = TelemetryProcessor.traceStart(Action.UpdateDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, }); setIsExecuting(true); return updateDocument(props.collection, selectedDocumentId, documentContent) .then( (updatedDocument: Item & { _rid: string }) => { const value: string = renderObjectForEditor(updatedDocument || {}, null, 4); setSelectedDocumentContentBaseline(value); setInitialDocumentContent(value); setSelectedDocumentContent(value); documentIds.forEach((documentId: DocumentId) => { if (documentId.rid === updatedDocument._rid) { documentId.id(updatedDocument.id); } }); setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); TelemetryProcessor.traceSuccess( Action.UpdateDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, }, startKey, ); }, (error) => { setIsExecutionError(true); const errorMessage = getErrorMessage(error); useDialog.getState().showOkModalDialog("Update document failed", errorMessage); TelemetryProcessor.traceFailure( Action.UpdateDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, error: errorMessage, errorStack: getErrorStack(error), }, startKey, ); }, ) .finally(() => setIsExecuting(false)); }; const onRevertExisitingDocumentClick = (): void => { setSelectedDocumentContentBaseline(initialDocumentContent); // this.initialDocumentContent.valueHasMutated(); setSelectedDocumentContent(selectedDocumentContentBaseline); // setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); }; const onDeleteExisitingDocumentsClick = async (): Promise => { // const selectedDocumentId = this.selectedDocumentId(); // TODO: Rework this for localization const isPlural = selectedRows.size > 1; const documentName = !props.isPreferredApiMongoDB ? isPlural ? `the selected ${selectedRows.size} items` : "the selected item" : isPlural ? `the selected ${selectedRows.size} documents` : "the selected document"; const msg = `Are you sure you want to delete ${documentName}?`; useDialog.getState().showOkCancelModalDialog( "Confirm delete", msg, "Delete", // async () => await _deleteDocuments(selectedDocumentId), () => deleteDocuments(Array.from(selectedRows).map((index) => documentIds[index as number])), "Cancel", undefined, ); }; const deleteDocuments = (toDeleteDocumentIds: DocumentId[]): void => { setIsExecutionError(false); setIsExecuting(true); const promises = toDeleteDocumentIds.map((documentId) => _deleteDocuments(documentId)); Promise.all(promises) .then((deletedDocumentIds: DocumentId[]) => { const newDocumentIds = [...documentIds]; deletedDocumentIds.forEach((deletedDocumentId) => { if (deletedDocumentId !== undefined) { // documentIds.remove((documentId: DocumentId) => documentId.rid === selectedDocumentId.rid); const index = toDeleteDocumentIds.findIndex((documentId) => documentId.rid === deletedDocumentId.rid); if (index !== -1) { newDocumentIds.splice(index, 1); } } }); setDocumentIds(newDocumentIds); setSelectedDocumentContent(undefined); setClickedRow(undefined); setSelectedRows(new Set()); setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected); }) .finally(() => setIsExecuting(false)); }; let __deleteDocument = (documentId: DocumentId): Promise => deleteDocument(props.collection, documentId); const _deleteDocuments = (documentId: DocumentId): Promise => { // setIsExecutionError(false); const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, }); // setIsExecuting(true); return __deleteDocument(documentId).then( () => { TelemetryProcessor.traceSuccess( Action.DeleteDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, }, startKey, ); return documentId; }, (error) => { setIsExecutionError(true); console.error(error); TelemetryProcessor.traceFailure( Action.DeleteDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, error: getErrorMessage(error), errorStack: getErrorStack(error), }, startKey, ); return undefined; }, ); // .finally(() => setIsExecuting(false)); }; const onShowFilterClick = () => { setIsFilterCreated(true); setIsFilterExpanded(true); // TODO convert this $(".filterDocExpanded").addClass("active"); $("#content").addClass("active"); $(".querydropdown").focus(); }; const queryTimeoutEnabled = (): boolean => !props.isPreferredApiMongoDB && LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled); let 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: FeedOptions = {}; // TODO: Property 'enableCrossPartitionQuery' does not exist on type 'FeedOptions'. // options.enableCrossPartitionQuery = HeadersUtility.shouldEnableCrossPartitionKey(); if (resourceTokenPartitionKey) { options.partitionKey = resourceTokenPartitionKey; } // Fixes compile error error TS2741: Property 'throwIfAborted' is missing in type 'AbortSignal' but required in type 'import("/home/runner/work/cosmos-explorer/cosmos-explorer/node_modules/node-abort-controller/index").AbortSignal'. options.abortSignal = { ..._queryAbortController.signal, throwIfAborted: () => {} }; 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 = (): void => { setIsFilterExpanded(false); // this.isFilterExpanded(false); // $(".filterDocExpanded").removeClass("active"); // $("#content").removeClass("active"); // $(".queryButton").focus(); }; 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", // ); // } // }); let 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: DataModels.DocumentId) => { return currentDocumentsRids.indexOf(d._rid) < 0; }) // map raw response to view model .map((rawDocument: DataModels.DocumentId & { _partitionKeyValue: string[] }) => { const partitionKeyValue = rawDocument._partitionKeyValue; // TODO: Mock documentsTab. Fix this const partitionKey = props.partitionKey || (props.collection && props.collection.partitionKey); const partitionKeyPropertyHeaders = props.collection?.partitionKeyPropertyHeaders || partitionKey?.paths; const partitionKeyProperties = partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) => partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""), ); return new DocumentId( { partitionKey, partitionKeyPropertyHeaders, partitionKeyProperties, } as DocumentsTab, 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 _loadNextPageInternal = (): Promise => { return documentsIterator.iterator.fetchNext().then((response) => response.resources); }; // TODO: use this when generating column headers 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 getTabsButtons = (): CommandButtonComponentProps[] => { if (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) { // All the following buttons require write access return []; } const buttons: CommandButtonComponentProps[] = []; const label = !props.isPreferredApiMongoDB ? "New Item" : "New Document"; if (getNewDocumentButtonState().visible) { buttons.push({ iconSrc: NewDocumentIcon, iconAlt: label, onCommandClick: onNewDocumentClick, commandButtonLabel: label, ariaLabel: label, hasPopup: false, disabled: !getNewDocumentButtonState().enabled || useSelectedNode.getState().isQueryCopilotCollectionSelected(), id: "mongoNewDocumentBtn", }); } if (getSaveNewDocumentButtonState().visible) { const label = "Save"; buttons.push({ iconSrc: SaveIcon, iconAlt: label, onCommandClick: onSaveNewDocumentClick, commandButtonLabel: label, ariaLabel: label, hasPopup: false, disabled: !getSaveNewDocumentButtonState().enabled || useSelectedNode.getState().isQueryCopilotCollectionSelected(), }); } if (getDiscardNewDocumentChangesButtonState().visible) { const label = "Discard"; buttons.push({ iconSrc: DiscardIcon, iconAlt: label, onCommandClick: onRevertNewDocumentClick, commandButtonLabel: label, ariaLabel: label, hasPopup: false, disabled: !getDiscardNewDocumentChangesButtonState().enabled || useSelectedNode.getState().isQueryCopilotCollectionSelected(), }); } if (getSaveExistingDocumentButtonState().visible) { const label = "Update"; buttons.push({ iconSrc: SaveIcon, iconAlt: label, onCommandClick: onSaveExistingDocumentClick, commandButtonLabel: label, ariaLabel: label, hasPopup: false, disabled: !getSaveExistingDocumentButtonState().enabled || useSelectedNode.getState().isQueryCopilotCollectionSelected(), }); } if (getDiscardExisitingDocumentChangesButtonState().visible) { const label = "Discard"; buttons.push({ iconSrc: DiscardIcon, iconAlt: label, onCommandClick: onRevertExisitingDocumentClick, commandButtonLabel: label, ariaLabel: label, hasPopup: false, disabled: !getDiscardExisitingDocumentChangesButtonState().enabled || useSelectedNode.getState().isQueryCopilotCollectionSelected(), }); } if (getDeleteExisitingDocumentButtonState().visible) { const label = "Delete"; buttons.push({ iconSrc: DeleteDocumentIcon, iconAlt: label, onCommandClick: onDeleteExisitingDocumentsClick, commandButtonLabel: label, ariaLabel: label, hasPopup: false, disabled: !getDeleteExisitingDocumentButtonState().enabled || useSelectedNode.getState().isQueryCopilotCollectionSelected(), }); } if (!props.isPreferredApiMongoDB) { buttons.push(DocumentsTab._createUploadButton(props.collection.container)); } return buttons; }; const _isQueryCopilotSampleContainer = props.collection?.isSampleCollection && props.collection?.databaseId === QueryCopilotSampleDatabaseId && props.collection?.id() === QueryCopilotSampleContainerId; // Table config here const tableItems: DocumentsTableComponentItem[] = documentIds.map((documentId) => { const item: Record & { id: string } = { id: documentId.id(), }; if (partitionKeyPropertyHeaders && documentId.partitionKeyProperties) { for (let i = 0; i < partitionKeyPropertyHeaders.length; i++) { item[partitionKeyPropertyHeaders[i]] = documentId.stringPartitionKeyValues[i]; } } return item; }); const isEditorDirty = (): boolean => { switch (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 true; // return ( // this.selectedDocumentContent.getEditableOriginalValue() !== // this.selectedDocumentContent.getEditableCurrentValue() // ); default: return false; } }; /** * replicate logic of selectedDocument.click(); * Document has been clicked on in table * @param tabRowId */ const onDocumentClicked = (tabRowId: TableRowId) => { const index = tabRowId as number; setClickedRow(index); loadDocument(documentIds[index]); }; let loadDocument = (documentId: DocumentId) => (_isQueryCopilotSampleContainer ? readSampleDocument(documentId) : readDocument(props.collection, documentId)).then( (content) => { initDocumentEditor(documentId, content); }, ); const initDocumentEditor = (documentId: DocumentId, documentContent: unknown): void => { if (documentId) { const content: string = renderObjectForEditor(documentContent, null, 4); setSelectedDocumentContentBaseline(content); setSelectedDocumentContent(content); setInitialDocumentContent(content); const newState = documentId ? ViewModels.DocumentExplorerState.exisitingDocumentNoEdits : ViewModels.DocumentExplorerState.newDocumentValid; setEditorState(newState); } }; const _onEditorContentChange = (newContent: string): void => { setSelectedDocumentContent(newContent); if ( selectedDocumentContentBaseline === initialDocumentContent && newContent === initialDocumentContent && editorState !== ViewModels.DocumentExplorerState.newDocumentValid ) { setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); return; } try { JSON.parse(newContent); onValidDocumentEdit(); } catch (e) { onInvalidDocumentEdit(); } }; const onValidDocumentEdit = (): void => { if ( editorState === ViewModels.DocumentExplorerState.newDocumentInvalid || editorState === ViewModels.DocumentExplorerState.newDocumentValid ) { setEditorState(ViewModels.DocumentExplorerState.newDocumentValid); return; } setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid); }; const onInvalidDocumentEdit = (): void => { if ( editorState === ViewModels.DocumentExplorerState.newDocumentInvalid || editorState === ViewModels.DocumentExplorerState.newDocumentValid ) { setEditorState(ViewModels.DocumentExplorerState.newDocumentInvalid); return; } if ( editorState === ViewModels.DocumentExplorerState.exisitingDocumentNoEdits || editorState === ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid ) { setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid); return; } }; const tableContainerRef = useRef(null); const [tableContainerSizePx, setTableContainerSizePx] = useState<{ height: number; width: number }>(undefined); useEffect(() => { if (!tableContainerRef.current) { return undefined; } const resizeObserver = new ResizeObserver(() => setTableContainerSizePx({ height: tableContainerRef.current.offsetHeight, width: tableContainerRef.current.offsetWidth, }), ); resizeObserver.observe(tableContainerRef.current); return () => resizeObserver.disconnect(); // clean up }, []); const columnHeaders = { idHeader: props.isPreferredApiMongoDB ? "_id" : "id", partitionKeyHeaders: partitionKeyPropertyHeaders || [], }; const onSelectedRowsChange = (selectedRows: Set) => { confirmDiscardingChange(() => { if (selectedRows.size === 0) { setSelectedDocumentContent(undefined); setClickedRow(undefined); setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected); } // Find if clickedRow is in selectedRows.If not, clear clickedRow and content if (clickedRow !== undefined && !selectedRows.has(clickedRow)) { setClickedRow(undefined); setSelectedDocumentContent(undefined); setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected); } // If only one selection, we consider as a click if (selectedRows.size === 1) { const selectedRow = Array.from(selectedRows)[0]; onDocumentClicked(selectedRow); setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); } setSelectedRows(selectedRows); }); }; // ********* Override here for mongo (from MongoDocumentsTab) ********** console.log("isPreferredApiMongoDB", props.isPreferredApiMongoDB); if (props.isPreferredApiMongoDB) { loadDocument = (documentId: DocumentId) => MongoProxyClient.readDocument( props.collection.databaseId, props.collection as ViewModels.Collection, documentId, ).then((content) => { initDocumentEditor(documentId, content); }); renderObjectForEditor = (value: unknown): string => MongoUtility.tojson(value, null, false); const _hasShardKeySpecified = (document: unknown): boolean => { return Boolean(extractPartitionKeyValues(document, _getPartitionKeyDefinition() as PartitionKeyDefinition)); }; const _getPartitionKeyDefinition = (): DataModels.PartitionKey => { let partitionKey: DataModels.PartitionKey = props.partitionKey; if ( props.partitionKey && props.partitionKey.paths && props.partitionKey.paths.length && props.partitionKey.paths.length > 0 && props.partitionKey.paths[0].indexOf("$v") > -1 ) { // Convert BsonSchema2 to /path format partitionKey = { kind: partitionKey.kind, paths: ["/" + partitionKeyProperties?.[0].replace(/\./g, "/")], version: partitionKey.version, }; } return partitionKey; }; lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"]; partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => { if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) { partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, ""); } if (partitionKeyProperty && partitionKeyProperty.indexOf("$v") > -1) { // From $v.shard.$v.key.$v > shard.key partitionKeyProperty = partitionKeyProperty.replace(/.\$v/g, "").replace(/\$v./g, ""); partitionKeyPropertyHeaders[i] = "/" + partitionKeyProperty; } return partitionKeyProperty; }); __deleteDocument = (documentId: DocumentId): Promise => MongoProxyClient.deleteDocument( props.collection.databaseId, props.collection as ViewModels.Collection, documentId, ); onSaveNewDocumentClick = (): Promise => { const documentContent = JSON.parse(selectedDocumentContent); // this.displayedError(""); const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, }); const partitionKeyProperty = partitionKeyProperties?.[0]; if (partitionKeyProperty !== "_id" && !_hasShardKeySpecified(documentContent)) { const message = `The document is lacking the shard property: ${partitionKeyProperty}`; // TODO: Display error message here // this.displayedError(message); // const that = this; // setTimeout(() => { // that.displayedError(""); // }, Constants.ClientDefaults.errorNotificationTimeoutMs); // this.isExecutionError(true); TelemetryProcessor.traceFailure( Action.CreateDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, error: message, }, startKey, ); Logger.logError("Failed to save new document: Document shard key not defined", "MongoDocumentsTab"); throw new Error("Document without shard key"); } setIsExecutionError(false); setIsExecuting(true); return MongoProxyClient.createDocument( props.collection.databaseId, props.collection as ViewModels.Collection, partitionKeyProperties?.[0], documentContent, ) .then( (savedDocument: { _self: unknown }) => { const partitionKeyArray: PartitionKey[] = extractPartitionKeyValues( savedDocument, _getPartitionKeyDefinition() as PartitionKeyDefinition, ); const id = new ObjectId(this, savedDocument, partitionKeyArray); const ids = documentIds; ids.push(id); delete savedDocument._self; const value: string = renderObjectForEditor(savedDocument || {}, null, 4); setSelectedDocumentContentBaseline(value); // this.selectedDocumentId(id); setDocumentIds(ids); setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); TelemetryProcessor.traceSuccess( Action.CreateDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, }, startKey, ); }, (error) => { setIsExecutionError(true); const errorMessage = getErrorMessage(error); useDialog.getState().showOkModalDialog("Create document failed", errorMessage); TelemetryProcessor.traceFailure( Action.CreateDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, error: errorMessage, errorStack: getErrorStack(error), }, startKey, ); }, ) .finally(() => setIsExecuting(false)); }; onSaveExistingDocumentClick = (): Promise => { // const selectedDocumentId = this.selectedDocumentId(); const documentContent = selectedDocumentContent; setIsExecutionError(false); setIsExecuting(true); const startKey: number = TelemetryProcessor.traceStart(Action.UpdateDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, }); const selectedDocumentId = documentIds[clickedRow as number]; return MongoProxyClient.updateDocument( props.collection.databaseId, props.collection as ViewModels.Collection, selectedDocumentId, documentContent, ) .then( (updatedDocument: { _rid: string }) => { const value: string = renderObjectForEditor(updatedDocument || {}, null, 4); setSelectedDocumentContentBaseline(value); documentIds.forEach((documentId: DocumentId) => { if (documentId.rid === updatedDocument._rid) { const partitionKeyArray: PartitionKey[] = extractPartitionKeyValues( updatedDocument, _getPartitionKeyDefinition() as PartitionKeyDefinition, ); const id = new ObjectId(this, updatedDocument, partitionKeyArray); documentId.id(id.id()); } }); setEditorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits); TelemetryProcessor.traceSuccess( Action.UpdateDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, }, startKey, ); }, (error) => { setIsExecutionError(true); const errorMessage = getErrorMessage(error); useDialog.getState().showOkModalDialog("Update document failed", errorMessage); TelemetryProcessor.traceFailure( Action.UpdateDocument, { dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, error: errorMessage, errorStack: getErrorStack(error), }, startKey, ); }, ) .finally(() => setIsExecuting(false)); }; buildQuery = (filter: string): string => { return filter || "{}"; }; loadNextPage = (): Promise => { setIsExecuting(true); setIsExecutionError(false); const filter: string = filterContent.trim(); const query: string = buildQuery(filter); return MongoProxyClient.queryDocuments( props.collection.databaseId, props.collection as ViewModels.Collection, true, query, continuationToken, ) .then( ({ continuationToken: newContinuationToken, documents }) => { setContinuationToken(newContinuationToken); let currentDocuments = documentIds; const currentDocumentsRids = currentDocuments.map((currentDocument) => currentDocument.rid); const nextDocumentIds = documents .filter((d: { _rid: string }) => { return currentDocumentsRids.indexOf(d._rid) < 0; }) // eslint-disable-next-line @typescript-eslint/no-explicit-any .map((rawDocument: any) => { const partitionKeyValue = rawDocument._partitionKeyValue; return new DocumentId(this, rawDocument, [partitionKeyValue]); }); const merged = currentDocuments.concat(nextDocumentIds); setDocumentIds(merged); // currentDocuments = this.documentIds();/ currentDocuments = merged; if (filterContent.length > 0 && currentDocuments.length > 0) { currentDocuments[0].click(); } else { setSelectedDocumentContent(""); // this.selectedDocumentId(null); setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected); } if (props.onLoadStartKey !== null && props.onLoadStartKey !== undefined) { TelemetryProcessor.traceSuccess( Action.Tab, { databaseName: props.collection.databaseId, collectionName: props.collection.id(), dataExplorerArea: Constants.Areas.Tab, tabTitle: props.tabTitle, }, props.onLoadStartKey, ); // TODO: Set on Load start key to null to stop telemetry traces // this.onLoadStartKey = null; } }, (error: any) => { 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), }, props.onLoadStartKey, ); // TODO: Set on Load start key to null to stop telemetry traces // this.onLoadStartKey = null; } }, ) .finally(() => setIsExecuting(false)); }; } // ***************** Mongo *************************** return (
{/* */} {isFilterCreated && (
{/* */} {!isFilterExpanded && !props.isPreferredApiMongoDB && (
SELECT * FROM c {appliedFilter}
)} {!isFilterExpanded && props.isPreferredApiMongoDB && (
{appliedFilter.length > 0 && ( Filter : )} {!(appliedFilter.length > 0) && ( No filter applied )} {appliedFilter}
)} {/* */} {/* */} {isFilterExpanded && (
{!props.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) => ( {!props.isPreferredApiMongoDB && isExecuting && ( )} Hide filter
)} {/* */}
)} {/* */} {/* doesn't like to be a flex child */}
{selectedDocumentContent && selectedRows.size <= 1 && ( )} {selectedRows.size > 1 && ( Number of selected documents: {selectedRows.size} )}
); };