mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-03-13 05:15:30 +00:00
1966 lines
69 KiB
TypeScript
1966 lines
69 KiB
TypeScript
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 { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||
import {
|
||
DocumentsTabPrefs,
|
||
readDocumentsTabPrefs,
|
||
saveDocumentsTabPrefs,
|
||
saveDocumentsTabPrefsDebounced,
|
||
} 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 { defaultQueryFields, extractPartitionKeyValues } from "../../../Utils/QueryUtils";
|
||
import DocumentId from "../../Tree/DocumentId";
|
||
import ObjectId from "../../Tree/ObjectId";
|
||
import TabsBase from "../TabsBase";
|
||
import { ColumnDefinition, 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);
|
||
}
|
||
}
|
||
|
||
// Use this value to initialize the very time the component is rendered
|
||
const RESET_INDEX = -1;
|
||
|
||
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,
|
||
additionalField?: string[],
|
||
): string => {
|
||
if (isMongo) {
|
||
return filter || "{}";
|
||
}
|
||
|
||
// Filter out fields starting with "/" (partition keys)
|
||
return QueryUtils.buildDocumentsQuery(
|
||
filter,
|
||
partitionKeyProperties,
|
||
partitionKey,
|
||
additionalField.filter((f) => !f.startsWith("/")),
|
||
);
|
||
};
|
||
|
||
// 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 [clickedRowIndex, setClickedRowIndex] = useState<number>(RESET_INDEX);
|
||
// 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]);
|
||
|
||
// Clicked row must be defined
|
||
useEffect(() => {
|
||
if (documentIds.length > 0) {
|
||
let currentClickedRowIndex = clickedRowIndex;
|
||
if (
|
||
(currentClickedRowIndex === RESET_INDEX &&
|
||
editorState === ViewModels.DocumentExplorerState.noDocumentSelected) ||
|
||
currentClickedRowIndex > documentIds.length - 1
|
||
) {
|
||
// reset clicked row or the current clicked row is out of bounds
|
||
currentClickedRowIndex = 0;
|
||
setSelectedRows(new Set([0]));
|
||
onDocumentClicked(currentClickedRowIndex, documentIds);
|
||
}
|
||
}
|
||
}, [documentIds, clickedRowIndex, editorState]);
|
||
|
||
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],
|
||
);
|
||
|
||
const [selectedColumnIds, setSelectedColumnIds] = useState<string[]>(() => {
|
||
const columnsIds = ["id"];
|
||
if (showPartitionKey(_collection, isPreferredApiMongoDB)) {
|
||
columnsIds.push(...partitionKeyPropertyHeaders);
|
||
}
|
||
return columnsIds;
|
||
});
|
||
|
||
// new DocumentId() requires a DocumentTab which we mock with only the required properties
|
||
const newDocumentId = useCallback(
|
||
(rawDocument: DataModels.DocumentId, partitionKeyProperties: string[], partitionKeyValue: string[]) => ({
|
||
...rawDocument,
|
||
...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(false);
|
||
} 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());
|
||
setClickedRowIndex(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) => {
|
||
// TODO: Reuse initDocumentEditor() to remove code duplication
|
||
const value: string = renderObjectForEditor(savedDocument || {}, null, 4);
|
||
setSelectedDocumentContentBaseline(value);
|
||
setSelectedDocumentContent(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[clickedRowIndex 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,
|
||
clickedRowIndex,
|
||
]);
|
||
|
||
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);
|
||
setClickedRowIndex(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,
|
||
selectedColumnIds,
|
||
);
|
||
// 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,
|
||
selectedColumnIds,
|
||
]);
|
||
|
||
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;
|
||
};
|
||
|
||
const updateDocumentIds = (newDocumentsIds: DocumentId[]): void => {
|
||
setDocumentIds(newDocumentsIds);
|
||
|
||
if (onLoadStartKey !== null && onLoadStartKey !== undefined) {
|
||
TelemetryProcessor.traceSuccess(
|
||
Action.Tab,
|
||
{
|
||
databaseName: _collection.databaseId,
|
||
collectionName: _collection.id(),
|
||
|
||
dataExplorerArea: Constants.Areas.Tab,
|
||
tabTitle,
|
||
},
|
||
onLoadStartKey,
|
||
);
|
||
setOnLoadStartKey(undefined);
|
||
}
|
||
};
|
||
|
||
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);
|
||
updateDocumentIds(merged);
|
||
},
|
||
(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, false);
|
||
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 { ...documentId, ...item };
|
||
});
|
||
|
||
const extractColumnDefinitionsFromDocument = (document: unknown): ColumnDefinition[] => {
|
||
let columnDefinitions: ColumnDefinition[] = Object.keys(document)
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
.filter((key) => typeof (document as any)[key] === "string" || typeof (document as any)[key] === "number") // Only allow safe types for displayable React children
|
||
.map((key) =>
|
||
key === "id"
|
||
? { id: key, label: isPreferredApiMongoDB ? "_id" : "id", group: undefined }
|
||
: { id: key, label: key, group: undefined },
|
||
);
|
||
|
||
if (showPartitionKey(_collection, isPreferredApiMongoDB)) {
|
||
columnDefinitions.push(
|
||
...partitionKeyPropertyHeaders.map((key) => ({ id: key, label: key, group: "Partition Key" })),
|
||
);
|
||
|
||
// Remove properties that are the partition keys, since they are already included
|
||
columnDefinitions = columnDefinitions.filter(
|
||
(columnDefinition) => !partitionKeyProperties.includes(columnDefinition.id),
|
||
);
|
||
}
|
||
|
||
return columnDefinitions;
|
||
};
|
||
|
||
/**
|
||
* Extract column definitions from document and add to the definitions
|
||
* @param document
|
||
*/
|
||
const setColumnDefinitionsFromDocument = (document: unknown): void => {
|
||
const currentIds = new Set(columnDefinitions.map((columnDefinition) => columnDefinition.id));
|
||
extractColumnDefinitionsFromDocument(document).forEach((columnDefinition) => {
|
||
if (!currentIds.has(columnDefinition.id)) {
|
||
columnDefinitions.push(columnDefinition);
|
||
}
|
||
});
|
||
setColumnDefinitions([...columnDefinitions]);
|
||
};
|
||
|
||
/**
|
||
* replicate logic of selectedDocument.click();
|
||
* Document has been clicked on in table
|
||
* @param tabRowId
|
||
*/
|
||
const onDocumentClicked = (tabRowId: TableRowId, currentDocumentIds: DocumentId[]) => {
|
||
const index = tabRowId as number;
|
||
setClickedRowIndex(index);
|
||
loadDocument(currentDocumentIds[index]);
|
||
};
|
||
|
||
let loadDocument = (documentId: DocumentId) =>
|
||
(_isQueryCopilotSampleContainer ? readSampleDocument(documentId) : readDocument(_collection, documentId)).then(
|
||
(content) => {
|
||
initDocumentEditor(documentId, content);
|
||
|
||
// Update columns
|
||
setColumnDefinitionsFromDocument(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
|
||
}, []);
|
||
|
||
// Column definition is a map<id, ColumnDefinition> to garantee uniqueness
|
||
const [columnDefinitions, setColumnDefinitions] = useState<ColumnDefinition[]>(() =>
|
||
extractColumnDefinitionsFromDocument({
|
||
id: "id",
|
||
}),
|
||
);
|
||
|
||
const onSelectedRowsChange = (selectedRows: Set<TableRowId>) => {
|
||
confirmDiscardingChange(() => {
|
||
if (selectedRows.size === 0) {
|
||
setSelectedDocumentContent(undefined);
|
||
setClickedRowIndex(undefined);
|
||
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
|
||
}
|
||
|
||
// Find if clickedRow is in selectedRows.If not, clear clickedRow and content
|
||
if (clickedRowIndex !== undefined && !selectedRows.has(clickedRowIndex)) {
|
||
setClickedRowIndex(undefined);
|
||
setSelectedDocumentContent(undefined);
|
||
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
|
||
}
|
||
|
||
// If only one selection, we consider as a click
|
||
if (selectedRows.size === 1) {
|
||
setEditorState(ViewModels.DocumentExplorerState.existingDocumentNoEdits);
|
||
onDocumentClicked(selectedRows.values().next().value, documentIds);
|
||
}
|
||
|
||
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);
|
||
setSelectedDocumentContent(value);
|
||
setInitialDocumentContent(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[clickedRowIndex 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, selectedColumnIds);
|
||
|
||
return MongoProxyClient.queryDocuments(
|
||
_collection.databaseId,
|
||
_collection as ViewModels.Collection,
|
||
true,
|
||
query,
|
||
continuationToken,
|
||
)
|
||
.then(
|
||
({ continuationToken: newContinuationToken, documents }) => {
|
||
setContinuationToken(newContinuationToken);
|
||
const currentDocumentsRids = documentIds.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]);
|
||
});
|
||
|
||
const merged = documentIds.concat(nextDocumentIds);
|
||
updateDocumentIds(merged);
|
||
},
|
||
// 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(
|
||
(applyFilterButtonPressed: boolean): 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);
|
||
|
||
// If apply filter is pressed, reset current selected document
|
||
if (applyFilterButtonPressed) {
|
||
setClickedRowIndex(RESET_INDEX);
|
||
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
|
||
setSelectedDocumentContent(undefined);
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
useDialog.getState().showOkModalDialog("Refresh documents grid failed", getErrorMessage(error));
|
||
}
|
||
},
|
||
[createIterator, filterContent],
|
||
);
|
||
|
||
const onTableColumnResize = (columnId: string, width: number) => {
|
||
if (!prefs.columnWidths) {
|
||
prefs.columnWidths = {};
|
||
}
|
||
prefs.columnWidths[columnId] = width;
|
||
saveDocumentsTabPrefsDebounced(prefs);
|
||
setPrefs({ ...prefs });
|
||
};
|
||
|
||
const onColumnSelectionChange = (newSelectedColumnIds: string[]): void => {
|
||
setSelectedColumnIds(newSelectedColumnIds);
|
||
};
|
||
|
||
const prevSelectedColumnIds = usePrevious({ selectedColumnIds, setSelectedColumnIds });
|
||
|
||
useEffect(() => {
|
||
// If we are adding a field, let's refresh to include the field in the query
|
||
let addedField = false;
|
||
for (const field of selectedColumnIds) {
|
||
if (
|
||
!defaultQueryFields.includes(field) &&
|
||
prevSelectedColumnIds &&
|
||
!prevSelectedColumnIds.selectedColumnIds.includes(field)
|
||
) {
|
||
addedField = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (addedField) {
|
||
refreshDocumentsGrid(false);
|
||
}
|
||
}, [prevSelectedColumnIds, refreshDocumentsGrid, selectedColumnIds]);
|
||
|
||
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={(index) => onDocumentClicked(index, documentIds)}
|
||
onSelectedRowsChange={onSelectedRowsChange}
|
||
selectedRows={selectedRows}
|
||
size={tableContainerSizePx}
|
||
selectedColumnIds={selectedColumnIds}
|
||
columnDefinitions={columnDefinitions}
|
||
isSelectionDisabled={
|
||
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
|
||
}
|
||
onColumnResize={onTableColumnResize}
|
||
onColumnSelectionChange={onColumnSelectionChange}
|
||
/>
|
||
{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>
|
||
);
|
||
};
|