From d42eebaa5a47c718109d8506b57a24c3ab375d2a Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Fri, 1 Nov 2024 16:59:26 +0100 Subject: [PATCH] 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 {}} + />
-
- -
+ + +
- - -
+ } + /> +
+
`;