1851 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { Button, FluentProvider, Input, TableRowId } from "@fluentui/react-components";
import { ArrowClockwise16Filled, Dismiss16Filled } from "@fluentui/react-icons";
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 { StyleConstants } from "Common/StyleConstants";
import { createDocument } from "Common/dataAccess/createDocument";
import { deleteDocuments as deleteNoSqlDocuments } 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 Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import {
DocumentsTabPrefs,
readDocumentsTabPrefs,
saveDocumentsTabPrefs,
} from "Explorer/Tabs/DocumentsTabV2/documentsTabPrefs";
import { getPlatformTheme } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
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, useMemo, useRef, useState } from "react";
import { format } from "react-string-format";
import { CSSProperties } from "styled-components";
import DeleteDocumentIcon from "../../../../images/DeleteDocument.svg";
import NewDocumentIcon from "../../../../images/NewDocument.svg";
import UploadIcon from "../../../../images/Upload_16x16.svg";
import DiscardIcon from "../../../../images/discard.svg";
import SaveIcon from "../../../../images/save-cosmos.svg";
import * as Constants from "../../../Common/Constants";
import * as HeadersUtility from "../../../Common/HeadersUtility";
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 { ColumnsDefinition, DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent";
export class DocumentsTabV2 extends TabsBase {
public partitionKey: DataModels.PartitionKey;
private documentIds: DocumentId[];
private title: string;
private resourceTokenPartitionKey: string;
constructor(options: ViewModels.DocumentsTabOptions) {
super(options);
this.documentIds = options.documentIds();
this.title = options.title;
this.partitionKey = options.partitionKey;
this.resourceTokenPartitionKey = options.resourceTokenPartitionKey;
}
public render(): JSX.Element {
return (
<DocumentsTabComponent
isPreferredApiMongoDB={userContext.apiType === "Mongo"}
documentIds={this.documentIds}
collection={this.collection}
partitionKey={this.partitionKey}
onLoadStartKey={this.onLoadStartKey}
tabTitle={this.title}
resourceTokenPartitionKey={this.resourceTokenPartitionKey}
onExecutionErrorChange={(isExecutionError: boolean) => this.isExecutionError(isExecutionError)}
onIsExecutingChange={(isExecuting: boolean) => this.isExecuting(isExecuting)}
isTabActive={this.isActive()}
/>
);
}
public onActivate(): void {
super.onActivate();
this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Documents);
}
}
const filterButtonStyle: CSSProperties = {
marginLeft: 8,
};
// From TabsBase.renderObjectForEditor()
let renderObjectForEditor = (
value: unknown,
replacer: (this: unknown, key: string, value: unknown) => unknown,
space: string | number,
): string => JSON.stringify(value, replacer, space);
// Export to expose to unit tests
export const getSaveNewDocumentButtonState = (editorState: ViewModels.DocumentExplorerState) => ({
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;
}
})(),
});
// Export to expose to unit tests
export const getDiscardNewDocumentChangesButtonState = (editorState: ViewModels.DocumentExplorerState) => ({
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;
}
})(),
});
// Export to expose to unit tests
export const getSaveExistingDocumentButtonState = (editorState: ViewModels.DocumentExplorerState) => ({
enabled: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.existingDocumentDirtyValid:
return true;
default:
return false;
}
})(),
visible: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.existingDocumentNoEdits:
case ViewModels.DocumentExplorerState.existingDocumentDirtyInvalid:
case ViewModels.DocumentExplorerState.existingDocumentDirtyValid:
return true;
default:
return false;
}
})(),
});
// Export to expose to unit tests
export const getDiscardExistingDocumentChangesButtonState = (editorState: ViewModels.DocumentExplorerState) => ({
enabled: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.existingDocumentDirtyInvalid:
case ViewModels.DocumentExplorerState.existingDocumentDirtyValid:
return true;
default:
return false;
}
})(),
visible: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.existingDocumentNoEdits:
case ViewModels.DocumentExplorerState.existingDocumentDirtyInvalid:
case ViewModels.DocumentExplorerState.existingDocumentDirtyValid:
return true;
default:
return false;
}
})(),
});
type UiKeyboardEvent = (e: KeyboardEvent | React.SyntheticEvent<Element, Event>) => void;
// Export to expose to unit tests
export type ButtonsDependencies = {
_collection: ViewModels.CollectionBase;
selectedRows: Set<TableRowId>;
editorState: ViewModels.DocumentExplorerState;
isPreferredApiMongoDB: boolean;
onNewDocumentClick: UiKeyboardEvent;
onSaveNewDocumentClick: UiKeyboardEvent;
onRevertNewDocumentClick: UiKeyboardEvent;
onSaveExistingDocumentClick: UiKeyboardEvent;
onRevertExistingDocumentClick: UiKeyboardEvent;
onDeleteExistingDocumentsClick: UiKeyboardEvent;
};
const createUploadButton = (container: Explorer): CommandButtonComponentProps => {
const label = "Upload Item";
return {
id: UPLOAD_BUTTON_ID,
iconSrc: UploadIcon,
iconAlt: label,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection();
selectedCollection && container.openUploadItemsPanePane();
},
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
disabled:
useSelectedNode.getState().isDatabaseNodeOrNoneSelected() ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
};
};
// Export to expose to unit tests
export const NEW_DOCUMENT_BUTTON_ID = "mongoNewDocumentBtn";
export const SAVE_BUTTON_ID = "saveBtn";
export const UPDATE_BUTTON_ID = "updateBtn";
export const DISCARD_BUTTON_ID = "discardBtn";
export const DELETE_BUTTON_ID = "deleteBtn";
export const UPLOAD_BUTTON_ID = "uploadItemBtn";
// Export to expose in unit tests
export const getTabsButtons = ({
_collection,
selectedRows,
editorState,
isPreferredApiMongoDB,
onNewDocumentClick,
onSaveNewDocumentClick,
onRevertNewDocumentClick,
onSaveExistingDocumentClick,
onRevertExistingDocumentClick,
onDeleteExistingDocumentsClick,
}: ButtonsDependencies): CommandButtonComponentProps[] => {
if (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) {
// All the following buttons require write access
return [];
}
const buttons: CommandButtonComponentProps[] = [];
const label = !isPreferredApiMongoDB ? "New Item" : "New Document";
if (getNewDocumentButtonState(editorState).visible) {
buttons.push({
iconSrc: NewDocumentIcon,
iconAlt: label,
keyboardAction: KeyboardAction.NEW_ITEM,
onCommandClick: onNewDocumentClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled:
!getNewDocumentButtonState(editorState).enabled ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
id: NEW_DOCUMENT_BUTTON_ID,
});
}
if (getSaveNewDocumentButtonState(editorState).visible) {
const label = "Save";
buttons.push({
iconSrc: SaveIcon,
iconAlt: label,
keyboardAction: KeyboardAction.SAVE_ITEM,
onCommandClick: onSaveNewDocumentClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled:
!getSaveNewDocumentButtonState(editorState).enabled ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
id: SAVE_BUTTON_ID,
});
}
if (getDiscardNewDocumentChangesButtonState(editorState).visible) {
const label = "Discard";
buttons.push({
iconSrc: DiscardIcon,
iconAlt: label,
keyboardAction: KeyboardAction.CANCEL_OR_DISCARD,
onCommandClick: onRevertNewDocumentClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled:
!getDiscardNewDocumentChangesButtonState(editorState).enabled ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
id: DISCARD_BUTTON_ID,
});
}
if (getSaveExistingDocumentButtonState(editorState).visible) {
const label = "Update";
buttons.push({
iconSrc: SaveIcon,
iconAlt: label,
keyboardAction: KeyboardAction.SAVE_ITEM,
onCommandClick: onSaveExistingDocumentClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled:
!getSaveExistingDocumentButtonState(editorState).enabled ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
id: UPDATE_BUTTON_ID,
});
}
if (getDiscardExistingDocumentChangesButtonState(editorState).visible) {
const label = "Discard";
buttons.push({
iconSrc: DiscardIcon,
iconAlt: label,
keyboardAction: KeyboardAction.CANCEL_OR_DISCARD,
onCommandClick: onRevertExistingDocumentClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled:
!getDiscardExistingDocumentChangesButtonState(editorState).enabled ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
id: DISCARD_BUTTON_ID,
});
}
if (selectedRows.size > 0) {
const label = "Delete";
buttons.push({
iconSrc: DeleteDocumentIcon,
iconAlt: label,
keyboardAction: KeyboardAction.DELETE_ITEM,
onCommandClick: onDeleteExistingDocumentsClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled: useSelectedNode.getState().isQueryCopilotCollectionSelected(),
id: DELETE_BUTTON_ID,
});
}
if (!isPreferredApiMongoDB) {
buttons.push(createUploadButton(_collection.container));
}
return buttons;
};
const updateNavbarWithTabsButtons = (isTabActive: boolean, dependencies: ButtonsDependencies): void => {
if (isTabActive) {
useCommandBar.getState().setContextButtons(getTabsButtons(dependencies));
}
};
const getNewDocumentButtonState = (editorState: ViewModels.DocumentExplorerState) => ({
enabled: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.noDocumentSelected:
case ViewModels.DocumentExplorerState.existingDocumentNoEdits:
return true;
default:
return false;
}
})(),
visible: true,
});
const _loadNextPageInternal = (
iterator: QueryIterator<ItemDefinition & Resource>,
): Promise<DataModels.DocumentId[]> => {
return iterator.fetchNext().then((response) => response.resources);
};
// Export to expose to unit tests
export const showPartitionKey = (collection: ViewModels.CollectionBase, isPreferredApiMongoDB: boolean) => {
if (!collection) {
return false;
}
if (!collection.partitionKey) {
return false;
}
if (collection.partitionKey.systemKey && isPreferredApiMongoDB) {
return false;
}
return true;
};
// Export to expose to unit tests
export const buildQuery = (
isMongo: boolean,
filter: string,
partitionKeyProperties?: string[],
partitionKey?: DataModels.PartitionKey,
): string => {
if (isMongo) {
return filter || "{}";
}
return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey);
};
// Export to expose to unit tests
export interface IDocumentsTabComponentProps {
isPreferredApiMongoDB: boolean;
documentIds: DocumentId[]; // TODO: this contains ko observables. We need to convert them to React state.
collection: ViewModels.CollectionBase;
partitionKey: DataModels.PartitionKey;
onLoadStartKey: number;
tabTitle: string;
resourceTokenPartitionKey?: string;
onExecutionErrorChange: (isExecutionError: boolean) => void;
onIsExecutingChange: (isExecuting: boolean) => void;
isTabActive: boolean;
}
// Export to expose to unit tests
export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
isPreferredApiMongoDB,
documentIds: _documentIds,
collection: _collection,
partitionKey: _partitionKey,
onLoadStartKey: _onLoadStartKey,
tabTitle,
resourceTokenPartitionKey,
onExecutionErrorChange,
onIsExecutingChange,
isTabActive,
}): JSX.Element => {
const [isFilterCreated, setIsFilterCreated] = useState<boolean>(true);
const [isFilterExpanded, setIsFilterExpanded] = useState<boolean>(false);
const [isFilterFocused, setIsFilterFocused] = useState<boolean>(false);
const [appliedFilter, setAppliedFilter] = useState<string>("");
const [filterContent, setFilterContent] = useState<string>("");
const [documentIds, setDocumentIds] = useState<DocumentId[]>([]);
const [isExecuting, setIsExecuting] = useState<boolean>(false);
const filterInput = useRef<HTMLInputElement>(null);
// Query
const [documentsIterator, setDocumentsIterator] = useState<{
iterator: QueryIterator<ItemDefinition & Resource>;
applyFilterButtonPressed: boolean;
}>(undefined);
const [queryAbortController, setQueryAbortController] = useState<AbortController>(undefined);
const [cancelQueryTimeoutID, setCancelQueryTimeoutID] = useState<NodeJS.Timeout>(undefined);
const [onLoadStartKey, setOnLoadStartKey] = useState<number>(_onLoadStartKey);
const [initialDocumentContent, setInitialDocumentContent] = useState<string>(undefined);
const [selectedDocumentContent, setSelectedDocumentContent] = useState<string>(undefined);
const [selectedDocumentContentBaseline, setSelectedDocumentContentBaseline] = useState<string>(undefined);
// Table user clicked on this row
const [clickedRow, setClickedRow] = useState<TableRowId>(undefined);
// Table multiple selection
const [selectedRows, setSelectedRows] = React.useState<Set<TableRowId>>(() => new Set<TableRowId>([0]));
// Command buttons
const [editorState, setEditorState] = useState<ViewModels.DocumentExplorerState>(
ViewModels.DocumentExplorerState.noDocumentSelected,
);
// Preferences
const [prefs, setPrefs] = useState<DocumentsTabPrefs>(readDocumentsTabPrefs());
const isQueryCopilotSampleContainer =
_collection?.isSampleCollection &&
_collection?.databaseId === QueryCopilotSampleDatabaseId &&
_collection?.id() === QueryCopilotSampleContainerId;
// For Mongo only
const [continuationToken, setContinuationToken] = useState<string>(undefined);
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
useEffect(() => {
if (isFilterFocused) {
filterInput.current?.focus();
}
}, [isFilterFocused]);
let lastFilterContents = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
const applyFilterButton = {
enabled: true,
visible: true,
};
const partitionKey: DataModels.PartitionKey = useMemo(
() => _partitionKey || (_collection && _collection.partitionKey),
[_collection, _partitionKey],
);
const partitionKeyPropertyHeaders: string[] = useMemo(
() => _collection?.partitionKeyPropertyHeaders || partitionKey?.paths,
[_collection?.partitionKeyPropertyHeaders, partitionKey?.paths],
);
let partitionKeyProperties = useMemo(
() =>
partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) =>
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""),
),
[partitionKeyPropertyHeaders],
);
// new DocumentId() requires a DocumentTab which we mock with only the required properties
const newDocumentId = useCallback(
(rawDocument: DataModels.DocumentId, partitionKeyProperties: string[], partitionKeyValue: string[]) =>
new DocumentId(
{
partitionKey,
partitionKeyProperties,
// Fake unused mocks
isEditorDirty: () => false,
selectDocument: () => Promise.reject(),
},
rawDocument,
partitionKeyValue,
),
[partitionKey],
);
useEffect(() => {
setDocumentIds(_documentIds);
}, [_documentIds]);
// This is executed in onActivate() in the original code.
useEffect(() => {
setKeyboardActions({
[KeyboardAction.SEARCH]: () => {
onShowFilterClick();
return true;
},
[KeyboardAction.CLEAR_SEARCH]: () => {
setFilterContent("");
refreshDocumentsGrid(true);
return true;
},
});
if (!documentsIterator) {
try {
refreshDocumentsGrid();
} catch (error) {
if (onLoadStartKey !== null && onLoadStartKey !== undefined) {
TelemetryProcessor.traceFailure(
Action.Tab,
{
databaseName: _collection.databaseId,
collectionName: _collection.id(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
onLoadStartKey,
);
setOnLoadStartKey(undefined);
}
}
}
updateNavbarWithTabsButtons(isTabActive, {
_collection,
selectedRows,
editorState,
isPreferredApiMongoDB,
onNewDocumentClick,
onSaveNewDocumentClick,
onRevertNewDocumentClick,
onSaveExistingDocumentClick,
onRevertExistingDocumentClick,
onDeleteExistingDocumentsClick,
});
}, []);
const isEditorDirty = useCallback((): boolean => {
switch (editorState) {
case ViewModels.DocumentExplorerState.noDocumentSelected:
case ViewModels.DocumentExplorerState.existingDocumentNoEdits:
return false;
case ViewModels.DocumentExplorerState.newDocumentValid:
case ViewModels.DocumentExplorerState.newDocumentInvalid:
case ViewModels.DocumentExplorerState.existingDocumentDirtyInvalid:
return true;
case ViewModels.DocumentExplorerState.existingDocumentDirtyValid:
return true;
default:
return false;
}
}, [editorState]);
const confirmDiscardingChange = useCallback(
(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();
}
},
[isEditorDirty],
);
// Update parent (tab) if isExecuting has changed
useEffect(() => {
onIsExecutingChange(isExecuting);
}, [onIsExecutingChange, isExecuting]);
const onNewDocumentClick = useCallback(
(): void => confirmDiscardingChange(() => initializeNewDocument()),
[confirmDiscardingChange],
);
const initializeNewDocument = (): void => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newDocument: any = {
id: "replace_with_new_document_id",
};
partitionKeyProperties.forEach((partitionKeyProperty) => {
let target = newDocument;
const keySegments = partitionKeyProperty.split(".");
const finalSegment = keySegments.pop();
// Initialize nested objects as needed
keySegments.forEach((segment) => {
target = target[segment] = target[segment] || {};
});
target[finalSegment] = "replace_with_new_partition_key_value";
});
const defaultDocument: string = renderObjectForEditor(newDocument, null, 4);
setInitialDocumentContent(defaultDocument);
setSelectedDocumentContent(defaultDocument);
setSelectedDocumentContentBaseline(defaultDocument);
setSelectedRows(new Set());
setClickedRow(undefined);
setEditorState(ViewModels.DocumentExplorerState.newDocumentValid);
};
let onSaveNewDocumentClick = useCallback((): Promise<unknown> => {
onExecutionErrorChange(false);
const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, {
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
});
const sanitizedContent = selectedDocumentContent.replace("\n", "");
const document = JSON.parse(sanitizedContent);
setIsExecuting(true);
return createDocument(_collection, document)
.then(
(savedDocument: DataModels.DocumentId) => {
const value: string = renderObjectForEditor(savedDocument || {}, null, 4);
setSelectedDocumentContentBaseline(value);
setInitialDocumentContent(value);
const partitionKeyValueArray: PartitionKey[] = extractPartitionKeyValues(
savedDocument,
partitionKey as PartitionKeyDefinition,
);
const id = newDocumentId(savedDocument, partitionKeyProperties, partitionKeyValueArray as string[]);
const ids = documentIds;
ids.push(id);
setDocumentIds(ids);
setEditorState(ViewModels.DocumentExplorerState.existingDocumentNoEdits);
TelemetryProcessor.traceSuccess(
Action.CreateDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
},
startKey,
);
},
(error) => {
onExecutionErrorChange(true);
const errorMessage = getErrorMessage(error);
useDialog.getState().showOkModalDialog("Create document failed", errorMessage);
TelemetryProcessor.traceFailure(
Action.CreateDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
error: errorMessage,
errorStack: getErrorStack(error),
},
startKey,
);
},
)
.then(() => setSelectedRows(new Set([documentIds.length - 1])))
.finally(() => setIsExecuting(false));
}, [
onExecutionErrorChange,
tabTitle,
selectedDocumentContent,
_collection,
partitionKey,
newDocumentId,
partitionKeyProperties,
documentIds,
]);
const onRevertNewDocumentClick = useCallback((): void => {
setInitialDocumentContent("");
setSelectedDocumentContent("");
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
}, [setInitialDocumentContent, setSelectedDocumentContent, setEditorState]);
let onSaveExistingDocumentClick = useCallback((): Promise<void> => {
const documentContent = JSON.parse(selectedDocumentContent);
const partitionKeyValueArray: PartitionKey[] = extractPartitionKeyValues(
documentContent,
partitionKey as PartitionKeyDefinition,
);
const selectedDocumentId = documentIds[clickedRow as number];
selectedDocumentId.partitionKeyValue = partitionKeyValueArray;
onExecutionErrorChange(false);
const startKey: number = TelemetryProcessor.traceStart(Action.UpdateDocument, {
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
});
setIsExecuting(true);
return updateDocument(_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.existingDocumentNoEdits);
TelemetryProcessor.traceSuccess(
Action.UpdateDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
},
startKey,
);
},
(error) => {
onExecutionErrorChange(true);
const errorMessage = getErrorMessage(error);
useDialog.getState().showOkModalDialog("Update document failed", errorMessage);
TelemetryProcessor.traceFailure(
Action.UpdateDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
error: errorMessage,
errorStack: getErrorStack(error),
},
startKey,
);
},
)
.finally(() => setIsExecuting(false));
}, [onExecutionErrorChange, tabTitle, selectedDocumentContent, _collection, partitionKey, documentIds, clickedRow]);
const onRevertExistingDocumentClick = useCallback((): void => {
setSelectedDocumentContentBaseline(initialDocumentContent);
setSelectedDocumentContent(selectedDocumentContentBaseline);
}, [initialDocumentContent, selectedDocumentContentBaseline, setSelectedDocumentContent]);
/**
* Implementation using bulk delete
*/
let _deleteDocuments = useCallback(
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
onExecutionErrorChange(false);
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocuments, {
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
});
setIsExecuting(true);
return deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
.then(
(deletedIds) => {
TelemetryProcessor.traceSuccess(
Action.DeleteDocuments,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
},
startKey,
);
return deletedIds;
},
(error) => {
onExecutionErrorChange(true);
console.error(error);
TelemetryProcessor.traceFailure(
Action.DeleteDocuments,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey,
);
throw error;
},
)
.finally(() => setIsExecuting(false));
},
[_collection, onExecutionErrorChange, tabTitle],
);
const deleteDocuments = useCallback(
(toDeleteDocumentIds: DocumentId[]): void => {
onExecutionErrorChange(false);
setIsExecuting(true);
_deleteDocuments(toDeleteDocumentIds)
.then(
(deletedIds: DocumentId[]) => {
const deletedIdsSet = new Set(deletedIds.map((documentId) => documentId.id));
const newDocumentIds = [...documentIds.filter((documentId) => !deletedIdsSet.has(documentId.id))];
setDocumentIds(newDocumentIds);
setSelectedDocumentContent(undefined);
setClickedRow(undefined);
setSelectedRows(new Set());
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
useDialog
.getState()
.showOkModalDialog("Delete documents", `${deletedIds.length} document(s) successfully deleted.`);
},
(error: Error) =>
useDialog
.getState()
.showOkModalDialog("Delete documents", `Document(s) deleted failed (${JSON.stringify(error)})`),
)
.finally(() => setIsExecuting(false));
},
[onExecutionErrorChange, _deleteDocuments, documentIds],
);
const onDeleteExistingDocumentsClick = useCallback(async (): Promise<void> => {
// TODO: Rework this for localization
const isPlural = selectedRows.size > 1;
const documentName = !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",
() => deleteDocuments(Array.from(selectedRows).map((index) => documentIds[index as number])),
"Cancel",
undefined,
);
}, [deleteDocuments, documentIds, isPreferredApiMongoDB, selectedRows]);
// If editor state changes, update the nav
useEffect(
() =>
updateNavbarWithTabsButtons(isTabActive, {
_collection,
selectedRows,
editorState,
isPreferredApiMongoDB,
onNewDocumentClick,
onSaveNewDocumentClick,
onRevertNewDocumentClick,
onSaveExistingDocumentClick,
onRevertExistingDocumentClick: onRevertExistingDocumentClick,
onDeleteExistingDocumentsClick: onDeleteExistingDocumentsClick,
}),
[
_collection,
selectedRows,
editorState,
isPreferredApiMongoDB,
onNewDocumentClick,
onSaveNewDocumentClick,
onRevertNewDocumentClick,
onSaveExistingDocumentClick,
onRevertExistingDocumentClick,
onDeleteExistingDocumentsClick,
isTabActive,
],
);
const onShowFilterClick = () => {
setIsFilterCreated(true);
setIsFilterExpanded(true);
setIsFilterFocused(true);
};
const queryTimeoutEnabled = useCallback(
(): boolean => !isPreferredApiMongoDB && LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled),
[isPreferredApiMongoDB],
);
const createIterator = useCallback((): QueryIterator<ItemDefinition & Resource> => {
const _queryAbortController = new AbortController();
setQueryAbortController(_queryAbortController);
const filter: string = filterContent.trim();
const query: string = buildQuery(isPreferredApiMongoDB, filter, partitionKeyProperties, partitionKey);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options: any = {};
// 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;
return isQueryCopilotSampleContainer
? querySampleDocuments(query, options)
: queryDocuments(_collection.databaseId, _collection.id(), query, options);
}, [
filterContent,
isPreferredApiMongoDB,
partitionKeyProperties,
partitionKey,
resourceTokenPartitionKey,
isQueryCopilotSampleContainer,
_collection,
]);
const onHideFilterClick = (): void => {
setIsFilterExpanded(false);
};
const onCloseButtonKeyDown: KeyboardEventHandler<HTMLSpanElement> = (event) => {
if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) {
onHideFilterClick();
event.stopPropagation();
return false;
}
return true;
};
let loadNextPage = useCallback(
(iterator: QueryIterator<ItemDefinition & Resource>, applyFilterButtonClicked?: boolean): Promise<unknown> => {
setIsExecuting(true);
onExecutionErrorChange(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(iterator)
.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;
const partitionKey = _partitionKey || (_collection && _collection.partitionKey);
const partitionKeyPropertyHeaders = _collection?.partitionKeyPropertyHeaders || partitionKey?.paths;
const partitionKeyProperties = partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) =>
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""),
);
return newDocumentId(rawDocument, partitionKeyProperties, partitionKeyValue);
});
const merged = currentDocuments.concat(nextDocumentIds);
setDocumentIds(merged);
if (onLoadStartKey !== null && onLoadStartKey !== undefined) {
TelemetryProcessor.traceSuccess(
Action.Tab,
{
databaseName: _collection.databaseId,
collectionName: _collection.id(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
},
onLoadStartKey,
);
setOnLoadStartKey(undefined);
}
},
(error) => {
onExecutionErrorChange(true);
const errorMessage = getErrorMessage(error);
logConsoleError(errorMessage);
if (onLoadStartKey !== null && onLoadStartKey !== undefined) {
TelemetryProcessor.traceFailure(
Action.Tab,
{
databaseName: _collection.databaseId,
collectionName: _collection.id(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
error: errorMessage,
errorStack: getErrorStack(error),
},
onLoadStartKey,
);
setOnLoadStartKey(undefined);
}
},
)
.finally(() => {
setIsExecuting(false);
if (applyFilterButtonClicked && queryTimeoutEnabled()) {
clearTimeout(cancelQueryTimeoutID);
if (!automaticallyCancelQueryAfterTimeout) {
useDialog.getState().closeDialog();
}
}
});
},
[
onExecutionErrorChange,
queryTimeoutEnabled,
isExecuting,
queryAbortController,
documentIds,
onLoadStartKey,
_partitionKey,
_collection,
newDocumentId,
tabTitle,
cancelQueryTimeoutID,
],
);
useEffect(() => {
if (documentsIterator) {
loadNextPage(documentsIterator.iterator, documentsIterator.applyFilterButtonPressed);
}
}, [
documentsIterator, // loadNextPage: disabled as it will trigger a circular dependency and infinite loop
]);
const onRefreshKeyInput: KeyboardEventHandler<HTMLButtonElement> = (event) => {
if (event.key === " " || event.key === "Enter") {
const focusElement = event.target as HTMLElement;
refreshDocumentsGrid(false);
focusElement && focusElement.focus();
event.stopPropagation();
event.preventDefault();
}
};
const onLoadMoreKeyInput: KeyboardEventHandler<HTMLAnchorElement> = (event) => {
if (event.key === " " || event.key === "Enter") {
const focusElement = event.target as HTMLElement;
loadNextPage(documentsIterator.iterator);
focusElement && focusElement.focus();
event.stopPropagation();
event.preventDefault();
}
};
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") {
refreshDocumentsGrid(true);
// Suppress the default behavior of the key
e.preventDefault();
} else if (e.key === "Escape") {
onHideFilterClick();
// Suppress the default behavior of the key
e.preventDefault();
}
};
const _isQueryCopilotSampleContainer =
_collection?.isSampleCollection &&
_collection?.databaseId === QueryCopilotSampleDatabaseId &&
_collection?.id() === QueryCopilotSampleContainerId;
// Table config here
const tableItems: DocumentsTableComponentItem[] = documentIds.map((documentId) => {
const item: Record<string, string> & { id: string } = {
id: documentId.id(),
};
if (partitionKeyPropertyHeaders && documentId.stringPartitionKeyValues) {
for (let i = 0; i < partitionKeyPropertyHeaders.length; i++) {
item[partitionKeyPropertyHeaders[i]] = documentId.stringPartitionKeyValues[i];
}
}
return item;
});
/**
* 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(_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.existingDocumentNoEdits
: ViewModels.DocumentExplorerState.newDocumentValid;
setEditorState(newState);
}
};
const _onEditorContentChange = (newContent: string): void => {
setSelectedDocumentContent(newContent);
if (
selectedDocumentContentBaseline === initialDocumentContent &&
newContent === initialDocumentContent &&
editorState !== ViewModels.DocumentExplorerState.newDocumentValid
) {
setEditorState(ViewModels.DocumentExplorerState.existingDocumentNoEdits);
return;
}
// Mongo uses BSON format for _id, trying to parse it as JSON blocks normal flow in an edit
// Bypass validation for mongo
if (isPreferredApiMongoDB) {
onValidDocumentEdit();
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.existingDocumentDirtyValid);
};
const onInvalidDocumentEdit = (): void => {
if (
editorState === ViewModels.DocumentExplorerState.newDocumentInvalid ||
editorState === ViewModels.DocumentExplorerState.newDocumentValid
) {
setEditorState(ViewModels.DocumentExplorerState.newDocumentInvalid);
return;
}
if (
editorState === ViewModels.DocumentExplorerState.existingDocumentNoEdits ||
editorState === ViewModels.DocumentExplorerState.existingDocumentDirtyValid
) {
setEditorState(ViewModels.DocumentExplorerState.existingDocumentDirtyInvalid);
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 columnsDefinition: ColumnsDefinition = [
{
id: "id",
label: isPreferredApiMongoDB ? "_id" : "id",
},
];
if (showPartitionKey(_collection, isPreferredApiMongoDB)) {
partitionKeyPropertyHeaders.forEach((header) => {
columnsDefinition.push({
id: header,
label: header,
});
});
}
const onSelectedRowsChange = (selectedRows: Set<TableRowId>) => {
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) {
setEditorState(ViewModels.DocumentExplorerState.existingDocumentNoEdits);
}
setSelectedRows(selectedRows);
});
};
// ********* Override here for mongo (from MongoDocumentsTab) **********
if (isPreferredApiMongoDB) {
loadDocument = (documentId: DocumentId) =>
MongoProxyClient.readDocument(_collection.databaseId, _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 = _partitionKey;
if (
_partitionKey &&
_partitionKey.paths &&
_partitionKey.paths.length &&
_partitionKey.paths.length > 0 &&
_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;
});
/**
* Mongo implementation
* TODO: update proxy to use mongo driver deleteMany
*/
_deleteDocuments = (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
const promises = toDeleteDocumentIds.map((documentId) => _deleteDocument(documentId));
return Promise.all(promises);
};
const __deleteDocument = async (documentId: DocumentId): Promise<DocumentId> => {
await MongoProxyClient.deleteDocument(_collection.databaseId, _collection as ViewModels.Collection, documentId);
return documentId;
};
const _deleteDocument = useCallback(
(documentId: DocumentId): Promise<DocumentId> => {
onExecutionErrorChange(false);
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocument, {
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
});
setIsExecuting(true);
return __deleteDocument(documentId)
.then(
(deletedDocumentId) => {
TelemetryProcessor.traceSuccess(
Action.DeleteDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
},
startKey,
);
return deletedDocumentId;
},
(error) => {
onExecutionErrorChange(true);
console.error(error);
TelemetryProcessor.traceFailure(
Action.DeleteDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey,
);
return undefined;
},
)
.finally(() => setIsExecuting(false));
},
[__deleteDocument, onExecutionErrorChange, tabTitle],
);
onSaveNewDocumentClick = useCallback((): Promise<unknown> => {
const documentContent = JSON.parse(selectedDocumentContent);
const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, {
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
});
const partitionKeyProperty = partitionKeyProperties?.[0];
if (partitionKeyProperty !== "_id" && !_hasShardKeySpecified(documentContent)) {
const message = `The document is lacking the shard property: ${partitionKeyProperty}`;
useDialog.getState().showOkModalDialog("Create document failed", message);
onExecutionErrorChange(true);
TelemetryProcessor.traceFailure(
Action.CreateDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
error: message,
},
startKey,
);
Logger.logError("Failed to save new document: Document shard key not defined", "MongoDocumentsTab");
throw new Error("Document without shard key");
}
onExecutionErrorChange(false);
setIsExecuting(true);
return MongoProxyClient.createDocument(
_collection.databaseId,
_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);
setDocumentIds(ids);
setEditorState(ViewModels.DocumentExplorerState.existingDocumentNoEdits);
TelemetryProcessor.traceSuccess(
Action.CreateDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
},
startKey,
);
},
(error) => {
onExecutionErrorChange(true);
const errorMessage = getErrorMessage(error);
useDialog.getState().showOkModalDialog("Create document failed", errorMessage);
TelemetryProcessor.traceFailure(
Action.CreateDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
error: errorMessage,
errorStack: getErrorStack(error),
},
startKey,
);
},
)
.then(() => setSelectedRows(new Set([documentIds.length - 1])))
.finally(() => setIsExecuting(false));
}, [
selectedDocumentContent,
tabTitle,
partitionKeyProperties,
_hasShardKeySpecified,
onExecutionErrorChange,
_collection,
_getPartitionKeyDefinition,
documentIds,
]);
onSaveExistingDocumentClick = (): Promise<void> => {
const documentContent = selectedDocumentContent;
onExecutionErrorChange(false);
setIsExecuting(true);
const startKey: number = TelemetryProcessor.traceStart(Action.UpdateDocument, {
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
});
const selectedDocumentId = documentIds[clickedRow as number];
return MongoProxyClient.updateDocument(
_collection.databaseId,
_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.existingDocumentNoEdits);
TelemetryProcessor.traceSuccess(
Action.UpdateDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
},
startKey,
);
},
(error) => {
onExecutionErrorChange(true);
const errorMessage = getErrorMessage(error);
useDialog.getState().showOkModalDialog("Update document failed", errorMessage);
TelemetryProcessor.traceFailure(
Action.UpdateDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
error: errorMessage,
errorStack: getErrorStack(error),
},
startKey,
);
},
)
.finally(() => setIsExecuting(false));
};
loadNextPage = (): Promise<unknown> => {
setIsExecuting(true);
onExecutionErrorChange(false);
const filter: string = filterContent.trim();
const query: string = buildQuery(isPreferredApiMongoDB, filter);
return MongoProxyClient.queryDocuments(
_collection.databaseId,
_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 newDocumentId(rawDocument, partitionKeyProperties, [partitionKeyValue]);
// return new DocumentId(this, rawDocument, [partitionKeyValue]);
});
const merged = currentDocuments.concat(nextDocumentIds);
setDocumentIds(merged);
currentDocuments = merged;
if (filterContent.length > 0 && currentDocuments.length > 0) {
currentDocuments[0].click();
} else {
setSelectedDocumentContent("");
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
}
if (_onLoadStartKey !== null && _onLoadStartKey !== undefined) {
TelemetryProcessor.traceSuccess(
Action.Tab,
{
databaseName: _collection.databaseId,
collectionName: _collection.id(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
},
_onLoadStartKey,
);
setOnLoadStartKey(undefined);
}
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(error: any) => {
if (onLoadStartKey !== null && onLoadStartKey !== undefined) {
TelemetryProcessor.traceFailure(
Action.Tab,
{
databaseName: _collection.databaseId,
collectionName: _collection.id(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
_onLoadStartKey,
);
setOnLoadStartKey(undefined);
}
},
)
.finally(() => setIsExecuting(false));
};
}
// ***************** Mongo ***************************
const refreshDocumentsGrid = useCallback(
async (applyFilterButtonPressed?: boolean): Promise<void> => {
// clear documents grid
setDocumentIds([]);
setContinuationToken(undefined); // For mongo
try {
// reset iterator which will autoload documents (in useEffect)
setDocumentsIterator({
iterator: createIterator(),
applyFilterButtonPressed,
});
// collapse filter
setAppliedFilter(filterContent);
setIsFilterExpanded(false);
} catch (error) {
console.error(error);
useDialog.getState().showOkModalDialog("Refresh documents grid failed", getErrorMessage(error));
}
},
[createIterator, filterContent],
);
return (
<FluentProvider theme={getPlatformTheme(configContext.platform)} style={{ height: "100%" }}>
<div className="tab-pane active documentsTab" role="tabpanel" style={{ display: "flex" }}>
{isFilterCreated && (
<div className="filterdivs">
{!isFilterExpanded && !isPreferredApiMongoDB && (
<div className="filterDocCollapsed">
<span className="selectQuery">SELECT * FROM c</span>
<span className="appliedQuery">{appliedFilter}</span>
<Button appearance="primary" onClick={onShowFilterClick} style={filterButtonStyle}>
Edit Filter
</Button>
</div>
)}
{!isFilterExpanded && isPreferredApiMongoDB && (
<div className="filterDocCollapsed">
{appliedFilter.length > 0 && <span className="selectQuery">Filter :</span>}
{!(appliedFilter.length > 0) && <span className="noFilterApplied">No filter applied</span>}
<span className="appliedQuery">{appliedFilter}</span>
<Button style={filterButtonStyle} appearance="primary" onClick={onShowFilterClick}>
Edit Filter
</Button>
</div>
)}
{isFilterExpanded && (
<div className="filterDocExpanded">
<div>
<div className="editFilterContainer">
{!isPreferredApiMongoDB && <span className="filterspan"> SELECT * FROM c </span>}
<Input
id="filterInput"
ref={filterInput}
type="text"
list="filtersList"
className={`${filterContent.length === 0 ? "placeholderVisible" : ""}`}
style={{ width: "100%" }}
title="Type a query predicate or choose one from the list."
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."
}
value={filterContent}
autoFocus={true}
onKeyDown={onFilterKeyDown}
onChange={(e) => setFilterContent(e.target.value)}
onBlur={() => setIsFilterFocused(false)}
/>
<datalist id="filtersList">
{lastFilterContents.map((filter) => (
<option key={filter} value={filter} />
))}
</datalist>
<span className="filterbuttonpad">
<Button
appearance="primary"
style={filterButtonStyle}
onClick={() => refreshDocumentsGrid(true)}
disabled={!applyFilterButton.enabled}
aria-label="Apply filter"
tabIndex={0}
>
Apply Filter
</Button>
</span>
<span className="filterbuttonpad">
{!isPreferredApiMongoDB && isExecuting && (
<Button
style={filterButtonStyle}
appearance="primary"
aria-label="Cancel Query"
onClick={() => queryAbortController.abort()}
tabIndex={0}
>
Cancel Query
</Button>
)}
</span>
<Button
aria-label="close filter"
tabIndex={0}
onClick={onHideFilterClick}
onKeyDown={onCloseButtonKeyDown}
appearance="transparent"
icon={<Dismiss16Filled />}
/>
</div>
</div>
</div>
)}
</div>
)}
{/* <Split> doesn't like to be a flex child */}
<div style={{ overflow: "hidden", height: "100%" }}>
<Split
onDragEnd={(preSize: number) => {
prefs.leftPaneWidthPercent = Math.min(100, Math.max(0, Math.round(100 * preSize) / 100));
saveDocumentsTabPrefs(prefs);
setPrefs({ ...prefs });
}}
>
<div
style={{
minWidth: 60,
width: `${prefs.leftPaneWidthPercent}%`,
overflow: "hidden",
position: "relative",
}}
ref={tableContainerRef}
>
<Button
appearance="transparent"
aria-label="Refresh"
size="small"
icon={<ArrowClockwise16Filled />}
style={{
position: "absolute",
top: 6,
right: 0,
float: "right",
backgroundColor: "white",
zIndex: 1,
color: StyleConstants.AccentMedium,
}}
onClick={() => refreshDocumentsGrid(false)}
onKeyDown={onRefreshKeyInput}
/>
<div
style={
{
height: "100%",
width: "calc(100% - 50px)",
} /* Fix to make table not resize beyond parent's width */
}
>
<DocumentsTableComponent
items={tableItems}
onItemClicked={onDocumentClicked}
onSelectedRowsChange={onSelectedRowsChange}
selectedRows={selectedRows}
size={tableContainerSizePx}
columnsDefinition={columnsDefinition}
isSelectionDisabled={
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
}
/>
{tableItems.length > 0 && (
<a
className="loadMore"
role="button"
tabIndex={0}
onClick={() => loadNextPage(documentsIterator.iterator, false)}
onKeyDown={onLoadMoreKeyInput}
>
Load more
</a>
)}
</div>
</div>
<div style={{ minWidth: 60, width: `${100 - prefs.leftPaneWidthPercent}%` }}>
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
<EditorReact
language={"json"}
content={selectedDocumentContent}
isReadOnly={false}
ariaLabel={"Document editor"}
lineNumbers={"on"}
theme={"_theme"}
onContentChanged={_onEditorContentChange}
/>
)}
{selectedRows.size > 1 && (
<span style={{ margin: 10 }}>Number of selected documents: {selectedRows.size}</span>
)}
</div>
</Split>
</div>
</div>
</FluentProvider>
);
};