From d478af3869657cbfdb234cd86316381ece17d5b6 Mon Sep 17 00:00:00 2001 From: bogercraig <124094535+bogercraig@users.noreply.github.com> Date: Tue, 22 Oct 2024 14:23:07 -0700 Subject: [PATCH 1/6] Correct spelling of CreateDocumen to CreateDocuments (#1995) --- src/Common/Constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"; From 236f075cf61300555efb7ec456b96f5c52bc02b0 Mon Sep 17 00:00:00 2001 From: Ashley Stanton-Nurse Date: Thu, 24 Oct 2024 09:59:03 -0700 Subject: [PATCH 2/6] fix layout issues in fabric (#1996) --- less/documentDB.less | 4 ++ less/documentDBFabric.less | 8 ++- src/Explorer/Sidebar.tsx | 120 +++++++++++++++++++------------------ 3 files changed, 70 insertions(+), 62 deletions(-) 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/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 && } + +
+ + ) : ( +
- - ) : ( - - )} -
- + + + )} +
+ + + )} + + - )} - - - - + +
); }; From 82de81f2b6b3cee1c63d730b4bf0316d1668bb7a Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Fri, 25 Oct 2024 12:08:55 +0200 Subject: [PATCH 3/6] Fix row selection issue in DocumentsTab when sorting rows (#1997) * Fix bug clicking on item highlights wrong row. Remove unused prop. * Fix clicking on table row on sorted rows and multi-select using ctrl * Update test snaphosts * Remove unnecessary setTimeout --- .../Tabs/DocumentsTabV2/DocumentsTabV2.tsx | 20 +----- .../DocumentsTableComponent.test.tsx | 1 - .../DocumentsTableComponent.tsx | 63 +++++++++++++++---- .../DocumentsTabV2.test.tsx.snap | 7 +-- .../DocumentsTableComponent.test.tsx.snap | 2 - 5 files changed, 53 insertions(+), 40 deletions(-) diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index 8bcb35aa1..d36f7cc4d 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -610,7 +610,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( @@ -663,23 +663,6 @@ export const DocumentsTabComponent: React.FunctionComponent { - 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. @@ -2232,7 +2215,6 @@ export const DocumentsTabComponent: React.FunctionComponent refreshDocumentsGrid(false)} items={tableItems} - onItemClicked={(index) => onDocumentClicked(index, documentIds)} onSelectedRowsChange={onSelectedRowsChange} selectedRows={selectedRows} size={tableContainerSizePx} diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.test.tsx index 4a5439d5e..a7fc6e32e 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.test.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.test.tsx @@ -14,7 +14,6 @@ describe("DocumentsTableComponent", () => { { [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 730e2c1a6..fc762dec4 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 = readSubComponentState(SubComponentName.ColumnSizes, collection, {}); const columnSizesPx: TableColumnSizingOptions = {}; @@ -291,22 +293,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); } @@ -320,16 +342,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} > @@ -420,6 +446,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..e7bae7192 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap +++ b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap @@ -90,7 +90,6 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = ` isRowSelectionDisabled={true} items={[]} onColumnSelectionChange={[Function]} - onItemClicked={[Function]} onRefreshTable={[Function]} onSelectedRowsChange={[Function]} selectedColumnIds={ @@ -98,11 +97,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = ` "id", ] } - selectedRows={ - Set { - 0, - } - } + 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={[]} From b93c90e7d11a8f4fa6d4ab8981919bc19288f431 Mon Sep 17 00:00:00 2001 From: jawelton74 <103591340+jawelton74@users.noreply.github.com> Date: Tue, 29 Oct 2024 10:56:04 -0700 Subject: [PATCH 4/6] Update query advisor privacy statement and link (#2000) * Update query advisor privacy details. * Update test snapshot. --- .../Modal/QueryCopilotFeedbackModal.tsx | 8 ++++-- .../QueryCopilotFeedbackModal.test.tsx.snap | 28 +++++++++---------- 2 files changed, 20 insertions(+), 16 deletions(-) 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 From 056be2a74d609030da4fcaec3a781858769ccd13 Mon Sep 17 00:00:00 2001 From: Ashley Stanton-Nurse Date: Wed, 30 Oct 2024 08:43:18 -0700 Subject: [PATCH 5/6] add more edge cases to Query Error parser (#2003) --- src/Common/QueryError.test.ts | 25 ++++++++++++++++++++++--- src/Common/QueryError.ts | 22 +++++++++++++++++----- 2 files changed, 39 insertions(+), 8 deletions(-) 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); From d42eebaa5a47c718109d8506b57a24c3ab375d2a Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Fri, 1 Nov 2024 16:59:26 +0100 Subject: [PATCH 6/6] Improve DocumentsTab filter input (#1998) * Rework Input and dropdown in DocumentsTab * Improve input: implement Escape and add clear button * Undo body :focus outline, since fluent UI has a nicer focus style * Close dropdown if last element is tabbed * Fix unit tests * Fix theme and remove autocomplete * Load theme inside rendering function to fix using correct colors * Remove commented code * Add aria-label to clear filter button * Fix format * Fix keyboard navigation with tab and arrow up/down. Clear button becomes down button. --------- Co-authored-by: Laurent Nguyen --- .../Controls/InputDataList/InputDataList.tsx | 314 ++++++++++++++++ .../DocumentsTabV2/DocumentsTabV2.test.tsx | 16 - .../Tabs/DocumentsTabV2/DocumentsTabV2.tsx | 337 +++++++----------- .../DocumentsTabV2.test.tsx.snap | 172 +++++---- 4 files changed, 530 insertions(+), 309 deletions(-) create mode 100644 src/Explorer/Controls/InputDataList/InputDataList.tsx 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/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 d36f7cc4d..83501336d 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"; @@ -26,6 +24,7 @@ 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 { 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"; @@ -74,6 +73,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({ @@ -90,12 +90,6 @@ export const useDocumentsTabStyles = makeStyles({ alignItems: "center", ...cosmosShorthands.borderBottom(), }, - filterInput: { - flexGrow: 1, - }, - appliedFilter: { - flexGrow: 1, - }, tableContainer: { marginRight: tokens.spacingHorizontalXXXL, }, @@ -556,8 +550,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"`), @@ -583,14 +575,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 @@ -657,12 +644,6 @@ export const DocumentsTabComponent: React.FunctionComponent { - if (isFilterFocused) { - filterInput.current?.focus(); - } - }, [isFilterFocused]); - /** * 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. @@ -756,11 +737,6 @@ export const DocumentsTabComponent: React.FunctionComponent _partitionKey || (_collection && _collection.partitionKey), [_collection, _partitionKey], @@ -831,10 +807,6 @@ export const DocumentsTabComponent: React.FunctionComponent { setKeyboardActions({ - [KeyboardAction.SEARCH]: () => { - onShowFilterClick(); - return true; - }, [KeyboardAction.CLEAR_SEARCH]: () => { setFilterContent(""); refreshDocumentsGrid(true); @@ -1317,12 +1289,6 @@ export const DocumentsTabComponent: React.FunctionComponent { - setIsFilterCreated(true); - setIsFilterExpanded(true); - setIsFilterFocused(true); - }; - const queryTimeoutEnabled = useCallback( (): boolean => !isPreferredApiMongoDB && LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled), [isPreferredApiMongoDB], @@ -1364,19 +1330,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); @@ -1518,14 +1471,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(); } @@ -2023,10 +1971,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]); - saveSubComponentState(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]); + saveSubComponentState(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 && ( - 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 {}} + />
-
- -
+ + +
- - -
+ } + /> +
+
`;