diff --git a/packages/insomnia/src/ui/components/base/__tests__/dropdown.test.tsx b/packages/insomnia/src/ui/components/base/__tests__/dropdown.test.tsx new file mode 100644 index 000000000..48ea9d473 --- /dev/null +++ b/packages/insomnia/src/ui/components/base/__tests__/dropdown.test.tsx @@ -0,0 +1,118 @@ +import { cleanup, fireEvent, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { Dropdown, dropdownsContainerId } from '../dropdown/dropdown'; +import { DropdownButton } from '../dropdown/dropdown-button'; +import { DropdownItem } from '../dropdown/dropdown-item'; + +const prepareDom = () => { + const dropdownsContainer = document.createElement('div'); + dropdownsContainer.setAttribute('id', dropdownsContainerId); + + dropdownsContainer.style.position = 'fixed'; + dropdownsContainer.style.right = '-90000px'; + dropdownsContainer.style.left = '-90000px'; + dropdownsContainer.style.width = '100vw'; + dropdownsContainer.style.height = '100vh'; + + document.body.appendChild(dropdownsContainer); +}; + +describe('Dropdown', () => { + beforeEach(() => { + prepareDom(); + }); + + afterEach(() => { + cleanup(); + }); + + it('should render a dropdown', async () => { + const onSelect = jest.fn(); + + const options = [ + { id: 1, label: 'List of Numbers', value: [1, 2, 3] }, + { id: 2, label: 'Another List of Numbers', value: [4, 5, 6] }, + { id: 3, label: 'List of more Numbers', value: [7, 8, 9] }, + ]; + + const { queryByText } = render( + + + Open + + {options.map(option => ( + + {option.label} + + ))} + + ); + + const button = queryByText('Open'); + + fireEvent.click(button); + + const option2 = queryByText(options[1].label); + + fireEvent.click(option2); + + expect(onSelect).toHaveBeenCalledWith(options[1].value, expect.any(Object)); + + cleanup(); + }); + + it('handle navigation via keyboard', async () => { + const user = userEvent.setup(); + const onSelect = jest.fn(); + + const options = [ + { id: 1, label: 'List of Numbers', value: [1, 2, 3] }, + { id: 2, label: 'Another List of Numbers', value: [4, 5, 6] }, + { id: 3, label: 'List of more Numbers', value: [7, 8, 9] }, + ]; + + const { queryByText, queryByTitle } = render( + + + Open + + {options.map(option => ( + + {option.label} + + ))} + + ); + + // Click the open button + const button = queryByText('Open'); + fireEvent.click(button); + + // Navigate with the arrows to the second option + await user.keyboard('[ArrowDown]'); + await user.keyboard('[ArrowDown]'); + + const parent = queryByTitle(options[1].label)?.parentElement; + expect(parent).toHaveClass('active'); + + // Press enter on the second option + await user.keyboard('[Enter]'); + + expect(onSelect).toHaveBeenCalledWith(options[1].value, expect.any(Object)); + + // The dropdown button should regain focus + expect(button).toHaveFocus(); + + }); +}); diff --git a/packages/insomnia/src/ui/components/base/dropdown/dropdown.tsx b/packages/insomnia/src/ui/components/base/dropdown/dropdown.tsx index 152e5b155..0e7c37ef7 100644 --- a/packages/insomnia/src/ui/components/base/dropdown/dropdown.tsx +++ b/packages/insomnia/src/ui/components/base/dropdown/dropdown.tsx @@ -1,10 +1,20 @@ -import { autoBindMethodsForReact } from 'class-autobind-decorator'; import classnames from 'classnames'; import { any, equals } from 'ramda'; -import React, { CSSProperties, Fragment, PureComponent, ReactNode } from 'react'; +import React, { + CSSProperties, + forwardRef, + Fragment, + isValidElement, + ReactNode, + useCallback, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import ReactDOM from 'react-dom'; -import { AUTOBIND_CFG } from '../../../../common/constants'; import { hotKeyRefs } from '../../../../common/hotkeys'; import { executeHotKey } from '../../../../common/hotkeys-listener'; import { fuzzyMatch } from '../../../../common/misc'; @@ -12,7 +22,6 @@ import { KeydownBinder } from '../../keydown-binder'; import { DropdownButton } from './dropdown-button'; import { DropdownDivider } from './dropdown-divider'; import { DropdownItem } from './dropdown-item'; -export const dropdownsContainerId = 'dropdowns-container'; export interface DropdownProps { children: ReactNode; @@ -26,454 +35,455 @@ export interface DropdownProps { beside?: boolean; } -interface State { - open: boolean; - dropUp: boolean; - filter: string; - filterVisible: boolean; - filterItems?: number[] | null; - filterActiveIndex: number; - forcedPosition?: { x: number; y: number } | null; - uniquenessKey: number; -} +export const dropdownsContainerId = 'dropdowns-container'; -const isComponent = (match: string) => (child: ReactNode) => any(equals(match), [ - // @ts-expect-error this is required by our API for Dropdown - child.type.name, - // @ts-expect-error this is required by our API for Dropdown - child.type.displayName, -]); +const isComponent = (match: string) => (child: ReactNode) => + any(equals(match), [ + // @ts-expect-error this is required by our API for Dropdown + child.type.name, + // @ts-expect-error this is required by our API for Dropdown + child.type.displayName, + ]); const isDropdownItem = isComponent(DropdownItem.name); const isDropdownButton = isComponent(DropdownButton.name); const isDropdownDivider = isComponent(DropdownDivider.name); -@autoBindMethodsForReact(AUTOBIND_CFG) -export class Dropdown extends PureComponent { - private _node: HTMLDivElement | null = null; - private _dropdownList: HTMLDivElement | null = null; - private _filter: HTMLInputElement | null = null; +// This walks the children tree and returns the dropdown specific components. +// It allows us to use arrays, fragments etc. +const _getFlattenedChildren = (children: ReactNode[] | ReactNode) => { + let newChildren: ReactNode[] = []; + // Ensure children is an array + const flatChildren = Array.isArray(children) ? children : [children]; - state: State = { - open: false, - dropUp: false, - // Filter Stuff - filter: '', - filterVisible: false, - filterItems: null, - filterActiveIndex: 0, - // Position - forcedPosition: null, - // Use this to force new menu every time dropdown opens - uniquenessKey: 0, - }; + for (const child of flatChildren) { + if (!child) { + // Ignore null components + continue; + } - _setRef(node: HTMLDivElement) { - this._node = node; - } - - _handleCheckFilterSubmit(event: React.KeyboardEvent) { - if (event.key === 'Enter') { - // Listen for the Enter key and "click" on the active list item - const selector = `li[data-filter-index="${this.state.filterActiveIndex}"] button`; - - const button = this._dropdownList?.querySelector(selector); - - // @ts-expect-error -- TSCONVERSION - button?.click(); + if (isValidElement(child) && child.type === Fragment) { + newChildren = [ + ...newChildren, + ..._getFlattenedChildren(child.props.children), + ]; + } else if (Array.isArray(child)) { + newChildren = [...newChildren, ..._getFlattenedChildren(child)]; + } else { + newChildren.push(child); } } - _handleChangeFilter(event: React.ChangeEvent) { - const newFilter = event.target.value; + return newChildren; +}; - // Nothing to do if the filter didn't change - if (newFilter === this.state.filter) { - return; - } +export interface DropdownHandle { + show: ( + filterVisible?: boolean, + ) => void; + hide: () => void; + toggle: (filterVisible?: boolean) => void; +} - // Filter the list items that are filterable (have data-filter-index property) - const filterItems: number[] = []; +export const Dropdown = forwardRef( + ({ right, outline, className, style, children, beside, onOpen, onHide, wide }, ref) => { + const [open, setOpen] = useState(false); + // @TODO: This is a hack to force new menu every time dropdown opens + const [uniquenessKey, setUniquenessKey] = useState(0); + const [filter, setFilter] = useState(''); + const [filterVisible, setFilterVisible] = useState(false); + const [filterItems, setFilterItems] = useState(null); + const [filterActiveIndex, setFilterActiveIndex] = useState(0); - // @ts-expect-error -- TSCONVERSION convert to array or use querySelectorAll().forEach - for (const listItem of this._dropdownList.querySelectorAll('li')) { - if (!listItem.hasAttribute('data-filter-index')) { - continue; - } + const dropdownContainerRef = useRef(null); + const dropdownListRef = useRef(null); + const filterInputRef = useRef(null); - const match = fuzzyMatch(newFilter, listItem.textContent || ''); + const _handleCheckFilterSubmit = useCallback(( + event: React.KeyboardEvent + ) => { + if (event.key === 'Enter') { + // Listen for the Enter key and "click" on the active list item + const selector = `li[data-filter-index="${filterActiveIndex}"] button`; - if (!newFilter || match) { - const filterIndex = listItem.getAttribute('data-filter-index'); - if (filterIndex) { - filterItems.push(parseInt(filterIndex, 10)); + const button = dropdownListRef.current?.querySelector(selector); + + if (button instanceof HTMLButtonElement) { + button.click(); } } - } + }, [filterActiveIndex]); - this.setState({ - filter: newFilter, - filterItems: newFilter ? filterItems : null, - filterActiveIndex: filterItems[0] || -1, - filterVisible: this.state.filterVisible ? true : newFilter.length > 0, - }); - } + const _handleChangeFilter = useCallback((event: React.ChangeEvent) => { + const newFilter = event.target.value; - _handleDropdownNavigation(event: KeyboardEvent) { - const { key, shiftKey } = event; - // Handle tab and arrows to move up and down dropdown entries - const { filterItems, filterActiveIndex } = this.state; + // Nothing to do if the filter didn't change + if (newFilter === filter) { + return; + } - if (['Tab', 'ArrowDown', 'ArrowUp'].includes(key)) { - event.preventDefault(); - const items = filterItems || []; + // Filter the list items that are filterable (have data-filter-index property) + const filterItems: number[] = []; + + const filterableItems = dropdownListRef.current?.querySelectorAll('li'); + + if (filterableItems instanceof NodeList) { + for (const listItem of filterableItems) { + if (!listItem.hasAttribute('data-filter-index')) { + continue; + } + + const match = fuzzyMatch(newFilter, listItem.textContent || ''); + + if (!newFilter || match) { + const filterIndex = listItem.getAttribute('data-filter-index'); - if (!filterItems) { - // @ts-expect-error -- TSCONVERSION convert to array or use querySelectorAll().forEach - for (const li of this._dropdownList.querySelectorAll('li')) { - if (li.hasAttribute('data-filter-index')) { - const filterIndex = li.getAttribute('data-filter-index'); if (filterIndex) { - items.push(parseInt(filterIndex, 10)); + filterItems.push(parseInt(filterIndex, 10)); } } } + + setFilter(newFilter); + setFilterItems(newFilter ? filterItems : null); + setFilterActiveIndex(filterItems[0] || -1); + setFilterVisible(filterVisible || newFilter.length > 0); + } + }, [filter, filterVisible]); + + const _handleDropdownNavigation = useCallback((event: KeyboardEvent) => { + const { key, shiftKey } = event; + // Handle tab and arrows to move up and down dropdown entries + if (['Tab', 'ArrowDown', 'ArrowUp'].includes(key)) { + event.preventDefault(); + const items = filterItems || []; + + if (!filterItems) { + const filterableItems = dropdownListRef.current?.querySelectorAll('li'); + + if (filterableItems instanceof NodeList) { + for (const li of filterableItems) { + if (li.hasAttribute('data-filter-index')) { + const filterIndex = li.getAttribute('data-filter-index'); + if (filterIndex) { + items.push(parseInt(filterIndex, 10)); + } + } + } + } + } + + const i = items.indexOf(filterActiveIndex); + + if (key === 'ArrowUp' || (key === 'Tab' && shiftKey)) { + const nextI = i > 0 ? items[i - 1] : items[items.length - 1]; + setFilterActiveIndex(nextI); + } else { + setFilterActiveIndex(items[i + 1] || items[0]); + } } - const i = items.indexOf(filterActiveIndex); + filterInputRef.current?.focus(); + }, [filterActiveIndex, filterItems]); - if (key === 'ArrowUp' || (key === 'Tab' && shiftKey)) { - const nextI = i > 0 ? items[i - 1] : items[items.length - 1]; - this.setState({ - filterActiveIndex: nextI, - }); + const _handleBodyKeyDown = (event: KeyboardEvent) => { + if (!open) { + return; + } + + // Catch all key presses (like global app hotkeys) if we're open + event.stopPropagation(); + + _handleDropdownNavigation(event); + + executeHotKey(event, hotKeyRefs.CLOSE_DROPDOWN, () => { + hide(); + }); + }; + + const isNearBottomOfScreen = () => { + if (!dropdownContainerRef.current) { + return false; + } + + const bodyHeight = document.body.getBoundingClientRect().height; + const dropdownTop = dropdownContainerRef.current.getBoundingClientRect().top; + + return dropdownTop > bodyHeight - 200; + }; + + // Recalculate the position of the dropdown + useLayoutEffect(() => { + if (!open || !dropdownListRef.current) { + return; + } + + // Compute the size of all the menus + const dropdownBtnRect = dropdownContainerRef.current?.getBoundingClientRect(); + if (!dropdownBtnRect) { + return; + } + const bodyRect = document.body.getBoundingClientRect(); + const dropdownListRect = dropdownListRef.current.getBoundingClientRect(); + + // Reset all the things so we can start fresh + dropdownListRef.current.style.left = 'initial'; + dropdownListRef.current.style.right = 'initial'; + dropdownListRef.current.style.top = 'initial'; + dropdownListRef.current.style.bottom = 'initial'; + dropdownListRef.current.style.minWidth = 'initial'; + dropdownListRef.current.style.maxWidth = 'initial'; + + const screenMargin = 6; + if (right || wide) { + // Prevent dropdown from squishing against left side of screen + const rightMargin = Math.max( + dropdownListRect.width + screenMargin, + dropdownBtnRect.right + ); + + const offset = beside ? dropdownBtnRect.width - 40 : 0; + dropdownListRef.current.style.right = `${ + bodyRect.width - rightMargin + offset + }px`; + dropdownListRef.current.style.maxWidth = `${Math.min( + dropdownListRect.width, + rightMargin + offset + )}px`; + } + + if (!right || wide) { + const offset = beside ? dropdownBtnRect.width - 40 : 0; + // Prevent dropdown from squishing against right side of screen + const leftMargin = Math.min( + bodyRect.width - dropdownListRect.width - screenMargin, + dropdownBtnRect.left + ); + dropdownListRef.current.style.left = `${leftMargin + offset}px`; + dropdownListRef.current.style.maxWidth = `${Math.min( + dropdownListRect.width, + bodyRect.width - leftMargin - offset + )}px`; + } + + if (isNearBottomOfScreen()) { + dropdownListRef.current.style.bottom = `${ + bodyRect.height - dropdownBtnRect.top + }px`; + dropdownListRef.current.style.maxHeight = `${ + dropdownBtnRect.top - screenMargin + }px`; } else { - this.setState({ - filterActiveIndex: items[i + 1] || items[0], - }); + dropdownListRef.current.style.top = `${dropdownBtnRect.bottom}px`; + dropdownListRef.current.style.maxHeight = `${ + bodyRect.height - dropdownBtnRect.bottom - screenMargin + }px`; } - } + }, [beside, open, right, wide]); - this._filter?.focus(); - } + const _handleClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + toggle(); + }; - _handleBodyKeyDown(event: KeyboardEvent) { - if (!this.state.open) { - return; - } + const _handleMouseDown = (event: React.MouseEvent) => { + // Intercept mouse down so that clicks don't trigger things like drag and drop. + event.preventDefault(); + }; - // Catch all key presses (like global app hotkeys) if we're open - event.stopPropagation(); + const hide = useCallback(() => { + // Focus the dropdown button after hiding + if (dropdownContainerRef.current) { + const button = dropdownContainerRef.current.querySelector('button'); - this._handleDropdownNavigation(event); - - executeHotKey(event, hotKeyRefs.CLOSE_DROPDOWN, () => { - this.hide(); - }); - } - - _checkSizeAndPosition() { - if (!this.state.open || !this._dropdownList) { - return; - } - - // Get dropdown menu - const dropdownList = this._dropdownList; - - // Compute the size of all the menus - // @ts-expect-error -- TSCONVERSION should exit if node is not defined - let dropdownBtnRect = this._node.getBoundingClientRect(); - - const bodyRect = document.body.getBoundingClientRect(); - const dropdownListRect = dropdownList.getBoundingClientRect(); - const { forcedPosition } = this.state; - - if (forcedPosition) { - // @ts-expect-error -- TSCONVERSION missing properties - dropdownBtnRect = { - left: forcedPosition.x, - right: bodyRect.width - forcedPosition.x, - top: forcedPosition.y, - bottom: bodyRect.height - forcedPosition.y, - width: 100, - height: 10, - }; - } - - // Should it drop up? - const bodyHeight = bodyRect.height; - const dropdownTop = dropdownBtnRect.top; - const dropUp = dropdownTop > bodyHeight - 200; - // Reset all the things so we can start fresh - this._dropdownList.style.left = 'initial'; - this._dropdownList.style.right = 'initial'; - this._dropdownList.style.top = 'initial'; - this._dropdownList.style.bottom = 'initial'; - this._dropdownList.style.minWidth = 'initial'; - this._dropdownList.style.maxWidth = 'initial'; - const screenMargin = 6; - const { right, wide } = this.props; - - if (right || wide) { - const { right: originalRight } = dropdownBtnRect; - // Prevent dropdown from squishing against left side of screen - const right = Math.max(dropdownListRect.width + screenMargin, originalRight); - const { beside } = this.props; - const offset = beside ? dropdownBtnRect.width - 40 : 0; - this._dropdownList.style.right = `${bodyRect.width - right + offset}px`; - this._dropdownList.style.maxWidth = `${Math.min(dropdownListRect.width, right + offset)}px`; - } - - if (!right || wide) { - const { left: originalLeft } = dropdownBtnRect; - const { beside } = this.props; - const offset = beside ? dropdownBtnRect.width - 40 : 0; - // Prevent dropdown from squishing against right side of screen - const left = Math.min(bodyRect.width - dropdownListRect.width - screenMargin, originalLeft); - this._dropdownList.style.left = `${left + offset}px`; - this._dropdownList.style.maxWidth = `${Math.min( - dropdownListRect.width, - bodyRect.width - left - offset, - )}px`; - } - - if (dropUp) { - const { top } = dropdownBtnRect; - this._dropdownList.style.bottom = `${bodyRect.height - top}px`; - this._dropdownList.style.maxHeight = `${top - screenMargin}px`; - } else { - const { bottom } = dropdownBtnRect; - this._dropdownList.style.top = `${bottom}px`; - this._dropdownList.style.maxHeight = `${bodyRect.height - bottom - screenMargin}px`; - } - } - - _handleClick(event: React.MouseEvent) { - event.preventDefault(); - event.stopPropagation(); - this.toggle(); - } - - static _handleMouseDown(event: React.MouseEvent) { - // Intercept mouse down so that clicks don't trigger things like drag and drop. - event.preventDefault(); - } - - _addDropdownListRef(dropdownList: HTMLDivElement) { - this._dropdownList = dropdownList; - } - - _addFilterRef(filter: HTMLInputElement) { - this._filter = filter; - - // Automatically focus the filter element when mounted so we can start typing - if (this._filter) { - this._filter.focus(); - } - } - - // TODO: children should not be 'any'. - _getFlattenedChildren(children: any) { - let newChildren: ReactNode[] = []; - // Ensure children is an array - children = Array.isArray(children) ? children : [children]; - - for (const child of children) { - if (!child) { - // Ignore null components - continue; + button?.focus(); } - if (child.type === Fragment) { - newChildren = [...newChildren, ...this._getFlattenedChildren(child.props.children)]; - } else if (Array.isArray(child)) { - newChildren = [...newChildren, ...this._getFlattenedChildren(child)]; - } else { - newChildren.push(child); - } - } + setOpen(false); - return newChildren; - } + onHide?.(); + }, [onHide]); - componentDidUpdate() { - this._checkSizeAndPosition(); - } + const show = useCallback( + ( + filterVisible = false, + ) => { + setOpen(true); + setFilterVisible(filterVisible); + setFilter(''); + setFilterItems(null); + setFilterActiveIndex(-1); + setUniquenessKey(uniquenessKey + 1); - hide() { - // Focus the dropdown button after hiding - if (this._node) { - const button = this._node.querySelector('button'); + onOpen?.(); + }, + [onOpen, uniquenessKey] + ); - button?.focus(); - } + const toggle = useCallback( + (filterVisible = false) => { + if (open) { + hide(); + } else { + show(filterVisible); + } + }, + [hide, open, show] + ); - this.setState({ - open: false, - }); - this.props.onHide?.(); - } + useImperativeHandle( + ref, + () => ({ + show, + hide, + toggle, + }), + [hide, show, toggle] + ); - show(filterVisible = false, forcedPosition: { x: number; y: number } | null = null) { - const bodyHeight = document.body.getBoundingClientRect().height; - - // @ts-expect-error -- TSCONVERSION _node can be undefined - const dropdownTop = this._node.getBoundingClientRect().top; - - const dropUp = dropdownTop > bodyHeight - 200; - this.setState({ - open: true, - dropUp, - forcedPosition, - filterVisible, - filter: '', - filterItems: null, - filterActiveIndex: -1, - uniquenessKey: this.state.uniquenessKey + 1, - }); - this.props.onOpen?.(); - } - - toggle(filterVisible = false) { - if (this.state.open) { - this.hide(); - } else { - this.show(filterVisible); - } - } - - render() { - const { right, outline, wide, className, style, children } = this.props; - const { - dropUp, - open, - uniquenessKey, - filterVisible, - filterActiveIndex, - filterItems, - filter, - } = this.state; const classes = classnames('dropdown', className, { 'dropdown--wide': wide, 'dropdown--open': open, }); + const menuClasses = classnames({ // eslint-disable-next-line camelcase dropdown__menu: true, 'theme--dropdown__menu': true, 'dropdown__menu--open': open, 'dropdown__menu--outlined': outline, - 'dropdown__menu--up': dropUp, + 'dropdown__menu--up': isNearBottomOfScreen(), 'dropdown__menu--right': right, }); - const dropdownButtons: ReactNode[] = []; - const dropdownItems: ReactNode[] = []; - const allChildren = this._getFlattenedChildren(children); + const dropdownChildren = useMemo(() => { + const dropdownButtons: ReactNode[] = []; + const dropdownItems: ReactNode[] = []; - const visibleChildren = allChildren.filter((child, i) => { - if (isDropdownItem(child)) { - return true; - } + const allChildren = _getFlattenedChildren(children); - // It's visible if its index is in the filterItems - return !filterItems || filterItems.includes(i); - }); - - for (let i = 0; i < allChildren.length; i++) { - const child = allChildren[i]; - - if (isDropdownButton(child)) { - dropdownButtons.push(child); - } else if (isDropdownItem(child)) { - const active = i === filterActiveIndex; - const hide = !visibleChildren.includes(child); - dropdownItems.push( -
  • - {child} -
  • , - ); - } else if (isDropdownDivider(child)) { - const currentIndex = visibleChildren.indexOf(child); - const nextChild = visibleChildren[currentIndex + 1]; - - // Only show the divider if the next child is a DropdownItem - if (nextChild && isDropdownItem(nextChild)) { - dropdownItems.push(
  • {child}
  • ); + const visibleChildren = allChildren.filter((child, i) => { + if (!isDropdownItem(child)) { + return true; } - } - } - let finalChildren: React.ReactNode = []; - - if (dropdownButtons.length !== 1) { - console.error(`Dropdown needs exactly one DropdownButton! Got ${dropdownButtons.length}`, { - allChildren, + // It's visible if its index is in the filterItems + return !filterItems || filterItems.includes(i); }); - } else { - const noResults = filter && filterItems && filterItems.length === 0; - finalChildren = [ - dropdownButtons[0], - ReactDOM.createPortal( -
    -
    -
    -
    - - -
    - {noResults &&
    No match :(
    } -
      + ); + } else if (isDropdownDivider(child)) { + const currentIndex = visibleChildren.indexOf(child); + const nextChild = visibleChildren[currentIndex + 1]; + + // Only show the divider if the next child is a DropdownItem + if (nextChild && isDropdownItem(nextChild)) { + dropdownItems.push(
    • {child}
    • ); + } + } + } + + let finalChildren: React.ReactNode = []; + + if (dropdownButtons.length !== 1) { + console.error( + `Dropdown needs exactly one DropdownButton! Got ${dropdownButtons.length}`, + { + allChildren, + } + ); + } else { + const noResults = filter && filterItems && filterItems.length === 0; + const dropdownsContainer = document.getElementById(dropdownsContainerId); + + if (!dropdownsContainer) { + console.error('Dropdown: a #dropdowns-container element is required for a dropdown to render properly'); + + return null; + } + + finalChildren = [ + dropdownButtons[0], + ReactDOM.createPortal( +
      +
      +
      - {dropdownItems} -
    -
    -
    , - // @ts-expect-error -- TSCONVERSION - document.getElementById(dropdownsContainerId), - ), - ]; - } +
    + + +
    + {noResults && ( +
    {'No match :('}
    + )} +
      + {dropdownItems} +
    +
    + , + dropdownsContainer + ), + ]; + } + + return finalChildren; + }, [_handleChangeFilter, _handleCheckFilterSubmit, children, filter, filterActiveIndex, filterItems, filterVisible, menuClasses, open, uniquenessKey]); return ( - +
    - {finalChildren} + {dropdownChildren}
    ); } -} +); + +Dropdown.displayName = 'Dropdown'; diff --git a/packages/insomnia/src/ui/components/dropdowns/environments-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/environments-dropdown.tsx index d8db33a03..26d5d4ab6 100644 --- a/packages/insomnia/src/ui/components/dropdowns/environments-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/environments-dropdown.tsx @@ -5,7 +5,7 @@ import { hotKeyRefs } from '../../../common/hotkeys'; import { executeHotKey } from '../../../common/hotkeys-listener'; import type { Environment } from '../../../models/environment'; import type { Workspace } from '../../../models/workspace'; -import { Dropdown } from '../base/dropdown/dropdown'; +import { type DropdownHandle, Dropdown } from '../base/dropdown/dropdown'; import { DropdownButton } from '../base/dropdown/dropdown-button'; import { DropdownDivider } from '../base/dropdown/dropdown-divider'; import { DropdownHint } from '../base/dropdown/dropdown-hint'; @@ -32,7 +32,7 @@ export const EnvironmentsDropdown: FC = ({ hotKeyRegistry, workspace, }) => { - const dropdownRef = useRef(null); + const dropdownRef = useRef(null); const handleShowEnvironmentModal = useCallback(() => { showModal(WorkspaceEnvironmentsEditModal, workspace); }, [workspace]); diff --git a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx index b98f62298..138f4ec84 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx @@ -21,7 +21,7 @@ import type { UpdateGitRepositoryCallback, } from '../../redux/modules/git'; import * as gitActions from '../../redux/modules/git'; -import { Dropdown } from '../base/dropdown/dropdown'; +import { type DropdownHandle, Dropdown } from '../base/dropdown/dropdown'; import { DropdownButton } from '../base/dropdown/dropdown-button'; import { DropdownDivider } from '../base/dropdown/dropdown-divider'; import { DropdownItem } from '../base/dropdown/dropdown-item'; @@ -55,7 +55,7 @@ interface State { @autoBindMethodsForReact(AUTOBIND_CFG) class GitSyncDropdown extends PureComponent { - _dropdown: Dropdown | null = null; + _dropdown: DropdownHandle | null = null; state: State = { initializing: false, @@ -66,7 +66,7 @@ class GitSyncDropdown extends PureComponent { branches: [], }; - _setDropdownRef(dropdown: Dropdown) { + _setDropdownRef(dropdown: DropdownHandle) { this._dropdown = dropdown; } diff --git a/packages/insomnia/src/ui/components/dropdowns/method-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/method-dropdown.tsx index d70aaf777..e525f3c91 100644 --- a/packages/insomnia/src/ui/components/dropdowns/method-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/method-dropdown.tsx @@ -2,7 +2,7 @@ import React, { forwardRef, useCallback, useState } from 'react'; import * as constants from '../../../common/constants'; import { METHOD_GRPC } from '../../../common/constants'; -import { Dropdown } from '../base/dropdown/dropdown'; +import { type DropdownHandle, Dropdown } from '../base/dropdown/dropdown'; import { DropdownButton } from '../base/dropdown/dropdown-button'; import { DropdownDivider } from '../base/dropdown/dropdown-divider'; import { DropdownItem } from '../base/dropdown/dropdown-item'; @@ -19,7 +19,7 @@ interface Props { showGrpc?: boolean; } -export const MethodDropdown = forwardRef(({ +export const MethodDropdown = forwardRef(({ className, method, onChange, diff --git a/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx index 6c5318555..277c6ba16 100644 --- a/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx @@ -15,7 +15,7 @@ import { incrementDeletedRequests } from '../../../models/stats'; import type { RequestAction } from '../../../plugins'; import { getRequestActions } from '../../../plugins'; import * as pluginContexts from '../../../plugins/context/index'; -import { Dropdown, DropdownProps } from '../base/dropdown/dropdown'; +import { type DropdownHandle, type DropdownProps, Dropdown } from '../base/dropdown/dropdown'; import { DropdownButton } from '../base/dropdown/dropdown-button'; import { DropdownDivider } from '../base/dropdown/dropdown-divider'; import { DropdownHint } from '../base/dropdown/dropdown-hint'; @@ -37,7 +37,7 @@ interface Props extends Pick { requestGroup?: RequestGroup; } -export const RequestActionsDropdown = forwardRef(({ +export const RequestActionsDropdown = forwardRef(({ activeEnvironment, activeProject, handleCopyAsCurl, @@ -49,6 +49,7 @@ export const RequestActionsDropdown = forwardRef(({ isPinned, request, requestGroup, + right, }, ref) => { const [actionPlugins, setActionPlugins] = useState([]); const [loadingActions, setLoadingActions] = useState>({}); @@ -109,7 +110,7 @@ export const RequestActionsDropdown = forwardRef(({ // Can only generate code for regular requests, not gRPC requests const canGenerateCode = isRequest(request); return ( - + diff --git a/packages/insomnia/src/ui/components/dropdowns/request-group-actions-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/request-group-actions-dropdown.tsx index d449b8a91..c6ebd7b16 100644 --- a/packages/insomnia/src/ui/components/dropdowns/request-group-actions-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/request-group-actions-dropdown.tsx @@ -13,7 +13,7 @@ import * as pluginContexts from '../../../plugins/context/index'; import { createRequest, CreateRequestType } from '../../hooks/create-request'; import { createRequestGroup } from '../../hooks/create-request-group'; import { selectActiveEnvironment, selectActiveProject, selectActiveWorkspace } from '../../redux/selectors'; -import { Dropdown, DropdownProps } from '../base/dropdown/dropdown'; +import { type DropdownHandle, type DropdownProps, Dropdown } from '../base/dropdown/dropdown'; import { DropdownButton } from '../base/dropdown/dropdown-button'; import { DropdownDivider } from '../base/dropdown/dropdown-divider'; import { DropdownHint } from '../base/dropdown/dropdown-hint'; @@ -42,7 +42,7 @@ export const RequestGroupActionsDropdown = forwardRef { const [actionPlugins, setActionPlugins] = useState([]); const [loadingActions, setLoadingActions] = useState< Record>({}); - const dropdownRef = useRef(null); + const dropdownRef = useRef(null); const activeProject = useSelector(selectActiveProject); const activeEnvironment = useSelector(selectActiveEnvironment); diff --git a/packages/insomnia/src/ui/components/dropdowns/response-history-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/response-history-dropdown.tsx index 031b0b1ee..af10b8616 100644 --- a/packages/insomnia/src/ui/components/dropdowns/response-history-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/response-history-dropdown.tsx @@ -7,7 +7,7 @@ import { decompressObject } from '../../../common/misc'; import type { Environment } from '../../../models/environment'; import type { RequestVersion } from '../../../models/request-version'; import type { Response } from '../../../models/response'; -import { Dropdown } from '../base/dropdown/dropdown'; +import { type DropdownHandle, Dropdown } from '../base/dropdown/dropdown'; import { DropdownButton } from '../base/dropdown/dropdown-button'; import { DropdownDivider } from '../base/dropdown/dropdown-divider'; import { DropdownItem } from '../base/dropdown/dropdown-item'; @@ -42,7 +42,7 @@ export const ResponseHistoryDropdown: FC = ({ requestVersions, responses, }) => { - const dropdownRef = useRef(null); + const dropdownRef = useRef(null); const now = new Date(); const categories: Record = { diff --git a/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx index 3491381d1..31b9c6742 100644 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx @@ -14,7 +14,7 @@ import { ConfigGenerator, getConfigGenerators, getWorkspaceActions } from '../.. import * as pluginContexts from '../../../plugins/context'; import { selectIsLoading } from '../../redux/modules/global'; import { selectActiveApiSpec, selectActiveEnvironment, selectActiveProject, selectActiveWorkspace, selectActiveWorkspaceName, selectSettings } from '../../redux/selectors'; -import { Dropdown } from '../base/dropdown/dropdown'; +import { type DropdownHandle, Dropdown } from '../base/dropdown/dropdown'; import { DropdownButton } from '../base/dropdown/dropdown-button'; import { DropdownDivider } from '../base/dropdown/dropdown-divider'; import { DropdownHint } from '../base/dropdown/dropdown-hint'; @@ -36,7 +36,7 @@ export const WorkspaceDropdown: FC = () => { const [actionPlugins, setActionPlugins] = useState([]); const [configGeneratorPlugins, setConfigGeneratorPlugins] = useState([]); const [loadingActions, setLoadingActions] = useState>({}); - const dropdownRef = useRef(null); + const dropdownRef = useRef(null); const handlePluginClick = useCallback(async ({ action, plugin, label }: WorkspaceAction, workspace: Workspace) => { setLoadingActions({ ...loadingActions, [label]: true }); diff --git a/packages/insomnia/src/ui/components/request-url-bar.tsx b/packages/insomnia/src/ui/components/request-url-bar.tsx index b118d8ca1..c1d345fd0 100644 --- a/packages/insomnia/src/ui/components/request-url-bar.tsx +++ b/packages/insomnia/src/ui/components/request-url-bar.tsx @@ -6,7 +6,7 @@ import { hotKeyRefs } from '../../common/hotkeys'; import { executeHotKey } from '../../common/hotkeys-listener'; import type { Request } from '../../models/request'; import { useTimeoutWhen } from '../hooks/useTimeoutWhen'; -import { Dropdown } from './base/dropdown/dropdown'; +import { type DropdownHandle, Dropdown } from './base/dropdown/dropdown'; import { DropdownButton } from './base/dropdown/dropdown-button'; import { DropdownDivider } from './base/dropdown/dropdown-divider'; import { DropdownHint } from './base/dropdown/dropdown-hint'; @@ -51,8 +51,8 @@ export const RequestUrlBar = forwardRef(({ uniquenessKey, }, ref) => { - const methodDropdownRef = useRef(null); - const dropdownRef = useRef(null); + const methodDropdownRef = useRef(null); + const dropdownRef = useRef(null); const inputRef = useRef(null); const focusInput = useCallback(() => { diff --git a/packages/insomnia/src/ui/components/sidebar/sidebar-request-row.tsx b/packages/insomnia/src/ui/components/sidebar/sidebar-request-row.tsx index f93ac225d..6c62146e5 100644 --- a/packages/insomnia/src/ui/components/sidebar/sidebar-request-row.tsx +++ b/packages/insomnia/src/ui/components/sidebar/sidebar-request-row.tsx @@ -13,7 +13,7 @@ import { RequestGroup } from '../../../models/request-group'; import { useNunjucks } from '../../context/nunjucks/use-nunjucks'; import { createRequest } from '../../hooks/create-request'; import { selectActiveEnvironment, selectActiveProject, selectActiveWorkspace } from '../../redux/selectors'; -import { Dropdown } from '../base/dropdown/dropdown'; +import type { DropdownHandle } from '../base/dropdown/dropdown'; import { Editable } from '../base/editable'; import { Highlight } from '../base/highlight'; import { RequestActionsDropdown } from '../dropdowns/request-actions-dropdown'; @@ -99,7 +99,7 @@ export const _SidebarRequestRow: FC = forwardRef(({ const [renderedUrl, setRenderedUrl] = useState(''); - const requestActionsDropdown = useRef(null); + const requestActionsDropdown = useRef(null); const handleShowRequestActions = useCallback((event: MouseEvent) => { event.preventDefault();