diff --git a/less/documentDB.less b/less/documentDB.less index 1abbc9b30..acce65d6a 100644 --- a/less/documentDB.less +++ b/less/documentDB.less @@ -3117,3 +3117,7 @@ a:link { background: white; height: 100%; } + +.sidebarContainer { + height: 100%; +} diff --git a/less/documentDBFabric.less b/less/documentDBFabric.less index 5f89f72ea..c191ba8b1 100644 --- a/less/documentDBFabric.less +++ b/less/documentDBFabric.less @@ -20,14 +20,18 @@ a:focus { text-decoration: underline; } +.splashLoaderContainer { + background-color: #f5f5f5; +} + #divExplorer { background-color: #f5f5f5; + padding: @FabricBoxMargin; } .resourceTreeAndTabs { border-radius: 0px; box-shadow: @FabricBoxBorderShadow; - margin: @FabricBoxMargin; margin-top: 0px; margin-bottom: 0px; background-color: #ffffff; @@ -46,7 +50,6 @@ a:focus { background-color: #ffffff; border-radius: @FabricBoxBorderRadius @FabricBoxBorderRadius 0px 0px; box-shadow: @FabricBoxBorderShadow; - margin: @FabricBoxMargin; margin-top: 0px; margin-bottom: 0px; padding-top: 2px; @@ -167,7 +170,6 @@ a:focus { .dataExplorerErrorConsoleContainer { border-radius: 0px 0px @FabricBoxBorderRadius @FabricBoxBorderRadius; box-shadow: @FabricBoxBorderShadow; - margin: @FabricBoxMargin; margin-top: 0px; width: auto; align-self: auto; diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 23155cbdf..7459df960 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -158,7 +158,7 @@ export class MongoProxyEndpoints { export class MongoProxyApi { public static readonly ResourceList: string = "ResourceList"; public static readonly QueryDocuments: string = "QueryDocuments"; - public static readonly CreateDocument: string = "CreateDocumen"; + public static readonly CreateDocument: string = "CreateDocument"; public static readonly ReadDocument: string = "ReadDocument"; public static readonly UpdateDocument: string = "UpdateDocument"; public static readonly DeleteDocument: string = "DeleteDocument"; diff --git a/src/Common/QueryError.test.ts b/src/Common/QueryError.test.ts index 2eea29a62..7924c398d 100644 --- a/src/Common/QueryError.test.ts +++ b/src/Common/QueryError.test.ts @@ -36,7 +36,7 @@ describe("QueryError.tryParse", () => { code: "BadRequest", message: "Your query is bad, and you should feel bad", }; - const message = JSON.stringify(innerError); + const message = `Message: ${JSON.stringify(innerError)}\r\nActivity ID: 42`; const outerError = { code: "BadRequest", message, @@ -48,7 +48,7 @@ describe("QueryError.tryParse", () => { ]); }); - // Imitate the value coming from the backend, which has the syntax errors serialized as JSON in the message. + // Imitate the value coming from the backend, which has the syntax errors serialized as JSON in the message, along with a prefix and activity id. it("handles single-nested error", () => { const errors = [ { @@ -69,7 +69,7 @@ describe("QueryError.tryParse", () => { message: "Your query is bad, and you should feel bad", errors, }; - const message = JSON.stringify(innerError); + const message = `Message: ${JSON.stringify(innerError)}\r\nActivity ID: 42`; const outerError = { code: "BadRequest", message, @@ -91,4 +91,23 @@ describe("QueryError.tryParse", () => { ), ]); }); + + // Imitate another value we've gotten from the backend, which has a doubly-nested JSON payload. + it("handles double-nested error", () => { + const outerError = { + code: "BadRequest", + message: + '{"code":"BadRequest","message":"{\\"errors\\":[{\\"severity\\":\\"Error\\",\\"location\\":{\\"start\\":7,\\"end\\":18},\\"code\\":\\"SC2005\\",\\"message\\":\\"\'nonexistent\' is not a recognized built-in function name.\\"}]}\\r\\nActivityId: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa, Windows/10.0.20348 cosmos-netstandard-sdk/3.18.0"}', + }; + + const result = QueryError.tryParse(outerError, testErrorLocationResolver); + expect(result).toEqual([ + new QueryError( + "'nonexistent' is not a recognized built-in function name.", + QueryErrorSeverity.Error, + "SC2005", + new QueryErrorLocation({ offset: 7, lineNumber: 7, column: 7 }, { offset: 18, lineNumber: 18, column: 18 }), + ), + ]); + }); }); diff --git a/src/Common/QueryError.ts b/src/Common/QueryError.ts index 51748d1a8..c1bab1c02 100644 --- a/src/Common/QueryError.ts +++ b/src/Common/QueryError.ts @@ -214,16 +214,28 @@ export default class QueryError { return null; } - // Assign to a new variable because of a TypeScript flow typing quirk, see below. - if (message.startsWith("Message: ")) { - // Reassigning this to 'error' restores the original type of 'error', which is 'unknown'. - // So we use a separate variable to avoid this. - message = message.substring("Message: ".length); + // Some newer backends produce a message that contains a doubly-nested JSON payload. + // In this case, the message we get is a fully-complete JSON object we can parse. + // So let's try that first + if (message.startsWith("{") && message.endsWith("}")) { + let outer: unknown = undefined; + try { + outer = JSON.parse(message); + if (typeof outer === "object" && "message" in outer && typeof outer.message === "string") { + message = outer.message; + } + } catch (e) { + // Just continue if the parsing fails. We'll use the fallback logic below. + } } const lines = message.split("\n"); message = lines[0].trim(); + if (message.startsWith("Message: ")) { + message = message.substring("Message: ".length); + } + let parsed: unknown; try { parsed = JSON.parse(message); diff --git a/src/Explorer/Controls/InputDataList/InputDataList.tsx b/src/Explorer/Controls/InputDataList/InputDataList.tsx new file mode 100644 index 000000000..cd31db53b --- /dev/null +++ b/src/Explorer/Controls/InputDataList/InputDataList.tsx @@ -0,0 +1,314 @@ +// This component is used to create a dropdown list of options for the user to select from. +// The options are displayed in a dropdown list when the user clicks on the input field. +// The user can then select an option from the list. The selected option is then displayed in the input field. + +import { getTheme } from "@fluentui/react"; +import { + Button, + Divider, + Input, + Link, + makeStyles, + Popover, + PopoverProps, + PopoverSurface, + PositioningImperativeRef, +} from "@fluentui/react-components"; +import { ArrowDownRegular, DismissRegular } from "@fluentui/react-icons"; +import { NormalizedEventKey } from "Common/Constants"; +import { tokens } from "Explorer/Theme/ThemeUtil"; +import React, { FC, useEffect, useRef } from "react"; + +const useStyles = makeStyles({ + container: { + padding: 0, + }, + input: { + flexGrow: 1, + paddingRight: 0, + outline: "none", + "& input:focus": { + outline: "none", // Undo body :focus dashed outline + }, + }, + inputButton: { + border: 0, + }, + dropdownHeader: { + width: "100%", + fontSize: tokens.fontSizeBase300, + fontWeight: 600, + padding: `${tokens.spacingVerticalM} 0 0 ${tokens.spacingVerticalM}`, + }, + dropdownStack: { + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalS, + marginTop: tokens.spacingVerticalS, + marginBottom: "1px", + }, + dropdownOption: { + fontSize: tokens.fontSizeBase300, + fontWeight: 400, + justifyContent: "left", + padding: `${tokens.spacingHorizontalXS} ${tokens.spacingHorizontalS} ${tokens.spacingHorizontalXS} ${tokens.spacingHorizontalL}`, + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + border: 0, + ":hover": { + outline: `1px dashed ${tokens.colorNeutralForeground1Hover}`, + backgroundColor: tokens.colorNeutralBackground2Hover, + color: tokens.colorNeutralForeground1, + }, + }, + bottomSection: { + fontSize: tokens.fontSizeBase300, + fontWeight: 400, + padding: `${tokens.spacingHorizontalXS} ${tokens.spacingHorizontalS} ${tokens.spacingHorizontalXS} ${tokens.spacingHorizontalL}`, + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + }, +}); + +export interface InputDatalistDropdownOptionSection { + label: string; + options: string[]; +} + +export interface InputDataListProps { + dropdownOptions: InputDatalistDropdownOptionSection[]; + placeholder?: string; + title?: string; + value: string; + onChange: (value: string) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + autofocus?: boolean; // true: acquire focus on first render + bottomLink?: { + text: string; + url: string; + }; +} + +export const InputDataList: FC = ({ + dropdownOptions, + placeholder, + title, + value, + onChange, + onKeyDown, + autofocus, + bottomLink, +}) => { + const styles = useStyles(); + const [showDropdown, setShowDropdown] = React.useState(false); + const inputRef = useRef(null); + const positioningRef = React.useRef(null); + const [isInputFocused, setIsInputFocused] = React.useState(autofocus); + const [autofocusFirstDropdownItem, setAutofocusFirstDropdownItem] = React.useState(false); + + const theme = getTheme(); + const itemRefs = useRef([]); + + useEffect(() => { + if (inputRef.current) { + positioningRef.current?.setTarget(inputRef.current); + } + }, [inputRef, positioningRef]); + + useEffect(() => { + if (isInputFocused) { + inputRef.current?.focus(); + } + }, [isInputFocused]); + + useEffect(() => { + if (autofocusFirstDropdownItem && showDropdown) { + // Autofocus on first item if input isn't focused + itemRefs.current[0]?.focus(); + setAutofocusFirstDropdownItem(false); + } + }, [autofocusFirstDropdownItem, showDropdown]); + + const handleOpenChange: PopoverProps["onOpenChange"] = (e, data) => { + if (isInputFocused && !data.open) { + // Don't close if input is focused and we're opening the dropdown (which will steal the focus) + return; + } + + setShowDropdown(data.open || false); + if (data.open) { + setIsInputFocused(true); + } + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === NormalizedEventKey.Escape) { + setShowDropdown(false); + } else if (e.key === NormalizedEventKey.DownArrow) { + setShowDropdown(true); + setAutofocusFirstDropdownItem(true); + } + onKeyDown(e); + }; + + const handleDownDropdownItemKeyDown = ( + e: React.KeyboardEvent, + index: number, + ) => { + if (e.key === NormalizedEventKey.Enter) { + e.currentTarget.click(); + } else if (e.key === NormalizedEventKey.Escape) { + setShowDropdown(false); + inputRef.current?.focus(); + } else if (e.key === NormalizedEventKey.DownArrow) { + if (index + 1 < itemRefs.current.length) { + itemRefs.current[index + 1].focus(); + } else { + setIsInputFocused(true); + } + } else if (e.key === NormalizedEventKey.UpArrow) { + if (index - 1 >= 0) { + itemRefs.current[index - 1].focus(); + } else { + // Last item, focus back to input + setIsInputFocused(true); + } + } + }; + + // Flatten dropdownOptions to better manage refs and focus + let flatIndex = 0; + const indexMap = new Map(); + for (let sectionIndex = 0; sectionIndex < dropdownOptions.length; sectionIndex++) { + const section = dropdownOptions[sectionIndex]; + for (let optionIndex = 0; optionIndex < section.options.length; optionIndex++) { + indexMap.set(`${sectionIndex}-${optionIndex}`, flatIndex); + flatIndex++; + } + } + + return ( + <> + { + const newValue = e.target.value; + // Don't show dropdown if there is already a value in the input field (when user is typing) + setShowDropdown(!(newValue.length > 0)); + onChange(newValue); + }} + onClick={(e) => { + e.stopPropagation(); + }} + onFocus={() => { + // Don't show dropdown if there is already a value in the input field + // or isInputFocused is undefined which means component is mounting + setShowDropdown(!(value.length > 0) && isInputFocused !== undefined); + + setIsInputFocused(true); + }} + onBlur={() => { + setIsInputFocused(false); + }} + contentAfter={ + value.length > 0 ? ( + + ))} + + + ))} + {bottomLink && ( + <> + +
+ (itemRefs.current[flatIndex] = el)} + href={bottomLink.url} + target="_blank" + onBlur={() => setShowDropdown(false)} + onKeyDown={(e: React.KeyboardEvent) => handleDownDropdownItemKeyDown(e, flatIndex)} + > + {bottomLink.text} + +
+ + )} + + + + ); +}; diff --git a/src/Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal.tsx b/src/Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal.tsx index c96f8d179..7485b5515 100644 --- a/src/Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal.tsx +++ b/src/Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal.tsx @@ -79,9 +79,13 @@ export const QueryCopilotFeedbackModal = ({ readOnly /> - By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the{" "} + Microsoft will process the feedback you submit pursuant to your organization’s instructions in order to + improve your and your organization’s experience with this product. If you have any questions about the use + of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the + Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the + feedback you submit is considered Personal Data under that addendum. Please see the{" "} { - + Privacy statement }{" "} diff --git a/src/Explorer/QueryCopilot/Modal/__snapshots__/QueryCopilotFeedbackModal.test.tsx.snap b/src/Explorer/QueryCopilot/Modal/__snapshots__/QueryCopilotFeedbackModal.test.tsx.snap index 4c39f7032..5f4830030 100644 --- a/src/Explorer/QueryCopilot/Modal/__snapshots__/QueryCopilotFeedbackModal.test.tsx.snap +++ b/src/Explorer/QueryCopilot/Modal/__snapshots__/QueryCopilotFeedbackModal.test.tsx.snap @@ -99,10 +99,10 @@ exports[`Query Copilot Feedback Modal snapshot test shoud render and match snaps } } > - By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the + Microsoft will process the feedback you submit pursuant to your organization’s instructions in order to improve your and your organization’s experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the Privacy statement @@ -236,10 +236,10 @@ exports[`Query Copilot Feedback Modal snapshot test should cancel submission 1`] } } > - By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the + Microsoft will process the feedback you submit pursuant to your organization’s instructions in order to improve your and your organization’s experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the Privacy statement @@ -373,10 +373,10 @@ exports[`Query Copilot Feedback Modal snapshot test should close on cancel click } } > - By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the + Microsoft will process the feedback you submit pursuant to your organization’s instructions in order to improve your and your organization’s experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the Privacy statement @@ -510,10 +510,10 @@ exports[`Query Copilot Feedback Modal snapshot test should get user unput 1`] = } } > - By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the + Microsoft will process the feedback you submit pursuant to your organization’s instructions in order to improve your and your organization’s experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the Privacy statement @@ -647,10 +647,10 @@ exports[`Query Copilot Feedback Modal snapshot test should not render dont show } } > - By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the + Microsoft will process the feedback you submit pursuant to your organization’s instructions in order to improve your and your organization’s experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the Privacy statement @@ -784,10 +784,10 @@ exports[`Query Copilot Feedback Modal snapshot test should render dont show agai } } > - By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the + Microsoft will process the feedback you submit pursuant to your organization’s instructions in order to improve your and your organization’s experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the Privacy statement @@ -936,10 +936,10 @@ exports[`Query Copilot Feedback Modal snapshot test should submit submission 1`] } } > - By pressing submit, your feedback will be used to improve Microsoft products and services. Please see the + Microsoft will process the feedback you submit pursuant to your organization’s instructions in order to improve your and your organization’s experience with this product. If you have any questions about the use of feedback data, please contact your tenant administrator. Processing of feedback data is governed by the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the feedback you submit is considered Personal Data under that addendum. Please see the Privacy statement diff --git a/src/Explorer/Sidebar.tsx b/src/Explorer/Sidebar.tsx index b0a0db7e2..a6a0e6a3e 100644 --- a/src/Explorer/Sidebar.tsx +++ b/src/Explorer/Sidebar.tsx @@ -282,67 +282,69 @@ export const SidebarContainer: React.FC = ({ explorer }) => { ); return ( - - {/* Collections Tree - Start */} - {hasSidebar && ( - // When collapsed, we force the pane to 24 pixels wide and make it non-resizable. - - -
- {loading && ( - // The Fluent UI progress bar has some issues in reduced-motion environments so we use a simple CSS animation here. - // https://github.com/microsoft/fluentui/issues/29076 -
- )} - {expanded ? ( - <> -
-
- - +
+ + {/* Collections Tree - Start */} + {hasSidebar && ( + // When collapsed, we force the pane to 24 pixels wide and make it non-resizable. + + +
+ {loading && ( + // The Fluent UI progress bar has some issues in reduced-motion environments so we use a simple CSS animation here. + // https://github.com/microsoft/fluentui/issues/29076 +
+ )} + {expanded ? ( + <> +
+
+ + +
-
-
+ {hasGlobalCommands && } + +
+ + ) : ( +
- - ) : ( - - )} -
- + + + )} +
+ + + )} + + - )} - - - - + +
); }; diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx index 14c1f16a8..070ebc8e3 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx @@ -385,22 +385,6 @@ describe("Documents tab (noSql API)", () => { it("should render the page", () => { expect(wrapper).toMatchSnapshot(); }); - - it("clicking on Edit filter should render the Apply Filter button", () => { - wrapper - .findWhere((node) => node.text() === "Edit Filter") - .at(0) - .simulate("click"); - expect(wrapper.findWhere((node) => node.text() === "Apply Filter").exists()).toBeTruthy(); - }); - - it("clicking on Edit filter should render input for filter", () => { - wrapper - .findWhere((node) => node.text() === "Edit Filter") - .at(0) - .simulate("click"); - expect(wrapper.find("Input.filterInput").exists()).toBeTruthy(); - }); }); describe("Command bar buttons", () => { diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index af8ab361c..efb3eac02 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -1,7 +1,6 @@ import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos"; import { Button, - Input, Link, MessageBar, MessageBarBody, @@ -10,8 +9,7 @@ import { makeStyles, shorthands, } from "@fluentui/react-components"; -import { Dismiss16Filled } from "@fluentui/react-icons"; -import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants"; +import { QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants"; import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; import MongoUtility from "Common/MongoUtility"; import { createDocument } from "Common/dataAccess/createDocument"; @@ -27,6 +25,7 @@ import { ActionType, OpenCollectionTab, TabKind } from "Contracts/ActionContract import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent"; import { useDialog } from "Explorer/Controls/Dialog"; import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; +import { InputDataList, InputDatalistDropdownOptionSection } from "Explorer/Controls/InputDataList/InputDataList"; import { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog"; import Explorer from "Explorer/Explorer"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; @@ -75,6 +74,7 @@ const MAX_FILTER_HISTORY_COUNT = 100; // Datalist will become scrollable, so we const NO_SQL_THROTTLING_DOC_URL = "https://learn.microsoft.com/azure/cosmos-db/nosql/troubleshoot-request-rate-too-large"; const MONGO_THROTTLING_DOC_URL = "https://learn.microsoft.com/azure/cosmos-db/mongodb/prevent-rate-limiting-errors"; +const DATA_EXPLORER_DOC_URL = "https://learn.microsoft.com/en-us/azure/cosmos-db/data-explorer"; const loadMoreHeight = LayoutConstants.rowHeight; export const useDocumentsTabStyles = makeStyles({ @@ -91,12 +91,6 @@ export const useDocumentsTabStyles = makeStyles({ alignItems: "center", ...cosmosShorthands.borderBottom(), }, - filterInput: { - flexGrow: 1, - }, - appliedFilter: { - flexGrow: 1, - }, tableContainer: { marginRight: tokens.spacingHorizontalXXXL, }, @@ -566,8 +560,6 @@ export interface IDocumentsTabComponentProps { isTabActive: boolean; } -const getUniqueId = (collection: ViewModels.CollectionBase): string => `${collection.databaseId}-${collection.id()}`; - const getDefaultSqlFilters = (partitionKeys: string[]) => ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC', "ORDER BY c._ts ASC"].concat( partitionKeys.map((partitionKey) => `WHERE c.${partitionKey} = "foo"`), @@ -593,14 +585,9 @@ export const DocumentsTabComponent: React.FunctionComponent { - const [isFilterCreated, setIsFilterCreated] = useState(true); - const [isFilterExpanded, setIsFilterExpanded] = useState(false); - const [isFilterFocused, setIsFilterFocused] = useState(false); - const [appliedFilter, setAppliedFilter] = useState(""); const [filterContent, setFilterContent] = useState(""); const [documentIds, setDocumentIds] = useState([]); const [isExecuting, setIsExecuting] = useState(false); - const filterInput = useRef(null); const styles = useDocumentsTabStyles(); // Query @@ -620,7 +607,7 @@ export const DocumentsTabComponent: React.FunctionComponent(RESET_INDEX); // Table multiple selection - const [selectedRows, setSelectedRows] = React.useState>(() => new Set([0])); + const [selectedRows, setSelectedRows] = React.useState>(() => new Set()); // Command buttons const [editorState, setEditorState] = useState( @@ -667,29 +654,6 @@ export const DocumentsTabComponent: React.FunctionComponent { - 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 = INITIAL_SELECTED_ROW_INDEX; - setSelectedRows(new Set([INITIAL_SELECTED_ROW_INDEX])); - onDocumentClicked(currentClickedRowIndex, documentIds); - } - } - }, [documentIds, clickedRowIndex, editorState]); - /** * Recursively delete all documents by retrying throttled requests (429). * This only works for NoSQL, because the bulk response includes status for each delete document request. @@ -783,11 +747,6 @@ export const DocumentsTabComponent: React.FunctionComponent _partitionKey || (_collection && _collection.partitionKey), [_collection, _partitionKey], @@ -858,10 +817,6 @@ export const DocumentsTabComponent: React.FunctionComponent { setKeyboardActions({ - [KeyboardAction.SEARCH]: () => { - onShowFilterClick(); - return true; - }, [KeyboardAction.CLEAR_SEARCH]: () => { setFilterContent(""); refreshDocumentsGrid(true); @@ -1344,12 +1299,6 @@ export const DocumentsTabComponent: React.FunctionComponent { - setIsFilterCreated(true); - setIsFilterExpanded(true); - setIsFilterFocused(true); - }; - const queryTimeoutEnabled = useCallback( (): boolean => !isPreferredApiMongoDB && LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled), [isPreferredApiMongoDB], @@ -1391,19 +1340,6 @@ export const DocumentsTabComponent: React.FunctionComponent { - setIsFilterExpanded(false); - }; - - const onCloseButtonKeyDown: KeyboardEventHandler = (event) => { - if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) { - onHideFilterClick(); - event.stopPropagation(); - return false; - } - return true; - }; - const updateDocumentIds = (newDocumentsIds: DocumentId[]): void => { setDocumentIds(newDocumentsIds); @@ -1545,14 +1481,9 @@ export const DocumentsTabComponent: React.FunctionComponent): void => { - if (e.key === "Enter") { + if (e.key === Constants.NormalizedEventKey.Enter) { onApplyFilterClick(); - // Suppress the default behavior of the key - e.preventDefault(); - } else if (e.key === "Escape") { - onHideFilterClick(); - // Suppress the default behavior of the key e.preventDefault(); } @@ -2050,10 +1981,6 @@ export const DocumentsTabComponent: React.FunctionComponent { + const options: InputDatalistDropdownOptionSection[] = []; + const nonBlankLastFilters = lastFilterContents.filter((filter) => filter.trim() !== ""); + if (nonBlankLastFilters.length > 0) { + options.push({ + label: "Saved filters", + options: nonBlankLastFilters, + }); + } + options.push({ + label: "Default filters", + options: isPreferredApiMongoDB ? defaultMongoFilters : getDefaultSqlFilters(partitionKeyProperties), + }); + return options; + }; + return (
- {isFilterCreated && ( - <> - {!isFilterExpanded && !isPreferredApiMongoDB && ( -
- SELECT * FROM c - {appliedFilter} - -
- )} - {!isFilterExpanded && isPreferredApiMongoDB && ( -
- {appliedFilter.length > 0 && Filter :} - {!(appliedFilter.length > 0) && No filter applied} - {appliedFilter} - -
- )} - {isFilterExpanded && ( -
- {!isPreferredApiMongoDB && SELECT * FROM c } - setFilterContent(e.target.value)} - onBlur={() => setIsFilterFocused(false)} - /> - - - {addStringsNoDuplicate( - lastFilterContents, - isPreferredApiMongoDB ? defaultMongoFilters : getDefaultSqlFilters(partitionKeyProperties), - ).map((filter) => ( - - - - {!isPreferredApiMongoDB && isExecuting && ( - - )} -
- )} - - )} - {/* doesn't like to be a flex child */} -
- { - tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]); - saveDocumentsTabSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData); - setTabStateData(tabStateData); +
+ {!isPreferredApiMongoDB && SELECT * FROM c } + setFilterContent(value)} + onKeyDown={onFilterKeyDown} + bottomLink={{ text: "Learn more", url: DATA_EXPLORER_DOC_URL }} + /> +
+ { + tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]); + saveDocumentsTabSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData); + setTabStateData(tabStateData); + }} + > + +
+
+
+ refreshDocumentsGrid(false)} + items={tableItems} + onSelectedRowsChange={onSelectedRowsChange} + selectedRows={selectedRows} + size={tableContainerSizePx} + selectedColumnIds={selectedColumnIds} + columnDefinitions={columnDefinitions} + isRowSelectionDisabled={ + isBulkDeleteDisabled || + (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) + } + onColumnSelectionChange={onColumnSelectionChange} + defaultColumnSelection={getInitialColumnSelection()} + collection={_collection} + isColumnSelectionDisabled={isPreferredApiMongoDB} + /> +
+
+ {tableItems.length > 0 && ( + loadNextPage(documentsIterator.iterator, false)} + onKeyDown={onLoadMoreKeyInput} + > + Load more + + )} +
+
+ +
+ {isTabActive && selectedDocumentContent && selectedRows.size <= 1 && ( + + )} + {selectedRows.size > 1 && ( + Number of selected documents: {selectedRows.size} + )} +
+
+
{bulkDeleteOperation && ( { { [ID_HEADER]: "2", [PARTITION_KEY_HEADER]: "pk2" }, { [ID_HEADER]: "3", [PARTITION_KEY_HEADER]: "pk3" }, ], - onItemClicked: (): void => {}, onSelectedRowsChange: (): void => {}, selectedRows: new Set(), size: { diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx index e71502634..b804ae60d 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx @@ -68,7 +68,6 @@ export type ColumnDefinition = { export interface IDocumentsTableComponentProps { onRefreshTable: () => void; items: DocumentsTableComponentItem[]; - onItemClicked: (index: number) => void; onSelectedRowsChange: (selectedItemsIndices: Set) => void; selectedRows: Set; size: { height: number; width: number }; @@ -98,6 +97,7 @@ const defaultSize = { idealWidth: 200, minWidth: 50, }; + export const DocumentsTableComponent: React.FC = ({ onRefreshTable, items, @@ -115,6 +115,8 @@ export const DocumentsTableComponent: React.FC = }: IDocumentsTableComponentProps) => { const styles = useDocumentsTabStyles(); + const sortedRowsRef = React.useRef(null); + const [columnSizingOptions, setColumnSizingOptions] = React.useState(() => { const columnSizesMap: ColumnSizesMap = readDocumentsTabSubComponentState( SubComponentName.ColumnSizes, @@ -303,22 +305,42 @@ export const DocumentsTableComponent: React.FC = const [selectionStartIndex, setSelectionStartIndex] = React.useState(INITIAL_SELECTED_ROW_INDEX); const onTableCellClicked = useCallback( - (e: React.MouseEvent, index: number) => { + (e: React.MouseEvent | undefined, index: number, rowId: TableRowId) => { if (isSelectionDisabled) { // Only allow click - onSelectedRowsChange(new Set([index])); + onSelectedRowsChange(new Set([rowId])); setSelectionStartIndex(index); return; } + // The selection helper computes in the index space (what's visible to the user in the table, ie the sorted array). + // selectedRows is in the rowId space (the index of the original unsorted array), so it must be converted to the index space. + const selectedRowsIndex = new Set(); + selectedRows.forEach((rowId) => { + const index = sortedRowsRef.current.findIndex((row: TableRowData) => row.rowId === rowId); + if (index !== -1) { + selectedRowsIndex.add(index); + } else { + // This should never happen + console.error(`Row with rowId ${rowId} not found in sorted rows`); + } + }); + const result = selectionHelper( - selectedRows as Set, + selectedRowsIndex, index, - isEnvironmentShiftPressed(e), - isEnvironmentCtrlPressed(e), + e && isEnvironmentShiftPressed(e), + e && isEnvironmentCtrlPressed(e), selectionStartIndex, ); - onSelectedRowsChange(result.selection); + + // Convert selectionHelper result from index space back to rowId space + const selectedRowIds = new Set(); + result.selection.forEach((index) => { + selectedRowIds.add(sortedRowsRef.current[index].rowId); + }); + onSelectedRowsChange(selectedRowIds); + if (result.selectionStartIndex !== undefined) { setSelectionStartIndex(result.selectionStartIndex); } @@ -332,16 +354,20 @@ export const DocumentsTableComponent: React.FC = * - a key is down and the cell is clicked by the mouse */ const onIdClicked = useCallback( - (e: React.KeyboardEvent, index: number) => { + (e: React.KeyboardEvent, rowId: TableRowId) => { if (e.key === NormalizedEventKey.Enter || e.key === NormalizedEventKey.Space) { - onSelectedRowsChange(new Set([index])); + onSelectedRowsChange(new Set([rowId])); } }, [onSelectedRowsChange], ); const RenderRow = ({ index, style, data }: ReactWindowRenderFnProps) => { - const { item, selected, appearance, onClick, onKeyDown } = data[index]; + // WARNING: because the table sorts the data, 'index' is not the same as 'rowId' + // The rowId is the index of the item in the original array, + // while the index is the index of the item in the sorted array + const { item, selected, appearance, onClick, onKeyDown, rowId } = data[index]; + return ( = key={column.columnId} className={styles.tableCell} // When clicking on a cell with shift/ctrl key, onKeyDown is called instead of onClick. - onClick={(e: React.MouseEvent) => onTableCellClicked(e, index)} - onKeyPress={(e: React.KeyboardEvent) => onIdClicked(e, index)} + onClick={(e: React.MouseEvent) => onTableCellClicked(e, index, rowId)} + onKeyPress={(e: React.KeyboardEvent) => onIdClicked(e, rowId)} {...columnSizing.getTableCellProps(column.columnId)} tabIndex={column.columnId === "id" ? 0 : -1} > @@ -432,6 +458,19 @@ export const DocumentsTableComponent: React.FC = }), ); + // Store the sorted rows in a ref which won't trigger a re-render (as opposed to a state) + sortedRowsRef.current = rows; + + // If there are no selected rows, auto select the first row + const [autoSelectFirstDoc, setAutoSelectFirstDoc] = React.useState(true); + React.useEffect(() => { + if (autoSelectFirstDoc && sortedRowsRef.current?.length > 0 && selectedRows.size === 0) { + setAutoSelectFirstDoc(false); + const DOC_INDEX_TO_SELECT = 0; + onTableCellClicked(undefined, DOC_INDEX_TO_SELECT, sortedRowsRef.current[DOC_INDEX_TO_SELECT].rowId); + } + }, [selectedRows, onTableCellClicked, autoSelectFirstDoc]); + const toggleAllKeydown = React.useCallback( (e: React.KeyboardEvent) => { if (e.key === " ") { diff --git a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap index 794d609b8..0c97def4e 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap +++ b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap @@ -17,111 +17,124 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = ` className="___11ktxfv_0000000 f1o614cb fy9rknc f22iagw fsnqrgy f1f5gg8d fjodcmx f122n59 f1f09k3d fg706s2 frpde29" > - SELECT * FROM c + SELECT * FROM c -
-
- -
-
- -
+ "id": "id", + "isPartitionKey": false, + "label": "id", + }, + ] + } + defaultColumnSelection={ + [ + "id", + ] + } + isColumnSelectionDisabled={false} + isRowSelectionDisabled={true} + items={[]} + onColumnSelectionChange={[Function]} + onRefreshTable={[Function]} + onSelectedRowsChange={[Function]} + selectedColumnIds={ + [ + "id", + ] + } + selectedRows={Set {}} + />
-
- -
+ + +
- - -
+ } + /> +
+
`; diff --git a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap index 0976393b2..4f9fa580f 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap +++ b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap @@ -39,7 +39,6 @@ exports[`DocumentsTableComponent should not render selection column when isSelec }, ] } - onItemClicked={[Function]} onRefreshTable={[Function]} onSelectedRowsChange={[Function]} selectedColumnIds={[]} @@ -504,7 +503,6 @@ exports[`DocumentsTableComponent should render documents and partition keys in h }, ] } - onItemClicked={[Function]} onRefreshTable={[Function]} onSelectedRowsChange={[Function]} selectedColumnIds={[]}