Add nav buttons

This commit is contained in:
Laurent Nguyen
2024-03-25 15:20:38 +01:00
parent 4a3b092b8b
commit 464f8293f1
2 changed files with 386 additions and 29 deletions

View File

@@ -5,19 +5,27 @@ import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId }
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import { queryDocuments } from "Common/dataAccess/queryDocuments"; import { queryDocuments } from "Common/dataAccess/queryDocuments";
import { readDocument } from "Common/dataAccess/readDocument"; import { readDocument } from "Common/dataAccess/readDocument";
import { Platform, configContext } from "ConfigContext";
import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent";
import { useDialog } from "Explorer/Controls/Dialog"; import { useDialog } from "Explorer/Controls/Dialog";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities"; import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import DocumentsTab from "Explorer/Tabs/DocumentsTab"; import DocumentsTab from "Explorer/Tabs/DocumentsTab";
import { dataExplorerLightTheme } from "Explorer/Theme/ThemeUtil"; import { dataExplorerLightTheme } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { QueryConstants } from "Shared/Constants"; import { QueryConstants } from "Shared/Constants";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { Action } from "Shared/Telemetry/TelemetryConstants"; import { Action } from "Shared/Telemetry/TelemetryConstants";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import { logConsoleError } from "Utils/NotificationConsoleUtils"; import { logConsoleError } from "Utils/NotificationConsoleUtils";
import React, { KeyboardEventHandler, useEffect, useMemo, useRef, useState } from "react"; import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { format } from "react-string-format"; import { format } from "react-string-format";
import DeleteDocumentIcon from "../../../../images/DeleteDocument.svg";
import NewDocumentIcon from "../../../../images/NewDocument.svg";
import CloseIcon from "../../../../images/close-black.svg"; import CloseIcon from "../../../../images/close-black.svg";
import DiscardIcon from "../../../../images/discard.svg";
import SaveIcon from "../../../../images/save-cosmos.svg";
import * as Constants from "../../../Common/Constants"; import * as Constants from "../../../Common/Constants";
import * as HeadersUtility from "../../../Common/HeadersUtility"; import * as HeadersUtility from "../../../Common/HeadersUtility";
import * as DataModels from "../../../Contracts/DataModels"; import * as DataModels from "../../../Contracts/DataModels";
@@ -42,6 +50,10 @@ export class DocumentsTabV2 extends TabsBase {
} }
public render(): JSX.Element { public render(): JSX.Element {
if (!this.isActive) {
return <></>;
}
return ( return (
<DocumentsTabComponent <DocumentsTabComponent
isPreferredApiMongoDB={undefined} isPreferredApiMongoDB={undefined}
@@ -61,6 +73,10 @@ export class DocumentsTabV2 extends TabsBase {
} }
} }
// From TabsBase.renderObjectForEditor()
const renderObjectForEditor = (value: unknown, replacer: unknown, space: string | number): string =>
JSON.stringify(value, replacer, space);
const DocumentsTabComponent: React.FunctionComponent<{ const DocumentsTabComponent: React.FunctionComponent<{
isPreferredApiMongoDB: boolean; isPreferredApiMongoDB: boolean;
documentIds: DocumentId[]; // TODO: this contains ko observables. We need to convert them to React state. documentIds: DocumentId[]; // TODO: this contains ko observables. We need to convert them to React state.
@@ -97,11 +113,145 @@ const DocumentsTabComponent: React.FunctionComponent<{
const [isExecutionError, setIsExecutionError] = useState<boolean>(false); const [isExecutionError, setIsExecutionError] = useState<boolean>(false);
const [onLoadStartKey, setOnLoadStartKey] = useState<number>(props.onLoadStartKey); const [onLoadStartKey, setOnLoadStartKey] = useState<number>(props.onLoadStartKey);
const [currentDocument, setCurrentDocument] = useState<unknown>(undefined); const [selectedDocumentContent, setSelectedDocumentContent] = useState<unknown>(undefined);
const [selectedDocumentContentBaseline, setSelectedDocumentContentBaseline] = useState<unknown>(undefined);
// Command buttons
const [editorState, setEditorState] = useState<ViewModels.DocumentExplorerState>(
ViewModels.DocumentExplorerState.noDocumentSelected,
);
// Editor
const [initialDocumentContent, setInitialDocumentContent] = useState<string>(undefined);
const getNewDocumentButtonState = () => ({
enabled: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.noDocumentSelected:
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
return true;
default:
return false;
}
})(),
visible: true,
});
const getSaveNewDocumentButtonState = () => ({
enabled: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.newDocumentValid:
return true;
default:
return false;
}
})(),
visible: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.newDocumentValid:
case ViewModels.DocumentExplorerState.newDocumentInvalid:
return true;
default:
return false;
}
})(),
});
const getDiscardNewDocumentChangesButtonState = () => ({
enabled: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.newDocumentValid:
case ViewModels.DocumentExplorerState.newDocumentInvalid:
return true;
default:
return false;
}
})(),
visible: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.newDocumentValid:
case ViewModels.DocumentExplorerState.newDocumentInvalid:
return true;
default:
return false;
}
})(),
});
const getSaveExistingDocumentButtonState = () => ({
enabled: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
return true;
default:
return false;
}
})(),
visible: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
return true;
default:
return false;
}
})(),
});
const getDiscardExisitingDocumentChangesButtonState = () => ({
enabled: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
return true;
default:
return false;
}
})(),
visible: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
return true;
default:
return false;
}
})(),
});
const getDeleteExisitingDocumentButtonState = () => ({
enabled: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
return true;
default:
return false;
}
})(),
visible: (() => {
switch (editorState) {
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
return true;
default:
return false;
}
})(),
});
// TODO remove this?
const applyFilterButton = { const applyFilterButton = {
enabled: true, enabled: true,
visible: true,
}; };
const documentContentsContainerId = `documentContentsContainer${props.tabId}`; const documentContentsContainerId = `documentContentsContainer${props.tabId}`;
@@ -119,6 +269,12 @@ const DocumentsTabComponent: React.FunctionComponent<{
[props.isPreferredApiMongoDB], [props.isPreferredApiMongoDB],
); );
const updateNavbarWithTabsButtons = (): void => {
// if (this.isActive()) {
useCommandBar.getState().setContextButtons(getTabsButtons());
// }
};
useEffect(() => { useEffect(() => {
setDocumentIds(props.documentIds); setDocumentIds(props.documentIds);
}, [props.documentIds]); }, [props.documentIds]);
@@ -152,14 +308,48 @@ const DocumentsTabComponent: React.FunctionComponent<{
} }
} }
} }
updateNavbarWithTabsButtons();
}, []); }, []);
// If editor state changes, update the nav
useEffect(() => updateNavbarWithTabsButtons(), [editorState]);
useEffect(() => { useEffect(() => {
if (documentsIterator) { if (documentsIterator) {
loadNextPage(documentsIterator.applyFilterButtonPressed); loadNextPage(documentsIterator.applyFilterButtonPressed);
} }
}, [documentsIterator]); }, [documentsIterator]);
useEffect(() => {
setShouldShowEditor(!!selectedDocumentContent);
}, [selectedDocumentContent]);
const onNewDocumentClick = useCallback((): void => {
if (isEditorDirty()) {
useDialog
.getState()
.showOkCancelModalDialog(
"Unsaved changes",
"Changes will be lost. Do you want to continue?",
"OK",
() => initializeNewDocument(),
"Cancel",
undefined,
);
} else {
initializeNewDocument();
}
}, [editorState /* TODO isEditorDirty depends on more than just editorState */]);
const initializeNewDocument = (): void => {
this.selectedDocumentId(null);
const defaultDocument: string = renderObjectForEditor({ id: "replace_with_new_document_id" }, null, 4);
this.initialDocumentContent(defaultDocument);
this.selectedDocumentContent.setBaseline(defaultDocument);
this.editorState(ViewModels.DocumentExplorerState.newDocumentValid);
};
const onShowFilterClick = () => { const onShowFilterClick = () => {
setIsFilterCreated(true); setIsFilterCreated(true);
setIsFilterExpanded(true); setIsFilterExpanded(true);
@@ -423,6 +613,108 @@ const DocumentsTabComponent: React.FunctionComponent<{
return true; return true;
})(); })();
const getTabsButtons = useCallback((): 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().visible) {
buttons.push({
iconSrc: NewDocumentIcon,
iconAlt: label,
onCommandClick: onNewDocumentClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled: !getNewDocumentButtonState().enabled || useSelectedNode.getState().isQueryCopilotCollectionSelected(),
id: "mongoNewDocumentBtn",
});
}
if (getSaveNewDocumentButtonState().visible) {
const label = "Save";
buttons.push({
iconSrc: SaveIcon,
iconAlt: label,
onCommandClick: undefined, // TODO implement: onSaveNewDocumentClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled:
getSaveNewDocumentButtonState().enabled || useSelectedNode.getState().isQueryCopilotCollectionSelected(),
});
}
if (getDiscardNewDocumentChangesButtonState().visible) {
const label = "Discard";
buttons.push({
iconSrc: DiscardIcon,
iconAlt: label,
onCommandClick: undefined, // TODO implement: onRevertNewDocumentClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled:
!getDiscardNewDocumentChangesButtonState().enabled ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
});
}
if (getSaveExistingDocumentButtonState().visible) {
const label = "Update";
buttons.push({
iconSrc: SaveIcon,
iconAlt: label,
onCommandClick: undefined, // TODO implement: onSaveExistingDocumentClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled:
!getSaveExistingDocumentButtonState().enabled ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
});
}
if (getDiscardExisitingDocumentChangesButtonState().visible) {
const label = "Discard";
buttons.push({
iconSrc: DiscardIcon,
iconAlt: label,
onCommandClick: undefined, // TODO implement: onRevertExisitingDocumentClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled:
!getDiscardExisitingDocumentChangesButtonState().enabled ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
});
}
if (getDeleteExisitingDocumentButtonState().visible) {
const label = "Delete";
buttons.push({
iconSrc: DeleteDocumentIcon,
iconAlt: label,
onCommandClick: undefined, // TODO implement: onDeleteExisitingDocumentClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled:
!getDeleteExisitingDocumentButtonState().enabled ||
useSelectedNode.getState().isQueryCopilotCollectionSelected(),
});
}
if (!isPreferredApiMongoDB) {
buttons.push(DocumentsTab._createUploadButton(props.collection.container));
}
return buttons;
}, [editorState]);
const _isQueryCopilotSampleContainer = const _isQueryCopilotSampleContainer =
props.collection?.isSampleCollection && props.collection?.isSampleCollection &&
props.collection?.databaseId === QueryCopilotSampleDatabaseId && props.collection?.databaseId === QueryCopilotSampleDatabaseId &&
@@ -441,22 +733,76 @@ const DocumentsTabComponent: React.FunctionComponent<{
} }
return item; return item;
// TODO: for now, merge all the pk values into a single string/column
// type: documentId.partitionKeyProperties ? documentId.stringPartitionKeyValues.join(",") : undefined,
}); });
const onSelectedDocument = (index: number) => readSingleDocument(documentIds[index]); const isEditorDirty = (): boolean => {
switch (editorState) {
case ViewModels.DocumentExplorerState.noDocumentSelected:
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
return false;
// TODO: replicate logic of selectedDocument.click(); case ViewModels.DocumentExplorerState.newDocumentValid:
// TODO: Check if editor is dirty case ViewModels.DocumentExplorerState.newDocumentInvalid:
const readSingleDocument = (documentId: DocumentId) => case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
return true;
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
return (
this.selectedDocumentContent.getEditableOriginalValue() !==
this.selectedDocumentContent.getEditableCurrentValue()
);
default:
return false;
}
};
/**
* replicate logic of selectedDocument.click();
* Document has been clicked on in table
* @param index
*/
const onDocumentClicked = (index: number) => {
if (isEditorDirty()) {
useDialog
.getState()
.showOkCancelModalDialog(
"Unsaved changes",
"Your unsaved changes will be lost. Do you want to continue?",
"OK",
() => loadDocument(documentIds[index]),
"Cancel",
undefined,
);
} else {
loadDocument(documentIds[index]);
}
};
const onDocumentsSelectionChange = (selectedItemsIndices: Set<number>) => {
// TODO: Update some state?
};
const loadDocument = (documentId: DocumentId) =>
(_isQueryCopilotSampleContainer ? readSampleDocument(documentId) : readDocument(props.collection, documentId)).then( (_isQueryCopilotSampleContainer ? readSampleDocument(documentId) : readDocument(props.collection, documentId)).then(
(content) => { (content) => {
// this.initDocumentEditor(documentId, content); initDocumentEditor(documentId, content);
setCurrentDocument(content); setSelectedDocumentContent(content);
}, },
); );
const initDocumentEditor = (documentId: DocumentId, documentContent: unknown): void => {
if (documentId) {
const content: string = renderObjectForEditor(documentContent, null, 4);
setSelectedDocumentContentBaseline(content);
setInitialDocumentContent(content);
const newState = documentId
? ViewModels.DocumentExplorerState.exisitingDocumentNoEdits
: ViewModels.DocumentExplorerState.newDocumentValid;
setEditorState(newState);
}
};
const tableContainerRef = useRef(null); const tableContainerRef = useRef(null);
const [tableContainerSizePx, setTableContainerSizePx] = useState<{ height: number; width: number }>(undefined); const [tableContainerSizePx, setTableContainerSizePx] = useState<{ height: number; width: number }>(undefined);
useEffect(() => { useEffect(() => {
@@ -624,11 +970,12 @@ const DocumentsTabComponent: React.FunctionComponent<{
{/* <Split> doesn't like to be a flex child */} {/* <Split> doesn't like to be a flex child */}
<div style={{ overflow: "hidden", height: "100%" }}> <div style={{ overflow: "hidden", height: "100%" }}>
<Split> <Split>
<div style={{ minWidth: 480, width: "20%" }} ref={tableContainerRef}> <div style={{ minWidth: 200, width: "20%" }} ref={tableContainerRef}>
<DocumentsTableComponent <DocumentsTableComponent
style={{ width: 200 }} style={{ width: 200 }}
items={tableItems} items={tableItems}
onSelectedItem={onSelectedDocument} onItemClicked={onDocumentClicked}
onSelectedItemsChange={onDocumentsSelectionChange}
size={tableContainerSizePx} size={tableContainerSizePx}
columnHeaders={columnHeaders} columnHeaders={columnHeaders}
/> />
@@ -637,15 +984,17 @@ const DocumentsTabComponent: React.FunctionComponent<{
</a> </a>
</div> </div>
<div style={{ minWidth: "20%", width: "100%" }}> <div style={{ minWidth: "20%", width: "100%" }}>
{shouldShowEditor && (
<EditorReact <EditorReact
language={"json"} language={"json"}
content={JSON.stringify(currentDocument, undefined, " ")} content={initialDocumentContent}
isReadOnly={false} isReadOnly={false}
ariaLabel={"Stored procedure body"} ariaLabel={"Document editor"}
lineNumbers={"on"} lineNumbers={"on"}
theme={"_theme"} theme={"_theme"}
onContentChanged={(newContent: string) => { }} onContentChanged={(newContent: string) => {}}
/> />
)}
</div> </div>
</Split> </Split>
</div> </div>

View File

@@ -37,7 +37,8 @@ export type ColumnHeaders = {
}; };
export interface IDocumentsTableComponentProps { export interface IDocumentsTableComponentProps {
items: DocumentsTableComponentItem[]; items: DocumentsTableComponentItem[];
onSelectedItem: (index: number) => void; onItemClicked: (index: number) => void;
onSelectedItemsChange: (selectedItemsIndices: Set<number>) => void;
size: { height: number; width: number }; size: { height: number; width: number };
columnHeaders: ColumnHeaders; columnHeaders: ColumnHeaders;
style?: React.CSSProperties; style?: React.CSSProperties;
@@ -55,7 +56,8 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
items, items,
onSelectedItem, onItemClicked,
onSelectedItemsChange,
style, style,
size, size,
columnHeaders, columnHeaders,
@@ -65,7 +67,6 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
const [activeItemIndex, setActiveItemIndex] = React.useState<number>(undefined); const [activeItemIndex, setActiveItemIndex] = React.useState<number>(undefined);
const initialSizingOptions: TableColumnSizingOptions = { const initialSizingOptions: TableColumnSizingOptions = {
id: { id: {
idealWidth: 280, idealWidth: 280,
@@ -93,6 +94,13 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
const [selectedRows, setSelectedRows] = React.useState<Set<TableRowId>>(() => new Set<TableRowId>([0])); const [selectedRows, setSelectedRows] = React.useState<Set<TableRowId>>(() => new Set<TableRowId>([0]));
// If selected rows change, call props
useEffect(() => {
if (onSelectedItemsChange) {
onSelectedItemsChange(selectedRows);
}
}, [selectedRows, onSelectedItemsChange]);
// Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes // Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes
const columns: TableColumnDefinition<DocumentsTableComponentItem>[] = useMemo( const columns: TableColumnDefinition<DocumentsTableComponentItem>[] = useMemo(
() => () =>
@@ -203,7 +211,7 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
if (selectedRows.size === 1 && items.length > 0) { if (selectedRows.size === 1 && items.length > 0) {
const newActiveItemIndex = selectedRows.values().next().value; const newActiveItemIndex = selectedRows.values().next().value;
if (newActiveItemIndex !== activeItemIndex) { if (newActiveItemIndex !== activeItemIndex) {
onSelectedItem(newActiveItemIndex); onItemClicked(newActiveItemIndex);
setActiveItemIndex(newActiveItemIndex); setActiveItemIndex(newActiveItemIndex);
} }
} }